diff options
Diffstat (limited to 'app/routes')
| -rw-r--r-- | app/routes/api.auth.$step.ts | 63 | ||||
| -rw-r--r-- | app/routes/events.$id.tsx | 1 | ||||
| -rw-r--r-- | app/routes/events._index.tsx | 15 | ||||
| -rw-r--r-- | app/routes/events.by-date.tsx | 8 | ||||
| -rw-r--r-- | app/routes/favorites.tsx | 161 | ||||
| -rw-r--r-- | app/routes/login.tsx | 117 | ||||
| -rw-r--r-- | app/routes/venues.tsx | 1 |
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> |
