feat: Added CSS grid layout splitting technique page into prose (left)…
- "frontend/src/App.css" - "frontend/src/pages/TechniquePage.tsx" GSD-Task: S02/T01
This commit is contained in:
parent
171775452e
commit
b0038f21f7
2 changed files with 539 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
511
frontend/src/pages/TechniquePage.tsx
Normal file
511
frontend/src/pages/TechniquePage.tsx
Normal file
|
|
@ -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<string, unknown>) {
|
||||
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<string, unknown>)
|
||||
: 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<TechniqueDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showReport, setShowReport] = useState(false);
|
||||
|
||||
// Version switching
|
||||
const [versions, setVersions] = useState<TechniquePageVersionSummary[]>([]);
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>("current");
|
||||
const [versionDetail, setVersionDetail] =
|
||||
useState<TechniquePageVersionDetail | null>(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 <div className="loading">Loading technique…</div>;
|
||||
}
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="technique-404">
|
||||
<h2>Technique Not Found</h2>
|
||||
<p>The technique “{slug}” doesn’t exist.</p>
|
||||
<Link to="/" className="btn">
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !technique) {
|
||||
return (
|
||||
<div className="loading error-text">
|
||||
Error: {error ?? "Unknown error"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<article className="technique-page">
|
||||
{/* Back link */}
|
||||
<Link to="/" className="back-link">
|
||||
← Back
|
||||
</Link>
|
||||
|
||||
{/* Historical version banner */}
|
||||
{isHistorical && (
|
||||
<div className="technique-banner technique-banner--version">
|
||||
📋 Viewing version {versionDetail.version_number} from{" "}
|
||||
{formatDate(versionDetail.created_at)}
|
||||
<button
|
||||
className="btn btn--small btn--primary"
|
||||
style={{ marginLeft: "1rem" }}
|
||||
onClick={() => setSelectedVersion("current")}
|
||||
>
|
||||
Back to current
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unstructured content warning */}
|
||||
{displayQuality === "unstructured" && (
|
||||
<div className="technique-banner technique-banner--amber">
|
||||
⚠ This technique was sourced from a livestream and may have less
|
||||
structured content.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header className="technique-header">
|
||||
<h1 className="technique-header__title">{displayTitle}</h1>
|
||||
<div className="technique-header__meta">
|
||||
<span className="badge badge--category">{displayCategory}</span>
|
||||
{displayTags && displayTags.length > 0 && (
|
||||
<span className="technique-header__tags">
|
||||
{displayTags.map((tag) => (
|
||||
<span key={tag} className="pill">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{technique.creator_info && (
|
||||
<Link
|
||||
to={`/creators/${technique.creator_info.slug}`}
|
||||
className="technique-header__creator"
|
||||
>
|
||||
by {technique.creator_info.name}
|
||||
</Link>
|
||||
)}
|
||||
{displayQuality && (
|
||||
<span
|
||||
className={`badge badge--quality badge--quality-${displayQuality}`}
|
||||
>
|
||||
{displayQuality}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta stats line */}
|
||||
<div className="technique-header__stats">
|
||||
{(() => {
|
||||
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(" · ");
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Version switcher + report button row */}
|
||||
<div className="technique-header__actions">
|
||||
{versions.length > 0 && (
|
||||
<div className="version-switcher">
|
||||
<label className="version-switcher__label">Version:</label>
|
||||
<select
|
||||
className="version-switcher__select"
|
||||
value={selectedVersion}
|
||||
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||
disabled={versionLoading}
|
||||
>
|
||||
<option value="current">Current (live)</option>
|
||||
{versions.map((v) => (
|
||||
<option key={v.version_number} value={String(v.version_number)}>
|
||||
v{v.version_number} — {formatDate(v.created_at)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{versionLoading && (
|
||||
<span className="version-switcher__loading">Loading…</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn btn--secondary btn--small report-issue-btn"
|
||||
onClick={() => setShowReport(true)}
|
||||
>
|
||||
⚑ Report issue
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Pipeline metadata for historical versions */}
|
||||
{isHistorical && versionDetail.pipeline_metadata && (
|
||||
<div className="version-metadata">
|
||||
<h4 className="version-metadata__title">Pipeline metadata (v{versionDetail.version_number})</h4>
|
||||
<div className="version-metadata__grid">
|
||||
{"model" in versionDetail.pipeline_metadata && (
|
||||
<div className="version-metadata__item">
|
||||
<span className="version-metadata__key">Model</span>
|
||||
<span className="version-metadata__value">
|
||||
{String(versionDetail.pipeline_metadata.model)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{"captured_at" in versionDetail.pipeline_metadata && (
|
||||
<div className="version-metadata__item">
|
||||
<span className="version-metadata__key">Captured</span>
|
||||
<span className="version-metadata__value">
|
||||
{formatDate(String(versionDetail.pipeline_metadata.captured_at))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{typeof versionDetail.pipeline_metadata.prompt_hashes === "object" &&
|
||||
versionDetail.pipeline_metadata.prompt_hashes !== null && (
|
||||
<div className="version-metadata__item version-metadata__item--wide">
|
||||
<span className="version-metadata__key">Prompt hashes</span>
|
||||
<div className="version-metadata__hashes">
|
||||
{Object.entries(
|
||||
versionDetail.pipeline_metadata.prompt_hashes as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
).map(([file, hash]) => (
|
||||
<div key={file} className="version-metadata__hash">
|
||||
<span className="version-metadata__hash-file">
|
||||
{file.replace(/^.*\//, "")}
|
||||
</span>
|
||||
<code className="version-metadata__hash-value">
|
||||
{hash.slice(0, 12)}…
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report modal */}
|
||||
{showReport && (
|
||||
<ReportIssueModal
|
||||
contentType="technique_page"
|
||||
contentId={technique.id}
|
||||
contentTitle={technique.title}
|
||||
onClose={() => setShowReport(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="technique-columns">
|
||||
<div className="technique-columns__main">
|
||||
{/* Summary */}
|
||||
{displaySummary && (
|
||||
<section className="technique-summary">
|
||||
<p>{displaySummary}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Study guide prose — body_sections */}
|
||||
{displaySections &&
|
||||
Object.keys(displaySections).length > 0 && (
|
||||
<section className="technique-prose">
|
||||
{Object.entries(displaySections).map(
|
||||
([sectionTitle, content]: [string, unknown]) => (
|
||||
<div key={sectionTitle} className="technique-prose__section">
|
||||
<h2>{sectionTitle}</h2>
|
||||
{typeof content === "string" ? (
|
||||
<p>{content as string}</p>
|
||||
) : typeof content === "object" && content !== null ? (
|
||||
<pre className="technique-prose__json">
|
||||
{JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<p>{String(content as string)}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="technique-columns__sidebar">
|
||||
{/* Key moments (always from live data — not versioned) */}
|
||||
{technique.key_moments.length > 0 && (
|
||||
<section className="technique-moments">
|
||||
<h2>Key Moments</h2>
|
||||
<ol className="technique-moments__list">
|
||||
{technique.key_moments.map((km) => (
|
||||
<li key={km.id} className="technique-moment">
|
||||
<div className="technique-moment__header">
|
||||
<span className="technique-moment__title">{km.title}</span>
|
||||
{km.video_filename && (
|
||||
<span className="technique-moment__source">
|
||||
{km.video_filename}
|
||||
</span>
|
||||
)}
|
||||
<span className="technique-moment__time">
|
||||
{formatTime(km.start_time)} – {formatTime(km.end_time)}
|
||||
</span>
|
||||
<span className="badge badge--content-type">
|
||||
{km.content_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="technique-moment__summary">{km.summary}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Signal chains */}
|
||||
{displayChains &&
|
||||
displayChains.length > 0 && (
|
||||
<section className="technique-chains">
|
||||
<h2>Signal Chains</h2>
|
||||
{displayChains.map((chain, i) => {
|
||||
const chainObj = chain as Record<string, unknown>;
|
||||
const chainName =
|
||||
typeof chainObj["name"] === "string"
|
||||
? chainObj["name"]
|
||||
: `Chain ${i + 1}`;
|
||||
const steps = Array.isArray(chainObj["steps"])
|
||||
? (chainObj["steps"] as string[])
|
||||
: [];
|
||||
return (
|
||||
<div key={i} className="technique-chain">
|
||||
<h3>{chainName}</h3>
|
||||
{steps.length > 0 && (
|
||||
<div className="technique-chain__flow">
|
||||
{steps.map((step, j) => (
|
||||
<span key={j}>
|
||||
{j > 0 && (
|
||||
<span className="technique-chain__arrow">
|
||||
{" → "}
|
||||
</span>
|
||||
)}
|
||||
<span className="technique-chain__step">
|
||||
{String(step)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Plugins */}
|
||||
{displayPlugins && displayPlugins.length > 0 && (
|
||||
<section className="technique-plugins">
|
||||
<h2>Plugins Referenced</h2>
|
||||
<div className="pill-list">
|
||||
{displayPlugins.map((plugin) => (
|
||||
<span key={plugin} className="pill pill--plugin">
|
||||
{plugin}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Related techniques (always from live data) */}
|
||||
{technique.related_links.length > 0 && (
|
||||
<section className="technique-related">
|
||||
<h2>Related Techniques</h2>
|
||||
<ul className="technique-related__list">
|
||||
{technique.related_links.map((link) => (
|
||||
<li key={link.target_slug}>
|
||||
<Link to={`/techniques/${link.target_slug}`}>
|
||||
{link.target_title}
|
||||
</Link>
|
||||
<span className="technique-related__rel">
|
||||
({link.relationship})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue