Create your Link in Bio for Bluesky

絵文字アイコン機能追加 (#6)

authored by mkizka.dev and committed by GitHub fc3ac6a3 a19a152c

Changed files
+133 -9
app
lexicons
blue
linkat
+1
app/features/board/board-viewer.tsx
··· 42 42 return { 43 43 id: payload.id, 44 44 text: payload.text, 45 + emoji: payload.emoji, 45 46 url: resolvedUrl, 46 47 }; 47 48 }
+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
··· 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 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
··· 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
··· 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
··· 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
··· 3 3 export const cardSchema = z.object({ 4 4 url: z.string().url().or(z.literal("")).optional(), 5 5 text: z.string().optional(), 6 + emoji: z 7 + .string() 8 + .regex(/^\p{Emoji}$/u) 9 + .or(z.literal("")) 10 + .optional(), 6 11 }); 7 12 8 13 export type ValidCard = z.infer<typeof cardSchema>;
+5
app/tailwind.css
··· 23 23 .btn-bluesky { 24 24 --btn-color: 62.58% 0.205 254.95; 25 25 } 26 + 27 + em-emoji-picker { 28 + width: 100%; 29 + height: 300px; 30 + }
+4
lexicons/blue/linkat/board.json
··· 31 31 "text": { 32 32 "type": "string", 33 33 "description": "Text of the card" 34 + }, 35 + "emoji": { 36 + "type": "string", 37 + "description": "Emoji of the card" 34 38 } 35 39 } 36 40 }
+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
··· 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