Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -0,0 +1,393 @@
import React, { useEffect, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
async function requestJson(url, method, body) {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Request failed')
}
return payload
}
async function uploadFile(url, fieldName, file, extra = {}) {
const formData = new FormData()
formData.append(fieldName, file)
Object.entries(extra).forEach(([key, value]) => {
formData.append(key, String(value))
})
const response = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Upload failed')
}
return payload
}
function socialPlatformLabel(value) {
return value
.split(/[_-]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
export default function StudioProfile() {
const { props } = usePage()
const profile = props.profile || {}
const endpoints = props.endpoints || {}
const featuredContent = props.featuredContent || {}
const featuredModules = props.featuredModules || []
const avatarInputRef = useRef(null)
const coverInputRef = useRef(null)
const [form, setForm] = useState({
display_name: profile.name || '',
tagline: profile.tagline || '',
bio: profile.bio || '',
website: profile.website || '',
social_links: (profile.social_links || []).length > 0 ? profile.social_links : [{ platform: '', url: '' }],
})
const [coverPosition, setCoverPosition] = useState(profile.cover_position ?? 50)
const [savingProfile, setSavingProfile] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [uploadingCover, setUploadingCover] = useState(false)
const [savingCoverPosition, setSavingCoverPosition] = useState(false)
const [deletingCover, setDeletingCover] = useState(false)
useEffect(() => {
setForm({
display_name: profile.name || '',
tagline: profile.tagline || '',
bio: profile.bio || '',
website: profile.website || '',
social_links: (profile.social_links || []).length > 0 ? profile.social_links : [{ platform: '', url: '' }],
})
setCoverPosition(profile.cover_position ?? 50)
}, [profile.bio, profile.cover_position, profile.name, profile.social_links, profile.tagline, profile.website])
const updateSocialLink = (index, key, value) => {
setForm((current) => ({
...current,
social_links: current.social_links.map((link, linkIndex) => (
linkIndex === index ? { ...link, [key]: value } : link
)),
}))
}
const addSocialLink = () => {
setForm((current) => ({
...current,
social_links: [...current.social_links, { platform: '', url: '' }],
}))
}
const removeSocialLink = (index) => {
setForm((current) => ({
...current,
social_links: current.social_links.filter((_, linkIndex) => linkIndex !== index),
}))
}
const saveProfile = async () => {
setSavingProfile(true)
try {
await requestJson(endpoints.profile, 'PUT', {
display_name: form.display_name,
tagline: form.tagline || null,
bio: form.bio || null,
website: form.website || null,
social_links: form.social_links.filter((link) => link.platform.trim() && link.url.trim()),
})
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Unable to save profile.')
} finally {
setSavingProfile(false)
}
}
const handleAvatarSelected = async (event) => {
const file = event.target.files?.[0]
if (!file) return
setUploadingAvatar(true)
try {
await uploadFile(endpoints.avatarUpload, 'avatar', file, { avatar_position: 'center' })
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Unable to upload avatar.')
} finally {
event.target.value = ''
setUploadingAvatar(false)
}
}
const handleCoverSelected = async (event) => {
const file = event.target.files?.[0]
if (!file) return
setUploadingCover(true)
try {
await uploadFile(endpoints.coverUpload, 'cover', file)
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Unable to upload cover image.')
} finally {
event.target.value = ''
setUploadingCover(false)
}
}
const saveCoverPosition = async () => {
setSavingCoverPosition(true)
try {
await requestJson(endpoints.coverPosition, 'POST', { position: coverPosition })
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Unable to update cover position.')
} finally {
setSavingCoverPosition(false)
}
}
const deleteCover = async () => {
if (!window.confirm('Remove your current banner image?')) {
return
}
setDeletingCover(true)
try {
await requestJson(endpoints.coverDelete, 'DELETE')
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Unable to delete cover image.')
} finally {
setDeletingCover(false)
}
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6">
<section className="overflow-hidden rounded-[32px] border border-white/10 bg-white/[0.03]">
<div
className="relative min-h-[220px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.25),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(34,197,94,0.18),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.94),_rgba(2,6,23,1))]"
style={profile.cover_url ? {
backgroundImage: `linear-gradient(rgba(2,6,23,0.35), rgba(2,6,23,0.8)), url(${profile.cover_url})`,
backgroundSize: 'cover',
backgroundPosition: `center ${coverPosition}%`,
} : undefined}
>
<div className="flex flex-wrap items-start justify-between gap-4 p-6">
<div className="rounded-full border border-white/10 bg-black/30 px-4 py-2 text-xs uppercase tracking-[0.2em] text-slate-200">Creator identity</div>
<div className="flex flex-wrap gap-2">
<input ref={coverInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleCoverSelected} className="hidden" />
<button type="button" onClick={() => coverInputRef.current?.click()} disabled={uploadingCover} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-4 py-2 text-sm text-white disabled:opacity-50">
<i className="fa-solid fa-image" />
{uploadingCover ? 'Uploading...' : profile.cover_url ? 'Replace banner' : 'Upload banner'}
</button>
{profile.cover_url && (
<button type="button" onClick={deleteCover} disabled={deletingCover} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm text-rose-100 disabled:opacity-50">
<i className="fa-solid fa-trash" />
{deletingCover ? 'Removing...' : 'Remove banner'}
</button>
)}
<a href={profile.profile_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-4 py-2 text-sm text-white">
<i className="fa-solid fa-arrow-up-right-from-square" />
View public profile
</a>
</div>
</div>
<div className="p-6 pt-0">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="flex items-end gap-4">
<div className="relative">
{profile.avatar_url ? (
<img src={profile.avatar_url} alt={profile.username} className="h-24 w-24 rounded-[28px] border border-white/10 object-cover shadow-lg" />
) : (
<div className="flex h-24 w-24 items-center justify-center rounded-[28px] border border-white/10 bg-black/30 text-slate-400 shadow-lg">
<i className="fa-solid fa-user text-2xl" />
</div>
)}
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleAvatarSelected} className="hidden" />
<button type="button" onClick={() => avatarInputRef.current?.click()} disabled={uploadingAvatar} className="absolute -bottom-2 -right-2 inline-flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/25 bg-sky-300/15 text-sky-100 disabled:opacity-50">
<i className={`fa-solid ${uploadingAvatar ? 'fa-spinner fa-spin' : 'fa-camera'}`} />
</button>
</div>
<div>
<h2 className="text-3xl font-semibold text-white">{profile.name}</h2>
<p className="mt-1 text-sm text-slate-300">@{profile.username}</p>
<div className="mt-2 flex flex-wrap gap-4 text-sm text-slate-300">
<span>{Number(profile.followers || 0).toLocaleString()} followers</span>
{profile.location && <span>{profile.location}</span>}
</div>
</div>
</div>
{profile.cover_url && (
<div className="w-full max-w-sm rounded-[24px] border border-white/10 bg-black/30 p-4">
<label className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Banner position</label>
<input type="range" min="0" max="100" value={coverPosition} onChange={(event) => setCoverPosition(Number(event.target.value))} className="mt-3 w-full" />
<button type="button" onClick={saveCoverPosition} disabled={savingCoverPosition} className="mt-3 inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-white disabled:opacity-50">
<i className="fa-solid fa-arrows-up-down" />
{savingCoverPosition ? 'Saving...' : 'Save banner position'}
</button>
</div>
)}
</div>
</div>
</div>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-white">Public profile details</h2>
<p className="mt-1 text-sm text-slate-400">Update the creator information that supports your public presence across Nova.</p>
</div>
<button type="button" onClick={saveProfile} disabled={savingProfile} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:opacity-50">
<i className="fa-solid fa-floppy-disk" />
{savingProfile ? 'Saving...' : 'Save profile'}
</button>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm text-slate-300 md:col-span-2">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Display name</span>
<input value={form.display_name} onChange={(event) => setForm((current) => ({ ...current, display_name: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
</label>
<label className="space-y-2 text-sm text-slate-300 md:col-span-2">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Tagline</span>
<input value={form.tagline} onChange={(event) => setForm((current) => ({ ...current, tagline: event.target.value }))} placeholder="One-line creator summary" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500" />
</label>
<label className="space-y-2 text-sm text-slate-300 md:col-span-2">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Bio</span>
<textarea value={form.bio} onChange={(event) => setForm((current) => ({ ...current, bio: event.target.value }))} rows={5} placeholder="Tell visitors what you create and what makes your work distinct." className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500" />
</label>
<label className="space-y-2 text-sm text-slate-300 md:col-span-2">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Website</span>
<input value={form.website} onChange={(event) => setForm((current) => ({ ...current, website: event.target.value }))} placeholder="https://example.com" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500" />
</label>
</div>
<div className="mt-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-white">Social links</h3>
<p className="mt-1 text-sm text-slate-400">Add the channels that matter for your creator identity.</p>
</div>
<button type="button" onClick={addSocialLink} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-white">
<i className="fa-solid fa-plus" />
Add link
</button>
</div>
<div className="mt-4 space-y-3">
{form.social_links.map((link, index) => (
<div key={`${index}-${link.platform}`} className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 md:grid-cols-[180px_minmax(0,1fr)_auto]">
<input value={link.platform} onChange={(event) => updateSocialLink(index, 'platform', event.target.value)} placeholder="instagram" className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500" />
<input value={link.url} onChange={(event) => updateSocialLink(index, 'url', event.target.value)} placeholder="https://..." className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500" />
<button type="button" onClick={() => removeSocialLink(index)} className="inline-flex items-center justify-center rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">
<i className="fa-solid fa-trash" />
</button>
</div>
))}
</div>
</div>
</section>
<div className="space-y-6">
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Publishing footprint</h2>
<div className="mt-5 grid gap-4">
{(props.moduleSummaries || []).map((item) => (
<div key={item.key} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center gap-3 text-slate-200">
<i className={item.icon} />
<span>{item.label}</span>
</div>
<div className="mt-3 text-3xl font-semibold text-white">{Number(item.count || 0).toLocaleString()}</div>
<p className="mt-2 text-sm text-slate-400">{Number(item.published_count || 0).toLocaleString()} published, {Number(item.draft_count || 0).toLocaleString()} drafts</p>
</div>
))}
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold text-white">Featured identity</h2>
<a href="/studio/featured" className="text-sm font-medium text-sky-100">Manage featured</a>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{featuredModules.length > 0 ? featuredModules.map((module) => (
<span key={module} className="inline-flex items-center rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100">
{socialPlatformLabel(module)}
</span>
)) : (
<p className="text-sm text-slate-400">No featured modules selected yet.</p>
)}
</div>
<div className="mt-4 space-y-3">
{Object.entries(featuredContent).map(([module, item]) => item ? (
<a key={module} href={item.view_url || item.preview_url || '/studio/featured'} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 p-3">
{item.image_url ? (
<img src={item.image_url} alt={item.title} className="h-14 w-14 rounded-2xl object-cover" />
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-slate-400">
<i className={item.module_icon || 'fa-solid fa-star'} />
</div>
)}
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{socialPlatformLabel(module)}</div>
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
</div>
</a>
) : null)}
</div>
</section>
</div>
</div>
</div>
</StudioLayout>
)
}