feat: Added updateCreatorProfile() API client and inline bio/social-lin…

- "frontend/src/api/public-client.ts"
- "frontend/src/pages/CreatorDetail.tsx"
- "frontend/src/App.css"

GSD-Task: S04/T01
This commit is contained in:
jlightner 2026-04-03 09:18:39 +00:00
parent ef52ef6967
commit dc8f7ea6cf
3 changed files with 341 additions and 12 deletions

View file

@ -2721,6 +2721,148 @@ a.app-footer__repo:hover {
height: 1.25rem; 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 { .creator-detail__stats-bar {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -811,6 +811,31 @@ export interface AdminTechniquePageListResponse {
limit: number; limit: number;
} }
// ── Admin: Creator Profile ──────────────────────────────────────────────────
export interface UpdateCreatorProfilePayload {
bio?: string | null;
social_links?: Record<string, string> | 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<UpdateCreatorProfileResponse> {
return request<UpdateCreatorProfileResponse>(
`${BASE}/admin/pipeline/creators/${creatorId}`,
{ method: "PUT", body: JSON.stringify(payload) },
);
}
export async function fetchAdminTechniquePages( export async function fetchAdminTechniquePages(
params: { params: {
multi_source_only?: boolean; multi_source_only?: boolean;

View file

@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { import {
fetchCreator, fetchCreator,
updateCreatorProfile,
type CreatorDetailResponse, type CreatorDetailResponse,
} from "../api/public-client"; } from "../api/public-client";
import CreatorAvatar from "../components/CreatorAvatar"; import CreatorAvatar from "../components/CreatorAvatar";
@ -25,6 +26,18 @@ const CREATOR_SORT_OPTIONS = [
{ value: "alpha", label: "AZ" }, { value: "alpha", label: "AZ" },
]; ];
const PLATFORM_OPTIONS = [
"Instagram",
"YouTube",
"Bandcamp",
"SoundCloud",
"Twitter",
"Spotify",
"Facebook",
"Twitch",
"Website",
];
export default function CreatorDetail() { export default function CreatorDetail() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const [creator, setCreator] = useState<CreatorDetailResponse | null>(null); const [creator, setCreator] = useState<CreatorDetailResponse | null>(null);
@ -33,6 +46,13 @@ export default function CreatorDetail() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sort, setSort] = useSortPreference("newest"); const [sort, setSort] = useSortPreference("newest");
// 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"); useDocumentTitle(creator ? `${creator.name} — Chrysopedia` : "Chrysopedia");
useEffect(() => { useEffect(() => {
@ -69,6 +89,65 @@ export default function CreatorDetail() {
}; };
}, [slug]); }, [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<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) { if (loading) {
return <div className="loading">Loading creator</div>; return <div className="loading">Loading creator</div>;
} }
@ -120,7 +199,87 @@ export default function CreatorDetail() {
size={96} size={96}
/> />
<div className="creator-hero__info"> <div className="creator-hero__info">
<div className="creator-hero__name-row">
<h1 className="creator-hero__name">{creator.name}</h1> <h1 className="creator-hero__name">{creator.name}</h1>
{!editMode && (
<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 && ( {creator.bio && (
<p className="creator-hero__bio">{creator.bio}</p> <p className="creator-hero__bio">{creator.bio}</p>
)} )}
@ -134,6 +293,9 @@ export default function CreatorDetail() {
))} ))}
</div> </div>
)} )}
</>
)}
{creator.genres && creator.genres.length > 0 && ( {creator.genres && creator.genres.length > 0 && (
<div className="creator-hero__genres"> <div className="creator-hero__genres">
{creator.genres.map((g) => ( {creator.genres.map((g) => (