404 lines
17 KiB
TypeScript
404 lines
17 KiB
TypeScript
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 のエピソードURL(partId付き)を入力してください。");
|
||
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>
|
||
);
|
||
}
|