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:
parent
bd8a928c95
commit
edfabb037a
5 changed files with 299 additions and 1 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
244
frontend/src/pages/AdminTechniquePages.tsx
Normal file
244
frontend/src/pages/AdminTechniquePages.tsx
Normal 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">A–Z</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"}
|
||||
Loading…
Add table
Reference in a new issue