summaryrefslogtreecommitdiff
path: root/app/routes
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes')
-rw-r--r--app/routes/api.scrape.ts37
-rw-r--r--app/routes/events.$id.tsx124
-rw-r--r--app/routes/events._index.tsx94
-rw-r--r--app/routes/index.tsx5
-rw-r--r--app/routes/venues.tsx68
5 files changed, 328 insertions, 0 deletions
diff --git a/app/routes/api.scrape.ts b/app/routes/api.scrape.ts
new file mode 100644
index 0000000..4071985
--- /dev/null
+++ b/app/routes/api.scrape.ts
@@ -0,0 +1,37 @@
+/**
+ * Resource route: POST /api/scrape
+ * Triggers scraping for all venues (or a specific one via ?venue_id=xxx).
+ * Returns JSON results and redirects back if called from a form.
+ */
+import { redirect } from "react-router";
+import type { Route } from "./+types/api.scrape";
+import { runAllScrapers, runScraper } from "~/lib/scraper-runner.server";
+
+export async function action({ request }: Route.ActionArgs) {
+ const formData = await request.formData();
+ const venueId = formData.get("venue_id");
+
+ const results = venueId
+ ? [await runScraper(String(venueId))]
+ : await runAllScrapers();
+
+ // If called from a browser form, redirect back
+ const referer = request.headers.get("Referer");
+ if (referer) {
+ return redirect(referer);
+ }
+
+ return Response.json({ results });
+}
+
+// Allow GET for quick testing in the browser
+export async function loader({ request }: Route.LoaderArgs) {
+ const url = new URL(request.url);
+ const venueId = url.searchParams.get("venue_id");
+
+ const results = venueId
+ ? [await runScraper(venueId)]
+ : await runAllScrapers();
+
+ return Response.json({ results });
+}
diff --git a/app/routes/events.$id.tsx b/app/routes/events.$id.tsx
new file mode 100644
index 0000000..cecb282
--- /dev/null
+++ b/app/routes/events.$id.tsx
@@ -0,0 +1,124 @@
+import { useLoaderData, Link } from "react-router";
+import type { Route } from "./+types/events.$id";
+import { getEvent } from "~/lib/db.server";
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const id = parseInt(params.id, 10);
+ if (isNaN(id)) throw new Response("Not Found", { status: 404 });
+ const event = getEvent(id);
+ if (!event) throw new Response("Not Found", { status: 404 });
+ return { event };
+}
+
+export default function EventDetail() {
+ const { event } = useLoaderData<typeof loader>();
+
+ 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-3xl mx-auto px-4 py-10">
+ <Link to="/events" className="text-sm text-indigo-400 hover:underline">
+ ← イベント一覧に戻る
+ </Link>
+
+ <div className="mt-6">
+ {event.image_url && (
+ <img
+ src={event.image_url}
+ alt={event.title}
+ className="w-full max-h-72 object-cover rounded-xl mb-6"
+ />
+ )}
+
+ <div className="flex items-start justify-between gap-4 flex-wrap">
+ <div>
+ <h1 className="text-3xl font-bold leading-tight">{event.title}</h1>
+ {event.artist && (
+ <p className="mt-1 text-lg text-gray-300">{event.artist}</p>
+ )}
+ </div>
+ <span className="rounded-full bg-indigo-700/60 px-3 py-1 text-sm font-medium whitespace-nowrap">
+ {event.venue_name}
+ </span>
+ </div>
+
+ <dl className="mt-8 grid grid-cols-2 gap-4 text-sm">
+ <Detail label="日付" value={formatDate(event.date)} />
+ {event.open_time && <Detail label="OPEN" value={event.open_time} />}
+ {event.start_time && <Detail label="START" value={event.start_time} />}
+ {event.price && <Detail label="料金" value={event.price} />}
+ {event.venue_area && <Detail label="エリア" value={event.venue_area} />}
+ </dl>
+
+ {event.description && (
+ <p className="mt-8 text-gray-300 leading-relaxed whitespace-pre-line">
+ {event.description}
+ </p>
+ )}
+
+ <div className="mt-8 flex gap-4 flex-wrap">
+ {event.ticket_url && (
+ <a
+ href={event.ticket_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="rounded-md bg-indigo-600 px-5 py-2 text-sm font-medium hover:bg-indigo-500 transition-colors"
+ >
+ チケット購入
+ </a>
+ )}
+ {event.source_url && (
+ <a
+ href={event.source_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="rounded-md bg-gray-700 px-5 py-2 text-sm font-medium hover:bg-gray-600 transition-colors"
+ >
+ 詳細ページ
+ </a>
+ )}
+ {event.venue_url && (
+ <a
+ href={event.venue_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="rounded-md bg-gray-700 px-5 py-2 text-sm font-medium hover:bg-gray-600 transition-colors"
+ >
+ 会場サイト
+ </a>
+ )}
+ </div>
+
+ <p className="mt-10 text-xs text-gray-600">
+ 最終取得: {event.fetched_at}
+ </p>
+ </div>
+ </main>
+ </div>
+ );
+}
+
+function Detail({ label, value }: { label: string; value: string }) {
+ return (
+ <div className="rounded-lg bg-gray-800/60 p-3">
+ <dt className="text-xs text-gray-500 mb-1">{label}</dt>
+ <dd className="font-medium">{value}</dd>
+ </div>
+ );
+}
+
+function formatDate(iso: string): string {
+ const [y, m, d] = iso.split("-");
+ const days = ["日", "月", "火", "水", "木", "金", "土"];
+ const day = days[new Date(iso).getDay()];
+ return `${y}年${m}月${d}日(${day})`;
+}
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();
+}
diff --git a/app/routes/index.tsx b/app/routes/index.tsx
new file mode 100644
index 0000000..1cdb9a4
--- /dev/null
+++ b/app/routes/index.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "react-router";
+
+export function loader() {
+ return redirect("/events");
+}
diff --git a/app/routes/venues.tsx b/app/routes/venues.tsx
new file mode 100644
index 0000000..23b052f
--- /dev/null
+++ b/app/routes/venues.tsx
@@ -0,0 +1,68 @@
+import { useLoaderData, Link } from "react-router";
+import type { Route } from "./+types/venues";
+import { getVenues } from "~/lib/db.server";
+import { getScraperIds } from "~/lib/venue-meta.server";
+
+export async function loader(_: Route.LoaderArgs) {
+ const venues = getVenues();
+ const scraperIds = getScraperIds();
+ return { venues, scraperIds };
+}
+
+export default function Venues() {
+ const { venues, scraperIds: scraperIdList } = useLoaderData<typeof loader>();
+ const scraperIds = new Set(scraperIdList);
+
+ 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="text-white font-medium">会場一覧</Link>
+ </nav>
+ </header>
+
+ <main className="max-w-4xl mx-auto px-4 py-10">
+ <div className="mb-8">
+ <h1 className="text-2xl font-bold">会場一覧</h1>
+ <p className="mt-1 text-sm text-gray-400">
+ 現在 {scraperIdList.length} 会場のスクレイパーが登録されています。
+ 新しい会場を追加するには <code className="bg-gray-800 px-1 rounded">app/scrapers/</code> に
+ モジュールを追加して <code className="bg-gray-800 px-1 rounded">index.ts</code> に登録してください。
+ </p>
+ </div>
+
+ {venues.length === 0 ? (
+ <p className="text-gray-500">まだ会場データがありません。「情報を更新」してください。</p>
+ ) : (
+ <div className="grid gap-4 sm:grid-cols-2">
+ {venues.map((v) => (
+ <Link
+ key={v.id}
+ to={`/events?venue_id=${v.id}`}
+ className="flex items-center justify-between rounded-xl bg-gray-800/60 p-4 hover:bg-gray-800 transition-colors border border-gray-700/50"
+ >
+ <div>
+ <p className="font-semibold">{v.name}</p>
+ {v.area && <p className="text-sm text-gray-400">{v.area}</p>}
+ {scraperIds.has(v.id) && (
+ <span className="mt-1 inline-block rounded-full bg-emerald-700/40 px-2 py-0.5 text-xs text-emerald-300">
+ スクレイパー登録済
+ </span>
+ )}
+ </div>
+ <span className="text-2xl font-bold text-gray-500">
+ {v.event_count ?? 0}
+ <span className="text-sm font-normal ml-1">件</span>
+ </span>
+ </Link>
+ ))}
+ </div>
+ )}
+ </main>
+ </div>
+ );
+}