Create your Link in Bio for Bluesky

設定ページを追加 (#11)

authored by mkizka.dev and committed by GitHub a0905c84 e531c822

Changed files
+269 -80
app
components
features
i18n
locales
routes
server
jetstream
service
boardService
e2e
+13
app/components/back-button.tsx
··· 1 + import { ChevronLeftIcon } from "@heroicons/react/24/outline"; 2 + import { useTranslation } from "react-i18next"; 3 + import { Link } from "react-router"; 4 + 5 + export function BackButton() { 6 + const { t } = useTranslation(); 7 + return ( 8 + <Link to="/" className="btn btn-ghost justify-start p-1"> 9 + <ChevronLeftIcon className="size-6" /> 10 + {t("back-button.text")} 11 + </Link> 12 + ); 13 + }
+69 -50
app/components/layout.tsx
··· 1 - import { LanguageIcon } from "@heroicons/react/24/outline"; 1 + import { Cog8ToothIcon, LanguageIcon } from "@heroicons/react/24/outline"; 2 2 import { type ReactNode, useRef } from "react"; 3 3 import GitHubButton from "react-github-btn"; 4 4 import { useTranslation } from "react-i18next"; ··· 9 9 import { BlueskyIcon } from "./icons/bluesky"; 10 10 import { GitHubIcon } from "./icons/github"; 11 11 12 - type Props = { 13 - className?: string; 14 - children?: ReactNode; 12 + type HeaderProps = { 13 + isLogin?: boolean; 15 14 }; 16 15 17 - export function Header() { 16 + export function Header({ isLogin }: HeaderProps) { 18 17 const detailsRef = useRef<HTMLDetailsElement>(null); 19 18 const handleClick = () => { 20 19 if (detailsRef.current) { ··· 28 27 Linkat 29 28 </Link> 30 29 </div> 31 - <details 32 - className="dropdown dropdown-end absolute right-2 top-2" 33 - ref={detailsRef} 34 - onClick={(event) => { 35 - if ((event.target as HTMLElement).tagName === "BUTTON") return; 36 - void umami.track("click-header-lang", { 37 - action: "open", 38 - }); 39 - }} 40 - > 41 - <summary className="btn btn-square m-1 shadow dark:btn-neutral light:bg-white"> 42 - <LanguageIcon className="size-6" /> 43 - </summary> 44 - <Form> 45 - <ul className="menu dropdown-content z-[1] w-52 rounded-box p-2 shadow light:bg-white dark:bg-neutral"> 46 - <li> 47 - <button 48 - type="submit" 49 - name="lng" 50 - value="ja" 51 - onClick={handleClick} 52 - data-umami-event="click-header-lang" 53 - data-umami-event-action="select-ja" 54 - > 55 - 日本語 56 - </button> 57 - </li> 58 - <li> 59 - <button 60 - type="submit" 61 - name="lng" 62 - value="en" 63 - onClick={handleClick} 64 - data-umami-event="click-header-lang" 65 - data-umami-event-action="select-en" 66 - > 67 - English 68 - </button> 69 - </li> 70 - </ul> 71 - </Form> 72 - </details> 30 + <div className="absolute right-2 top-2 flex gap-1"> 31 + <details 32 + className="dropdown dropdown-end" 33 + ref={detailsRef} 34 + onClick={(event) => { 35 + if ((event.target as HTMLElement).tagName === "BUTTON") return; 36 + void umami.track("click-header-lang", { 37 + action: "open", 38 + }); 39 + }} 40 + > 41 + <summary className="btn btn-square m-1 shadow dark:btn-neutral light:bg-white"> 42 + <LanguageIcon className="size-6" /> 43 + </summary> 44 + <Form> 45 + <ul className="menu dropdown-content z-[1] w-52 rounded-box p-2 shadow light:bg-white dark:bg-neutral"> 46 + <li> 47 + <button 48 + type="submit" 49 + name="lng" 50 + value="ja" 51 + onClick={handleClick} 52 + data-umami-event="click-header-lang" 53 + data-umami-event-action="select-ja" 54 + > 55 + 日本語 56 + </button> 57 + </li> 58 + <li> 59 + <button 60 + type="submit" 61 + name="lng" 62 + value="en" 63 + onClick={handleClick} 64 + data-umami-event="click-header-lang" 65 + data-umami-event-action="select-en" 66 + > 67 + English 68 + </button> 69 + </li> 70 + </ul> 71 + </Form> 72 + </details> 73 + {isLogin && ( 74 + <Link 75 + to="/settings" 76 + className="btn btn-square m-1 shadow dark:btn-neutral light:bg-white" 77 + > 78 + <Cog8ToothIcon className="size-6" /> 79 + </Link> 80 + )} 81 + </div> 73 82 </header> 74 83 ); 75 84 } 76 85 77 - export function Main({ className, children }: Props) { 86 + type MainProps = { 87 + className?: string; 88 + children?: ReactNode; 89 + }; 90 + 91 + export function Main({ className, children }: MainProps) { 78 92 return ( 79 93 <main 80 94 className={cn( ··· 140 154 ); 141 155 } 142 156 143 - export function RootLayout({ children }: { children: ReactNode }) { 157 + type RootLayoutProps = { 158 + isLogin?: boolean; 159 + children: ReactNode; 160 + }; 161 + 162 + export function RootLayout({ isLogin, children }: RootLayoutProps) { 144 163 return ( 145 164 <> 146 - <Header /> 165 + <Header isLogin={isLogin} /> 147 166 {children} 148 167 <Footer /> 149 168 </>
+2 -2
app/components/logout-button.tsx app/features/settings/logout-button.tsx
··· 1 1 import { useTranslation } from "react-i18next"; 2 2 import { Form, useSubmit } from "react-router"; 3 3 4 - import { Button } from "./button"; 4 + import { Button } from "~/components/button"; 5 5 6 6 export function LogoutButton() { 7 7 const { t } = useTranslation(); ··· 22 22 <Form action="/logout" method="post" onSubmit={handleSubmit}> 23 23 <Button 24 24 type="submit" 25 - className="btn btn-ghost btn-sm w-64 text-error" 25 + className="btn btn-error" 26 26 data-umami-event="click-logout-button" 27 27 data-testid="logout-button" 28 28 >
+33
app/features/settings/delete-button.tsx
··· 1 + import { useTranslation } from "react-i18next"; 2 + import { Form, useSubmit } from "react-router"; 3 + 4 + import { Button } from "~/components/button"; 5 + 6 + export function DeleteBoardButton() { 7 + const { t } = useTranslation(); 8 + const submit = useSubmit(); 9 + 10 + const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { 11 + event.preventDefault(); 12 + const ok = confirm(t("delete-board-button.confirm-message")); 13 + if (ok) { 14 + void submit(event.currentTarget); 15 + } 16 + void umami.track("handle-", { 17 + action: ok ? "confirm" : "cancel", 18 + }); 19 + }; 20 + 21 + return ( 22 + <Form action="/delete" method="post" onSubmit={handleSubmit}> 23 + <Button 24 + type="submit" 25 + className="btn btn-error" 26 + data-umami-event="click-delete-board-button" 27 + data-testid="delete-board-button" 28 + > 29 + {t("delete-board-button.text")} 30 + </Button> 31 + </Form> 32 + ); 33 + }
+15 -2
app/i18n/locales/en.json
··· 87 87 "text": "Logout", 88 88 "confirm-message": "Are you sure you want to log out?" 89 89 }, 90 - "about": { 91 - "back-to-top": "Back to top" 90 + "back-button": { 91 + "text": "Back to top" 92 + }, 93 + "delete": { 94 + "invalid-session-error-message": "The login information was invalid" 95 + }, 96 + "delete-board-button": { 97 + "text": "Delete this board", 98 + "confirm-message": "Are you sure you want to delete your board?" 99 + }, 100 + "settings": { 101 + "title": "Settings", 102 + "header-account": "Account", 103 + "header-board": "Board", 104 + "delete-board-warning": "Delete your board saved in PDS. This action cannot be undone." 92 105 } 93 106 }
+15 -2
app/i18n/locales/ja.json
··· 88 88 "text": "ログアウト", 89 89 "confirm-message": "ログアウトしますか?" 90 90 }, 91 - "about": { 92 - "back-to-top": "トップに戻る" 91 + "back-button": { 92 + "text": "トップに戻る" 93 + }, 94 + "delete": { 95 + "invalid-session-error-message": "ログイン情報が無効でした" 96 + }, 97 + "delete-board-button": { 98 + "text": "ボードを削除する", 99 + "confirm-message": "本当にボードを削除しますか?" 100 + }, 101 + "settings": { 102 + "title": "設定", 103 + "header-account": "アカウント", 104 + "header-board": "ボード", 105 + "delete-board-warning": "PDSに保存されているボードを削除します。削除後は元に戻せません。" 93 106 } 94 107 }
+7 -9
app/routes/_index.tsx
··· 7 7 import { Link } from "react-router"; 8 8 9 9 import { Main, RootLayout } from "~/components/layout"; 10 - import { LogoutButton } from "~/components/logout-button"; 11 10 import { i18nServer } from "~/i18n/i18n"; 12 11 import { getSessionUserDid } from "~/server/oauth/session"; 13 12 import { cn } from "~/utils/cn"; ··· 36 35 const { isLogin } = loaderData; 37 36 const { t, i18n } = useTranslation(); 38 37 return ( 39 - <RootLayout> 38 + <RootLayout isLogin={isLogin}> 40 39 <Main className="utils--center"> 41 40 <div className="text-center"> 42 41 <h2 ··· 49 48 </h2> 50 49 <div className="mt-12 flex flex-col items-center gap-2"> 51 50 {isLogin ? ( 52 - <Link to="/edit" className="btn btn-primary w-64"> 51 + <Link 52 + to="/edit" 53 + className="btn btn-primary w-64" 54 + data-testid="index__edit-link" 55 + > 53 56 <PencilSquareIcon className="size-6" /> 54 57 {t("_index.edit-link")} 55 58 </Link> ··· 63 66 <ArrowRightIcon className="size-6" /> 64 67 {t("_index.sample-link")} 65 68 </Link> 66 - {isLogin && <LogoutButton />} 67 - <div 68 - className={cn("flex flex-col gap-2", { 69 - "mt-8": !isLogin, 70 - })} 71 - > 69 + <div className={cn("flex flex-col gap-2 mt-8")}> 72 70 <p> 73 71 <Link to="/about" className="underline"> 74 72 {t("_index.notes-link")}
+2 -8
app/routes/about.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 - import { ChevronLeftIcon } from "@heroicons/react/24/outline"; 3 2 import { LRUCache } from "lru-cache"; 4 3 import markdownit from "markdown-it"; 5 4 import linkAttributes from "markdown-it-link-attributes"; 6 - import { useTranslation } from "react-i18next"; 7 - import { Link } from "react-router"; 8 5 import { z } from "zod"; 9 6 7 + import { BackButton } from "~/components/back-button"; 10 8 import { Card } from "~/components/card"; 11 9 import { Footer, Main } from "~/components/layout"; 12 10 import { i18nServer } from "~/i18n/i18n"; ··· 92 90 }; 93 91 94 92 export default function AboutPage({ loaderData }: Route.ComponentProps) { 95 - const { t } = useTranslation(); 96 93 const { about } = loaderData; 97 94 return ( 98 95 <> 99 96 <Main> 100 97 <Card className="my-4"> 101 98 <div className="card-body"> 102 - <Link to="/" className="flex h-8 items-center"> 103 - <ChevronLeftIcon className="size-6" /> 104 - {t("about.back-to-top")} 105 - </Link> 99 + <BackButton /> 106 100 <article className="prose mt-4"> 107 101 <h1 className="text-3xl">{about.title}</h1> 108 102 <div dangerouslySetInnerHTML={{ __html: about.content }} />
+28
app/routes/delete.tsx
··· 1 + import { redirect } from "react-router"; 2 + 3 + import { i18nServer } from "~/i18n/i18n"; 4 + import { getSessionAgent, getSessionUserDid } from "~/server/oauth/session"; 5 + import { boardService } from "~/server/service/boardService"; 6 + import { createLogger } from "~/utils/logger"; 7 + 8 + import type { Route } from "./+types/delete"; 9 + 10 + const logger = createLogger("delete"); 11 + 12 + export async function action({ request }: Route.ActionArgs) { 13 + const t = await i18nServer.getFixedT(request); 14 + const [userDid, agent] = await Promise.all([ 15 + getSessionUserDid(request), 16 + getSessionAgent(request), 17 + ]); 18 + if (!userDid || !agent) { 19 + return { error: t("delete.invalid-session-error-message") }; 20 + } 21 + try { 22 + await agent.deleteBoard(); 23 + } catch (error) { 24 + logger.error("PDSからボードの削除に失敗しました", { error }); 25 + } 26 + await boardService.deleteBoard(userDid); 27 + return redirect(`/`); 28 + }
+9
app/routes/login.tsx
··· 6 6 import { RouteToaster } from "~/features/toast/route"; 7 7 import { i18nServer } from "~/i18n/i18n"; 8 8 import { createOAuthClient } from "~/server/oauth/client"; 9 + import { getSessionUserDid } from "~/server/oauth/session"; 9 10 import { createLogger } from "~/utils/logger"; 10 11 11 12 import type { Route } from "./+types/login"; ··· 33 34 return { error: t("login.default-error-message") }; 34 35 } 35 36 } 37 + 38 + export const loader = async ({ request }: Route.LoaderArgs) => { 39 + const userDid = await getSessionUserDid(request); 40 + if (userDid) { 41 + return redirect("/"); 42 + } 43 + return null; 44 + }; 36 45 37 46 export default function LoginPage() { 38 47 return (
+48
app/routes/settings.tsx
··· 1 + import { useTranslation } from "react-i18next"; 2 + import { redirect } from "react-router"; 3 + 4 + import { BackButton } from "~/components/back-button"; 5 + import { Card } from "~/components/card"; 6 + import { Footer, Main } from "~/components/layout"; 7 + import { DeleteBoardButton } from "~/features/settings/delete-button"; 8 + import { LogoutButton } from "~/features/settings/logout-button"; 9 + import { getSessionUserDid } from "~/server/oauth/session"; 10 + 11 + import type { Route } from "./+types/settings"; 12 + 13 + export const loader = async ({ request }: Route.LoaderArgs) => { 14 + const userDid = await getSessionUserDid(request); 15 + if (!userDid) { 16 + throw redirect("/login"); 17 + } 18 + return null; 19 + }; 20 + 21 + export default function SettingsPage() { 22 + const { t } = useTranslation(); 23 + 24 + return ( 25 + <> 26 + <Main className="py-4"> 27 + <Card> 28 + <div className="card-body gap-4"> 29 + <BackButton /> 30 + <h1 className="card-title justify-center">{t("settings.title")}</h1> 31 + <h2 className="border-b-2 border-gray-200 pb-1 font-bold"> 32 + {t("settings.header-account")} 33 + </h2> 34 + <LogoutButton /> 35 + <h2 className="border-b-2 border-gray-200 pb-1 font-bold"> 36 + {t("settings.header-board")} 37 + </h2> 38 + <DeleteBoardButton /> 39 + <p className="text-gray-400"> 40 + {t("settings.delete-board-warning")} 41 + </p> 42 + </div> 43 + </Card> 44 + </Main> 45 + <Footer /> 46 + </> 47 + ); 48 + }
+5
app/server/jetstream/subscription.ts
··· 57 57 jetstream.onCreate("blue.linkat.board", handleCreateOrUpdate); 58 58 59 59 jetstream.onUpdate("blue.linkat.board", handleCreateOrUpdate); 60 + 61 + jetstream.onDelete("blue.linkat.board", async (event) => { 62 + await boardService.deleteBoard(event.did); 63 + logger.info("ボードを削除しました", { userDid: event.did }); 64 + });
+8
app/server/service/boardService/board.ts
··· 95 95 board: boardInPDS, 96 96 }); 97 97 }; 98 + 99 + export const deleteBoard = async (userDid: string) => { 100 + await prisma.board.deleteMany({ 101 + where: { 102 + userDid, 103 + }, 104 + }); 105 + };
+13 -5
e2e/edit.spec.ts
··· 70 70 await expect(card1Edited).toBeVisible(); 71 71 await expect(card2).toBeVisible(); 72 72 73 - // カードを編集 73 + // カードを削除(後続の削除処理の確認のために1つ残す) 74 74 await page.getByTestId("profile-card__edit").click(); 75 75 page.on("dialog", (dialog) => dialog.accept()); 76 76 await card1Edited.getByTestId("sortable-card__edit").click(); 77 77 await page.getByTestId("card-form__delete").click(); 78 - await card2.getByTestId("sortable-card__edit").click(); 79 - await page.getByTestId("card-form__delete").click(); 80 78 81 - // 保存して閲覧ページで順番を確認 79 + // 保存して閲覧ページで削除を確認 82 80 await page.getByTestId("board-viewer__submit").click(); 83 81 await page.waitForURL((url) => url.pathname !== "/edit"); 84 82 await page.getByTestId("show-modal__close").click(); 85 83 await expect(card1Edited).not.toBeVisible(); 86 - await expect(card2).not.toBeVisible(); 84 + 85 + // ボードを削除 86 + await page.goto("/settings"); 87 + await page.getByTestId("delete-board-button").click(); 88 + 89 + // 編集ページで削除されていることを確認 90 + await page.getByTestId("index__edit-link").click(); 91 + await page.waitForURL("/edit"); 92 + await expect( 93 + page.locator('[data-testid="sortable-card"]'), 94 + ).not.toBeVisible(); 87 95 }); 88 96 });
+2 -2
e2e/logout.spec.ts
··· 4 4 test("ログアウトボタンを押すとダイアログが出てログアウトできる", async ({ 5 5 page, 6 6 }) => { 7 - await page.goto("/"); 7 + await page.goto("/settings"); 8 8 page.on("dialog", (dialog) => dialog.accept()); 9 9 const logoutButton = page.getByTestId("logout-button"); 10 10 await logoutButton.click(); 11 11 await expect(logoutButton).not.toBeVisible(); 12 12 }); 13 13 test("ログアウトボタンを押したあとキャンセルできる", async ({ page }) => { 14 - await page.goto("/"); 14 + await page.goto("/settings"); 15 15 page.on("dialog", (dialog) => dialog.dismiss()); 16 16 const logoutButton = page.getByTestId("logout-button"); 17 17 await logoutButton.click();