From dc8f7ea6cf8623c9b27796cc14c86c9e01b738d2 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 09:18:39 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20updateCreatorProfile()=20API=20?= =?UTF-8?q?client=20and=20inline=20bio/social-lin=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/api/public-client.ts" - "frontend/src/pages/CreatorDetail.tsx" - "frontend/src/App.css" GSD-Task: S04/T01 --- frontend/src/App.css | 142 ++++++++++++++++++++ frontend/src/api/public-client.ts | 25 ++++ frontend/src/pages/CreatorDetail.tsx | 186 +++++++++++++++++++++++++-- 3 files changed, 341 insertions(+), 12 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 4d8a3e6..50e22d6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2721,6 +2721,148 @@ a.app-footer__repo:hover { height: 1.25rem; } +/* Creator Hero — Edit Mode */ +.creator-hero__name-row { + display: flex; + align-items: baseline; + gap: 0.75rem; +} + +.creator-hero__edit-btn { + background: none; + border: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease; + white-space: nowrap; +} + +.creator-hero__edit-btn:hover { + color: var(--color-accent); + border-color: var(--color-accent); +} + +.creator-edit-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 36rem; +} + +.creator-edit-form__label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + margin-bottom: -0.375rem; +} + +.creator-edit-form__bio { + width: 100%; + min-height: 5rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); + color: var(--color-text); + font-family: inherit; + font-size: 0.9375rem; + line-height: 1.6; + resize: vertical; +} + +.creator-edit-form__bio:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px rgba(var(--color-accent-rgb, 99, 102, 241), 0.15); +} + +.creator-edit-form__links { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.creator-edit-form__link-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.creator-edit-form__platform-select { + flex: 0 0 8rem; + padding: 0.375rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-surface); + color: var(--color-text); + font-size: 0.875rem; +} + +.creator-edit-form__url-input { + flex: 1; + min-width: 0; + padding: 0.375rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-surface); + color: var(--color-text); + font-size: 0.875rem; +} + +.creator-edit-form__url-input:focus, +.creator-edit-form__platform-select:focus { + outline: none; + border-color: var(--color-accent); +} + +.creator-edit-form__remove-btn { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + font-size: 1rem; + padding: 0.25rem; + line-height: 1; + border-radius: 4px; +} + +.creator-edit-form__remove-btn:hover { + color: var(--color-danger, #ef4444); +} + +.creator-edit-form__add-btn { + align-self: flex-start; + background: none; + border: 1px dashed var(--color-border); + color: var(--color-text-muted); + font-size: 0.8125rem; + padding: 0.375rem 0.75rem; + border-radius: 4px; + cursor: pointer; +} + +.creator-edit-form__add-btn:hover { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.creator-edit-form__error { + color: var(--color-danger, #ef4444); + font-size: 0.8125rem; + padding: 0.375rem 0; +} + +.creator-edit-form__actions { + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; +} + .creator-detail__stats-bar { display: flex; align-items: center; diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index 723ebc0..de4c659 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -811,6 +811,31 @@ export interface AdminTechniquePageListResponse { limit: number; } +// ── Admin: Creator Profile ────────────────────────────────────────────────── + +export interface UpdateCreatorProfilePayload { + bio?: string | null; + social_links?: Record | null; + featured?: boolean; + avatar_url?: string | null; +} + +export interface UpdateCreatorProfileResponse { + status: string; + creator: string; + fields: string[]; +} + +export async function updateCreatorProfile( + creatorId: string, + payload: UpdateCreatorProfilePayload, +): Promise { + return request( + `${BASE}/admin/pipeline/creators/${creatorId}`, + { method: "PUT", body: JSON.stringify(payload) }, + ); +} + export async function fetchAdminTechniquePages( params: { multi_source_only?: boolean; diff --git a/frontend/src/pages/CreatorDetail.tsx b/frontend/src/pages/CreatorDetail.tsx index 3da76fc..96e6f4a 100644 --- a/frontend/src/pages/CreatorDetail.tsx +++ b/frontend/src/pages/CreatorDetail.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { fetchCreator, + updateCreatorProfile, type CreatorDetailResponse, } from "../api/public-client"; import CreatorAvatar from "../components/CreatorAvatar"; @@ -25,6 +26,18 @@ const CREATOR_SORT_OPTIONS = [ { 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 [creator, setCreator] = useState(null); @@ -33,6 +46,13 @@ export default function CreatorDetail() { const [error, setError] = useState(null); const [sort, setSort] = useSortPreference("newest"); + // Edit mode state + const [editMode, setEditMode] = useState(false); + const [editBio, setEditBio] = useState(""); + const [editLinks, setEditLinks] = useState>([]); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + useDocumentTitle(creator ? `${creator.name} — Chrysopedia` : "Chrysopedia"); useEffect(() => { @@ -69,6 +89,65 @@ export default function CreatorDetail() { }; }, [slug]); + 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 | 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
Loading creator…
; } @@ -120,20 +199,103 @@ export default function CreatorDetail() { size={96} />
-

{creator.name}

- {creator.bio && ( -

{creator.bio}

- )} - {creator.social_links && Object.keys(creator.social_links).length > 0 && ( -
- {Object.entries(creator.social_links).map(([platform, url]) => ( - - - - ))} +
+

{creator.name}

+ {!editMode && ( + + )} +
+ + {editMode ? ( +
+ +