diff options
| author | yyamashita <yyamashita@mosquit.one> | 2026-05-06 22:07:53 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@mosquit.one> | 2026-05-06 22:07:53 +0900 |
| commit | be55729482296663da8c96723bfd22080e6762c1 (patch) | |
| tree | fcd94b1dc5c55f3a80796c90a555863d13fc9a95 /app/components/EventCard.tsx | |
| parent | 014b29bc22b1c207a03dd560051ecdd5df63f0b1 (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/EventCard.tsx')
| -rw-r--r-- | app/components/EventCard.tsx | 89 |
1 files changed, 89 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(" / "); +} |
