- "frontend/src/api/public-client.ts" - "frontend/src/pages/TechniquePage.tsx" GSD-Task: S04/T03
300 lines
9.6 KiB
TypeScript
300 lines
9.6 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|