+69
-50
app/components/layout.tsx
+69
-50
app/components/layout.tsx
···
1
-
import { LanguageIcon } from "@heroicons/react/24/outline";
2
import { type ReactNode, useRef } from "react";
3
import GitHubButton from "react-github-btn";
4
import { useTranslation } from "react-i18next";
···
9
import { BlueskyIcon } from "./icons/bluesky";
10
import { GitHubIcon } from "./icons/github";
11
12
-
type Props = {
13
-
className?: string;
14
-
children?: ReactNode;
15
};
16
17
-
export function Header() {
18
const detailsRef = useRef<HTMLDetailsElement>(null);
19
const handleClick = () => {
20
if (detailsRef.current) {
···
28
Linkat
29
</Link>
30
</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>
73
</header>
74
);
75
}
76
77
-
export function Main({ className, children }: Props) {
78
return (
79
<main
80
className={cn(
···
140
);
141
}
142
143
-
export function RootLayout({ children }: { children: ReactNode }) {
144
return (
145
<>
146
-
<Header />
147
{children}
148
<Footer />
149
</>
···
1
+
import { Cog8ToothIcon, LanguageIcon } from "@heroicons/react/24/outline";
2
import { type ReactNode, useRef } from "react";
3
import GitHubButton from "react-github-btn";
4
import { useTranslation } from "react-i18next";
···
9
import { BlueskyIcon } from "./icons/bluesky";
10
import { GitHubIcon } from "./icons/github";
11
12
+
type HeaderProps = {
13
+
isLogin?: boolean;
14
};
15
16
+
export function Header({ isLogin }: HeaderProps) {
17
const detailsRef = useRef<HTMLDetailsElement>(null);
18
const handleClick = () => {
19
if (detailsRef.current) {
···
27
Linkat
28
</Link>
29
</div>
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>
82
</header>
83
);
84
}
85
86
+
type MainProps = {
87
+
className?: string;
88
+
children?: ReactNode;
89
+
};
90
+
91
+
export function Main({ className, children }: MainProps) {
92
return (
93
<main
94
className={cn(
···
154
);
155
}
156
157
+
type RootLayoutProps = {
158
+
isLogin?: boolean;
159
+
children: ReactNode;
160
+
};
161
+
162
+
export function RootLayout({ isLogin, children }: RootLayoutProps) {
163
return (
164
<>
165
+
<Header isLogin={isLogin} />
166
{children}
167
<Footer />
168
</>
+15
-2
app/i18n/locales/en.json
+15
-2
app/i18n/locales/en.json
···
87
"text": "Logout",
88
"confirm-message": "Are you sure you want to log out?"
89
},
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."
105
}
106
}
+15
-2
app/i18n/locales/ja.json
+15
-2
app/i18n/locales/ja.json
···
88
"text": "ログアウト",
89
"confirm-message": "ログアウトしますか?"
90
},
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に保存されているボードを削除します。削除後は元に戻せません。"
106
}
107
}
+7
-9
app/routes/_index.tsx
+7
-9
app/routes/_index.tsx
···
7
import { Link } from "react-router";
8
9
import { Main, RootLayout } from "~/components/layout";
10
-
import { LogoutButton } from "~/components/logout-button";
11
import { i18nServer } from "~/i18n/i18n";
12
import { getSessionUserDid } from "~/server/oauth/session";
13
import { cn } from "~/utils/cn";
···
36
const { isLogin } = loaderData;
37
const { t, i18n } = useTranslation();
38
return (
39
-
<RootLayout>
40
<Main className="utils--center">
41
<div className="text-center">
42
<h2
···
49
</h2>
50
<div className="mt-12 flex flex-col items-center gap-2">
51
{isLogin ? (
52
-
<Link to="/edit" className="btn btn-primary w-64">
53
<PencilSquareIcon className="size-6" />
54
{t("_index.edit-link")}
55
</Link>
···
63
<ArrowRightIcon className="size-6" />
64
{t("_index.sample-link")}
65
</Link>
66
-
{isLogin && <LogoutButton />}
67
-
<div
68
-
className={cn("flex flex-col gap-2", {
69
-
"mt-8": !isLogin,
70
-
})}
71
-
>
72
<p>
73
<Link to="/about" className="underline">
74
{t("_index.notes-link")}
···
7
import { Link } from "react-router";
8
9
import { Main, RootLayout } from "~/components/layout";
10
import { i18nServer } from "~/i18n/i18n";
11
import { getSessionUserDid } from "~/server/oauth/session";
12
import { cn } from "~/utils/cn";
···
35
const { isLogin } = loaderData;
36
const { t, i18n } = useTranslation();
37
return (
38
+
<RootLayout isLogin={isLogin}>
39
<Main className="utils--center">
40
<div className="text-center">
41
<h2
···
48
</h2>
49
<div className="mt-12 flex flex-col items-center gap-2">
50
{isLogin ? (
51
+
<Link
52
+
to="/edit"
53
+
className="btn btn-primary w-64"
54
+
data-testid="index__edit-link"
55
+
>
56
<PencilSquareIcon className="size-6" />
57
{t("_index.edit-link")}
58
</Link>
···
66
<ArrowRightIcon className="size-6" />
67
{t("_index.sample-link")}
68
</Link>
69
+
<div className={cn("flex flex-col gap-2 mt-8")}>
70
<p>
71
<Link to="/about" className="underline">
72
{t("_index.notes-link")}
+2
-8
app/routes/about.tsx
+2
-8
app/routes/about.tsx
···
1
import { AtUri } from "@atproto/api";
2
-
import { ChevronLeftIcon } from "@heroicons/react/24/outline";
3
import { LRUCache } from "lru-cache";
4
import markdownit from "markdown-it";
5
import linkAttributes from "markdown-it-link-attributes";
6
-
import { useTranslation } from "react-i18next";
7
-
import { Link } from "react-router";
8
import { z } from "zod";
9
10
import { Card } from "~/components/card";
11
import { Footer, Main } from "~/components/layout";
12
import { i18nServer } from "~/i18n/i18n";
···
92
};
93
94
export default function AboutPage({ loaderData }: Route.ComponentProps) {
95
-
const { t } = useTranslation();
96
const { about } = loaderData;
97
return (
98
<>
99
<Main>
100
<Card className="my-4">
101
<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>
106
<article className="prose mt-4">
107
<h1 className="text-3xl">{about.title}</h1>
108
<div dangerouslySetInnerHTML={{ __html: about.content }} />
···
1
import { AtUri } from "@atproto/api";
2
import { LRUCache } from "lru-cache";
3
import markdownit from "markdown-it";
4
import linkAttributes from "markdown-it-link-attributes";
5
import { z } from "zod";
6
7
+
import { BackButton } from "~/components/back-button";
8
import { Card } from "~/components/card";
9
import { Footer, Main } from "~/components/layout";
10
import { i18nServer } from "~/i18n/i18n";
···
90
};
91
92
export default function AboutPage({ loaderData }: Route.ComponentProps) {
93
const { about } = loaderData;
94
return (
95
<>
96
<Main>
97
<Card className="my-4">
98
<div className="card-body">
99
+
<BackButton />
100
<article className="prose mt-4">
101
<h1 className="text-3xl">{about.title}</h1>
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
import { RouteToaster } from "~/features/toast/route";
7
import { i18nServer } from "~/i18n/i18n";
8
import { createOAuthClient } from "~/server/oauth/client";
9
import { createLogger } from "~/utils/logger";
10
11
import type { Route } from "./+types/login";
···
33
return { error: t("login.default-error-message") };
34
}
35
}
36
37
export default function LoginPage() {
38
return (
···
6
import { RouteToaster } from "~/features/toast/route";
7
import { i18nServer } from "~/i18n/i18n";
8
import { createOAuthClient } from "~/server/oauth/client";
9
+
import { getSessionUserDid } from "~/server/oauth/session";
10
import { createLogger } from "~/utils/logger";
11
12
import type { Route } from "./+types/login";
···
34
return { error: t("login.default-error-message") };
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
+
};
45
46
export default function LoginPage() {
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
jetstream.onCreate("blue.linkat.board", handleCreateOrUpdate);
58
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
await expect(card1Edited).toBeVisible();
71
await expect(card2).toBeVisible();
72
73
-
// カードを編集
74
await page.getByTestId("profile-card__edit").click();
75
page.on("dialog", (dialog) => dialog.accept());
76
await card1Edited.getByTestId("sortable-card__edit").click();
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
81
-
// 保存して閲覧ページで順番を確認
82
await page.getByTestId("board-viewer__submit").click();
83
await page.waitForURL((url) => url.pathname !== "/edit");
84
await page.getByTestId("show-modal__close").click();
85
await expect(card1Edited).not.toBeVisible();
86
-
await expect(card2).not.toBeVisible();
87
});
88
});
···
70
await expect(card1Edited).toBeVisible();
71
await expect(card2).toBeVisible();
72
73
+
// カードを削除(後続の削除処理の確認のために1つ残す)
74
await page.getByTestId("profile-card__edit").click();
75
page.on("dialog", (dialog) => dialog.accept());
76
await card1Edited.getByTestId("sortable-card__edit").click();
77
await page.getByTestId("card-form__delete").click();
78
79
+
// 保存して閲覧ページで削除を確認
80
await page.getByTestId("board-viewer__submit").click();
81
await page.waitForURL((url) => url.pathname !== "/edit");
82
await page.getByTestId("show-modal__close").click();
83
await expect(card1Edited).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();
95
});
96
});
+2
-2
e2e/logout.spec.ts
+2
-2
e2e/logout.spec.ts
···
4
test("ログアウトボタンを押すとダイアログが出てログアウトできる", async ({
5
page,
6
}) => {
7
-
await page.goto("/");
8
page.on("dialog", (dialog) => dialog.accept());
9
const logoutButton = page.getByTestId("logout-button");
10
await logoutButton.click();
11
await expect(logoutButton).not.toBeVisible();
12
});
13
test("ログアウトボタンを押したあとキャンセルできる", async ({ page }) => {
14
-
await page.goto("/");
15
page.on("dialog", (dialog) => dialog.dismiss());
16
const logoutButton = page.getByTestId("logout-button");
17
await logoutButton.click();
···
4
test("ログアウトボタンを押すとダイアログが出てログアウトできる", async ({
5
page,
6
}) => {
7
+
await page.goto("/settings");
8
page.on("dialog", (dialog) => dialog.accept());
9
const logoutButton = page.getByTestId("logout-button");
10
await logoutButton.click();
11
await expect(logoutButton).not.toBeVisible();
12
});
13
test("ログアウトボタンを押したあとキャンセルできる", async ({ page }) => {
14
+
await page.goto("/settings");
15
page.on("dialog", (dialog) => dialog.dismiss());
16
const logoutButton = page.getByTestId("logout-button");
17
await logoutButton.click();