feat(auth): integrate Firebase authentication and add auth status component
- Added Firebase as a dependency for authentication. - Created AuthProvider to manage authentication state and user sessions. - Implemented AuthStatus component to display authentication status in the UI. - Added verifyFirebaseIdToken function to validate Firebase ID tokens against the backend. - Updated API endpoints to include Firebase OAuth verification. - Enhanced ShowsPage to allow authenticated users to delete shows. - Updated configuration to include Firebase settings and authentication enablement. - Styled authentication components and added necessary CSS for better UI.
This commit is contained in:
parent
8b268640a5
commit
008f8a3cca
@ -26,6 +26,14 @@ ADDR=:8082
|
||||
# Auth (optional)
|
||||
AUTH_ENABLED=false
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_CREDENTIALS_FILE=/secrets/firebase_credentials.json
|
||||
# Use one of the following to supply Firebase service account:
|
||||
# FIREBASE_CREDENTIALS_JSON=
|
||||
# FIREBASE_CREDENTIALS_FILE=/path/to/firebase.json
|
||||
|
||||
# Frontend Firebase (for Google sign-in)
|
||||
VITE_AUTH_ENABLED=true
|
||||
VITE_FIREBASE_API_KEY=
|
||||
VITE_FIREBASE_AUTH_DOMAIN=
|
||||
VITE_FIREBASE_PROJECT_ID=
|
||||
VITE_FIREBASE_APP_ID=
|
||||
|
||||
996
frontend/package-lock.json
generated
996
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"firebase": "^12.6.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.5"
|
||||
|
||||
@ -5,6 +5,7 @@ import ShowsPage from "./pages/ShowsPage";
|
||||
import TimeSyncNotice from "./components/TimeSyncNotice";
|
||||
import { ToastViewport } from "./components/Toasts";
|
||||
import DebugOverlay from "./components/DebugOverlay";
|
||||
import AuthStatus from "./components/AuthStatus";
|
||||
import "./index.css";
|
||||
|
||||
const TIME_SYNC_OFF_THRESHOLD = 100;
|
||||
@ -36,6 +37,9 @@ export default function App() {
|
||||
</svg>
|
||||
</button>
|
||||
<Link to="/" className="brand">Watch Party</Link>
|
||||
<div className="auth-slot">
|
||||
<AuthStatus />
|
||||
</div>
|
||||
</header>
|
||||
{/* Time sync banner (checks every 5 min; shows if |skew| > 500ms) */}
|
||||
<TimeSyncNotice thresholdMs={TIME_SYNC_OFF_THRESHOLD} lang="ja" />
|
||||
|
||||
13
frontend/src/api/auth.ts
Normal file
13
frontend/src/api/auth.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { API_ENDPOINT } from "./endpoint";
|
||||
import { apiFetch } from "./client";
|
||||
import type { FirebaseAuthResponse } from "./types";
|
||||
|
||||
export async function verifyFirebaseIdToken(idToken: string): Promise<FirebaseAuthResponse> {
|
||||
return apiFetch<FirebaseAuthResponse>(API_ENDPOINT.v1.OAUTH_FIREBASE, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id_token: idToken }),
|
||||
timeoutMs: 10_000,
|
||||
logLabel: "verify firebase token",
|
||||
});
|
||||
}
|
||||
@ -6,5 +6,6 @@ export const API_ENDPOINT = {
|
||||
SHOWS: buildApiUrl("/v1/shows"),
|
||||
TIME: buildApiUrl("/v1/time"),
|
||||
DANIME: buildApiUrl("/v1/danime"),
|
||||
OAUTH_FIREBASE: buildApiUrl("/v1/oauth/firebase"),
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -28,3 +28,10 @@ export type DanimeEpisode = {
|
||||
season_name: string;
|
||||
playback_length: string;
|
||||
};
|
||||
|
||||
export type FirebaseAuthResponse = {
|
||||
uid: string;
|
||||
email?: string;
|
||||
issuer?: string;
|
||||
expires?: number;
|
||||
};
|
||||
|
||||
@ -136,3 +136,19 @@ export async function createShow(payload: {
|
||||
logLabel: "create show",
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteShow(id: number, idToken: string) {
|
||||
if (!idToken) {
|
||||
throw new ApiError("Missing auth token for delete");
|
||||
}
|
||||
const url = `${API_ENDPOINT.v1.SHOWS}?id=${encodeURIComponent(id)}`;
|
||||
await apiFetch<void>(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${idToken}`,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
expect: "void",
|
||||
logLabel: "delete show",
|
||||
});
|
||||
}
|
||||
|
||||
126
frontend/src/auth/AuthProvider.tsx
Normal file
126
frontend/src/auth/AuthProvider.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import { GoogleAuthProvider, getAuth, getIdToken, onIdTokenChanged, signInWithPopup, signOut as fbSignOut, type User } from "firebase/auth";
|
||||
import { getFirebaseApp } from "./firebaseClient";
|
||||
import { config } from "../config";
|
||||
import { verifyFirebaseIdToken } from "../api/auth";
|
||||
import type { FirebaseAuthResponse } from "../api/types";
|
||||
|
||||
type AuthStatus = "disabled" | "loading" | "ready" | "error";
|
||||
|
||||
type AuthContextShape = {
|
||||
enabled: boolean;
|
||||
status: AuthStatus;
|
||||
user: User | null;
|
||||
idToken: string | null;
|
||||
backendClaims: FirebaseAuthResponse | null;
|
||||
verifying: boolean;
|
||||
error: string | null;
|
||||
signInWithGoogle: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
refreshToken: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = React.createContext<AuthContextShape | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const enabled = config.auth.enabled;
|
||||
const [status, setStatus] = React.useState<AuthStatus>(enabled ? "loading" : "disabled");
|
||||
const [user, setUser] = React.useState<User | null>(null);
|
||||
const [idToken, setIdToken] = React.useState<string | null>(null);
|
||||
const [backendClaims, setBackendClaims] = React.useState<FirebaseAuthResponse | null>(null);
|
||||
const [verifying, setVerifying] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Subscribe to Firebase auth state
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const app = getFirebaseApp();
|
||||
const auth = getAuth(app);
|
||||
const unsub = onIdTokenChanged(auth, async (fbUser) => {
|
||||
setUser(fbUser);
|
||||
setBackendClaims(null);
|
||||
if (!fbUser) {
|
||||
setIdToken(null);
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = await getIdToken(fbUser, true);
|
||||
setIdToken(token);
|
||||
setVerifying(true);
|
||||
const claims = await verifyFirebaseIdToken(token);
|
||||
setBackendClaims(claims);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to verify token";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
setStatus("ready");
|
||||
}
|
||||
});
|
||||
return () => unsub();
|
||||
}, [enabled]);
|
||||
|
||||
const signInWithGoogle = React.useCallback(async () => {
|
||||
if (!enabled) {
|
||||
setError("Auth disabled");
|
||||
return;
|
||||
}
|
||||
const app = getFirebaseApp();
|
||||
const auth = getAuth(app);
|
||||
const provider = new GoogleAuthProvider();
|
||||
await signInWithPopup(auth, provider);
|
||||
}, [enabled]);
|
||||
|
||||
const signOut = React.useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
const app = getFirebaseApp();
|
||||
const auth = getAuth(app);
|
||||
await fbSignOut(auth);
|
||||
setBackendClaims(null);
|
||||
setIdToken(null);
|
||||
}, [enabled]);
|
||||
|
||||
const refreshToken = React.useCallback(async () => {
|
||||
if (!enabled || !user) return;
|
||||
const token = await getIdToken(user, true);
|
||||
setIdToken(token);
|
||||
setVerifying(true);
|
||||
try {
|
||||
const claims = await verifyFirebaseIdToken(token);
|
||||
setBackendClaims(claims);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to verify token";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
}, [enabled, user]);
|
||||
|
||||
const value: AuthContextShape = React.useMemo(() => ({
|
||||
enabled,
|
||||
status,
|
||||
user,
|
||||
idToken,
|
||||
backendClaims,
|
||||
verifying,
|
||||
error,
|
||||
signInWithGoogle,
|
||||
signOut,
|
||||
refreshToken,
|
||||
}), [enabled, status, user, idToken, backendClaims, verifying, error, signInWithGoogle, signOut, refreshToken]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = React.useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
17
frontend/src/auth/firebaseClient.ts
Normal file
17
frontend/src/auth/firebaseClient.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { getApp, getApps, initializeApp, type FirebaseApp } from "firebase/app";
|
||||
import { config } from "../config";
|
||||
|
||||
let cachedApp: FirebaseApp | null = null;
|
||||
|
||||
export function getFirebaseApp(): FirebaseApp {
|
||||
if (cachedApp) return cachedApp;
|
||||
if (!config.auth.enabled) {
|
||||
throw new Error("Firebase auth not enabled");
|
||||
}
|
||||
if (!getApps().length) {
|
||||
cachedApp = initializeApp(config.auth.firebase);
|
||||
} else {
|
||||
cachedApp = getApp();
|
||||
}
|
||||
return cachedApp;
|
||||
}
|
||||
33
frontend/src/components/AuthStatus.tsx
Normal file
33
frontend/src/components/AuthStatus.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useAuth } from "../auth/AuthProvider";
|
||||
|
||||
export default function AuthStatus() {
|
||||
const { enabled, status, user, backendClaims, verifying, signInWithGoogle, signOut, error } = useAuth();
|
||||
|
||||
if (!enabled) {
|
||||
return <div className="auth-chip muted">Auth off</div>;
|
||||
}
|
||||
if (status === "loading") {
|
||||
return <div className="auth-chip">Loading auth…</div>;
|
||||
}
|
||||
if (!user) {
|
||||
return (
|
||||
<button className="auth-btn" onClick={() => signInWithGoogle().catch(() => {})}>
|
||||
<span className="auth-icon">G</span>
|
||||
<span>Google でサインイン</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="auth-chip signed-in">
|
||||
{user.photoURL && <img src={user.photoURL} alt="" className="auth-avatar" referrerPolicy="no-referrer" />}
|
||||
<div className="auth-meta">
|
||||
<div className="auth-name">{user.displayName || user.email || user.uid}</div>
|
||||
<div className="auth-subtle">
|
||||
{verifying ? "確認中…" : backendClaims ? "バックエンド認証済み" : "未確認"}
|
||||
</div>
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
</div>
|
||||
<button className="auth-signout" onClick={() => signOut().catch(() => {})}>サインアウト</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,10 @@ type AppConfig = {
|
||||
basePath: string;
|
||||
apiBase: string;
|
||||
backendOrigin: string;
|
||||
auth: {
|
||||
enabled: boolean;
|
||||
firebase: FirebaseWebConfig;
|
||||
};
|
||||
intervals: {
|
||||
timeSyncMs: number;
|
||||
timeSkewMs: number;
|
||||
@ -11,6 +15,13 @@ type AppConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
type FirebaseWebConfig = {
|
||||
apiKey: string;
|
||||
authDomain: string;
|
||||
projectId: string;
|
||||
appId: string;
|
||||
};
|
||||
|
||||
export function normalizeBasePath(raw?: string) {
|
||||
let v = (raw || "/").trim();
|
||||
if (!v.startsWith("/")) v = `/${v}`;
|
||||
@ -40,6 +51,15 @@ const apiBase = normalizeApiBase(import.meta.env.VITE_BACKEND_ORIGIN);
|
||||
const timeSyncMs = envNumber("VITE_INTERVAL_TIME_SYNC_MS", 60_000);
|
||||
const timeSkewMs = envNumber("VITE_INTERVAL_TIME_SKEW_MS", 5 * 60_000);
|
||||
const schedulePollMs = envNumber("VITE_INTERVAL_SCHEDULE_POLL_MS", 60_000);
|
||||
const firebaseConfig: FirebaseWebConfig = {
|
||||
apiKey: (import.meta.env.VITE_FIREBASE_API_KEY || "").toString(),
|
||||
authDomain: (import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || "").toString(),
|
||||
projectId: (import.meta.env.VITE_FIREBASE_PROJECT_ID || "").toString(),
|
||||
appId: (import.meta.env.VITE_FIREBASE_APP_ID || "").toString(),
|
||||
};
|
||||
const authEnabledEnv = (import.meta.env.VITE_AUTH_ENABLED ?? "true").toString().toLowerCase();
|
||||
const authEnabled = authEnabledEnv !== "false";
|
||||
const hasFirebaseConfig = firebaseConfig.apiKey && firebaseConfig.authDomain && firebaseConfig.projectId && firebaseConfig.appId;
|
||||
|
||||
export const config: AppConfig = {
|
||||
mode: rawMode || "production",
|
||||
@ -47,6 +67,10 @@ export const config: AppConfig = {
|
||||
basePath,
|
||||
apiBase,
|
||||
backendOrigin: apiBase, // alias for clarity
|
||||
auth: {
|
||||
enabled: authEnabled && !!hasFirebaseConfig,
|
||||
firebase: firebaseConfig,
|
||||
},
|
||||
intervals: {
|
||||
timeSyncMs,
|
||||
timeSkewMs,
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
--text: #e6eef8;
|
||||
--subtle: #9fb3c8;
|
||||
--accent: #79c0ff;
|
||||
--success: #3dd598;
|
||||
--danger: #ff6b6b;
|
||||
--header-h: 56px;
|
||||
}
|
||||
|
||||
@ -192,6 +194,66 @@ kbd {
|
||||
letter-spacing: 0.2px;
|
||||
text-shadow: 0 1px 10px rgba(0,0,0,0.25);
|
||||
}
|
||||
.auth-slot {
|
||||
grid-area: banner;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.auth-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.auth-chip.signed-in {
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
.auth-chip.muted { color: var(--subtle); }
|
||||
.auth-avatar {
|
||||
width: 32px; height: 32px; border-radius: 50%; object-fit: cover;
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
}
|
||||
.auth-meta { display: grid; gap: 2px; }
|
||||
.auth-name { font-weight: 700; font-size: 13px; }
|
||||
.auth-subtle { color: var(--subtle); font-size: 11px; }
|
||||
.auth-error { color: var(--danger); font-size: 11px; }
|
||||
.auth-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-btn:hover { background: rgba(255,255,255,0.12); }
|
||||
.auth-icon {
|
||||
width: 20px; height: 20px;
|
||||
display: grid; place-items: center;
|
||||
background: white;
|
||||
color: #4285f4;
|
||||
border-radius: 6px;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
}
|
||||
.auth-signout {
|
||||
margin-left: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-signout:hover { background: rgba(255,255,255,0.1); }
|
||||
|
||||
/* Sidebar (full height, left) */
|
||||
.sidebar {
|
||||
@ -615,4 +677,5 @@ kbd {
|
||||
text-decoration: underline;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.link-btn.danger { color: var(--danger); }
|
||||
.link-btn:hover { color: #bfe3ff; }
|
||||
|
||||
@ -4,11 +4,14 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import { config } from "./config";
|
||||
import { AuthProvider } from "./auth/AuthProvider";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename={config.basePath}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<AuthProvider>
|
||||
<BrowserRouter basename={config.basePath}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@ -2,8 +2,9 @@ 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, fetchDanimeEpisode, fetchShows, postCurrentEpisode } from "../api/watchparty";
|
||||
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;
|
||||
|
||||
@ -60,9 +61,12 @@ export default function ShowsPage() {
|
||||
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, signInWithGoogle } = useAuth();
|
||||
const isAuthed = authEnabled && !!idToken;
|
||||
|
||||
// フォーム状態
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
@ -220,6 +224,32 @@ export default function ShowsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
@ -281,9 +311,24 @@ export default function ShowsPage() {
|
||||
</div>
|
||||
|
||||
<h2 className="h1" style={{ marginBottom: 8 }}>エピソード一覧</h2>
|
||||
<p className="subtle" style={{ marginTop: 0 }}>
|
||||
エピソードを選び、必要に応じて開始時刻(HH:MM)を入力し、「現在のエピソードに設定」を押してください。
|
||||
</p>
|
||||
<div className="subtle" style={{ marginTop: 0, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span>エピソードを選び、必要に応じて開始時刻(HH:MM)を入力し、「現在のエピソードに設定」を押してください。</span>
|
||||
<span>
|
||||
削除は認証後のみ実行できます。
|
||||
{!isAuthed && (
|
||||
<>
|
||||
{" "} <button className="link-btn" onClick={() => signInWithGoogle().catch(() => {})}>
|
||||
Googleでサインイン
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAuthed && (
|
||||
<span style={{ color: backendClaims ? "#6de3a2" : "#f0d000" }}>
|
||||
{verifying ? "トークン確認中…" : backendClaims ? "バックエンドで確認済み" : "未確認トークン"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <div className="subtle">読み込み中…</div>}
|
||||
{error && (
|
||||
@ -308,7 +353,17 @@ export default function ShowsPage() {
|
||||
setStartTime(s.start_time.slice(0, 5));
|
||||
}}
|
||||
>
|
||||
<div className="title">第{s.ep_num}話:{s.ep_title}</div>
|
||||
<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)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user