watch-party/frontend/src/pages/ShowsPage.tsx
2025-11-05 16:03:19 +09:00

175 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from "react";
type Show = {
id: number;
ep_num: number;
ep_title: string;
season_name: string;
start_time: string;
playback_length: string;
date_created: string;
};
const GET_URL = "/api/v1/shows";
const POST_URL = "/api/v1/current";
const HHMM = /^(\d{1,2}):([0-5]\d)$/;
function toHHMMSS(v: string): string | null {
const m = v.trim().match(HHMM);
if (!m) return null;
const h = Math.abs(parseInt(m[1], 10));
if (h > 23) return null;
const hh = String(h).padStart(2, "0");
const mm = m[2];
return `${hh}:${mm}:00`;
}
function toHHMM(v: string): string | null {
const m = v.trim().match(/^(\d{1,2}):([0-5]\d)$/);
if (!m) return null;
const h = Number(m[1]); if (h > 23) return null;
return `${String(h).padStart(2, "0")}:${m[2]}`;
}
export default function ShowsPage() {
const [shows, setShows] = useState<Show[]>([]);
const [loading, setLoading] = useState(true);
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
// フォーム状態
const [selectedId, setSelectedId] = useState<number | null>(null);
const [startTime, setStartTime] = useState("");
function formatPlaybackLen(v: string): string {
const parts = v.split(":").map(Number);
if (parts.length === 2) {
// already MM:SS
const [mm, ss] = parts;
return `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
}
if (parts.length === 3) {
const [hh, mm, ss] = parts;
if (hh <= 0) {
// show MM:SS when hours are zero
return `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
}
// show HH:MM:SS when hours exist
return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
}
return v; // fallback (unexpected format)
}
// 一覧取得
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
const res = await fetch(GET_URL, { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as Show[];
if (!cancelled) setShows(data);
} catch (e: any) {
if (!cancelled) setError(e.message || "番組一覧の取得に失敗しました。");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
const current = useMemo(() => shows.find(s => s.id === selectedId) || null, [shows, selectedId]);
async function submit() {
setError(null);
if (!selectedId) { setError("エピソードを選択してください。"); return; }
let payload: any = { id: selectedId };
if (startTime) {
const normalized = toHHMMSS(startTime);
if (!normalized) { setError("開始時刻は HH:MM の形式で入力してください。"); return; }
payload.start_time = normalized; // API expects HH:MM:SS
}
try {
setPosting(true);
const res = await fetch(POST_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`POST 失敗 (${res.status})`);
setStartTime("");
} catch (e: any) {
setError(e.message || "現在のエピソード設定に失敗しました。");
} finally {
setPosting(false);
}
}
return (
<div className="shows-page">
<h2 className="h1" style={{ marginBottom: 8 }}></h2>
<p className="subtle" style={{ marginTop: 0 }}>
HH:MM
</p>
{loading && <div className="subtle"></div>}
{error && <div className="timer-status" style={{ color: "#f88" }}>{error}</div>}
{!loading && shows.length === 0 && <div className="subtle"></div>}
{shows.length > 0 && (
<div className="shows-grid">
{shows.map(s => (
<button
key={s.id}
className={`show-card ${selectedId === s.id ? "selected" : ""}`}
onClick={() => setSelectedId(s.id)}
>
<div className="title">{s.ep_num}{s.ep_title}</div>
<div className="season subtle">{s.season_name}</div>
<div className="meta subtle">
{s.start_time.slice(0, 5)} {formatPlaybackLen(s.playback_length)}
</div>
</button>
))}
</div>
)}
<div className="form-row">
<input
className="input"
type="text"
inputMode="numeric"
placeholder="HH:MM"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
onBlur={(e) => {
const v = toHHMM(e.target.value);
if (v) setStartTime(v);
}}
/>
<button
className="primary"
disabled={
posting ||
!selectedId ||
(!!startTime && !toHHMMSS(startTime))
}
onClick={submit}
>
{posting ? "設定中…" : "現在のエピソードに設定"}
</button>
</div>
{current && (
<div className="subtle" style={{ marginTop: 8 }}>
{current.ep_num}{current.ep_title}
</div>
)}
</div>
);
}