From b0038f21f746915b64f41c13484259b71a0ef186 Mon Sep 17 00:00:00 2001 From: jlightner Date: Mon, 30 Mar 2026 08:47:55 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20CSS=20grid=20layout=20splitting?= =?UTF-8?q?=20technique=20page=20into=20prose=20(left)=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/App.css" - "frontend/src/pages/TechniquePage.tsx" GSD-Task: S02/T01 --- frontend/src/App.css | 31 +- frontend/src/pages/TechniquePage.tsx | 511 +++++++++++++++++++++++++++ 2 files changed, 539 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/TechniquePage.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index 08b2bfd..7e82800 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1028,7 +1028,7 @@ body { /* ── Search results page ──────────────────────────────────────────────────── */ .search-results-page { - max-width: 48rem; + max-width: 64rem; } .search-fallback-banner { @@ -1177,7 +1177,32 @@ body { /* ── Technique page ───────────────────────────────────────────────────────── */ .technique-page { - max-width: 48rem; + max-width: 64rem; +} + +.technique-columns { + display: grid; + grid-template-columns: 1fr 22rem; + gap: 2rem; + align-items: start; +} + +.technique-columns__main { + min-width: 0; /* prevent grid blowout */ +} + +.technique-columns__sidebar { + position: sticky; + top: 1.5rem; +} + +@media (max-width: 768px) { + .technique-columns { + grid-template-columns: 1fr; + } + .technique-columns__sidebar { + position: static; + } } .technique-404 { @@ -1631,7 +1656,7 @@ body { ══════════════════════════════════════════════════════════════════════════════ */ .creator-detail { - max-width: 48rem; + max-width: 64rem; } .creator-detail__header { diff --git a/frontend/src/pages/TechniquePage.tsx b/frontend/src/pages/TechniquePage.tsx new file mode 100644 index 0000000..e892913 --- /dev/null +++ b/frontend/src/pages/TechniquePage.tsx @@ -0,0 +1,511 @@ +/** + * Technique page detail view with version switching. + * + * Fetches a single technique by slug. When historical versions exist, + * shows a version switcher that lets admins view previous snapshots + * with pipeline metadata (prompt hashes, model config). + */ + +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { + fetchTechnique, + fetchTechniqueVersions, + fetchTechniqueVersion, + type TechniquePageDetail as TechniqueDetail, + type TechniquePageVersionSummary, + type TechniquePageVersionDetail, +} from "../api/public-client"; +import ReportIssueModal from "../components/ReportIssueModal"; + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** Extract the displayable fields from a version snapshot, matching TechniqueDetail shape. */ +function snapshotToOverlay(snapshot: Record) { + return { + title: typeof snapshot.title === "string" ? snapshot.title : undefined, + summary: typeof snapshot.summary === "string" ? snapshot.summary : undefined, + topic_category: + typeof snapshot.topic_category === "string" + ? snapshot.topic_category + : undefined, + topic_tags: Array.isArray(snapshot.topic_tags) + ? (snapshot.topic_tags as string[]) + : undefined, + body_sections: + typeof snapshot.body_sections === "object" && snapshot.body_sections !== null + ? (snapshot.body_sections as Record) + : undefined, + signal_chains: Array.isArray(snapshot.signal_chains) + ? (snapshot.signal_chains as unknown[]) + : undefined, + plugins: Array.isArray(snapshot.plugins) + ? (snapshot.plugins as string[]) + : undefined, + source_quality: + typeof snapshot.source_quality === "string" + ? snapshot.source_quality + : undefined, + }; +} + +export default function TechniquePage() { + const { slug } = useParams<{ slug: string }>(); + const [technique, setTechnique] = useState(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(null); + const [showReport, setShowReport] = useState(false); + + // Version switching + const [versions, setVersions] = useState([]); + const [selectedVersion, setSelectedVersion] = useState("current"); + const [versionDetail, setVersionDetail] = + useState(null); + const [versionLoading, setVersionLoading] = useState(false); + + // Load technique + version list + useEffect(() => { + if (!slug) return; + + let cancelled = false; + setLoading(true); + setNotFound(false); + setError(null); + setSelectedVersion("current"); + setVersionDetail(null); + setVersions([]); + + void (async () => { + try { + const data = await fetchTechnique(slug); + if (!cancelled) { + setTechnique(data); + // Load versions if any exist + if (data.version_count > 0) { + try { + const vRes = await fetchTechniqueVersions(slug); + if (!cancelled) setVersions(vRes.items); + } catch { + // Non-critical — version list fails silently + } + } + } + } catch (err) { + if (!cancelled) { + if (err instanceof Error && err.message.includes("404")) { + setNotFound(true); + } else { + setError( + err instanceof Error ? err.message : "Failed to load technique", + ); + } + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [slug]); + + // Load version detail when selection changes + useEffect(() => { + if (!slug || selectedVersion === "current") { + setVersionDetail(null); + return; + } + + let cancelled = false; + setVersionLoading(true); + + void (async () => { + try { + const detail = await fetchTechniqueVersion( + slug, + Number(selectedVersion), + ); + if (!cancelled) setVersionDetail(detail); + } catch { + if (!cancelled) setVersionDetail(null); + } finally { + if (!cancelled) setVersionLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [slug, selectedVersion]); + + if (loading) { + return
Loading technique…
; + } + + if (notFound) { + return ( +
+

Technique Not Found

+

The technique “{slug}” doesn’t exist.

+ + Back to Home + +
+ ); + } + + if (error || !technique) { + return ( +
+ Error: {error ?? "Unknown error"} +
+ ); + } + + // Overlay snapshot fields when viewing a historical version + const isHistorical = selectedVersion !== "current" && versionDetail != null; + const overlay = isHistorical + ? snapshotToOverlay(versionDetail.content_snapshot) + : null; + + const displayTitle = overlay?.title ?? technique.title; + const displaySummary = overlay?.summary ?? technique.summary; + const displayCategory = overlay?.topic_category ?? technique.topic_category; + const displayTags = overlay?.topic_tags ?? technique.topic_tags; + const displaySections = overlay?.body_sections ?? technique.body_sections; + const displayChains = overlay?.signal_chains ?? technique.signal_chains; + const displayPlugins = overlay?.plugins ?? technique.plugins; + const displayQuality = overlay?.source_quality ?? technique.source_quality; + + return ( +
+ {/* Back link */} + + ← Back + + + {/* Historical version banner */} + {isHistorical && ( +
+ 📋 Viewing version {versionDetail.version_number} from{" "} + {formatDate(versionDetail.created_at)} + +
+ )} + + {/* Unstructured content warning */} + {displayQuality === "unstructured" && ( +
+ ⚠ This technique was sourced from a livestream and may have less + structured content. +
+ )} + + {/* Header */} +
+

