From be55729482296663da8c96723bfd22080e6762c1 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Wed, 6 May 2026 22:07:53 +0900 Subject: 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 --- app/components/EventCard.tsx | 89 ++++++++++++++++++++++++++++++++++++++++++++ app/components/FilterBar.tsx | 85 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 app/components/EventCard.tsx create mode 100644 app/components/FilterBar.tsx (limited to 'app/components') 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 ( + + {event.image_url ? ( + {event.title} + ) : ( +
+ 🎸 +
+ )} + +
+ {/* Title */} +

+ {event.title} +

+ + {/* Artist — required */} +

+ {event.artist ?? "出演者未定"} +

+ + {/* Date + time */} +
+ 📅 {formattedDate} + {timeLabel && | {timeLabel}} +
+ + {/* Venue */} +
+ 📍 + + {event.venue_name} + {event.venue_area ? `(${event.venue_area})` : ""} + +
+ + {/* Fee */} + {event.price && ( +

¥ {event.price}

+ )} + + {/* Ticket URL */} + {event.ticket_url && ( + e.stopPropagation()} + className="mt-auto inline-flex items-center gap-1 text-xs text-indigo-400 hover:underline" + > + 🎟 チケット + + )} +
+ + ); +} + +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 ( +
+ {/* Keyword */} +
+ + +
+ + {/* Venue */} +
+ + +
+ + {/* Date from */} +
+ + +
+ + {/* Date to */} +
+ + +
+ + + + {hasFilters(searchParams) && ( + + クリア + + )} +
+ ); +} + +function hasFilters(params: URLSearchParams): boolean { + return ["keyword", "venue_id", "date_from", "date_to"].some((k) => params.get(k)); +} -- cgit v1.2.3