summaryrefslogtreecommitdiff
path: root/app/components
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/components
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/components')
-rw-r--r--app/components/EventCard.tsx89
-rw-r--r--app/components/FilterBar.tsx85
2 files changed, 174 insertions, 0 deletions
diff --git a/app/components/EventCard.tsx b/app/components/EventCard.tsx
new file mode 100644
index 0000000..6651ff9
--- /dev/null
+++ b/app/components/EventCard.tsx
@@ -0,0 +1,89 @@
+import { Link } from "react-router";
+import type { Event } from "~/lib/db.server";
+
+interface Props {
+ event: Event;
+}
+
+export default function EventCard({ event }: Props) {
+ const formattedDate = formatDate(event.date);
+ const timeLabel = buildTimeLabel(event.open_time, event.start_time);
+
+ return (
+ <Link
+ to={`/events/${event.id}`}
+ className="group flex flex-col rounded-xl bg-gray-800/60 border border-gray-700/40 overflow-hidden hover:border-indigo-500/60 hover:bg-gray-800 transition-all"
+ >
+ {event.image_url ? (
+ <img
+ src={event.image_url}
+ alt={event.title}
+ className="h-36 w-full object-cover"
+ />
+ ) : (
+ <div className="h-36 w-full bg-gradient-to-br from-gray-800 to-gray-900 flex items-center justify-center">
+ <span className="text-4xl opacity-20">🎸</span>
+ </div>
+ )}
+
+ <div className="flex-1 p-4 flex flex-col gap-2">
+ {/* Title */}
+ <h2 className="font-semibold text-sm leading-snug group-hover:text-indigo-300 transition-colors line-clamp-2">
+ {event.title}
+ </h2>
+
+ {/* Artist — required */}
+ <p className="text-xs text-indigo-300 font-medium line-clamp-1">
+ {event.artist ?? "出演者未定"}
+ </p>
+
+ {/* Date + time */}
+ <div className="flex items-center gap-2 text-xs text-gray-300">
+ <span>📅 {formattedDate}</span>
+ {timeLabel && <span className="text-gray-500">| {timeLabel}</span>}
+ </div>
+
+ {/* Venue */}
+ <div className="flex items-center gap-1 text-xs text-gray-400">
+ <span>📍</span>
+ <span className="rounded-full bg-gray-700/60 px-2 py-0.5">
+ {event.venue_name}
+ {event.venue_area ? `(${event.venue_area})` : ""}
+ </span>
+ </div>
+
+ {/* Fee */}
+ {event.price && (
+ <p className="text-xs text-emerald-400">¥ {event.price}</p>
+ )}
+
+ {/* Ticket URL */}
+ {event.ticket_url && (
+ <a
+ href={event.ticket_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ onClick={(e) => e.stopPropagation()}
+ className="mt-auto inline-flex items-center gap-1 text-xs text-indigo-400 hover:underline"
+ >
+ 🎟 チケット
+ </a>
+ )}
+ </div>
+ </Link>
+ );
+}
+
+function formatDate(iso: string): string {
+ const [y, m, d] = iso.split("-");
+ const days = ["日", "月", "火", "水", "木", "金", "土"];
+ const dayIdx = new Date(`${iso}T00:00:00`).getDay();
+ return `${y}/${m}/${d}(${days[dayIdx]})`;
+}
+
+function buildTimeLabel(open: string | null, start: string | null): string {
+ const parts: string[] = [];
+ if (open) parts.push(`OPEN ${open}`);
+ if (start) parts.push(`START ${start}`);
+ return parts.join(" / ");
+}
diff --git a/app/components/FilterBar.tsx b/app/components/FilterBar.tsx
new file mode 100644
index 0000000..97a3c02
--- /dev/null
+++ b/app/components/FilterBar.tsx
@@ -0,0 +1,85 @@
+import { Form, useSearchParams } from "react-router";
+import type { Venue } from "~/lib/db.server";
+
+interface Props {
+ venues: Venue[];
+}
+
+export default function FilterBar({ venues }: Props) {
+ const [searchParams] = useSearchParams();
+
+ return (
+ <Form method="get" className="flex flex-wrap gap-3 items-end">
+ {/* Keyword */}
+ <div className="flex flex-col gap-1">
+ <label className="text-xs text-gray-400">キーワード</label>
+ <input
+ name="keyword"
+ type="text"
+ defaultValue={searchParams.get("keyword") ?? ""}
+ placeholder="アーティスト名、イベント名..."
+ 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 w-52"
+ />
+ </div>
+
+ {/* Venue */}
+ <div className="flex flex-col gap-1">
+ <label className="text-xs text-gray-400">会場</label>
+ <select
+ name="venue_id"
+ defaultValue={searchParams.get("venue_id") ?? ""}
+ 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"
+ >
+ <option value="">すべて</option>
+ {venues.map((v) => (
+ <option key={v.id} value={v.id}>
+ {v.name}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {/* Date from */}
+ <div className="flex flex-col gap-1">
+ <label className="text-xs text-gray-400">開始日</label>
+ <input
+ name="date_from"
+ type="date"
+ defaultValue={searchParams.get("date_from") ?? ""}
+ 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"
+ />
+ </div>
+
+ {/* Date to */}
+ <div className="flex flex-col gap-1">
+ <label className="text-xs text-gray-400">終了日</label>
+ <input
+ name="date_to"
+ type="date"
+ defaultValue={searchParams.get("date_to") ?? ""}
+ 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"
+ />
+ </div>
+
+ <button
+ type="submit"
+ className="rounded-md bg-gray-700 px-4 py-1.5 text-sm font-medium hover:bg-gray-600 transition-colors"
+ >
+ 絞り込む
+ </button>
+
+ {hasFilters(searchParams) && (
+ <a
+ href="/events"
+ className="rounded-md border border-gray-700 px-4 py-1.5 text-sm text-gray-400 hover:text-white transition-colors"
+ >
+ クリア
+ </a>
+ )}
+ </Form>
+ );
+}
+
+function hasFilters(params: URLSearchParams): boolean {
+ return ["keyword", "venue_id", "date_from", "date_to"].some((k) => params.get(k));
+}