{displayTitle}

+
+ {displayCategory} + {displayTags && displayTags.length > 0 && ( + + {displayTags.map((tag) => ( + + {tag} + + ))} + + )} + {technique.creator_info && ( + + by {technique.creator_info.name} + + )} + {displayQuality && ( + + {displayQuality} + + )} +
+ + {/* Meta stats line */} +
+ {(() => { + const sourceCount = new Set( + technique.key_moments + .map((km) => km.video_filename) + .filter(Boolean), + ).size; + const momentCount = technique.key_moments.length; + const updated = new Date(technique.updated_at).toLocaleDateString( + "en-US", + { year: "numeric", month: "short", day: "numeric" }, + ); + const parts = [ + `Compiled from ${sourceCount} source${sourceCount !== 1 ? "s" : ""}`, + `${momentCount} key moment${momentCount !== 1 ? "s" : ""}`, + ]; + if (technique.version_count > 0) { + parts.push( + `${technique.version_count} version${technique.version_count !== 1 ? "s" : ""}`, + ); + } + parts.push(`Last updated ${updated}`); + return parts.join(" · "); + })()} +
+ + {/* Version switcher + report button row */} +
+ {versions.length > 0 && ( +
+ + + {versionLoading && ( + Loading… + )} +
+ )} + +
+
+ + {/* Pipeline metadata for historical versions */} + {isHistorical && versionDetail.pipeline_metadata && ( +
+

Pipeline metadata (v{versionDetail.version_number})

+
+ {"model" in versionDetail.pipeline_metadata && ( +
+ Model + + {String(versionDetail.pipeline_metadata.model)} + +
+ )} + {"captured_at" in versionDetail.pipeline_metadata && ( +
+ Captured + + {formatDate(String(versionDetail.pipeline_metadata.captured_at))} + +
+ )} + {typeof versionDetail.pipeline_metadata.prompt_hashes === "object" && + versionDetail.pipeline_metadata.prompt_hashes !== null && ( +
+ Prompt hashes +
+ {Object.entries( + versionDetail.pipeline_metadata.prompt_hashes as Record< + string, + string + >, + ).map(([file, hash]) => ( +
+ + {file.replace(/^.*\//, "")} + + + {hash.slice(0, 12)}… + +
+ ))} +
+
+ )} +
+
+ )} + + {/* Report modal */} + {showReport && ( + setShowReport(false)} + /> + )} + +
+
+ {/* Summary */} + {displaySummary && ( +
+

{displaySummary}

+
+ )} + + {/* Study guide prose — body_sections */} + {displaySections && + Object.keys(displaySections).length > 0 && ( +
+ {Object.entries(displaySections).map( + ([sectionTitle, content]: [string, unknown]) => ( +
+

{sectionTitle}

+ {typeof content === "string" ? ( +

{content as string}

+ ) : typeof content === "object" && content !== null ? ( +
+                      {JSON.stringify(content, null, 2)}
+                    
+ ) : ( +

{String(content as string)}

+ )} +
+ ), + )} +
+ )} + +
+
+ {/* Key moments (always from live data — not versioned) */} + {technique.key_moments.length > 0 && ( +
+

Key Moments

+
    + {technique.key_moments.map((km) => ( +
  1. +
    + {km.title} + {km.video_filename && ( + + {km.video_filename} + + )} + + {formatTime(km.start_time)} – {formatTime(km.end_time)} + + + {km.content_type} + +
    +

    {km.summary}

    +
  2. + ))} +
+
+ )} + + {/* Signal chains */} + {displayChains && + displayChains.length > 0 && ( +
+

Signal Chains

+ {displayChains.map((chain, i) => { + const chainObj = chain as Record; + const chainName = + typeof chainObj["name"] === "string" + ? chainObj["name"] + : `Chain ${i + 1}`; + const steps = Array.isArray(chainObj["steps"]) + ? (chainObj["steps"] as string[]) + : []; + return ( +
+

{chainName}

+ {steps.length > 0 && ( +
+ {steps.map((step, j) => ( + + {j > 0 && ( + + {" → "} + + )} + + {String(step)} + + + ))} +
+ )} +
+ ); + })} +
+ )} + + {/* Plugins */} + {displayPlugins && displayPlugins.length > 0 && ( +
+

Plugins Referenced

+
+ {displayPlugins.map((plugin) => ( + + {plugin} + + ))} +
+
+ )} + + {/* Related techniques (always from live data) */} + {technique.related_links.length > 0 && ( +
+

Related Techniques

+
    + {technique.related_links.map((link) => ( +
  • + + {link.target_title} + + + ({link.relationship}) + +
  • + ))} +
+
+ )} +
+
+
+ ); +}