blob: 9b8093a095ddb342146037cfb28891948cef1165 (
plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
|
import { useLoaderData, Link } from "react-router";
import type { Route } from "./+types/venues";
import { getVenues, getLastScrapePerVenue, type ScrapeLog } from "~/lib/db.server";
import ScrapeButton from "~/components/ScrapeButton";
import { useVenueFilter } from "~/lib/venue-filter";
export async function loader(_: Route.LoaderArgs) {
const venues = getVenues();
const scrapeStatus = getLastScrapePerVenue();
return { venues, scrapeStatus };
}
export default function Venues() {
const { venues, scrapeStatus } = useLoaderData<typeof loader>();
const statusMap = new Map<string, ScrapeLog>(scrapeStatus.map((s) => [s.venue_id, s]));
const { hiddenIds, toggle } = useVenueFilter();
const hiddenCount = hiddenIds.size;
return (
<div className="min-h-screen bg-gray-950 text-gray-100">
<header className="border-b border-gray-800 px-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
<Link to="/" className="text-xl font-bold tracking-tight text-white">
🎸 ライブに行くしかない
</Link>
<nav className="flex gap-4 sm:gap-6 text-sm text-gray-400">
<Link to="/events" className="hover:text-white transition-colors">イベント</Link>
<Link to="/events/by-date" 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 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">会場一覧</h1>
<p className="mt-1 text-sm text-gray-400">
現在 {venues.length} 会場が登録されています。
{hiddenCount > 0 && (
<span className="ml-2 text-yellow-400">{hiddenCount} 会場が非表示</span>
)}
</p>
</div>
<ScrapeButton />
</div>
{venues.length === 0 ? (
<p className="text-gray-500">まだ会場データがありません。「全会場を更新」してください。</p>
) : (
<div className="grid gap-3">
{venues.map((v) => {
const log = statusMap.get(v.id);
const hidden = hiddenIds.has(v.id);
return (
<div
key={v.id}
className={`flex items-center gap-4 rounded-xl border p-4 transition-opacity ${
hidden
? "bg-gray-900/40 border-gray-800/40 opacity-50"
: "bg-gray-800/60 border-gray-700/40"
}`}
>
{/* 会場名 + エリア */}
<div className="flex-1 min-w-0">
<Link
to={`/events?venue_id=${v.id}`}
className="font-semibold hover:text-indigo-300 transition-colors"
>
{v.name}
</Link>
<div className="flex items-center gap-2 mt-0.5">
{v.area && <span className="text-xs text-gray-400">{v.area}</span>}
{v.url && (
<a
href={v.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-gray-600 hover:text-indigo-400 transition-colors"
>
公式サイト ↗
</a>
)}
</div>
</div>
{/* イベント件数 */}
<span className="text-sm text-gray-400 whitespace-nowrap">
<span className="text-lg font-bold text-gray-200">{v.event_count ?? 0}</span> 件
</span>
{/* 最終スクレイプ状態 */}
{log ? (
<ScrapeStatus log={log} />
) : (
<span className="text-xs text-gray-600 whitespace-nowrap">未実行</span>
)}
{/* 表示トグル */}
<button
onClick={() => toggle(v.id)}
aria-label={hidden ? `${v.name}を表示する` : `${v.name}を非表示にする`}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-900 ${
hidden ? "bg-gray-700" : "bg-indigo-600"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
hidden ? "translate-x-1" : "translate-x-6"
}`}
/>
</button>
</div>
);
})}
</div>
)}
</main>
</div>
);
}
function ScrapeStatus({ log }: { log: ScrapeLog }) {
if (log.status === "running") {
return <span className="text-xs text-yellow-400 whitespace-nowrap">⟳ 実行中...</span>;
}
if (log.status === "error") {
return (
<span className="text-xs text-red-400 whitespace-nowrap" title={log.error ?? ""}>
✖ エラー
</span>
);
}
const time = log.finished_at?.slice(0, 16).replace("T", " ") ?? "";
return (
<span className="text-xs text-emerald-400 whitespace-nowrap" title={time}>
✔ {time}
</span>
);
}
|