- "frontend/src/components/PostsFeed.tsx" - "frontend/src/components/PostsFeed.module.css" - "frontend/src/pages/PostsList.tsx" - "frontend/src/pages/PostsList.module.css" - "frontend/src/pages/CreatorDetail.tsx" - "frontend/src/App.tsx" - "frontend/src/pages/CreatorDashboard.tsx" GSD-Task: S01/T04
494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
/**
|
||
* Creator detail page.
|
||
*
|
||
* Shows creator info (name, genres, video/technique counts) and lists
|
||
* their technique pages with links. Handles loading and 404 states.
|
||
*/
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Link, useParams } from "react-router-dom";
|
||
import {
|
||
fetchCreator,
|
||
updateCreatorProfile,
|
||
followCreator,
|
||
unfollowCreator,
|
||
getFollowStatus,
|
||
type CreatorDetailResponse,
|
||
} from "../api";
|
||
import { useAuth } from "../context/AuthContext";
|
||
import CreatorAvatar from "../components/CreatorAvatar";
|
||
import { SocialIcon } from "../components/SocialIcons";
|
||
import ChatWidget from "../components/ChatWidget";
|
||
import PostsFeed from "../components/PostsFeed";
|
||
import PersonalityProfile from "../components/PersonalityProfile";
|
||
import SortDropdown from "../components/SortDropdown";
|
||
import TagList from "../components/TagList";
|
||
import { catSlug } from "../utils/catSlug";
|
||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||
import { useSortPreference } from "../hooks/useSortPreference";
|
||
|
||
const CREATOR_SORT_OPTIONS = [
|
||
{ value: "newest", label: "Newest" },
|
||
{ value: "oldest", label: "Oldest" },
|
||
{ value: "alpha", label: "A–Z" },
|
||
];
|
||
|
||
const PLATFORM_OPTIONS = [
|
||
"Instagram",
|
||
"YouTube",
|
||
"Bandcamp",
|
||
"SoundCloud",
|
||
"Twitter",
|
||
"Spotify",
|
||
"Facebook",
|
||
"Twitch",
|
||
"Website",
|
||
];
|
||
|
||
export default function CreatorDetail() {
|
||
const { slug } = useParams<{ slug: string }>();
|
||
const { isAuthenticated } = useAuth();
|
||
const [creator, setCreator] = useState<CreatorDetailResponse | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [notFound, setNotFound] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [sort, setSort] = useSortPreference("newest");
|
||
|
||
// Follow state
|
||
const [following, setFollowing] = useState(false);
|
||
const [followLoading, setFollowLoading] = useState(false);
|
||
const [followerCount, setFollowerCount] = useState(0);
|
||
|
||
// Edit mode state
|
||
const [editMode, setEditMode] = useState(false);
|
||
const [editBio, setEditBio] = useState("");
|
||
const [editLinks, setEditLinks] = useState<Array<{ platform: string; url: string }>>([]);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saveError, setSaveError] = useState<string | null>(null);
|
||
|
||
useDocumentTitle(creator ? `${creator.name} — Chrysopedia` : "Chrysopedia");
|
||
|
||
useEffect(() => {
|
||
if (!slug) return;
|
||
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
setNotFound(false);
|
||
setError(null);
|
||
|
||
void (async () => {
|
||
try {
|
||
const creatorData = await fetchCreator(slug);
|
||
if (!cancelled) {
|
||
setCreator(creatorData);
|
||
}
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
if (err instanceof Error && err.message.includes("404")) {
|
||
setNotFound(true);
|
||
} else {
|
||
setError(
|
||
err instanceof Error ? err.message : "Failed to load creator",
|
||
);
|
||
}
|
||
}
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [slug]);
|
||
|
||
// Check follow status when creator loads and user is authenticated
|
||
useEffect(() => {
|
||
if (!creator || !isAuthenticated) {
|
||
setFollowing(false);
|
||
return;
|
||
}
|
||
setFollowerCount(creator.follower_count);
|
||
let cancelled = false;
|
||
void (async () => {
|
||
try {
|
||
const status = await getFollowStatus(creator.id);
|
||
if (!cancelled) setFollowing(status.following);
|
||
} catch {
|
||
// Non-critical — leave as not following
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [creator?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
async function handleFollowToggle() {
|
||
if (!creator || followLoading) return;
|
||
setFollowLoading(true);
|
||
try {
|
||
const res = following
|
||
? await unfollowCreator(creator.id)
|
||
: await followCreator(creator.id);
|
||
setFollowing(res.followed);
|
||
setFollowerCount(res.follower_count);
|
||
} catch {
|
||
// Silently fail — button state stays as-is
|
||
} finally {
|
||
setFollowLoading(false);
|
||
}
|
||
}
|
||
|
||
function enterEditMode() {
|
||
if (!creator) return;
|
||
setEditBio(creator.bio ?? "");
|
||
setEditLinks(
|
||
creator.social_links && Object.keys(creator.social_links).length > 0
|
||
? Object.entries(creator.social_links).map(([platform, url]) => ({ platform, url }))
|
||
: [],
|
||
);
|
||
setSaveError(null);
|
||
setEditMode(true);
|
||
}
|
||
|
||
function handleCancel() {
|
||
setEditMode(false);
|
||
setSaveError(null);
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (!creator) return;
|
||
setSaving(true);
|
||
setSaveError(null);
|
||
|
||
const filteredLinks = editLinks.filter((l) => l.platform && l.url);
|
||
const socialLinks: Record<string, string> | null =
|
||
filteredLinks.length > 0
|
||
? Object.fromEntries(filteredLinks.map((l) => [l.platform.toLowerCase(), l.url]))
|
||
: null;
|
||
|
||
try {
|
||
await updateCreatorProfile(creator.id, {
|
||
bio: editBio || null,
|
||
social_links: socialLinks,
|
||
});
|
||
// Optimistic local update
|
||
setCreator({
|
||
...creator,
|
||
bio: editBio || null,
|
||
social_links: socialLinks,
|
||
});
|
||
setEditMode(false);
|
||
} catch (err) {
|
||
setSaveError(err instanceof Error ? err.message : "Failed to save");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
function addLinkRow() {
|
||
setEditLinks([...editLinks, { platform: PLATFORM_OPTIONS[0] ?? "Instagram", url: "" }]);
|
||
}
|
||
|
||
function removeLinkRow(index: number) {
|
||
setEditLinks(editLinks.filter((_, i) => i !== index));
|
||
}
|
||
|
||
function updateLinkRow(index: number, field: "platform" | "url", value: string) {
|
||
setEditLinks(editLinks.map((row, i) => (i === index ? { ...row, [field]: value } : row)));
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="loading">Loading creator…</div>;
|
||
}
|
||
|
||
if (notFound) {
|
||
return (
|
||
<div className="technique-404">
|
||
<h2>Creator Not Found</h2>
|
||
<p>The creator "{slug}" doesn't exist.</p>
|
||
<Link to="/creators" className="btn">
|
||
Back to Creators
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error || !creator) {
|
||
return (
|
||
<div className="loading error-text">
|
||
Error: {error ?? "Unknown error"}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const techniques = [...creator.techniques].sort((a, b) => {
|
||
switch (sort) {
|
||
case "oldest":
|
||
return a.created_at.localeCompare(b.created_at);
|
||
case "alpha":
|
||
return a.title.localeCompare(b.title);
|
||
case "newest":
|
||
default:
|
||
return b.created_at.localeCompare(a.created_at);
|
||
}
|
||
});
|
||
|
||
return (
|
||
<div className="creator-detail">
|
||
<Link to="/creators" className="back-link">
|
||
← Creators
|
||
</Link>
|
||
|
||
{/* Hero */}
|
||
<header className="creator-hero">
|
||
<CreatorAvatar
|
||
creatorId={creator.id}
|
||
name={creator.name}
|
||
imageUrl={creator.avatar_url ?? undefined}
|
||
size={96}
|
||
/>
|
||
<div className="creator-hero__info">
|
||
<div className="creator-hero__name-row">
|
||
<h1 className="creator-hero__name">{creator.name}</h1>
|
||
{!editMode && (
|
||
<>
|
||
{isAuthenticated && (
|
||
<button
|
||
className={`creator-hero__follow-btn${following ? " creator-hero__follow-btn--following" : ""}`}
|
||
onClick={handleFollowToggle}
|
||
disabled={followLoading}
|
||
>
|
||
{followLoading ? "…" : following ? "Following" : "Follow"}
|
||
</button>
|
||
)}
|
||
<button
|
||
className="creator-hero__edit-btn"
|
||
onClick={enterEditMode}
|
||
title="Edit profile"
|
||
>
|
||
✎ Edit
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{editMode ? (
|
||
<div className="creator-edit-form">
|
||
<label className="creator-edit-form__label">Bio</label>
|
||
<textarea
|
||
className="creator-edit-form__bio"
|
||
value={editBio}
|
||
onChange={(e) => setEditBio(e.target.value)}
|
||
rows={4}
|
||
placeholder="Creator bio…"
|
||
/>
|
||
|
||
<label className="creator-edit-form__label">Social Links</label>
|
||
<div className="creator-edit-form__links">
|
||
{editLinks.map((link, i) => (
|
||
<div key={i} className="creator-edit-form__link-row">
|
||
<select
|
||
value={link.platform}
|
||
onChange={(e) => updateLinkRow(i, "platform", e.target.value)}
|
||
className="creator-edit-form__platform-select"
|
||
>
|
||
{PLATFORM_OPTIONS.map((p) => (
|
||
<option key={p} value={p}>{p}</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
type="url"
|
||
className="creator-edit-form__url-input"
|
||
value={link.url}
|
||
onChange={(e) => updateLinkRow(i, "url", e.target.value)}
|
||
placeholder="https://…"
|
||
/>
|
||
<button
|
||
className="creator-edit-form__remove-btn"
|
||
onClick={() => removeLinkRow(i)}
|
||
title="Remove link"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button className="creator-edit-form__add-btn" onClick={addLinkRow}>
|
||
+ Add Link
|
||
</button>
|
||
</div>
|
||
|
||
{saveError && (
|
||
<div className="creator-edit-form__error">{saveError}</div>
|
||
)}
|
||
|
||
<div className="creator-edit-form__actions">
|
||
<button
|
||
className="btn btn--primary"
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
>
|
||
{saving ? "Saving…" : "Save"}
|
||
</button>
|
||
<button
|
||
className="btn btn--secondary"
|
||
onClick={handleCancel}
|
||
disabled={saving}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{creator.bio && (
|
||
<p className="creator-hero__bio">{creator.bio}</p>
|
||
)}
|
||
{creator.social_links && Object.keys(creator.social_links).length > 0 && (
|
||
<div className="creator-hero__socials">
|
||
{Object.entries(creator.social_links).map(([platform, url]) => (
|
||
<a key={platform} href={url} target="_blank" rel="noopener noreferrer"
|
||
className="creator-hero__social-link" title={platform}>
|
||
<SocialIcon platform={platform} />
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{creator.genres && creator.genres.length > 0 && (
|
||
<div className="creator-hero__genres">
|
||
{creator.genres.map((g) => (
|
||
<span key={g} className="pill">{g}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
{/* Stats */}
|
||
<div className="creator-detail__stats-bar">
|
||
<span className="creator-detail__stats">
|
||
{creator.technique_count} technique{creator.technique_count !== 1 ? "s" : ""}
|
||
</span>
|
||
<span className="creator-detail__stats-sep">·</span>
|
||
<span className="creator-detail__stats">
|
||
{creator.video_count} video{creator.video_count !== 1 ? "s" : ""}
|
||
</span>
|
||
<span className="creator-detail__stats-sep">·</span>
|
||
<span className="creator-detail__stats">
|
||
{creator.moment_count} moment{creator.moment_count !== 1 ? "s" : ""}
|
||
</span>
|
||
<span className="creator-detail__stats-sep">·</span>
|
||
<span className="creator-detail__stats">
|
||
{followerCount} follower{followerCount !== 1 ? "s" : ""}
|
||
</span>
|
||
{Object.keys(creator.genre_breakdown).length > 0 && (
|
||
<span className="creator-detail__topic-pills">
|
||
{Object.entries(creator.genre_breakdown)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.map(([cat, count]) => (
|
||
<span key={cat} className={`badge badge--cat-${catSlug(cat)}`}>
|
||
{cat}: {count}
|
||
</span>
|
||
))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Personality Profile */}
|
||
<PersonalityProfile profile={creator.personality_profile ?? null} />
|
||
|
||
{/* Technique pages */}
|
||
<section className="creator-techniques">
|
||
<div className="creator-techniques__header">
|
||
<h2 className="creator-techniques__title">
|
||
Techniques ({creator.technique_count})
|
||
</h2>
|
||
<SortDropdown
|
||
options={CREATOR_SORT_OPTIONS}
|
||
value={sort}
|
||
onChange={setSort}
|
||
/>
|
||
</div>
|
||
{techniques.length === 0 ? (
|
||
<div className="empty-state">No techniques yet.</div>
|
||
) : (
|
||
<>
|
||
{/* Featured technique — first in sorted order */}
|
||
{techniques[0] != null && (() => {
|
||
const featured = techniques[0];
|
||
return (
|
||
<Link
|
||
to={`/techniques/${featured.slug}`}
|
||
className="creator-featured"
|
||
>
|
||
<span className="creator-featured__label section-heading section-heading--accent">
|
||
Featured Technique
|
||
</span>
|
||
<span className="creator-featured__title">
|
||
{featured.title}
|
||
</span>
|
||
{featured.summary && (
|
||
<p className="creator-featured__summary">
|
||
{featured.summary}
|
||
</p>
|
||
)}
|
||
<span className="creator-featured__meta">
|
||
<span className={`badge badge--cat-${catSlug(featured.topic_category)}`}>
|
||
{featured.topic_category}
|
||
</span>
|
||
{featured.topic_tags && featured.topic_tags.length > 0 && (
|
||
<TagList tags={featured.topic_tags} max={4} pillClass="pill--tag" />
|
||
)}
|
||
</span>
|
||
<span className="creator-featured__moments">
|
||
{featured.key_moment_count} moment{featured.key_moment_count !== 1 ? "s" : ""}
|
||
</span>
|
||
</Link>
|
||
);
|
||
})()}
|
||
|
||
{/* Remaining techniques — recent-card grid */}
|
||
{techniques.length > 1 && (
|
||
<div className="creator-techniques__list">
|
||
{techniques.slice(1).map((t, i) => (
|
||
<Link
|
||
key={t.slug}
|
||
to={`/techniques/${t.slug}`}
|
||
className="recent-card card-stagger"
|
||
style={{ '--stagger-index': i } as React.CSSProperties}
|
||
>
|
||
<span className="recent-card__title">
|
||
{t.title}
|
||
</span>
|
||
{t.summary && (
|
||
<span className="recent-card__summary">
|
||
{t.summary}
|
||
</span>
|
||
)}
|
||
<span className="recent-card__meta">
|
||
<span className={`badge badge--cat-${catSlug(t.topic_category)}`}>
|
||
{t.topic_category}
|
||
</span>
|
||
{t.topic_tags && t.topic_tags.length > 0 && (
|
||
<TagList tags={t.topic_tags} max={3} pillClass="pill--tag" />
|
||
)}
|
||
</span>
|
||
<span className="recent-card__moments">
|
||
{t.key_moment_count} moment{t.key_moment_count !== 1 ? "s" : ""}
|
||
</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
|
||
{/* Posts feed */}
|
||
<PostsFeed creatorId={creator.id} />
|
||
|
||
<ChatWidget creatorName={creator.name} techniques={creator.techniques} />
|
||
</div>
|
||
);
|
||
}
|