blob: 75e48b1b04956714e893cbeb085fdeb9df443b62 (
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
|
import { useState, useEffect, useRef } from "react";
import { useFetcher } from "react-router";
interface ScrapeLog {
id: number;
run_id: string;
venue_id: string;
venue_name: string;
status: "running" | "ok" | "error";
events_saved: number;
error: string | null;
}
interface StatusData {
running: boolean;
results: ScrapeLog[];
}
interface ScrapeStartData {
run_id: string;
status: string;
}
export default function ScrapeButton({ venueId }: { venueId?: string }) {
const triggerFetcher = useFetcher<ScrapeStartData>();
const statusFetcher = useFetcher<StatusData>();
const statusFetcherRef = useRef(statusFetcher);
useEffect(() => { statusFetcherRef.current = statusFetcher; });
const [runId, setRunId] = useState<string | null>(null);
const [polling, setPolling] = useState(false);
const [done, setDone] = useState(false);
useEffect(() => {
const data = triggerFetcher.data;
if (data?.run_id) {
setRunId(data.run_id);
setPolling(true);
setDone(false);
}
}, [triggerFetcher.data]);
useEffect(() => {
if (!polling || !runId) return;
const id = setInterval(() => {
statusFetcherRef.current.load(`/api/scrape-status?run_id=${runId}`);
}, 2000);
return () => clearInterval(id);
}, [polling, runId]);
useEffect(() => {
const data = statusFetcher.data;
if (data && !data.running) {
setPolling(false);
setDone(true);
}
}, [statusFetcher.data]);
const results: ScrapeLog[] = statusFetcher.data?.results ?? [];
const running = statusFetcher.data?.running ?? false;
const isActive = triggerFetcher.state !== "idle" || polling;
const okCount = results.filter((r) => r.status === "ok").length;
const errCount = results.filter((r) => r.status === "error").length;
const apiUrl = venueId ? `/api/scrape?venue_id=${venueId}` : "/api/scrape";
return (
<div className="flex flex-col items-end gap-2">
<button
onClick={() => triggerFetcher.load(apiUrl)}
disabled={isActive}
className={`rounded-md px-4 py-1.5 text-sm font-medium transition-colors whitespace-nowrap ${
isActive
? "bg-gray-700 text-gray-500 cursor-not-allowed"
: "bg-indigo-600 text-white hover:bg-indigo-500"
}`}
>
{isActive ? "更新中..." : "情報を更新"}
</button>
{(isActive || done) && results.length > 0 && (
<div className="w-72 rounded-lg bg-gray-800/80 border border-gray-700/60 p-3 text-xs space-y-1">
<p className="text-gray-400 font-medium mb-2">
{running
? `スクレイプ中... ${okCount + errCount} / ${results.length} 完了`
: `完了 — ✔ ${okCount}件成功 / ✖ ${errCount}件失敗`}
</p>
{results.map((r) => (
<div key={r.id} className="flex items-center gap-2">
<span
className={
r.status === "ok"
? "text-emerald-400"
: r.status === "error"
? "text-red-400"
: "text-yellow-400"
}
>
{r.status === "ok" ? "✔" : r.status === "error" ? "✖" : "⟳"}
</span>
<span className="text-gray-300 flex-1 truncate">{r.venue_name}</span>
{r.status === "ok" && (
<span className="text-gray-500 shrink-0">{r.events_saved}件</span>
)}
{r.status === "error" && (
<span className="text-red-400 shrink-0" title={r.error ?? ""}>
エラー
</span>
)}
</div>
))}
</div>
)}
</div>
);
}
|