watch-party/frontend/src/pages/ShowsPage.tsx

404 lines
17 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 React, { useEffect, useMemo, useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { logApiError } from "../utils/logger";
import { toastError, toastInfo } from "../utils/toastBus";
import { createShow, deleteShow, fetchDanimeEpisode, fetchShows, postCurrentEpisode } from "../api/watchparty";
import type { DanimeEpisode, ShowItem } from "../api/watchparty";
import { useAuth } from "../auth/AuthProvider";
const REDIRECT_DELAY_S = 3;
const HHMM = /^(\d{1,2}):([0-5]\d)$/;
const HHMMSS_OPT = /^(\d{1,2}):([0-5]\d)(?::([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]}`;
}
function normalizeStartTimeInput(v: string): string | null {
const t = v.trim();
if (!t) return "";
const m = t.match(HHMMSS_OPT);
if (!m) return null;
const h = Number(m[1]);
if (h > 23) return null;
const mm = m[2];
const ss = m[3] ?? "00";
return `${String(h).padStart(2, "0")}:${mm}:${ss}`;
}
function extractPartId(urlStr: string): string | null {
const trimmed = urlStr.trim();
if (!trimmed) return null;
try {
const u = new URL(trimmed);
if (!u.hostname.endsWith("animestore.docomo.ne.jp")) return null;
const partId = u.searchParams.get("partId") || u.searchParams.get("partid");
if (partId) return partId;
} catch {
// fallback to regex
}
if (!trimmed.includes("animestore.docomo.ne.jp")) return null;
const m = trimmed.match(/partId=(\d+)/i);
return m ? m[1] : null;
}
export default function ShowsPage() {
const [shows, setShows] = useState<ShowItem[]>([]);
const [loading, setLoading] = useState(true);
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [redirectIn, setRedirectIn] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const redirectTid = useRef<number | null>(null);
const isRedirecting = redirectIn !== null;
const navigate = useNavigate();
const { enabled: authEnabled, idToken, backendClaims, verifying } = useAuth();
const isAuthed = authEnabled && !!idToken;
// フォーム状態
const [selectedId, setSelectedId] = useState<number | null>(null);
const [startTime, setStartTime] = useState("");
const [danimeUrl, setDanimeUrl] = useState("");
const [danimeStart, setDanimeStart] = useState("");
const [danimeResult, setDanimeResult] = useState<DanimeEpisode | null>(null);
const [scraping, setScraping] = useState(false);
const [registeringScrape, setRegisteringScrape] = useState(false);
const [scrapeError, setScrapeError] = useState<string | null>(null);
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)
}
const loadShows = React.useCallback(async () => {
setError(null);
try {
setLoading(true);
const data = await fetchShows();
data.sort((a, b) => a.id - b.id); // ASC
setShows(data);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "番組一覧を取得できませんでした。";
setError(msg);
logApiError("fetch shows", e);
toastError("番組一覧を取得できませんでした", msg);
} finally {
setLoading(false);
}
}, []);
// 一覧取得
useEffect(() => {
loadShows().catch(() => {});
}, [loadShows]);
useEffect(() => {
return () => {
if (redirectTid.current) {
window.clearInterval(redirectTid.current);
}
};
}, []);
const current = useMemo(() => shows.find(s => s.id === selectedId) || null, [shows, selectedId]);
async function submit() {
setError(null);
if (!selectedId) { setError("エピソードを選択してください。"); return; }
const selected = shows.find(s => s.id === selectedId);
if (!selected) { setError("選択中のエピソードが見つかりませんでした。"); return; }
const payload: { id: number; start_time?: string } = { id: selectedId };
if (startTime.trim()) {
const normalized = toHHMMSS(startTime);
if (!normalized) { setError("開始時刻は HH:MM 形式で入力してください。"); return; }
payload.start_time = normalized;
} else {
payload.start_time = selected.start_time;
}
try {
setPosting(true);
await postCurrentEpisode(payload);
toastInfo("現在のエピソードを設定しました");
setRedirectIn(REDIRECT_DELAY_S);
const start = Date.now();
redirectTid.current = window.setInterval(() => {
const left = REDIRECT_DELAY_S - Math.floor((Date.now() - start) / 1000);
if (left <= 0) { window.clearInterval(redirectTid.current!); navigate("/", { replace: true }); }
else setRedirectIn(left);
}, 250);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "現在のエピソードを設定できませんでした。";
setError(msg);
logApiError("set current episode", e);
toastError("現在のエピソードを設定できませんでした", msg);
} finally {
setPosting(false);
}
}
async function handleScrape() {
setScrapeError(null);
setDanimeResult(null);
const partId = extractPartId(danimeUrl);
if (!partId) {
setScrapeError("animestore.docomo.ne.jp のエピソードURLpartId付きを入力してください。");
return;
}
try {
setScraping(true);
const res = await fetchDanimeEpisode(partId);
setDanimeResult(res);
toastInfo("メタデータを取得しました");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "メタデータ取得に失敗しました。";
setScrapeError(msg);
logApiError("fetch danime", e);
toastError("メタデータ取得に失敗しました", msg);
} finally {
setScraping(false);
}
}
async function handleRegisterScraped() {
if (!danimeResult) {
setScrapeError("先にメタデータを取得してください。");
return;
}
const normalized = normalizeStartTimeInput(danimeStart);
if (normalized === null) {
setScrapeError("開始時刻は HH:MM または HH:MM:SS で入力してください。");
return;
}
try {
setRegisteringScrape(true);
await createShow({
ep_num: danimeResult.ep_num,
ep_title: danimeResult.ep_title,
season_name: danimeResult.season_name,
playback_length: danimeResult.playback_length,
start_time: normalized || undefined,
});
toastInfo("番組を登録しました");
setDanimeStart("");
setDanimeUrl("");
setDanimeResult(null);
await loadShows();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "登録に失敗しました。";
setScrapeError(msg);
logApiError("register show", e);
toastError("登録に失敗しました", msg);
} finally {
setRegisteringScrape(false);
}
}
async function handleDelete(showId: number) {
if (!authEnabled) {
toastError("認証が無効です", "管理者に確認してください");
return;
}
if (!idToken) {
toastError("サインインしてください", "Googleでサインイン後に削除できます");
return;
}
try {
setDeletingId(showId);
await deleteShow(showId, idToken);
toastInfo("エピソードを削除しました");
if (selectedId === showId) {
setSelectedId(null);
}
await loadShows();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "削除に失敗しました。";
toastError("削除に失敗しました", msg);
logApiError("delete show", e);
} finally {
setDeletingId(null);
}
}
return (
<div className="shows-page">
<div className="scrape-card">
<h2 className="h1" style={{ marginBottom: 8 }}>dアニメから登録</h2>
<p className="subtle" style={{ marginTop: 0 }}>
dアニメのエピソードURLを貼り付けて
</p>
<div className="scrape-row">
<input
className="input"
type="url"
placeholder="https://animestore.docomo.ne.jp/animestore/ci_pc?workId=...&partId=..."
value={danimeUrl}
onChange={e => setDanimeUrl(e.target.value)}
style={{ flex: 1, minWidth: 260 }}
/>
<button className="primary" disabled={scraping || !danimeUrl.trim()} onClick={handleScrape}>
{scraping ? "取得中…" : "メタデータ取得"}
</button>
</div>
{danimeResult && (
<div className="scrape-result">
<div className="scrape-meta">
<div className="pill">{danimeResult.ep_num}</div>
<div className="pill">{danimeResult.playback_length}</div>
</div>
<div className="scrape-title">{danimeResult.ep_title}</div>
<div className="subtle" style={{ margin: "4px 0 12px" }}>{danimeResult.season_name}</div>
<div className="scrape-row">
<input
className="input"
type="text"
inputMode="numeric"
placeholder="開始時刻 (HH:MM または HH:MM:SS、空欄可)"
value={danimeStart}
onChange={e => setDanimeStart(e.target.value)}
onBlur={e => {
const v = normalizeStartTimeInput(e.target.value);
if (v) setDanimeStart(v);
}}
style={{ flex: 1, minWidth: 220 }}
/>
<button
className="primary"
disabled={registeringScrape}
onClick={handleRegisterScraped}
>
{registeringScrape ? "登録中…" : "このデータで登録"}
</button>
</div>
</div>
)}
{scrapeError && (
<div className="timer-status" style={{ color: "#f88" }}>
{scrapeError}
</div>
)}
</div>
<h2 className="h1" style={{ marginBottom: 8 }}></h2>
<div className="subtle" style={{ marginTop: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<span>HH:MM</span>
<span>
{isAuthed && (
<span style={{ color: backendClaims ? "#6de3a2" : "#f0d000" }}>
{" "} {verifying ? "トークン確認中…" : backendClaims ? "バックエンドで確認済み" : "未確認トークン"}
</span>
)}
</span>
</div>
{loading && <div className="subtle"></div>}
{error && (
<div className="timer-status" style={{ color: "#f88" }}>
{error}{" "}
<button className="link-btn" onClick={() => loadShows()}>
</button>
</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);
setStartTime(s.start_time.slice(0, 5));
}}
>
<div className="title-row">
<div className="title">{s.ep_num}{s.ep_title}</div>
<button
className="link-btn danger"
onClick={(e) => { e.stopPropagation(); handleDelete(s.id).catch(() => {}); }}
disabled={deletingId === s.id || verifying}
title="このエピソードを削除(認証が必要)"
>
{deletingId === s.id ? "削除中…" : "削除"}
</button>
</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 ||
isRedirecting ||
(!!startTime && !toHHMMSS(startTime))
}
onClick={submit}
>
{redirectIn !== null ? `設定しました(${redirectIn}` : posting ? "設定中…" : "現在のエピソードに設定"}
</button>
</div>
{current && (
<div className="subtle" style={{ marginTop: 8 }}>
{current.ep_num}{current.ep_title}
</div>
)}
</div>
);
}