chrysopedia/frontend/src/pages/CreatorDetail.tsx
jlightner 9431aa2095 feat: Added PostsFeed component to creator profile pages with Tiptap HT…
- "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
2026-04-04 09:17:30 +00:00

494 lines
16 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.

/**
* 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: "AZ" },
];
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>
);
}