summaryrefslogtreecommitdiff
path: root/app/routes/favorites.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes/favorites.tsx')
-rw-r--r--app/routes/favorites.tsx161
1 files changed, 161 insertions, 0 deletions
diff --git a/app/routes/favorites.tsx b/app/routes/favorites.tsx
new file mode 100644
index 0000000..e2f620d
--- /dev/null
+++ b/app/routes/favorites.tsx
@@ -0,0 +1,161 @@
+import { Form, Link, useLoaderData, useNavigation } from "react-router";
+import type { Route } from "./+types/favorites";
+import {
+ addFavorite,
+ deleteFavorite,
+ deletePasskey,
+ listFavorites,
+ listPasskeys,
+ requireLogin,
+} from "~/lib/auth.server";
+
+export async function loader({ request }: Route.LoaderArgs) {
+ requireLogin(request);
+ return { favorites: listFavorites(), passkeys: listPasskeys() };
+}
+
+export async function action({ request }: Route.ActionArgs) {
+ requireLogin(request);
+ const form = await request.formData();
+ switch (form.get("intent")) {
+ case "add": {
+ const added = addFavorite(String(form.get("term") ?? ""));
+ return { error: added ? null : "空欄か、登録済みのワードです" };
+ }
+ case "delete":
+ deleteFavorite(Number(form.get("id")));
+ return { error: null };
+ case "delete-passkey":
+ return {
+ error: deletePasskey(String(form.get("id"))) ? null : "最後のパスキーは削除できません",
+ };
+ }
+ return { error: null };
+}
+
+export default function Favorites({ actionData }: Route.ComponentProps) {
+ const { favorites, passkeys } = useLoaderData<typeof loader>();
+ const navigation = useNavigation();
+ const adding = navigation.formData?.get("intent") === "add";
+
+ return (
+ <div className="min-h-screen bg-gray-950 text-gray-100">
+ <header className="border-b border-gray-800 px-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
+ <Link to="/" className="text-xl font-bold tracking-tight text-white">
+ 🎸 ライブに行くしかない
+ </Link>
+ <nav className="flex gap-4 sm:gap-6 text-sm text-gray-400">
+ <Link to="/events" className="hover:text-white transition-colors">イベント</Link>
+ <Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link>
+ <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link>
+ <Link to="/favorites" className="text-white font-medium">★ お気に入り</Link>
+ </nav>
+ </header>
+
+ <main className="max-w-2xl mx-auto px-4 py-8 flex flex-col gap-10">
+ <section>
+ <div className="mb-4 flex items-center justify-between">
+ <h1 className="text-2xl font-bold">お気に入り</h1>
+ <Link
+ to="/events?fav=1"
+ className="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium hover:bg-indigo-500 transition-colors"
+ >
+ 該当イベントを見る →
+ </Link>
+ </div>
+ <p className="mb-4 text-xs text-gray-400">
+ バンド名などを登録すると、タイトルか出演者に含まれるイベントに ★ が付きます(全角/半角・大文字/小文字は区別しません)。
+ </p>
+
+ <Form method="post" className="flex gap-2" key={favorites.length}>
+ <input type="hidden" name="intent" value="add" />
+ <input
+ name="term"
+ required
+ maxLength={100}
+ placeholder="バンド名、イベント名..."
+ className="flex-1 rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
+ />
+ <button
+ type="submit"
+ disabled={adding}
+ className="rounded-md bg-gray-700 px-4 py-1.5 text-sm font-medium hover:bg-gray-600 disabled:opacity-50 transition-colors"
+ >
+ 追加
+ </button>
+ </Form>
+ {actionData?.error && <p className="mt-2 text-sm text-red-400">{actionData.error}</p>}
+
+ {favorites.length === 0 ? (
+ <p className="mt-6 text-sm text-gray-500">まだ登録されていません</p>
+ ) : (
+ <ul className="mt-4 flex flex-col gap-1.5">
+ {favorites.map((f) => (
+ <li
+ key={f.id}
+ className="flex items-center gap-3 rounded-lg px-4 py-2.5 bg-gray-800/40 border border-gray-700/30 text-sm"
+ >
+ <Link
+ to={`/events?keyword=${encodeURIComponent(f.term)}`}
+ className="flex-1 min-w-0 truncate hover:text-indigo-300 transition-colors"
+ >
+ {f.term}
+ </Link>
+ <span className={`text-xs tabular-nums ${f.upcoming_count > 0 ? "text-indigo-300" : "text-gray-600"}`}>
+ {f.upcoming_count} 件
+ </span>
+ <Form method="post">
+ <input type="hidden" name="intent" value="delete" />
+ <input type="hidden" name="id" value={f.id} />
+ <button type="submit" className="text-gray-500 hover:text-red-400 transition-colors" title="削除">
+ ✕
+ </button>
+ </Form>
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+
+ <section className="border-t border-gray-800 pt-8">
+ <h2 className="mb-3 text-lg font-semibold">パスキー</h2>
+ <ul className="flex flex-col gap-1.5">
+ {passkeys.map((p) => (
+ <li key={p.id} className="flex items-center gap-3 rounded-lg px-4 py-2.5 bg-gray-800/40 border border-gray-700/30 text-sm">
+ <span className="flex-1 min-w-0 truncate">{p.label ?? "(名前なし)"}</span>
+ <span className="text-xs text-gray-500">
+ {p.last_used_at ? `最終使用 ${p.last_used_at.slice(0, 10)}` : `登録 ${p.created_at.slice(0, 10)}`}
+ </span>
+ {passkeys.length > 1 && (
+ <Form method="post">
+ <input type="hidden" name="intent" value="delete-passkey" />
+ <input type="hidden" name="id" value={p.id} />
+ <button type="submit" className="text-gray-500 hover:text-red-400 transition-colors" title="削除">
+ ✕
+ </button>
+ </Form>
+ )}
+ </li>
+ ))}
+ </ul>
+ <div className="mt-4 flex gap-3">
+ <Link
+ to="/login"
+ className="rounded-md border border-gray-700 px-4 py-1.5 text-sm text-gray-300 hover:text-white transition-colors"
+ >
+ パスキーを追加
+ </Link>
+ <Form method="post" action="/api/auth/logout">
+ <button
+ type="submit"
+ className="rounded-md border border-gray-700 px-4 py-1.5 text-sm text-gray-400 hover:text-white transition-colors"
+ >
+ ログアウト
+ </button>
+ </Form>
+ </div>
+ </section>
+ </main>
+ </div>
+ );
+}