summaryrefslogtreecommitdiff
path: root/app/routes
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-09-26 00:19:53 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-09-26 00:19:53 +0900
commit874749df46cc291ca61f2f98092ccabfdeffbf16 (patch)
treee4cb53b5bfa23974e7935eb2616e0e005954f782 /app/routes
parent999b7a3133fccc3f120ac5ae24c02bb38a4ecce9 (diff)
Add passkey login and favorite search termsHEADmaster
Single-user auth: any registered passkey logs in as the owner. First registration needs a one-time token that the app writes to data/registration-token when no passkey exists yet; once logged in, more passkeys can be added without a token. Logged in, /favorites manages a list of terms (e.g. band names). Events whose title or artist contains a term (NFKC + case-insensitive) get a ★, and the event list gains an "お気に入りのみ" filter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/routes')
-rw-r--r--app/routes/api.auth.$step.ts63
-rw-r--r--app/routes/events.$id.tsx1
-rw-r--r--app/routes/events._index.tsx15
-rw-r--r--app/routes/events.by-date.tsx8
-rw-r--r--app/routes/favorites.tsx161
-rw-r--r--app/routes/login.tsx117
-rw-r--r--app/routes/venues.tsx1
7 files changed, 361 insertions, 5 deletions
diff --git a/app/routes/api.auth.$step.ts b/app/routes/api.auth.$step.ts
new file mode 100644
index 0000000..64940f2
--- /dev/null
+++ b/app/routes/api.auth.$step.ts
@@ -0,0 +1,63 @@
+/**
+ * Resource route: POST /api/auth/:step
+ *
+ * register-options / register-verify — パスキー登録(ログイン済み or 登録トークン必須)
+ * login-options / login-verify — パスキーでログイン
+ * logout — セッション破棄して /events へ
+ */
+import { redirect } from "react-router";
+import type { Route } from "./+types/api.auth.$step";
+import {
+ authenticationOptions,
+ canRegister,
+ isLoggedIn,
+ logout,
+ registrationOptions,
+ verifyAuthentication,
+ verifyRegistration,
+} from "~/lib/auth.server";
+
+export async function action({ request, params }: Route.ActionArgs) {
+ switch (params.step) {
+ case "register-options": {
+ const { token } = await request.json();
+ if (!canRegister(request, token ?? null)) return forbidden();
+ const { options, setCookie } = await registrationOptions(request);
+ return Response.json(options, { headers: { "Set-Cookie": setCookie } });
+ }
+ case "register-verify": {
+ const { token, response, label } = await request.json();
+ const loggedIn = isLoggedIn(request);
+ if (!canRegister(request, token ?? null)) return forbidden();
+ return withSession(() =>
+ verifyRegistration(request, response, label ?? null, !loggedIn)
+ );
+ }
+ case "login-options": {
+ const { options, setCookie } = await authenticationOptions(request);
+ return Response.json(options, { headers: { "Set-Cookie": setCookie } });
+ }
+ case "login-verify": {
+ const { response } = await request.json();
+ return withSession(() => verifyAuthentication(request, response));
+ }
+ case "logout":
+ return redirect("/events", { headers: { "Set-Cookie": logout(request) } });
+ default:
+ throw new Response("Not Found", { status: 404 });
+ }
+}
+
+async function withSession(verify: () => Promise<string>): Promise<Response> {
+ try {
+ const setCookie = await verify();
+ return Response.json({ ok: true }, { headers: { "Set-Cookie": setCookie } });
+ } catch (e) {
+ console.warn("[auth]", e);
+ return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
+ }
+}
+
+function forbidden(): Response {
+ return Response.json({ ok: false, error: "登録トークンが無効です" }, { status: 403 });
+}
diff --git a/app/routes/events.$id.tsx b/app/routes/events.$id.tsx
index 87836ed..6837430 100644
--- a/app/routes/events.$id.tsx
+++ b/app/routes/events.$id.tsx
@@ -73,6 +73,7 @@ export default function EventDetail() {
<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="hover:text-white transition-colors">★ お気に入り</Link>
</nav>
</header>
diff --git a/app/routes/events._index.tsx b/app/routes/events._index.tsx
index 33568df..563b0c0 100644
--- a/app/routes/events._index.tsx
+++ b/app/routes/events._index.tsx
@@ -6,6 +6,7 @@ import EventListRow from "~/components/EventListRow";
import FilterBar from "~/components/FilterBar";
import ScrapeButton from "~/components/ScrapeButton";
import { useVenueFilter } from "~/lib/venue-filter";
+import { isLoggedIn } from "~/lib/auth.server";
function defaultWindow() {
const today = new Date();
@@ -27,19 +28,24 @@ export async function loader({ request }: Route.LoaderArgs) {
const keyword = url.searchParams.get("keyword") ?? undefined;
const capacity_range = (url.searchParams.get("capacity_range") ?? undefined) as CapacityRange | undefined;
const area = url.searchParams.get("area") ?? undefined;
+ const loggedIn = isLoggedIn(request);
+ const favorites_only = loggedIn && url.searchParams.get("fav") === "1";
const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10));
const limit = 30;
const offset = (page - 1) * limit;
- const events = queryEvents({ date_from, date_to, venue_id, keyword, capacity_range, area, limit, offset });
+ const events = queryEvents({
+ date_from, date_to, venue_id, keyword, capacity_range, area,
+ with_favorites: loggedIn, favorites_only, limit, offset,
+ });
const venues = getVenues();
const areas = [...new Set(venues.filter((v) => v.area).map((v) => v.area as string))].sort();
- return { events, venues, areas, page, hasMore: events.length === limit, date_from, date_to };
+ return { events, venues, areas, page, hasMore: events.length === limit, date_from, date_to, loggedIn };
}
export default function EventsIndex() {
- const { events, venues, areas, page, hasMore, date_from, date_to } = useLoaderData<typeof loader>();
+ const { events, venues, areas, page, hasMore, date_from, date_to, loggedIn } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const view = (searchParams.get("view") ?? "card") as "card" | "list";
const { hiddenIds } = useVenueFilter();
@@ -67,6 +73,7 @@ export default function EventsIndex() {
<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="hover:text-white transition-colors">★ お気に入り</Link>
</nav>
</header>
@@ -94,7 +101,7 @@ export default function EventsIndex() {
</div>
</div>
- <FilterBar venues={venues} areas={areas} defaultDateFrom={date_from} defaultDateTo={date_to} />
+ <FilterBar venues={venues} areas={areas} defaultDateFrom={date_from} defaultDateTo={date_to} showFavorites={loggedIn} />
{hiddenEventCount > 0 && (
<p className="mt-3 text-xs text-gray-500">
diff --git a/app/routes/events.by-date.tsx b/app/routes/events.by-date.tsx
index ee4a873..5c7876f 100644
--- a/app/routes/events.by-date.tsx
+++ b/app/routes/events.by-date.tsx
@@ -2,6 +2,7 @@ import { useLoaderData, useSearchParams, Link, Form } from "react-router";
import { queryEvents, getVenues, type CapacityRange } from "~/lib/db.server";
import type { Event } from "~/lib/db.server";
import { useVenueFilter } from "~/lib/venue-filter";
+import { isLoggedIn } from "~/lib/auth.server";
function todayJst(): string {
// JST = UTC+9
@@ -38,7 +39,10 @@ export async function loader({ request }: { request: Request }) {
const capacity_range = (url.searchParams.get("capacity_range") ?? undefined) as CapacityRange | undefined;
const area = url.searchParams.get("area") ?? undefined;
- const events = queryEvents({ date_from: date, date_to: date, keyword, venue_id, capacity_range, area, limit: 500 });
+ const events = queryEvents({
+ date_from: date, date_to: date, keyword, venue_id, capacity_range, area,
+ with_favorites: isLoggedIn(request), limit: 500,
+ });
const venues = getVenues();
const areas = [...new Set(venues.filter((v) => v.area).map((v) => v.area as string))].sort();
@@ -89,6 +93,7 @@ export default function EventsByDate() {
<Link to="/events" className="hover:text-white transition-colors">イベント</Link>
<Link to="/events/by-date" className="text-white">日付別</Link>
<Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link>
+ <Link to="/favorites" className="hover:text-white transition-colors">★ お気に入り</Link>
</nav>
</header>
@@ -265,6 +270,7 @@ function VenueEventRow({ event }: { event: Event }) {
{/* Title + artist */}
<span className="flex-1 min-w-0">
<span className="block truncate text-sm font-medium text-gray-100 group-hover:text-indigo-300 transition-colors">
+ {event.is_favorite ? <span className="text-amber-400" title="お気に入り">★ </span> : null}
{event.title}
</span>
{event.artist && (
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>
+ );
+}
diff --git a/app/routes/login.tsx b/app/routes/login.tsx
new file mode 100644
index 0000000..69353ea
--- /dev/null
+++ b/app/routes/login.tsx
@@ -0,0 +1,117 @@
+import { useState } from "react";
+import { Link, useLoaderData, useNavigate } from "react-router";
+import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
+import type { Route } from "./+types/login";
+import { ensureSetupToken, isLoggedIn } from "~/lib/auth.server";
+
+export async function loader({ request }: Route.LoaderArgs) {
+ ensureSetupToken();
+ const url = new URL(request.url);
+ const next = url.searchParams.get("next");
+ return {
+ loggedIn: isLoggedIn(request),
+ token: url.searchParams.get("token"),
+ // Only allow same-site relative redirects
+ next: next && /^\/(?![/\\])/.test(next) ? next : "/favorites",
+ };
+}
+
+async function postJson(path: string, body: unknown) {
+ const res = await fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json();
+ if (!res.ok || data.ok === false) throw new Error(data.error ?? `HTTP ${res.status}`);
+ return data;
+}
+
+export default function Login() {
+ const { loggedIn, token, next } = useLoaderData<typeof loader>();
+ const navigate = useNavigate();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [label, setLabel] = useState("");
+ const canRegister = loggedIn || !!token;
+
+ async function run(fn: () => Promise<void>) {
+ setBusy(true);
+ setError(null);
+ try {
+ await fn();
+ navigate(next);
+ } catch (e) {
+ const err = e as Error;
+ setError(err.name === "NotAllowedError" ? "キャンセルされました" : err.message);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const login = () =>
+ run(async () => {
+ const optionsJSON = await postJson("/api/auth/login-options", {});
+ const response = await startAuthentication({ optionsJSON });
+ await postJson("/api/auth/login-verify", { response });
+ });
+
+ const register = () =>
+ run(async () => {
+ const optionsJSON = await postJson("/api/auth/register-options", { token });
+ const response = await startRegistration({ optionsJSON });
+ await postJson("/api/auth/register-verify", { token, response, label: label || null });
+ });
+
+ return (
+ <div className="min-h-screen bg-gray-950 text-gray-100 flex items-center justify-center px-4">
+ <div className="w-full max-w-sm rounded-xl border border-gray-800 bg-gray-900 p-6 flex flex-col gap-5">
+ <Link to="/events" className="text-lg font-bold tracking-tight text-white">
+ 🎸 ライブに行くしかない
+ </Link>
+
+ {!loggedIn && (
+ <button
+ onClick={login}
+ disabled={busy}
+ className="rounded-md bg-indigo-600 px-4 py-2.5 text-sm font-medium hover:bg-indigo-500 disabled:opacity-50 transition-colors"
+ >
+ 🔑 パスキーでログイン
+ </button>
+ )}
+
+ {loggedIn && !token && (
+ <p className="text-sm text-gray-300">
+ ログイン済みです。{" "}
+ <Link to="/favorites" className="text-indigo-400 hover:text-indigo-300 underline">
+ お気に入りへ
+ </Link>
+ </p>
+ )}
+
+ {canRegister && (
+ <div className="flex flex-col gap-2 border-t border-gray-800 pt-5">
+ <p className="text-xs text-gray-400">
+ {loggedIn ? "このデバイスのパスキーを追加登録" : "パスキーを新規登録"}
+ </p>
+ <input
+ value={label}
+ onChange={(e) => setLabel(e.target.value)}
+ placeholder="デバイス名(例: iPhone)"
+ className="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
+ onClick={register}
+ disabled={busy}
+ className="rounded-md bg-gray-700 px-4 py-2 text-sm font-medium hover:bg-gray-600 disabled:opacity-50 transition-colors"
+ >
+ パスキーを登録
+ </button>
+ </div>
+ )}
+
+ {error && <p className="text-sm text-red-400">{error}</p>}
+ </div>
+ </div>
+ );
+}
diff --git a/app/routes/venues.tsx b/app/routes/venues.tsx
index 9b8093a..690b1c2 100644
--- a/app/routes/venues.tsx
+++ b/app/routes/venues.tsx
@@ -26,6 +26,7 @@ export default function Venues() {
<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="text-white font-medium">会場一覧</Link>
+ <Link to="/favorites" className="hover:text-white transition-colors">★ お気に入り</Link>
</nav>
</header>