diff --git a/frontend/src/App.css b/frontend/src/App.css
index bdab000..8114de8 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -618,3 +618,699 @@ body {
justify-content: space-between;
}
}
+
+/* ══════════════════════════════════════════════════════════════════════════════
+ PUBLIC PAGES
+ ══════════════════════════════════════════════════════════════════════════════ */
+
+/* ── Header brand link ────────────────────────────────────────────────────── */
+
+.app-header__brand {
+ text-decoration: none;
+ color: inherit;
+}
+
+.app-nav {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.app-nav a {
+ color: rgba(255, 255, 255, 0.8);
+ text-decoration: none;
+ font-size: 0.875rem;
+ transition: color 0.15s;
+}
+
+.app-nav a:hover {
+ color: #fff;
+}
+
+/* ── Home / Hero ──────────────────────────────────────────────────────────── */
+
+.home-hero {
+ text-align: center;
+ padding: 3rem 1rem 2rem;
+}
+
+.home-hero__title {
+ font-size: 2.25rem;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ margin-bottom: 0.375rem;
+}
+
+.home-hero__subtitle {
+ font-size: 1rem;
+ color: #6b7280;
+ margin-bottom: 1.5rem;
+}
+
+/* ── Search form ──────────────────────────────────────────────────────────── */
+
+.search-container {
+ position: relative;
+ max-width: 36rem;
+ margin: 0 auto;
+}
+
+.search-form {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.search-form--hero {
+ justify-content: center;
+}
+
+.search-form--inline {
+ margin-bottom: 1.25rem;
+}
+
+.search-input {
+ flex: 1;
+ padding: 0.625rem 1rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.5rem;
+ font-size: 0.9375rem;
+ font-family: inherit;
+ color: #374151;
+ background: #fff;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
+}
+
+.search-input--hero {
+ padding: 0.75rem 1.25rem;
+ font-size: 1.0625rem;
+ border-radius: 0.625rem;
+}
+
+.btn--search {
+ background: #1a1a2e;
+ color: #fff;
+ border-color: #1a1a2e;
+ border-radius: 0.5rem;
+ padding: 0.625rem 1.25rem;
+ font-weight: 600;
+}
+
+.btn--search:hover {
+ background: #2d2d4e;
+}
+
+/* ── Typeahead dropdown ───────────────────────────────────────────────────── */
+
+.typeahead-dropdown {
+ position: absolute;
+ top: calc(100% + 0.25rem);
+ left: 0;
+ right: 0;
+ background: #fff;
+ border: 1px solid #e2e2e8;
+ border-radius: 0.5rem;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ z-index: 50;
+ overflow: hidden;
+}
+
+.typeahead-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.625rem 1rem;
+ text-decoration: none;
+ color: inherit;
+ transition: background 0.1s;
+}
+
+.typeahead-item:hover {
+ background: #f3f4f6;
+}
+
+.typeahead-item__title {
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.typeahead-item__meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.typeahead-item__type {
+ padding: 0.0625rem 0.375rem;
+ border-radius: 0.25rem;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.typeahead-item__type--technique_page {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+.typeahead-item__type--key_moment {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.typeahead-see-all {
+ display: block;
+ padding: 0.5rem 1rem;
+ text-align: center;
+ font-size: 0.8125rem;
+ color: #6366f1;
+ text-decoration: none;
+ border-top: 1px solid #e2e2e8;
+ transition: background 0.1s;
+}
+
+.typeahead-see-all:hover {
+ background: #f3f4f6;
+}
+
+/* ── Navigation cards ─────────────────────────────────────────────────────── */
+
+.nav-cards {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 1rem;
+ max-width: 36rem;
+ margin: 0 auto 2rem;
+}
+
+.nav-card {
+ display: block;
+ padding: 1.5rem;
+ background: #fff;
+ border: 1px solid #e2e2e8;
+ border-radius: 0.625rem;
+ text-decoration: none;
+ color: inherit;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+ transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
+}
+
+.nav-card:hover {
+ border-color: #a5b4fc;
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1);
+ transform: translateY(-1px);
+}
+
+.nav-card__title {
+ font-size: 1.0625rem;
+ font-weight: 700;
+ margin-bottom: 0.25rem;
+}
+
+.nav-card__desc {
+ font-size: 0.8125rem;
+ color: #6b7280;
+ line-height: 1.4;
+}
+
+/* ── Recently Added section ───────────────────────────────────────────────── */
+
+.recent-section {
+ max-width: 36rem;
+ margin: 0 auto 2rem;
+}
+
+.recent-section__title {
+ font-size: 1.125rem;
+ font-weight: 700;
+ margin-bottom: 0.75rem;
+}
+
+.recent-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.recent-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.875rem 1rem;
+ background: #fff;
+ border: 1px solid #e2e2e8;
+ border-radius: 0.5rem;
+ text-decoration: none;
+ color: inherit;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.recent-card:hover {
+ border-color: #a5b4fc;
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
+}
+
+.recent-card__title {
+ font-size: 0.9375rem;
+ font-weight: 600;
+}
+
+.recent-card__meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.recent-card__summary {
+ font-size: 0.8125rem;
+ color: #6b7280;
+ line-height: 1.4;
+}
+
+/* ── Search results page ──────────────────────────────────────────────────── */
+
+.search-results-page {
+ max-width: 48rem;
+}
+
+.search-fallback-banner {
+ padding: 0.5rem 0.75rem;
+ background: #fef3c7;
+ border: 1px solid #fcd34d;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+ color: #92400e;
+ margin-bottom: 1rem;
+}
+
+.search-group {
+ margin-bottom: 1.5rem;
+}
+
+.search-group__title {
+ font-size: 1rem;
+ font-weight: 700;
+ margin-bottom: 0.5rem;
+ color: #374151;
+}
+
+.search-group__list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.search-result-card {
+ display: block;
+ padding: 1rem 1.25rem;
+ background: #fff;
+ border: 1px solid #e2e2e8;
+ border-radius: 0.5rem;
+ text-decoration: none;
+ color: inherit;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.search-result-card:hover {
+ border-color: #a5b4fc;
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
+}
+
+.search-result-card__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.25rem;
+}
+
+.search-result-card__title {
+ font-size: 0.9375rem;
+ font-weight: 600;
+}
+
+.search-result-card__summary {
+ font-size: 0.8125rem;
+ color: #6b7280;
+ line-height: 1.4;
+ margin-bottom: 0.375rem;
+}
+
+.search-result-card__meta {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.75rem;
+ color: #9ca3af;
+ flex-wrap: wrap;
+}
+
+.search-result-card__tags {
+ display: inline-flex;
+ gap: 0.25rem;
+ margin-left: 0.25rem;
+}
+
+/* ── Pills / tags ─────────────────────────────────────────────────────────── */
+
+.pill {
+ display: inline-block;
+ padding: 0.0625rem 0.5rem;
+ border-radius: 9999px;
+ font-size: 0.6875rem;
+ font-weight: 500;
+ background: #f3f4f6;
+ color: #374151;
+}
+
+.pill--plugin {
+ background: #ede9fe;
+ color: #5b21b6;
+}
+
+.pill-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.badge--category {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+.badge--type {
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.badge--type-technique_page {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+.badge--type-key_moment {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.badge--content-type {
+ background: #f3f4f6;
+ color: #374151;
+ font-size: 0.6875rem;
+}
+
+.badge--quality {
+ font-size: 0.6875rem;
+ text-transform: capitalize;
+}
+
+.badge--quality-structured {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.badge--quality-unstructured {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+/* ── Technique page ───────────────────────────────────────────────────────── */
+
+.technique-page {
+ max-width: 48rem;
+}
+
+.technique-404 {
+ text-align: center;
+ padding: 3rem 1rem;
+}
+
+.technique-404 h2 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin-bottom: 0.5rem;
+}
+
+.technique-404 p {
+ color: #6b7280;
+ margin-bottom: 1.5rem;
+}
+
+.technique-banner {
+ padding: 0.625rem 1rem;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+ margin-bottom: 1rem;
+}
+
+.technique-banner--amber {
+ background: #fef3c7;
+ border: 1px solid #fcd34d;
+ color: #92400e;
+}
+
+.technique-header {
+ margin-bottom: 1.5rem;
+}
+
+.technique-header__title {
+ font-size: 1.75rem;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ margin-bottom: 0.5rem;
+ line-height: 1.2;
+}
+
+.technique-header__meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.technique-header__tags {
+ display: inline-flex;
+ gap: 0.25rem;
+}
+
+.technique-header__creator {
+ font-size: 0.875rem;
+ color: #6366f1;
+ text-decoration: none;
+}
+
+.technique-header__creator:hover {
+ text-decoration: underline;
+}
+
+/* ── Technique prose / sections ───────────────────────────────────────────── */
+
+.technique-summary {
+ margin-bottom: 1.5rem;
+}
+
+.technique-summary p {
+ font-size: 1rem;
+ color: #374151;
+ line-height: 1.6;
+}
+
+.technique-prose {
+ margin-bottom: 2rem;
+}
+
+.technique-prose__section {
+ margin-bottom: 1.5rem;
+}
+
+.technique-prose__section h2 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ margin-bottom: 0.5rem;
+}
+
+.technique-prose__section p {
+ font-size: 0.9375rem;
+ color: #374151;
+ line-height: 1.7;
+}
+
+.technique-prose__json {
+ background: #f9fafb;
+ padding: 0.75rem;
+ border-radius: 0.375rem;
+ font-size: 0.8125rem;
+ overflow-x: auto;
+ line-height: 1.5;
+}
+
+/* ── Key moments list ─────────────────────────────────────────────────────── */
+
+.technique-moments {
+ margin-bottom: 2rem;
+}
+
+.technique-moments h2 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ margin-bottom: 0.75rem;
+}
+
+.technique-moments__list {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.technique-moment {
+ padding: 0.875rem 1rem;
+ background: #fff;
+ border: 1px solid #e2e2e8;
+ border-radius: 0.5rem;
+}
+
+.technique-moment__header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.25rem;
+ flex-wrap: wrap;
+}
+
+.technique-moment__title {
+ font-size: 0.9375rem;
+ font-weight: 600;
+}
+
+.technique-moment__time {
+ font-size: 0.75rem;
+ color: #6b7280;
+ font-variant-numeric: tabular-nums;
+}
+
+.technique-moment__summary {
+ font-size: 0.8125rem;
+ color: #6b7280;
+ line-height: 1.5;
+}
+
+/* ── Signal chains ────────────────────────────────────────────────────────── */
+
+.technique-chains {
+ margin-bottom: 2rem;
+}
+
+.technique-chains h2 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ margin-bottom: 0.75rem;
+}
+
+.technique-chain {
+ margin-bottom: 1rem;
+ padding: 1rem;
+ background: #fff;
+ border: 1px solid #e2e2e8;
+ border-radius: 0.5rem;
+}
+
+.technique-chain h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+}
+
+.technique-chain__steps {
+ padding-left: 1.25rem;
+ font-size: 0.875rem;
+ line-height: 1.6;
+ color: #374151;
+}
+
+/* ── Plugins ──────────────────────────────────────────────────────────────── */
+
+.technique-plugins {
+ margin-bottom: 2rem;
+}
+
+.technique-plugins h2 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ margin-bottom: 0.5rem;
+}
+
+/* ── Related techniques ───────────────────────────────────────────────────── */
+
+.technique-related {
+ margin-bottom: 2rem;
+}
+
+.technique-related h2 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ margin-bottom: 0.5rem;
+}
+
+.technique-related__list {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.technique-related__list a {
+ color: #6366f1;
+ text-decoration: none;
+ font-size: 0.9375rem;
+}
+
+.technique-related__list a:hover {
+ text-decoration: underline;
+}
+
+.technique-related__rel {
+ font-size: 0.75rem;
+ color: #9ca3af;
+ margin-left: 0.375rem;
+}
+
+/* ── Public responsive ────────────────────────────────────────────────────── */
+
+@media (max-width: 640px) {
+ .home-hero__title {
+ font-size: 1.75rem;
+ }
+
+ .nav-cards {
+ grid-template-columns: 1fr;
+ }
+
+ .technique-header__title {
+ font-size: 1.375rem;
+ }
+
+ .search-form {
+ flex-direction: column;
+ }
+
+ .search-input--hero {
+ width: 100%;
+ }
+
+ .app-nav {
+ gap: 0.75rem;
+ font-size: 0.8125rem;
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2979145..a0747ae 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,7 @@
-import { Navigate, Route, Routes } from "react-router-dom";
+import { Link, Navigate, Route, Routes } from "react-router-dom";
+import Home from "./pages/Home";
+import SearchResults from "./pages/SearchResults";
+import TechniquePage from "./pages/TechniquePage";
import ReviewQueue from "./pages/ReviewQueue";
import MomentDetail from "./pages/MomentDetail";
import ModeToggle from "./components/ModeToggle";
@@ -7,20 +10,33 @@ export default function App() {
return (
- Chrysopedia Admin
+
+ Chrysopedia
+
+ {/* Public routes */}
+ } />
+ } />
+ } />
+
+ {/* Admin routes */}
} />
} />
- } />
+
+ {/* Fallback */}
+ } />
diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts
new file mode 100644
index 0000000..e511d9c
--- /dev/null
+++ b/frontend/src/api/public-client.ts
@@ -0,0 +1,250 @@
+/**
+ * Typed API client for Chrysopedia public endpoints.
+ *
+ * Mirrors backend schemas: SearchResponse, TechniquePageDetail, TopicCategory, CreatorBrowseItem.
+ * Uses the same request pattern as client.ts.
+ */
+
+// ── Types ───────────────────────────────────────────────────────────────────
+
+export interface SearchResultItem {
+ title: string;
+ slug: string;
+ type: string;
+ score: number;
+ summary: string;
+ creator_name: string;
+ creator_slug: string;
+ topic_category: string;
+ topic_tags: string[];
+}
+
+export interface SearchResponse {
+ items: SearchResultItem[];
+ total: number;
+ query: string;
+ fallback_used: boolean;
+}
+
+export interface KeyMomentSummary {
+ id: string;
+ title: string;
+ summary: string;
+ start_time: number;
+ end_time: number;
+ content_type: string;
+ plugins: string[] | null;
+}
+
+export interface CreatorInfo {
+ name: string;
+ slug: string;
+ genres: string[] | null;
+}
+
+export interface RelatedLinkItem {
+ target_title: string;
+ target_slug: string;
+ relationship: string;
+}
+
+export interface TechniquePageDetail {
+ id: string;
+ title: string;
+ slug: string;
+ topic_category: string;
+ topic_tags: string[] | null;
+ summary: string | null;
+ body_sections: Record | null;
+ signal_chains: unknown[] | null;
+ plugins: string[] | null;
+ creator_id: string;
+ source_quality: string | null;
+ view_count: number;
+ review_status: string;
+ created_at: string;
+ updated_at: string;
+ key_moments: KeyMomentSummary[];
+ creator_info: CreatorInfo | null;
+ related_links: RelatedLinkItem[];
+}
+
+export interface TechniqueListItem {
+ id: string;
+ title: string;
+ slug: string;
+ topic_category: string;
+ topic_tags: string[] | null;
+ summary: string | null;
+ creator_id: string;
+ source_quality: string | null;
+ view_count: number;
+ review_status: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface TechniqueListResponse {
+ items: TechniqueListItem[];
+ total: number;
+ offset: number;
+ limit: number;
+}
+
+export interface TopicSubTopic {
+ name: string;
+ technique_count: number;
+ creator_count: number;
+}
+
+export interface TopicCategory {
+ name: string;
+ description: string;
+ sub_topics: TopicSubTopic[];
+}
+
+export interface CreatorBrowseItem {
+ id: string;
+ name: string;
+ slug: string;
+ genres: string[] | null;
+ folder_name: string;
+ view_count: number;
+ created_at: string;
+ updated_at: string;
+ technique_count: number;
+ video_count: number;
+}
+
+export interface CreatorBrowseResponse {
+ items: CreatorBrowseItem[];
+ total: number;
+ offset: number;
+ limit: number;
+}
+
+export interface CreatorDetailResponse {
+ id: string;
+ name: string;
+ slug: string;
+ genres: string[] | null;
+ folder_name: string;
+ view_count: number;
+ created_at: string;
+ updated_at: string;
+ video_count: number;
+}
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+const BASE = "/api/v1";
+
+class ApiError extends Error {
+ constructor(
+ public status: number,
+ public detail: string,
+ ) {
+ super(`API ${status}: ${detail}`);
+ this.name = "ApiError";
+ }
+}
+
+async function request(url: string, init?: RequestInit): Promise {
+ const res = await fetch(url, {
+ ...init,
+ headers: {
+ "Content-Type": "application/json",
+ ...init?.headers,
+ },
+ });
+
+ if (!res.ok) {
+ let detail = res.statusText;
+ try {
+ const body: unknown = await res.json();
+ if (typeof body === "object" && body !== null && "detail" in body) {
+ detail = String((body as { detail: unknown }).detail);
+ }
+ } catch {
+ // body not JSON — keep statusText
+ }
+ throw new ApiError(res.status, detail);
+ }
+
+ return res.json() as Promise;
+}
+
+// ── Search ───────────────────────────────────────────────────────────────────
+
+export async function searchApi(
+ q: string,
+ scope?: string,
+ limit?: number,
+): Promise {
+ const qs = new URLSearchParams({ q });
+ if (scope) qs.set("scope", scope);
+ if (limit !== undefined) qs.set("limit", String(limit));
+ return request(`${BASE}/search?${qs.toString()}`);
+}
+
+// ── Techniques ───────────────────────────────────────────────────────────────
+
+export interface TechniqueListParams {
+ limit?: number;
+ offset?: number;
+ category?: string;
+}
+
+export async function fetchTechniques(
+ params: TechniqueListParams = {},
+): Promise {
+ const qs = new URLSearchParams();
+ if (params.limit !== undefined) qs.set("limit", String(params.limit));
+ if (params.offset !== undefined) qs.set("offset", String(params.offset));
+ if (params.category) qs.set("category", params.category);
+ const query = qs.toString();
+ return request(
+ `${BASE}/techniques${query ? `?${query}` : ""}`,
+ );
+}
+
+export async function fetchTechnique(
+ slug: string,
+): Promise {
+ return request(`${BASE}/techniques/${slug}`);
+}
+
+// ── Topics ───────────────────────────────────────────────────────────────────
+
+export async function fetchTopics(): Promise {
+ return request(`${BASE}/topics`);
+}
+
+// ── Creators ─────────────────────────────────────────────────────────────────
+
+export interface CreatorListParams {
+ sort?: string;
+ genre?: string;
+ limit?: number;
+ offset?: number;
+}
+
+export async function fetchCreators(
+ params: CreatorListParams = {},
+): Promise {
+ const qs = new URLSearchParams();
+ if (params.sort) qs.set("sort", params.sort);
+ if (params.genre) qs.set("genre", params.genre);
+ if (params.limit !== undefined) qs.set("limit", String(params.limit));
+ if (params.offset !== undefined) qs.set("offset", String(params.offset));
+ const query = qs.toString();
+ return request(
+ `${BASE}/creators${query ? `?${query}` : ""}`,
+ );
+}
+
+export async function fetchCreator(
+ slug: string,
+): Promise {
+ return request(`${BASE}/creators/${slug}`);
+}
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
new file mode 100644
index 0000000..90bcade
--- /dev/null
+++ b/frontend/src/pages/Home.tsx
@@ -0,0 +1,222 @@
+/**
+ * Home / landing page.
+ *
+ * Prominent search bar with 300ms debounced typeahead (top 5 results after 2+ chars),
+ * navigation cards for Topics and Creators, and a "Recently Added" section.
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import {
+ searchApi,
+ fetchTechniques,
+ type SearchResultItem,
+ type TechniqueListItem,
+} from "../api/public-client";
+
+export default function Home() {
+ const [query, setQuery] = useState("");
+ const [suggestions, setSuggestions] = useState([]);
+ const [showDropdown, setShowDropdown] = useState(false);
+ const [recent, setRecent] = useState([]);
+ const [recentLoading, setRecentLoading] = useState(true);
+ const navigate = useNavigate();
+ const inputRef = useRef(null);
+ const debounceRef = useRef | null>(null);
+ const dropdownRef = useRef(null);
+
+ // Auto-focus search on mount
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ // Load recently added techniques
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const res = await fetchTechniques({ limit: 5 });
+ if (!cancelled) setRecent(res.items);
+ } catch {
+ // silently ignore — not critical
+ } finally {
+ if (!cancelled) setRecentLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Close dropdown on outside click
+ useEffect(() => {
+ function handleClick(e: MouseEvent) {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(e.target as Node)
+ ) {
+ setShowDropdown(false);
+ }
+ }
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, []);
+
+ // Debounced typeahead
+ const handleInputChange = useCallback(
+ (value: string) => {
+ setQuery(value);
+
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+
+ if (value.length < 2) {
+ setSuggestions([]);
+ setShowDropdown(false);
+ return;
+ }
+
+ debounceRef.current = setTimeout(() => {
+ void (async () => {
+ try {
+ const res = await searchApi(value, undefined, 5);
+ setSuggestions(res.items);
+ setShowDropdown(res.items.length > 0);
+ } catch {
+ setSuggestions([]);
+ setShowDropdown(false);
+ }
+ })();
+ }, 300);
+ },
+ [],
+ );
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (query.trim()) {
+ setShowDropdown(false);
+ navigate(`/search?q=${encodeURIComponent(query.trim())}`);
+ }
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === "Escape") {
+ setShowDropdown(false);
+ }
+ }
+
+ return (
+
+ {/* Hero search */}
+
+ Chrysopedia
+
+ Search techniques, key moments, and creators
+
+
+
+
+
+ {showDropdown && suggestions.length > 0 && (
+
+ {suggestions.map((item) => (
+ setShowDropdown(false)}
+ >
+ {item.title}
+
+
+ {item.type === "technique_page" ? "Technique" : "Key Moment"}
+
+ {item.creator_name && (
+
+ {item.creator_name}
+
+ )}
+
+
+ ))}
+ setShowDropdown(false)}
+ >
+ See all results for "{query}"
+
+
+ )}
+
+
+
+ {/* Navigation cards */}
+
+
+ Topics
+
+ Browse techniques organized by category and sub-topic
+
+
+
+ Creators
+
+ Discover creators and their technique libraries
+
+
+
+
+ {/* Recently Added */}
+
+ Recently Added
+ {recentLoading ? (
+ Loading…
+ ) : recent.length === 0 ? (
+ No techniques yet.
+ ) : (
+
+ {recent.map((t) => (
+
+ {t.title}
+
+
+ {t.topic_category}
+
+ {t.summary && (
+
+ {t.summary.length > 100
+ ? `${t.summary.slice(0, 100)}…`
+ : t.summary}
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/SearchResults.tsx b/frontend/src/pages/SearchResults.tsx
new file mode 100644
index 0000000..120b671
--- /dev/null
+++ b/frontend/src/pages/SearchResults.tsx
@@ -0,0 +1,184 @@
+/**
+ * Full search results page.
+ *
+ * Reads `q` from URL search params, calls searchApi, groups results by type
+ * (technique_pages first, then key_moments). Shows fallback banner when
+ * keyword search was used.
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Link, useSearchParams, useNavigate } from "react-router-dom";
+import { searchApi, type SearchResultItem } from "../api/public-client";
+
+export default function SearchResults() {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const q = searchParams.get("q") ?? "";
+
+ const [results, setResults] = useState([]);
+ const [fallbackUsed, setFallbackUsed] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [localQuery, setLocalQuery] = useState(q);
+ const debounceRef = useRef | null>(null);
+
+ const doSearch = useCallback(async (query: string) => {
+ if (!query.trim()) {
+ setResults([]);
+ setFallbackUsed(false);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await searchApi(query.trim());
+ setResults(res.items);
+ setFallbackUsed(res.fallback_used);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Search failed");
+ setResults([]);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // Search when URL param changes
+ useEffect(() => {
+ setLocalQuery(q);
+ if (q) void doSearch(q);
+ }, [q, doSearch]);
+
+ function handleInputChange(value: string) {
+ setLocalQuery(value);
+
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ if (value.trim()) {
+ navigate(`/search?q=${encodeURIComponent(value.trim())}`, {
+ replace: true,
+ });
+ }
+ }, 400);
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ if (localQuery.trim()) {
+ navigate(`/search?q=${encodeURIComponent(localQuery.trim())}`, {
+ replace: true,
+ });
+ }
+ }
+
+ // Group results by type
+ const techniqueResults = results.filter((r) => r.type === "technique_page");
+ const momentResults = results.filter((r) => r.type === "key_moment");
+
+ return (
+
+ {/* Inline search bar */}
+
+
+ {/* Status */}
+ {loading &&
Searching…
}
+ {error &&
Error: {error}
}
+
+ {/* Fallback banner */}
+ {!loading && fallbackUsed && results.length > 0 && (
+
+ Showing keyword results — semantic search unavailable
+
+ )}
+
+ {/* No results */}
+ {!loading && !error && q && results.length === 0 && (
+
+
No results found for "{q}"
+
+ )}
+
+ {/* Technique pages */}
+ {techniqueResults.length > 0 && (
+
+
+ Techniques ({techniqueResults.length})
+
+
+ {techniqueResults.map((item) => (
+
+ ))}
+
+
+ )}
+
+ {/* Key moments */}
+ {momentResults.length > 0 && (
+
+
+ Key Moments ({momentResults.length})
+
+
+ {momentResults.map((item, i) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function SearchResultCard({ item }: { item: SearchResultItem }) {
+ return (
+
+
+ {item.title}
+
+ {item.type === "technique_page" ? "Technique" : "Key Moment"}
+
+
+ {item.summary && (
+
+ {item.summary.length > 200
+ ? `${item.summary.slice(0, 200)}…`
+ : item.summary}
+
+ )}
+
+ {item.creator_name && {item.creator_name} }
+ {item.topic_category && (
+ <>
+ ·
+ {item.topic_category}
+ >
+ )}
+ {item.topic_tags.length > 0 && (
+
+ {item.topic_tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/TechniquePage.tsx b/frontend/src/pages/TechniquePage.tsx
new file mode 100644
index 0000000..a1bd890
--- /dev/null
+++ b/frontend/src/pages/TechniquePage.tsx
@@ -0,0 +1,260 @@
+/**
+ * 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(null);
+ const [loading, setLoading] = useState(true);
+ const [notFound, setNotFound] = useState(false);
+ const [error, setError] = useState(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 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"}
+
+ );
+ }
+
+ return (
+
+ {/* Back link */}
+
+ ← Back
+
+
+ {/* Unstructured content warning */}
+ {technique.source_quality === "unstructured" && (
+
+ ⚠ This technique was sourced from a livestream and may have less
+ structured content.
+
+ )}
+
+ {/* Header */}
+
+ {technique.title}
+
+
+ {technique.topic_category}
+
+ {technique.topic_tags && technique.topic_tags.length > 0 && (
+
+ {technique.topic_tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+ {technique.creator_info && (
+
+ by {technique.creator_info.name}
+
+ )}
+ {technique.source_quality && (
+
+ {technique.source_quality}
+
+ )}
+
+
+
+ {/* Summary */}
+ {technique.summary && (
+
+ )}
+
+ {/* Study guide prose — body_sections */}
+ {technique.body_sections &&
+ Object.keys(technique.body_sections).length > 0 && (
+
+ {Object.entries(technique.body_sections).map(
+ ([sectionTitle, content]) => (
+
+
{sectionTitle}
+ {typeof content === "string" ? (
+
{content}
+ ) : typeof content === "object" && content !== null ? (
+
+ {JSON.stringify(content, null, 2)}
+
+ ) : (
+
{String(content)}
+ )}
+
+ ),
+ )}
+
+ )}
+
+ {/* Key moments */}
+ {technique.key_moments.length > 0 && (
+
+ Key Moments
+
+ {technique.key_moments.map((km) => (
+
+
+ {km.title}
+
+ {formatTime(km.start_time)} – {formatTime(km.end_time)}
+
+
+ {km.content_type}
+
+
+ {km.summary}
+
+ ))}
+
+
+ )}
+
+ {/* Signal chains */}
+ {technique.signal_chains &&
+ technique.signal_chains.length > 0 && (
+
+ Signal Chains
+ {technique.signal_chains.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) => (
+ {String(step)}
+ ))}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ {/* Plugins */}
+ {technique.plugins && technique.plugins.length > 0 && (
+
+ Plugins Referenced
+
+ {technique.plugins.map((plugin) => (
+
+ {plugin}
+
+ ))}
+
+
+ )}
+
+ {/* Related techniques */}
+ {technique.related_links.length > 0 && (
+
+ Related Techniques
+
+ {technique.related_links.map((link) => (
+
+
+ {link.target_title}
+
+
+ ({link.relationship})
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo
index 5d0c952..abb36cf 100644
--- a/frontend/tsconfig.app.tsbuildinfo
+++ b/frontend/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/ModeToggle.tsx","./src/components/StatusBadge.tsx","./src/pages/MomentDetail.tsx","./src/pages/ReviewQueue.tsx"],"version":"5.6.3"}
\ No newline at end of file
+{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/public-client.ts","./src/components/ModeToggle.tsx","./src/components/StatusBadge.tsx","./src/pages/Home.tsx","./src/pages/MomentDetail.tsx","./src/pages/ReviewQueue.tsx","./src/pages/SearchResults.tsx","./src/pages/TechniquePage.tsx"],"version":"5.6.3"}
\ No newline at end of file