diff options
| author | yyamashita <yyamashita@mosquit.one> | 2026-05-07 10:22:07 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@mosquit.one> | 2026-05-07 10:22:07 +0900 |
| commit | 8aa1986661199e919df354dc0a5a2819155f023a (patch) | |
| tree | 2c2c6fa400bb914508daa543ca723639efa96821 /app | |
| parent | 0cd5fb770ca9bd3f304d9556a4b33a4ad4f45e7e (diff) | |
Add list view toggle to event listing
Adds EventListRow component and a card/list toggle (persisted in ?view=
URL param) so users can switch between the grid card layout and a
compact single-row list layout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app')
| -rw-r--r-- | app/components/EventListRow.tsx | 65 | ||||
| -rw-r--r-- | app/routes/events._index.tsx | 35 |
2 files changed, 98 insertions, 2 deletions
diff --git a/app/components/EventListRow.tsx b/app/components/EventListRow.tsx new file mode 100644 index 0000000..24f5f59 --- /dev/null +++ b/app/components/EventListRow.tsx @@ -0,0 +1,65 @@ +import { Link } from "react-router"; +import type { Event } from "~/lib/db.server"; + +interface Props { + event: Event; +} + +export default function EventListRow({ 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 items-center gap-3 rounded-lg px-4 py-3 bg-gray-800/40 border border-gray-700/30 hover:border-indigo-500/50 hover:bg-gray-800/70 transition-all text-sm" + > + {/* Date */} + <span className="w-32 shrink-0 text-xs text-gray-400 tabular-nums"> + {formattedDate} + {timeLabel && ( + <span className="block text-gray-600 text-[11px]">{timeLabel}</span> + )} + </span> + + {/* Venue */} + <span className="w-28 shrink-0 truncate rounded-full bg-gray-700/60 px-2 py-0.5 text-xs text-gray-300"> + {event.venue_name} + {event.venue_area ? `(${event.venue_area})` : ""} + </span> + + {/* Title + artist */} + <span className="flex-1 min-w-0"> + <span className="block truncate font-medium text-gray-100 group-hover:text-indigo-300 transition-colors"> + {event.title} + </span> + {event.artist && ( + <span className="block truncate text-xs text-indigo-300/80"> + {event.artist} + </span> + )} + </span> + + {/* Price */} + {event.price ? ( + <span className="shrink-0 text-xs text-emerald-400">¥{event.price}</span> + ) : ( + <span className="shrink-0 w-16" /> + )} + </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/routes/events._index.tsx b/app/routes/events._index.tsx index 1917ace..174c81e 100644 --- a/app/routes/events._index.tsx +++ b/app/routes/events._index.tsx @@ -2,6 +2,7 @@ import { useLoaderData, useSearchParams, Link } from "react-router"; import type { Route } from "./+types/events._index"; import { queryEvents, getVenues } from "~/lib/db.server"; import EventCard from "~/components/EventCard"; +import EventListRow from "~/components/EventListRow"; import FilterBar from "~/components/FilterBar"; function defaultWindow() { @@ -34,7 +35,15 @@ export async function loader({ request }: Route.LoaderArgs) { export default function EventsIndex() { const { events, venues, page, hasMore, date_from, date_to } = useLoaderData<typeof loader>(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const view = (searchParams.get("view") ?? "card") as "card" | "list"; + + function switchView(next: "card" | "list") { + const next_params = new URLSearchParams(searchParams); + next_params.set("view", next); + next_params.delete("page"); + setSearchParams(next_params, { preventScrollReset: true }); + } return ( <div className="min-h-screen bg-gray-950 text-gray-100"> @@ -49,8 +58,24 @@ export default function EventsIndex() { </header> <main className="max-w-6xl mx-auto px-4 py-8"> - <div className="mb-6"> + <div className="mb-6 flex items-center justify-between"> <h1 className="text-2xl font-bold">イベント一覧</h1> + <div className="flex rounded-lg overflow-hidden border border-gray-700 text-sm"> + <button + onClick={() => switchView("card")} + className={`px-3 py-1.5 transition-colors ${view === "card" ? "bg-indigo-600 text-white" : "bg-gray-800 text-gray-400 hover:text-white"}`} + title="カード表示" + > + ▦ + </button> + <button + onClick={() => switchView("list")} + className={`px-3 py-1.5 transition-colors ${view === "list" ? "bg-indigo-600 text-white" : "bg-gray-800 text-gray-400 hover:text-white"}`} + title="リスト表示" + > + ☰ + </button> + </div> </div> <FilterBar venues={venues} defaultDateFrom={date_from} defaultDateTo={date_to} /> @@ -60,6 +85,12 @@ export default function EventsIndex() { <p className="text-lg">イベントが見つかりません</p> <p className="mt-2 text-sm">スクレイパーを実行してデータを取得してください: <code>npm run scrape</code></p> </div> + ) : view === "list" ? ( + <div className="mt-6 flex flex-col gap-1.5"> + {events.map((event) => ( + <EventListRow key={event.id} event={event} /> + ))} + </div> ) : ( <div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {events.map((event) => ( |
