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:
parent
ef52ef6967
commit
dc8f7ea6cf
3 changed files with 341 additions and 12 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: "A–Z" },
|
{ value: "alpha", label: "A–Z" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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,20 +199,103 @@ export default function CreatorDetail() {
|
||||||
size={96}
|
size={96}
|
||||||
/>
|
/>
|
||||||
<div className="creator-hero__info">
|
<div className="creator-hero__info">
|
||||||
<h1 className="creator-hero__name">{creator.name}</h1>
|
<div className="creator-hero__name-row">
|
||||||
{creator.bio && (
|
<h1 className="creator-hero__name">{creator.name}</h1>
|
||||||
<p className="creator-hero__bio">{creator.bio}</p>
|
{!editMode && (
|
||||||
)}
|
<button
|
||||||
{creator.social_links && Object.keys(creator.social_links).length > 0 && (
|
className="creator-hero__edit-btn"
|
||||||
<div className="creator-hero__socials">
|
onClick={enterEditMode}
|
||||||
{Object.entries(creator.social_links).map(([platform, url]) => (
|
title="Edit profile"
|
||||||
<a key={platform} href={url} target="_blank" rel="noopener noreferrer"
|
>
|
||||||
className="creator-hero__social-link" title={platform}>
|
✎ Edit
|
||||||
<SocialIcon platform={platform} />
|
</button>
|
||||||
</a>
|
)}
|
||||||
))}
|
</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>
|
</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 && (
|
{creator.genres && creator.genres.length > 0 && (
|
||||||
<div className="creator-hero__genres">
|
<div className="creator-hero__genres">
|
||||||
{creator.genres.map((g) => (
|
{creator.genres.map((g) => (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue