+1
app/features/board/board-viewer.tsx
+1
app/features/board/board-viewer.tsx
+6
app/features/board/card/parser.ts
+6
app/features/board/card/parser.ts
···
22
22
icon: CardIconComponent;
23
23
text: string;
24
24
url: string;
25
+
emoji?: string;
25
26
};
26
27
27
28
export type ParsedTextCard = {
28
29
type: "text";
29
30
text: string;
31
+
emoji?: string;
30
32
};
31
33
32
34
export type ParsedEmbedCard = {
···
58
60
return {
59
61
type: "text",
60
62
text: card.text || card.url || "",
63
+
emoji: card.emoji,
61
64
};
62
65
}
63
66
const url = new URL(card.url);
···
89
92
icon: TwitterIcon,
90
93
text: card.text || `@${paths[1]}`,
91
94
url: card.url,
95
+
emoji: card.emoji,
92
96
};
93
97
}
94
98
if (isGitHubProfileUrl(url)) {
···
97
101
icon: GitHubIcon,
98
102
text: card.text || `@${paths[1]}`,
99
103
url: card.url,
104
+
emoji: card.emoji,
100
105
};
101
106
}
102
107
return {
···
104
109
icon: cardIcons[url.hostname] || LinkIcon,
105
110
text: card.text || card.url,
106
111
url: card.url,
112
+
emoji: card.emoji,
107
113
};
108
114
};
+8
-1
app/features/board/card/sortable-card.tsx
+8
-1
app/features/board/card/sortable-card.tsx
···
26
26
}
27
27
return (
28
28
<div className="card-body flex-row items-center gap-2">
29
-
{parsed.type === "link" && <parsed.icon className="-ml-2 size-6" />}
29
+
{parsed.emoji ? (
30
+
<div className="-ml-2 flex size-6 items-center justify-center text-xl">
31
+
{parsed.emoji}
32
+
</div>
33
+
) : (
34
+
parsed.type === "link" && <parsed.icon className="-ml-2 size-6" />
35
+
)}
30
36
<p className="flex-1 truncate">{parsed.text}</p>
31
37
</div>
32
38
);
···
66
72
const handleOpen = () => {
67
73
form.update({ name: "text", value: card.text });
68
74
form.update({ name: "url", value: card.url });
75
+
form.update({ name: "emoji", value: card.emoji });
69
76
form.update({ name: "id", value: card.id });
70
77
cardModal.open();
71
78
};
+3
-3
app/features/board/form/card-form-provider.tsx
+3
-3
app/features/board/form/card-form-provider.tsx
···
3
3
import { useTranslation } from "react-i18next";
4
4
import { z } from "zod";
5
5
6
-
const _schema = z.object({
7
-
text: z.string().optional(),
8
-
url: z.string().url().optional(),
6
+
import { cardSchema } from "~/models/card";
7
+
8
+
const _schema = cardSchema.extend({
9
9
id: z.string().optional(),
10
10
});
11
11
+58
-3
app/features/board/form/card-form.tsx
+58
-3
app/features/board/form/card-form.tsx
···
3
3
getInputProps,
4
4
useFormMetadata,
5
5
} from "@conform-to/react";
6
+
import Picker from "@emoji-mart/react";
7
+
import { XMarkIcon } from "@heroicons/react/24/outline";
6
8
import { Form } from "@remix-run/react";
9
+
import { useState } from "react";
7
10
import { useTranslation } from "react-i18next";
8
11
9
12
import { Button } from "~/components/button";
10
13
import { Input } from "~/components/input";
14
+
import { cn } from "~/utils/cn";
11
15
12
16
import type { CardFormPayload } from "./card-form-provider";
13
17
···
15
19
const { t } = useTranslation();
16
20
const form = useFormMetadata<CardFormPayload>();
17
21
const fields = form.getFieldset();
22
+
const [isEmojipickerOpen, setIsEmojiPickerOpen] = useState(false);
23
+
24
+
const handleResetEmoji = () => {
25
+
form.update({ name: "emoji", value: "" });
26
+
};
27
+
18
28
return (
19
29
<Form
20
30
method="post"
···
44
54
key={fields.text.key}
45
55
data-testid="card-form__text"
46
56
/>
57
+
<p className="text-sm text-gray-400">
58
+
{t("card-form.help-input-message")}
59
+
</p>
60
+
<div>
61
+
<label className="form-control">
62
+
<div className="label">
63
+
<span className="label-text">{t("card-form.emoji-label")}</span>
64
+
</div>
65
+
<input
66
+
{...getInputProps(fields.emoji, { type: "hidden" })}
67
+
key={fields.emoji.key}
68
+
/>
69
+
</label>
70
+
<div className="flex gap-2">
71
+
<Button
72
+
type="button"
73
+
className={cn("w-fit", {
74
+
"text-xl": !!fields.emoji.value,
75
+
})}
76
+
onClick={() => setIsEmojiPickerOpen(true)}
77
+
>
78
+
{fields.emoji.value ?? t("card-form.emoji-select-button")}
79
+
</Button>
80
+
{fields.emoji.value && (
81
+
<Button type="button" onClick={handleResetEmoji}>
82
+
<XMarkIcon className="size-6 text-error" />
83
+
{t("card-form.emoji-reset-button")}
84
+
</Button>
85
+
)}
86
+
</div>
87
+
{fields.emoji.errors && (
88
+
<p className="p-1 text-sm text-error">{fields.emoji.errors}</p>
89
+
)}
90
+
</div>
91
+
<p className="text-sm text-gray-400">
92
+
{t("card-form.help-emoji-message")}
93
+
</p>
94
+
{isEmojipickerOpen && (
95
+
<Picker
96
+
dynamicWidth
97
+
onClickOutside={() => setIsEmojiPickerOpen(false)}
98
+
previewPosition="none"
99
+
skinTonePosition="none"
100
+
onEmojiSelect={(emoji: { native: string }) => {
101
+
form.update({ name: "emoji", value: emoji.native });
102
+
}}
103
+
/>
104
+
)}
47
105
<input
48
106
{...getInputProps(fields.id, { type: "hidden" })}
49
107
key={fields.id.key}
···
72
130
</Button>
73
131
)}
74
132
</div>
75
-
<p className="pl-1 text-end text-sm text-gray-400">
76
-
{t("card-form.form-footer-message")}
77
-
</p>
78
133
</Form>
79
134
);
80
135
}
+5
-1
app/i18n/locales/en.json
+5
-1
app/i18n/locales/en.json
···
46
46
"change-button": "Change",
47
47
"add-button": "Add",
48
48
"delete-button": "Delete",
49
-
"form-footer-message": "Either the URL or text can be left blank."
49
+
"emoji-label": "Icon",
50
+
"emoji-select-button": "Select emoji",
51
+
"emoji-reset-button": "Reset selection",
52
+
"help-input-message": "Either the URL or text can be left blank.",
53
+
"help-emoji-message": "When not selected, it will be automatically determined based on the URL."
50
54
},
51
55
"card-form-modal": {
52
56
"add-card": "Add Card"
+5
-1
app/i18n/locales/ja.json
+5
-1
app/i18n/locales/ja.json
···
47
47
"change-button": "変更",
48
48
"add-button": "追加",
49
49
"delete-button": "削除",
50
-
"form-footer-message": "URLかテキストはどちらか空欄でもOKです。"
50
+
"emoji-label": "アイコン",
51
+
"emoji-select-button": "絵文字を選択",
52
+
"emoji-reset-button": "未選択に戻す",
53
+
"help-input-message": "URLかテキストはどちらか空欄でもOKです。",
54
+
"help-emoji-message": "未選択時はURLに応じて自動で決まります。"
51
55
},
52
56
"card-form-modal": {
53
57
"add-card": "カードを追加"
+5
app/models/card.ts
+5
app/models/card.ts
+5
app/tailwind.css
+5
app/tailwind.css
+4
lexicons/blue/linkat/board.json
+4
lexicons/blue/linkat/board.json
+3
package.json
+3
package.json
···
40
40
"@atproto/xrpc-server": "0.6.4",
41
41
"@conform-to/react": "1.2.2",
42
42
"@conform-to/zod": "1.2.2",
43
+
"@emoji-mart/data": "^1.2.1",
44
+
"@emoji-mart/react": "^1.1.1",
43
45
"@heroicons/react": "2.1.5",
44
46
"@prisma/client": "5.19.1",
45
47
"@remix-run/express": "2.12.1",
···
48
50
"@t3-oss/env-core": "0.11.1",
49
51
"@vercel/og": "^0.6.3",
50
52
"clsx": "2.1.1",
53
+
"emoji-mart": "^5.6.0",
51
54
"express": "4.21.0",
52
55
"i18next": "^23.15.2",
53
56
"i18next-browser-languagedetector": "^8.0.0",
+30
pnpm-lock.yaml
+30
pnpm-lock.yaml
···
41
41
'@conform-to/zod':
42
42
specifier: 1.2.2
43
43
version: 1.2.2(zod@3.23.8)
44
+
'@emoji-mart/data':
45
+
specifier: ^1.2.1
46
+
version: 1.2.1
47
+
'@emoji-mart/react':
48
+
specifier: ^1.1.1
49
+
version: 1.1.1(emoji-mart@5.6.0)(react@18.3.1)
44
50
'@heroicons/react':
45
51
specifier: 2.1.5
46
52
version: 2.1.5(react@18.3.1)
···
65
71
clsx:
66
72
specifier: 2.1.1
67
73
version: 2.1.1
74
+
emoji-mart:
75
+
specifier: ^5.6.0
76
+
version: 5.6.0
68
77
express:
69
78
specifier: 4.21.0
70
79
version: 4.21.0
···
609
618
resolution: {integrity: sha512-iK2fRD2+9vD6ps8OHYeSJCKCMy9H8A2yjTmVuhxqaVKMYZI1pw0hdWWTfB27czmXn7Lgk/B4ftE5zSYvrYmXZg==}
610
619
engines: {node: '>=18'}
611
620
621
+
'@emoji-mart/data@1.2.1':
622
+
resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==}
623
+
624
+
'@emoji-mart/react@1.1.1':
625
+
resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==}
626
+
peerDependencies:
627
+
emoji-mart: ^5.2
628
+
react: ^16.8 || ^17 || ^18
629
+
612
630
'@emotion/hash@0.9.2':
613
631
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
614
632
···
2567
2585
emittery@0.13.1:
2568
2586
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
2569
2587
engines: {node: '>=12'}
2588
+
2589
+
emoji-mart@5.6.0:
2590
+
resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
2570
2591
2571
2592
emoji-regex@10.4.0:
2572
2593
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
···
6298
6319
lodash: 4.17.21
6299
6320
tinycolor2: 1.6.0
6300
6321
6322
+
'@emoji-mart/data@1.2.1': {}
6323
+
6324
+
'@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@18.3.1)':
6325
+
dependencies:
6326
+
emoji-mart: 5.6.0
6327
+
react: 18.3.1
6328
+
6301
6329
'@emotion/hash@0.9.2': {}
6302
6330
6303
6331
'@esbuild/aix-ppc64@0.21.5':
···
8330
8358
electron-to-chromium@1.5.27: {}
8331
8359
8332
8360
emittery@0.13.1: {}
8361
+
8362
+
emoji-mart@5.6.0: {}
8333
8363
8334
8364
emoji-regex@10.4.0: {}
8335
8365