chrysopedia/frontend/src/pages/TechniquePage.tsx
jlightner 2a07583d6d feat: Added TypeScript version types, fetchTechniqueVersions function,…
- "frontend/src/api/public-client.ts"
- "frontend/src/pages/TechniquePage.tsx"

GSD-Task: S04/T03
2026-03-30 07:27:40 +00:00

300 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Technique page detail view.
*
* Fetches a single technique by slug. Renders:
* - Header with title, category badge, tags, creator link, source quality
* - Amber banner for unstructured (livestream-sourced) content
* - Study guide prose from body_sections JSONB
* - Key moments index
* - Signal chains (if present)
* - Plugins referenced (if present)
* - Related techniques (if present)
* - Loading and 404 states
*/
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
fetchTechnique,
type TechniquePageDetail as TechniqueDetail,
} from "../api/public-client";
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
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);
useEffect(() => {
if (!slug) return;
let cancelled = false;
setLoading(true);
setNotFound(false);
setError(null);
void (async () => {
try {
const data = await fetchTechnique(slug);
if (!cancelled) setTechnique(data);
} 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]);
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>
);
}
return (
<article className="technique-page">
{/* Back link */}
<Link to="/" className="back-link">
Back
</Link>
{/* Unstructured content warning */}
{technique.source_quality === "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">{technique.title}</h1>
<div className="technique-header__meta">
<span className="badge badge--category">
{technique.topic_category}
</span>
{technique.topic_tags && technique.topic_tags.length > 0 && (
<span className="technique-header__tags">
{technique.topic_tags.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>
)}
{technique.source_quality && (
<span
className={`badge badge--quality badge--quality-${technique.source_quality}`}
>
{technique.source_quality}
</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>
</header>
{/* Summary */}
{technique.summary && (
<section className="technique-summary">
<p>{technique.summary}</p>
</section>
)}
{/* Study guide prose — body_sections */}
{technique.body_sections &&
Object.keys(technique.body_sections).length > 0 && (
<section className="technique-prose">
{Object.entries(technique.body_sections).map(
([sectionTitle, content]) => (
<div key={sectionTitle} className="technique-prose__section">
<h2>{sectionTitle}</h2>
{typeof content === "string" ? (
<p>{content}</p>
) : typeof content === "object" && content !== null ? (
<pre className="technique-prose__json">
{JSON.stringify(content, null, 2)}
</pre>
) : (
<p>{String(content)}</p>
)}
</div>
),
)}
</section>
)}
{/* Key moments */}
{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 */}
{technique.signal_chains &&
technique.signal_chains.length > 0 && (
<section className="technique-chains">
<h2>Signal Chains</h2>
{technique.signal_chains.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 */}
{technique.plugins && technique.plugins.length > 0 && (
<section className="technique-plugins">
<h2>Plugins Referenced</h2>
<div className="pill-list">
{technique.plugins.map((plugin) => (
<span key={plugin} className="pill pill--plugin">
{plugin}
</span>
))}
</div>
</section>
)}
{/* Related techniques */}
{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>
)}
</article>
);
}