summaryrefslogtreecommitdiff
path: root/app/routes/events._index.tsx
diff options
context:
space:
mode:
authoryyamashita <yyamashita@mosquit.one>2026-05-06 22:07:53 +0900
committeryyamashita <yyamashita@mosquit.one>2026-05-06 22:07:53 +0900
commitbe55729482296663da8c96723bfd22080e6762c1 (patch)
treefcd94b1dc5c55f3a80796c90a555863d13fc9a95 /app/routes/events._index.tsx
parent014b29bc22b1c207a03dd560051ecdd5df63f0b1 (diff)
Add Tokyo livehouse event aggregator service
Full-stack React Router v7 app that scrapes event listings from major Tokyo live venues (Liquid Room, WWW/WWW X, Shibuya O-EAST, Shinjuku LOFT, Club Quattro) and stores them in SQLite for browsing and search. - Modular scraper architecture: add a new venue by dropping a file in app/scrapers/ and registering it in index.ts - Routes: /events (filter by keyword/venue/date), /events/:id, /venues, GET /api/scrape - EventCard shows artist, date/time, venue, ticket URL, and fee - Post-scrape per-venue Markdown files generated to events/ (dev reference) - /add-livehouse Claude Code skill defined in .claude/commands/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app/routes/events._index.tsx')
-rw-r--r--app/routes/events._index.tsx94
1 files changed, 94 insertions, 0 deletions
diff --git a/app/routes/events._index.tsx b/app/routes/events._index.tsx
new file mode 100644
index 0000000..3883d37
--- /dev/null
+++ b/app/routes/events._index.tsx
@@ -0,0 +1,94 @@
+import { useLoaderData, useSearchParams, Form, Link } from "react-router";
+import type { Route } from "./+types/events._index";
+import { queryEvents, getVenues } from "~/lib/db.server";
+import EventCard from "~/components/EventCard";
+import FilterBar from "~/components/FilterBar";
+
+export async function loader({ request }: Route.LoaderArgs) {
+ const url = new URL(request.url);
+ const date_from = url.searchParams.get("date_from") ?? undefined;
+ const date_to = url.searchParams.get("date_to") ?? undefined;
+ const venue_id = url.searchParams.get("venue_id") ?? undefined;
+ const keyword = url.searchParams.get("keyword") ?? undefined;
+ 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, limit, offset });
+ const venues = getVenues();
+
+ return { events, venues, page, hasMore: events.length === limit };
+}
+
+export default function EventsIndex() {
+ const { events, venues, page, hasMore } = useLoaderData<typeof loader>();
+ const [searchParams] = useSearchParams();
+
+ return (
+ <div className="min-h-screen bg-gray-950 text-gray-100">
+ <header className="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
+ <Link to="/" className="text-xl font-bold tracking-tight text-white">
+ 🎸 東京ライブハウス
+ </Link>
+ <nav className="flex gap-6 text-sm text-gray-400">
+ <Link to="/events" className="hover:text-white transition-colors">イベント</Link>
+ <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link>
+ </nav>
+ </header>
+
+ <main className="max-w-6xl mx-auto px-4 py-8">
+ <div className="mb-6 flex items-center justify-between">
+ <h1 className="text-2xl font-bold">イベント一覧</h1>
+ <Form method="post" action="/api/scrape">
+ <button
+ type="submit"
+ className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium hover:bg-indigo-500 transition-colors"
+ >
+ 情報を更新
+ </button>
+ </Form>
+ </div>
+
+ <FilterBar venues={venues} />
+
+ {events.length === 0 ? (
+ <div className="mt-16 text-center text-gray-500">
+ <p className="text-lg">イベントが見つかりません</p>
+ <p className="mt-2 text-sm">「情報を更新」ボタンでデータを取得してください。</p>
+ </div>
+ ) : (
+ <div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+ {events.map((event) => (
+ <EventCard key={event.id} event={event} />
+ ))}
+ </div>
+ )}
+
+ <div className="mt-8 flex justify-center gap-4">
+ {page > 1 && (
+ <Link
+ to={`?${buildPageParams(searchParams, page - 1)}`}
+ className="rounded bg-gray-800 px-4 py-2 text-sm hover:bg-gray-700"
+ >
+ ← 前のページ
+ </Link>
+ )}
+ {hasMore && (
+ <Link
+ to={`?${buildPageParams(searchParams, page + 1)}`}
+ className="rounded bg-gray-800 px-4 py-2 text-sm hover:bg-gray-700"
+ >
+ 次のページ →
+ </Link>
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}
+
+function buildPageParams(params: URLSearchParams, page: number): string {
+ const next = new URLSearchParams(params);
+ next.set("page", String(page));
+ return next.toString();
+}