diff options
Diffstat (limited to 'app/routes/favorites.tsx')
| -rw-r--r-- | app/routes/favorites.tsx | 161 |
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> + ); +} |
