+69
-50
app/components/layout.tsx
+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
</>
+15
-2
app/i18n/locales/en.json
+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
+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
-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
+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
+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
+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
+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
+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
+8
app/server/service/boardService/board.ts
+13
-5
e2e/edit.spec.ts
+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
+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();