chrysopedia/frontend/src/pages/TechniquePage.tsx
jlightner 6b099f3663 feat: Content issue reporting — submit from technique pages, manage in admin reports page
- ContentReport model with generic content_type/content_id (supports any entity)
- Alembic migration 003: content_reports table with status + content indexes
- POST /reports (public), GET/PATCH /admin/reports (admin triage)
- Report modal on technique pages with issue type dropdown + description
- Admin reports page with status filter, expand/collapse detail, triage actions
- All CSS uses var(--*) tokens, dark theme consistent
2026-03-30 02:53:56 -05:00

319 lines
10 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";
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")}`;
}
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);
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>
{/* Report issue button */}
<button
className="btn btn--secondary btn--small report-issue-btn"
onClick={() => setShowReport(true)}
>
Report issue
</button>
</header>
{/* Report modal */}
{showReport && (
<ReportIssueModal
contentType="technique_page"
contentId={technique.id}
contentTitle={technique.title}
onClose={() => setShowReport(false)}
/>
)}
{/* 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>
);
}