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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
|
|
|||
|
|
@ -811,6 +811,31 @@ export interface AdminTechniquePageListResponse {
|
|||
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(
|
||||
params: {
|
||||
multi_source_only?: boolean;
|
||||
|
|
|
|||
|
|
@ -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<CreatorDetailResponse | null>(null);
|
||||
|
|
@ -33,6 +46,13 @@ export default function CreatorDetail() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
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");
|
||||
|
||||
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<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>;
|
||||
}
|
||||
|
|
@ -120,7 +199,87 @@ export default function CreatorDetail() {
|
|||
size={96}
|
||||
/>
|
||||
<div className="creator-hero__info">
|
||||
<div className="creator-hero__name-row">
|
||||
<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 && (
|
||||
<p className="creator-hero__bio">{creator.bio}</p>
|
||||
)}
|
||||
|
|
@ -134,6 +293,9 @@ export default function CreatorDetail() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{creator.genres && creator.genres.length > 0 && (
|
||||
<div className="creator-hero__genres">
|
||||
{creator.genres.map((g) => (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue