From edfabb037aa594c49b641ab0981ed6dffdb9b81f Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 01:59:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20AdminTechniquePages=20page=20at?= =?UTF-8?q?=20/admin/techniques=20with=20table,=20e=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/pages/AdminTechniquePages.tsx" - "frontend/src/api/public-client.ts" - "frontend/src/App.tsx" - "frontend/src/components/AdminDropdown.tsx" GSD-Task: S06/T02 --- frontend/src/App.tsx | 2 + frontend/src/api/public-client.ts | 44 ++++ frontend/src/components/AdminDropdown.tsx | 8 + frontend/src/pages/AdminTechniquePages.tsx | 244 +++++++++++++++++++++ frontend/tsconfig.app.tsbuildinfo | 2 +- 5 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/AdminTechniquePages.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5ea450c..0c44003 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import TopicsBrowse from "./pages/TopicsBrowse"; import SubTopicPage from "./pages/SubTopicPage"; import AdminReports from "./pages/AdminReports"; import AdminPipeline from "./pages/AdminPipeline"; +import AdminTechniquePages from "./pages/AdminTechniquePages"; import About from "./pages/About"; import AdminDropdown from "./components/AdminDropdown"; import AppFooter from "./components/AppFooter"; @@ -128,6 +129,7 @@ export default function App() { {/* Admin routes */} } /> } /> + } /> {/* Info routes */} } /> diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index c5bf25c..2e436fd 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -739,3 +739,47 @@ export async function setDebugMode(enabled: boolean): Promise body: JSON.stringify({ debug_mode: enabled }), }); } + +// ── Admin: Technique Pages ───────────────────────────────────────────────── + +export interface AdminTechniquePageItem { + id: string; + title: string; + slug: string; + creator_name: string; + creator_slug: string; + topic_category: string; + body_sections_format: string; + source_video_count: number; + version_count: number; + created_at: string; + updated_at: string; +} + +export interface AdminTechniquePageListResponse { + items: AdminTechniquePageItem[]; + total: number; + offset: number; + limit: number; +} + +export async function fetchAdminTechniquePages( + params: { + multi_source_only?: boolean; + creator?: string; + sort?: string; + offset?: number; + limit?: number; + } = {}, +): Promise { + const qs = new URLSearchParams(); + if (params.multi_source_only) qs.set("multi_source_only", "true"); + if (params.creator) qs.set("creator", params.creator); + if (params.sort) qs.set("sort", params.sort); + if (params.offset !== undefined) qs.set("offset", String(params.offset)); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + const query = qs.toString(); + return request( + `${BASE}/admin/pipeline/technique-pages${query ? `?${query}` : ""}`, + ); +} diff --git a/frontend/src/components/AdminDropdown.tsx b/frontend/src/components/AdminDropdown.tsx index eb8d8c0..081c096 100644 --- a/frontend/src/components/AdminDropdown.tsx +++ b/frontend/src/components/AdminDropdown.tsx @@ -58,6 +58,14 @@ export default function AdminDropdown() { > Pipeline + setOpen(false)} + > + Techniques + )} diff --git a/frontend/src/pages/AdminTechniquePages.tsx b/frontend/src/pages/AdminTechniquePages.tsx new file mode 100644 index 0000000..c5e8eaa --- /dev/null +++ b/frontend/src/pages/AdminTechniquePages.tsx @@ -0,0 +1,244 @@ +/** + * Admin view for technique pages — list with source/version counts, + * expandable rows showing source videos, cross-links to pipeline admin. + */ + +import React, { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import { + fetchAdminTechniquePages, + fetchTechnique, + type AdminTechniquePageItem, + type SourceVideoSummary, +} from "../api/public-client"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function formatDate(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatBadge(format: string): string { + if (format === "v2_sections") return "v2"; + if (format === "v1_markdown") return "v1"; + return format; +} + +// ── Main Component ─────────────────────────────────────────────────────────── + +export default function AdminTechniquePages() { + useDocumentTitle("Admin — Technique Pages"); + + // List state + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Filters + const [multiSourceOnly, setMultiSourceOnly] = useState(false); + const [creatorFilter, setCreatorFilter] = useState(""); + const [sort, setSort] = useState("recent"); + + // Expand state + const [expandedSlug, setExpandedSlug] = useState(null); + const [expandedVideos, setExpandedVideos] = useState([]); + const [expandLoading, setExpandLoading] = useState(false); + + // Fetch list + const fetchList = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchAdminTechniquePages({ + multi_source_only: multiSourceOnly || undefined, + creator: creatorFilter || undefined, + sort, + limit: 100, + }); + setItems(data.items); + setTotal(data.total); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [multiSourceOnly, creatorFilter, sort]); + + useEffect(() => { + fetchList(); + }, [fetchList]); + + // Expand row — fetch technique detail for source videos + const handleExpand = useCallback( + async (slug: string) => { + if (expandedSlug === slug) { + setExpandedSlug(null); + return; + } + setExpandedSlug(slug); + setExpandLoading(true); + try { + const detail = await fetchTechnique(slug); + setExpandedVideos(detail.source_videos ?? []); + } catch { + setExpandedVideos([]); + } finally { + setExpandLoading(false); + } + }, + [expandedSlug], + ); + + return ( +
+
+

Technique Pages

+ {total} pages +
+ + {/* Filter bar */} +
+ + setCreatorFilter(e.target.value)} + /> + +
+ + {error &&
{error}
} + + {loading ? ( +
Loading…
+ ) : items.length === 0 ? ( +
No technique pages found.
+ ) : ( +
+ + + + + + + + + + + + + + + {items.map((item) => ( + + handleExpand(item.slug)} + style={{ cursor: "pointer" }} + > + + + + + + + + + + {expandedSlug === item.slug && ( + + + + )} + + ))} + +
TitleCreatorCategoryFormatSourcesVersionsUpdated
+ {expandedSlug === item.slug ? "▾" : "▸"} + + e.stopPropagation()} + title="View public page" + > + {item.title} + + + e.stopPropagation()} + > + {item.creator_name} + + {item.topic_category} + + {formatBadge(item.body_sections_format)} + + {item.source_video_count}{item.version_count}{formatDate(item.updated_at)}
+
+
+ + View public page → + +
+

Source Videos

+ {expandLoading ? ( +

Loading source videos…

+ ) : expandedVideos.length === 0 ? ( +

+ No source videos linked. +

+ ) : ( +
    + {expandedVideos.map((v) => ( +
  • + + {v.filename} + + {v.added_at && ( + + {" "} + — added {formatDate(v.added_at)} + + )} +
  • + ))} +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 9d3e49a..6c151ce 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file