diff --git a/backend/docs/docs.go b/backend/docs/docs.go index e7746e6..1219ede 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -394,14 +394,14 @@ const docTemplate = `{ } }, "delete": { - "description": "Delete a row from ` + "`" + `current` + "`" + ` by ID.", + "description": "Delete a row from ` + "`" + `current_archive` + "`" + ` by ID.", "produces": [ "application/json" ], "tags": [ "shows" ], - "summary": "Delete show", + "summary": "Delete archived show", "parameters": [ { "type": "integer", diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 4cd1ca7..e562319 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -392,14 +392,14 @@ } }, "delete": { - "description": "Delete a row from `current` by ID.", + "description": "Delete a row from `current_archive` by ID.", "produces": [ "application/json" ], "tags": [ "shows" ], - "summary": "Delete show", + "summary": "Delete archived show", "parameters": [ { "type": "integer", diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 92e7b28..c26e634 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -409,7 +409,7 @@ paths: - auth /api/v1/shows: delete: - description: Delete a row from `current` by ID. + description: Delete a row from `current_archive` by ID. parameters: - description: Show ID format: int64 @@ -434,7 +434,7 @@ paths: description: delete failed schema: $ref: '#/definitions/httpapi.HTTPError' - summary: Delete show + summary: Delete archived show tags: - shows get: diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index a5ddb51..ff8b5ba 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -270,8 +270,8 @@ func listShowsHandler(svc episode.UseCases) gin.HandlerFunc { } // deleteShowHandler godoc -// @Summary Delete show -// @Description Delete a row from `current` by ID. +// @Summary Delete archived show +// @Description Delete a row from `current_archive` by ID. // @Tags shows // @Produce json // @Param id query int64 true "Show ID" diff --git a/backend/internal/repo/episode_repo.go b/backend/internal/repo/episode_repo.go index 57d0bda..cb41e3c 100644 --- a/backend/internal/repo/episode_repo.go +++ b/backend/internal/repo/episode_repo.go @@ -290,7 +290,7 @@ func (r *pgxEpisodeRepo) MoveToArchive(ctx context.Context, ids []int64) (episod } func (r *pgxEpisodeRepo) Delete(ctx context.Context, id int64) error { - cmdTag, err := r.pool.Exec(ctx, `DELETE FROM current WHERE id = $1`, id) + cmdTag, err := r.pool.Exec(ctx, `DELETE FROM current_archive WHERE id = $1`, id) if err != nil { return err } diff --git a/backend/internal/repo/episode_repo_test.go b/backend/internal/repo/episode_repo_test.go index d6c05cf..ad5b943 100644 --- a/backend/internal/repo/episode_repo_test.go +++ b/backend/internal/repo/episode_repo_test.go @@ -19,7 +19,7 @@ func TestPGXEpisodeRepo_Delete(t *testing.T) { t.Run("not found", func(t *testing.T) { fp := &fakePool{ execFn: func(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { - if sql != `DELETE FROM current WHERE id = $1` { + if sql != `DELETE FROM current_archive WHERE id = $1` { t.Fatalf("unexpected sql: %s", sql) } return pgconn.NewCommandTag("DELETE 0"), nil @@ -32,9 +32,13 @@ func TestPGXEpisodeRepo_Delete(t *testing.T) { }) t.Run("ok", func(t *testing.T) { - var gotID int64 + var ( + gotSQL string + gotID int64 + ) fp := &fakePool{ execFn: func(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { + gotSQL = sql gotID = args[0].(int64) return pgconn.NewCommandTag("DELETE 1"), nil }, @@ -43,6 +47,9 @@ func TestPGXEpisodeRepo_Delete(t *testing.T) { if err := repo.Delete(context.Background(), 22); err != nil { t.Fatalf("unexpected err: %v", err) } + if gotSQL != `DELETE FROM current_archive WHERE id = $1` { + t.Fatalf("expected archive delete sql, got %s", gotSQL) + } if gotID != 22 { t.Fatalf("expected id 22, got %d", gotID) } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ee81d80..27e376f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,8 @@ "firebase": "^12.6.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.5" + "react-router-dom": "^7.9.5", + "react-snowfall": "^2.4.0" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -4215,6 +4216,12 @@ "react": "^19.1.1" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4263,6 +4270,19 @@ "react-dom": ">=18" } }, + "node_modules/react-snowfall": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-snowfall/-/react-snowfall-2.4.0.tgz", + "integrity": "sha512-KAPMiGnxt11PEgC2pTVrTQsvk5jt1kLUtG+ZamiKLphTZ7GiYT1Aa5kX6jp4jKWq1kqJHchnGT9CDm4g86A5Gg==", + "license": "MIT", + "dependencies": { + "react-fast-compare": "^3.2.2" + }, + "peerDependencies": { + "react": "^16.8 || 17.x || 18.x || 19.x", + "react-dom": "^16.8 || 17.x || 18.x || 19.x" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b4e1f4c..46d8442 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,8 @@ "firebase": "^12.6.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.5" + "react-router-dom": "^7.9.5", + "react-snowfall": "^2.4.0" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e29ce7d..198a80d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import DebugOverlay from "./components/DebugOverlay"; import AuthStatus from "./components/AuthStatus"; import { useAuth } from "./auth/AuthProvider"; import "./index.css"; +import Snowfall from "react-snowfall"; const TIME_SYNC_OFF_THRESHOLD = 100; @@ -29,6 +30,10 @@ export default function App() { return (
+ {/* Top-left header (outside the card) */}
diff --git a/frontend/src/api/watchparty.ts b/frontend/src/api/watchparty.ts index a3de934..e00e53f 100644 --- a/frontend/src/api/watchparty.ts +++ b/frontend/src/api/watchparty.ts @@ -164,6 +164,22 @@ export async function createShow(payload: { }); } +export async function deleteArchiveShow(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(url, { + method: "DELETE", + headers: { + "Authorization": `Bearer ${idToken}`, + }, + timeoutMs: 10_000, + expect: "void", + logLabel: "delete archived show", + }); +} + export async function archiveShow(id: number, idToken: string) { if (!idToken) { throw new ApiError("Missing auth token for archive"); diff --git a/frontend/src/index.css b/frontend/src/index.css index f5b7997..f722582 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -543,9 +543,20 @@ kbd { .archive-row { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, box-shadow 160ms ease; } .archive-row:hover { background: rgba(255,255,255,0.05); } .archive-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.archive-row.selected { + border-color: rgba(121,192,255,0.7); + background: linear-gradient(135deg, rgba(121,192,255,0.10), rgba(121,192,255,0.04)); + box-shadow: 0 10px 28px rgba(121,192,255,0.14); +} +.archive-row:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} @media (max-width: 820px) { .archive-header, .archive-row { @@ -558,6 +569,18 @@ kbd { .archive-row span:nth-child(4) { grid-column: span 2; } .archive-table { min-width: 100%; } } +.archive-detail-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} +.archive-detail-bar { + position: sticky; + bottom: 10px; + z-index: 5; +} .scrape-card { padding: 14px; diff --git a/frontend/src/pages/ArchivePage.tsx b/frontend/src/pages/ArchivePage.tsx index 79c8732..7a93449 100644 --- a/frontend/src/pages/ArchivePage.tsx +++ b/frontend/src/pages/ArchivePage.tsx @@ -1,8 +1,8 @@ import React from "react"; -import { fetchArchive } from "../api/watchparty"; +import { deleteArchiveShow, fetchArchive } from "../api/watchparty"; import type { ArchiveItem } from "../api/types"; import { useAuth } from "../auth/AuthProvider"; -import { toastError } from "../utils/toastBus"; +import { toastError, toastInfo } from "../utils/toastBus"; import { logApiError } from "../utils/logger"; type SortKey = "id" | "ep_num" | "ep_title" | "season_name" | "start_time" | "playback_length" | "date_created" | "date_archived"; @@ -16,11 +16,13 @@ function formatTimestamp(ts: string) { } export default function ArchivePage() { - const { enabled, idToken, isAdmin } = useAuth(); + const { enabled, idToken, isAdmin, verifying } = useAuth(); const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [sort, setSort] = React.useState<{ key: SortKey | null; dir: SortDir }>({ key: null, dir: null }); + const [selectedId, setSelectedId] = React.useState(null); + const [deletingId, setDeletingId] = React.useState(null); const load = React.useCallback(async () => { if (!idToken) { @@ -32,6 +34,7 @@ export default function ArchivePage() { setLoading(true); const data = await fetchArchive(idToken); setItems(data); + setSelectedId((prev) => (prev != null && data.some((it) => it.id === prev) ? prev : null)); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "アーカイブを取得できませんでした。"; setError(msg); @@ -95,6 +98,40 @@ export default function ArchivePage() { return ""; }; + const selectedItem = React.useMemo( + () => items.find((it) => it.id === selectedId) ?? null, + [items, selectedId], + ); + + const handleSelect = React.useCallback((id: number) => { + setSelectedId((prev) => (prev === id ? null : id)); + }, []); + + async function handleDeleteSelected() { + if (!selectedItem) return; + if (!enabled) { + toastError("認証が無効です", "管理者に確認してください"); + return; + } + if (!idToken) { + toastError("サインインしてください", "削除には管理者サインインが必要です"); + return; + } + try { + setDeletingId(selectedItem.id); + await deleteArchiveShow(selectedItem.id, idToken); + toastInfo("アーカイブを削除しました"); + setItems((prev) => prev.filter((it) => it.id !== selectedItem.id)); + setSelectedId((prev) => (prev === selectedItem.id ? null : prev)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "削除に失敗しました。"; + toastError("削除に失敗しました", msg); + logApiError("delete archived show", e); + } finally { + setDeletingId(null); + } + } + if (!enabled) { return
認証が無効です。
; } @@ -152,7 +189,20 @@ export default function ArchivePage() {
{sortedItems.map((it) => ( -
+
handleSelect(it.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelect(it.id); + } + }} + > #{it.id} 第{it.ep_num}話 {it.ep_title} @@ -166,6 +216,40 @@ export default function ArchivePage() {
)} + + {selectedItem && ( +
+
+
1件選択中
+
+ 第{selectedItem.ep_num}話:{selectedItem.ep_title} +
+
+ シーズン {selectedItem.season_name} ・ 開始 {selectedItem.start_time.slice(0, 5)} ・ 再生 {selectedItem.playback_length.slice(0, 5)} ・ ID #{selectedItem.id} +
+
+ アーカイブ日時 {formatTimestamp(selectedItem.date_archived)} +
+
+ {idToken + ? (verifying ? "トークン確認中…" : "サインイン済み") + : "削除には管理者サインインが必要です"} +
+
+
+ + +
+
+ )} ); }