feat: Built AdminTechniquePages page at /admin/techniques with table, e…

- "frontend/src/pages/AdminTechniquePages.tsx"
- "frontend/src/api/public-client.ts"
- "frontend/src/App.tsx"
- "frontend/src/components/AdminDropdown.tsx"

GSD-Task: S06/T02
This commit is contained in:
jlightner 2026-04-03 01:59:49 +00:00
parent bd8a928c95
commit edfabb037a
5 changed files with 299 additions and 1 deletions

View file

@ -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 */}
<Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/pipeline" element={<AdminPipeline />} />
<Route path="/admin/techniques" element={<AdminTechniquePages />} />
{/* Info routes */}
<Route path="/about" element={<About />} />

View file

@ -739,3 +739,47 @@ export async function setDebugMode(enabled: boolean): Promise<DebugModeResponse>
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<AdminTechniquePageListResponse> {
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<AdminTechniquePageListResponse>(
`${BASE}/admin/pipeline/technique-pages${query ? `?${query}` : ""}`,
);
}

View file

@ -58,6 +58,14 @@ export default function AdminDropdown() {
>
Pipeline
</Link>
<Link
to="/admin/techniques"
className="admin-dropdown__item"
role="menuitem"
onClick={() => setOpen(false)}
>
Techniques
</Link>
</div>
)}
</div>

View file

@ -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<AdminTechniquePageItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [multiSourceOnly, setMultiSourceOnly] = useState(false);
const [creatorFilter, setCreatorFilter] = useState("");
const [sort, setSort] = useState("recent");
// Expand state
const [expandedSlug, setExpandedSlug] = useState<string | null>(null);
const [expandedVideos, setExpandedVideos] = useState<SourceVideoSummary[]>([]);
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 (
<div className="admin-page">
<div className="admin-page__header">
<h1>Technique Pages</h1>
<span className="admin-page__count">{total} pages</span>
</div>
{/* Filter bar */}
<div className="admin-page__filters">
<label className="admin-page__checkbox">
<input
type="checkbox"
checked={multiSourceOnly}
onChange={(e) => setMultiSourceOnly(e.target.checked)}
/>
Multi-source only
</label>
<input
type="text"
className="admin-page__search"
placeholder="Filter by creator…"
value={creatorFilter}
onChange={(e) => setCreatorFilter(e.target.value)}
/>
<select
className="admin-page__select"
value={sort}
onChange={(e) => setSort(e.target.value)}
>
<option value="recent">Recent</option>
<option value="alpha">AZ</option>
<option value="creator">By Creator</option>
</select>
</div>
{error && <div className="admin-page__error">{error}</div>}
{loading ? (
<div className="admin-page__loading">Loading</div>
) : items.length === 0 ? (
<div className="admin-page__empty">No technique pages found.</div>
) : (
<div className="admin-page__table-wrap">
<table className="admin-page__table">
<thead>
<tr>
<th></th>
<th>Title</th>
<th>Creator</th>
<th>Category</th>
<th>Format</th>
<th>Sources</th>
<th>Versions</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<React.Fragment key={item.id}>
<tr
className={`admin-page__row${expandedSlug === item.slug ? " admin-page__row--expanded" : ""}`}
onClick={() => handleExpand(item.slug)}
style={{ cursor: "pointer" }}
>
<td className="admin-page__expand-toggle">
{expandedSlug === item.slug ? "▾" : "▸"}
</td>
<td>
<Link
to={`/techniques/${item.slug}`}
onClick={(e) => e.stopPropagation()}
title="View public page"
>
{item.title}
</Link>
</td>
<td>
<Link
to={`/creators/${item.creator_slug}`}
onClick={(e) => e.stopPropagation()}
>
{item.creator_name}
</Link>
</td>
<td>{item.topic_category}</td>
<td>
<span
className={`pipeline-badge ${item.body_sections_format === "v2_sections" ? "pipeline-badge--success" : "pipeline-badge--pending"}`}
>
{formatBadge(item.body_sections_format)}
</span>
</td>
<td>{item.source_video_count}</td>
<td>{item.version_count}</td>
<td>{formatDate(item.updated_at)}</td>
</tr>
{expandedSlug === item.slug && (
<tr className="admin-page__detail-row">
<td colSpan={8}>
<div className="admin-page__detail-panel">
<div className="admin-page__detail-links">
<Link to={`/techniques/${item.slug}`}>
View public page
</Link>
</div>
<h4>Source Videos</h4>
{expandLoading ? (
<p>Loading source videos</p>
) : expandedVideos.length === 0 ? (
<p className="admin-page__muted">
No source videos linked.
</p>
) : (
<ul className="admin-page__source-list">
{expandedVideos.map((v) => (
<li key={v.id}>
<Link
to={`/admin/pipeline?video=${v.id}`}
title="View in pipeline admin"
>
{v.filename}
</Link>
{v.added_at && (
<span className="admin-page__muted">
{" "}
added {formatDate(v.added_at)}
</span>
)}
</li>
))}
</ul>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -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"}
{"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"}