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 SubTopicPage from "./pages/SubTopicPage";
|
||||||
import AdminReports from "./pages/AdminReports";
|
import AdminReports from "./pages/AdminReports";
|
||||||
import AdminPipeline from "./pages/AdminPipeline";
|
import AdminPipeline from "./pages/AdminPipeline";
|
||||||
|
import AdminTechniquePages from "./pages/AdminTechniquePages";
|
||||||
import About from "./pages/About";
|
import About from "./pages/About";
|
||||||
import AdminDropdown from "./components/AdminDropdown";
|
import AdminDropdown from "./components/AdminDropdown";
|
||||||
import AppFooter from "./components/AppFooter";
|
import AppFooter from "./components/AppFooter";
|
||||||
|
|
@ -128,6 +129,7 @@ export default function App() {
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
<Route path="/admin/reports" element={<AdminReports />} />
|
<Route path="/admin/reports" element={<AdminReports />} />
|
||||||
<Route path="/admin/pipeline" element={<AdminPipeline />} />
|
<Route path="/admin/pipeline" element={<AdminPipeline />} />
|
||||||
|
<Route path="/admin/techniques" element={<AdminTechniquePages />} />
|
||||||
|
|
||||||
{/* Info routes */}
|
{/* Info routes */}
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
|
|
|
||||||
|
|
@ -739,3 +739,47 @@ export async function setDebugMode(enabled: boolean): Promise<DebugModeResponse>
|
||||||
body: JSON.stringify({ debug_mode: enabled }),
|
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
|
Pipeline
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin/techniques"
|
||||||
|
className="admin-dropdown__item"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Techniques
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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