346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
/**
|
|
* 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 { IconTopics, IconCreators } from "../components/CategoryIcons";
|
|
import SearchAutocomplete from "../components/SearchAutocomplete";
|
|
import TagList from "../components/TagList";
|
|
import { useEffect, useState } from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
|
import {
|
|
fetchTechniques,
|
|
fetchTopics,
|
|
fetchRandomTechnique,
|
|
fetchStats,
|
|
fetchPopularSearches,
|
|
type TechniqueListItem,
|
|
type StatsResponse,
|
|
type PopularSearchItem,
|
|
} from "../api/public-client";
|
|
|
|
export default function Home() {
|
|
useDocumentTitle("Chrysopedia — Production Knowledge, Distilled");
|
|
const [featured, setFeatured] = useState<TechniqueListItem | null>(null);
|
|
const [recent, setRecent] = useState<TechniqueListItem[]>([]);
|
|
const [recentLoading, setRecentLoading] = useState(true);
|
|
const [popularTopics, setPopularTopics] = useState<{name: string; count: number}[]>([]);
|
|
const [randomLoading, setRandomLoading] = useState(false);
|
|
const [randomError, setRandomError] = useState(false);
|
|
const [stats, setStats] = useState<StatsResponse | null>(null);
|
|
const [trending, setTrending] = useState<PopularSearchItem[] | null>(null);
|
|
const navigate = useNavigate();
|
|
|
|
const handleRandomTechnique = async () => {
|
|
setRandomLoading(true);
|
|
setRandomError(false);
|
|
try {
|
|
const { slug } = await fetchRandomTechnique();
|
|
navigate(`/techniques/${slug}`);
|
|
} catch {
|
|
setRandomError(true);
|
|
setTimeout(() => setRandomError(false), 2000);
|
|
} finally {
|
|
setRandomLoading(false);
|
|
}
|
|
};
|
|
|
|
// Load featured technique (random)
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const res = await fetchTechniques({ sort: "random", limit: 1 });
|
|
if (!cancelled && res.items.length > 0) setFeatured(res.items[0] ?? null);
|
|
} catch {
|
|
// silently ignore — optional section
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// Load recently added techniques
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const res = await fetchTechniques({ sort: "recent", limit: 6 });
|
|
if (!cancelled) setRecent(res.items);
|
|
} catch {
|
|
// silently ignore — not critical
|
|
} finally {
|
|
if (!cancelled) setRecentLoading(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// Load popular topics
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const categories = await fetchTopics();
|
|
const all = categories.flatMap((cat) =>
|
|
cat.sub_topics.map((st) => ({ name: st.name, count: st.technique_count }))
|
|
);
|
|
all.sort((a, b) => b.count - a.count);
|
|
if (!cancelled) setPopularTopics(all.slice(0, 8));
|
|
} catch {
|
|
// optional section — silently ignore
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// Load stats
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const data = await fetchStats();
|
|
if (!cancelled) setStats(data);
|
|
} catch {
|
|
// optional section — silently ignore
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// Load trending searches
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const data = await fetchPopularSearches();
|
|
if (!cancelled && data.items.length > 0) {
|
|
setTrending(data.items);
|
|
}
|
|
} catch {
|
|
// optional section — silently ignore
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="home">
|
|
{/* Hero search */}
|
|
<section className="home-hero">
|
|
<h1 className="home-hero__title">Production Knowledge, Distilled</h1>
|
|
<p className="home-hero__subtitle">
|
|
Search techniques, key moments, and creators
|
|
</p>
|
|
|
|
<SearchAutocomplete
|
|
variant="hero"
|
|
autoFocus
|
|
onSearch={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)}
|
|
/>
|
|
|
|
<p className="home-hero__value-prop">
|
|
Real music production techniques extracted from creator tutorials.
|
|
Skip the 4-hour videos — find the insight you need in seconds.
|
|
</p>
|
|
|
|
<div className="home-how-it-works">
|
|
<div className="home-how-it-works__step">
|
|
<span className="home-how-it-works__number">1</span>
|
|
<h2 className="home-how-it-works__title">Creators Share Techniques</h2>
|
|
<p className="home-how-it-works__desc">
|
|
Real producers and sound designers publish in-depth tutorials
|
|
</p>
|
|
</div>
|
|
<div className="home-how-it-works__step">
|
|
<span className="home-how-it-works__number">2</span>
|
|
<h2 className="home-how-it-works__title">AI Extracts Key Moments</h2>
|
|
<p className="home-how-it-works__desc">
|
|
We distill hours of video into structured, searchable knowledge
|
|
</p>
|
|
</div>
|
|
<div className="home-how-it-works__step">
|
|
<span className="home-how-it-works__number">3</span>
|
|
<h2 className="home-how-it-works__title">You Find Answers Fast</h2>
|
|
<p className="home-how-it-works__desc">
|
|
Search by topic, technique, or creator — get straight to the insight
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Link to="/topics" className="btn home-cta">Start Exploring</Link>
|
|
|
|
{popularTopics.length > 0 && (
|
|
<section className="home-popular-topics">
|
|
<h2 className="home-popular-topics__title">Popular Topics</h2>
|
|
<div className="home-popular-topics__list">
|
|
{popularTopics.map((topic) => (
|
|
<Link
|
|
key={topic.name}
|
|
to={`/search?q=${encodeURIComponent(topic.name)}&scope=topics`}
|
|
className="pill pill--topic-quick"
|
|
>
|
|
{topic.name}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</section>
|
|
|
|
{/* Navigation cards */}
|
|
<section className="nav-cards">
|
|
<Link to="/topics" className="nav-card card-stagger" style={{ '--stagger-index': 0 } as React.CSSProperties}>
|
|
<h2 className="nav-card__title"><IconTopics /> Topics</h2>
|
|
<p className="nav-card__desc">
|
|
Browse techniques organized by category and sub-topic
|
|
</p>
|
|
</Link>
|
|
<Link to="/creators" className="nav-card card-stagger" style={{ '--stagger-index': 1 } as React.CSSProperties}>
|
|
<h2 className="nav-card__title"><IconCreators /> Creators</h2>
|
|
<p className="nav-card__desc">
|
|
Discover creators and their technique libraries
|
|
</p>
|
|
</Link>
|
|
</section>
|
|
|
|
{/* Stats scorecard */}
|
|
{stats && (
|
|
<section className="home-stats card-stagger" style={{ '--stagger-index': 2 } as React.CSSProperties}>
|
|
<div className="home-stats__metric">
|
|
<span className="home-stats__number">{stats.technique_count}</span>
|
|
<span className="home-stats__label">Articles</span>
|
|
</div>
|
|
<div className="home-stats__metric">
|
|
<span className="home-stats__number">{stats.creator_count}</span>
|
|
<span className="home-stats__label">Creators</span>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Trending Searches */}
|
|
{trending && trending.length > 0 && (
|
|
<section className="home-trending card-stagger" style={{ '--stagger-index': 3 } as React.CSSProperties}>
|
|
<h2 className="home-trending__title">Trending Searches</h2>
|
|
<div className="home-trending__list">
|
|
{trending.map((item, i) => (
|
|
<Link
|
|
key={item.query}
|
|
to={`/search?q=${encodeURIComponent(item.query)}`}
|
|
className="pill pill--trending card-stagger"
|
|
style={{ '--stagger-index': i } as React.CSSProperties}
|
|
>
|
|
{item.query}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Random technique discovery */}
|
|
<div className="home-random">
|
|
<button
|
|
className="btn btn--random"
|
|
onClick={handleRandomTechnique}
|
|
disabled={randomLoading}
|
|
>
|
|
{randomLoading ? "Loading…" : randomError ? "Try again" : "🎲 Random Technique"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Featured Technique Spotlight */}
|
|
{featured && (
|
|
<section className="home-featured">
|
|
<h2 className="home-featured__label">Featured Technique</h2>
|
|
<Link to={`/techniques/${featured.slug}`} className="home-featured__title">
|
|
{featured.title}
|
|
</Link>
|
|
{featured.summary && (
|
|
<p className="home-featured__summary">{featured.summary}</p>
|
|
)}
|
|
<div className="home-featured__meta">
|
|
{featured.topic_category && (
|
|
<span className="badge badge--category">{featured.topic_category}</span>
|
|
)}
|
|
{featured.topic_tags && featured.topic_tags.length > 0 && (
|
|
<TagList tags={featured.topic_tags} pillClass="pill--tag" />
|
|
)}
|
|
{featured.key_moment_count > 0 && (
|
|
<span className="home-featured__moments">
|
|
{featured.key_moment_count} moment{featured.key_moment_count !== 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{featured.creator_name && (
|
|
<Link to={`/creators/${featured.creator_slug}`} className="home-featured__creator">
|
|
by {featured.creator_name}
|
|
</Link>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* Recently Added */}
|
|
<section className="recent-section">
|
|
<h2 className="recent-section__title">Recently Added</h2>
|
|
{recentLoading ? (
|
|
<div className="loading">Loading…</div>
|
|
) : recent.length === 0 ? (
|
|
<div className="empty-state">No techniques yet.</div>
|
|
) : (
|
|
<div className="recent-list">
|
|
{recent
|
|
.filter((t) => t.id !== featured?.id)
|
|
.slice(0, 4)
|
|
.map((t, i) => (
|
|
<Link
|
|
key={t.id}
|
|
to={`/techniques/${t.slug}`}
|
|
className="recent-card card-stagger"
|
|
style={{ '--stagger-index': i } as React.CSSProperties}
|
|
>
|
|
<span className="recent-card__title">{t.title}</span>
|
|
<span className="recent-card__meta">
|
|
<span className="badge badge--category">
|
|
{t.topic_category}
|
|
</span>
|
|
{t.topic_tags && t.topic_tags.length > 0 && (
|
|
<TagList tags={t.topic_tags} pillClass="pill--tag" />
|
|
)}
|
|
{t.summary && (
|
|
<span className="recent-card__summary">
|
|
{t.summary.length > 150
|
|
? `${t.summary.slice(0, 150)}…`
|
|
: t.summary}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="recent-card__footer">
|
|
<span className="recent-card__creator">
|
|
{t.creator_name || ''}
|
|
</span>
|
|
{t.created_at && (
|
|
<span className="recent-card__date">
|
|
{new Date(t.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|