175 lines
6.4 KiB
TypeScript
175 lines
6.4 KiB
TypeScript
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>
|
||
);
|
||
} |