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

@@ -1,7 +1,8 @@
import React, { useState, useCallback, useEffect } from 'react'
import React, { useState, useCallback, useEffect, useMemo } from 'react'
import { createRoot } from 'react-dom/client'
import axios from 'axios'
import ArtworkHero from '../components/artwork/ArtworkHero'
import ArtworkMediaStrip from '../components/artwork/ArtworkMediaStrip'
import ArtworkMeta from '../components/artwork/ArtworkMeta'
import ArtworkAwards from '../components/artwork/ArtworkAwards'
import ArtworkTags from '../components/artwork/ArtworkTags'
@@ -40,6 +41,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
const [related, setRelated] = useState(initialRelated)
const [comments, setComments] = useState(initialComments)
const [canonicalUrl, setCanonicalUrl] = useState(initialCanonical)
const [selectedMediaId, setSelectedMediaId] = useState('cover')
// Nav arrow state — populated by ArtworkNavigator once neighbors resolve
const [navState, setNavState] = useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null })
@@ -68,11 +70,48 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
setRelated([]) // cleared on navigation; user can scroll down for related
setComments([]) // cleared; per-page server data
setCanonicalUrl(data.canonical_url ?? window.location.href)
setSelectedMediaId('cover')
setViewerOpen(false) // close viewer when navigating away
}, [])
if (!artwork) return null
const mediaItems = useMemo(() => {
const coverItem = {
id: 'cover',
label: 'Cover art',
thumbUrl: presentSq?.url || presentMd?.url || presentLg?.url || artwork?.thumbs?.sq?.url || artwork?.thumbs?.md?.url || null,
mdUrl: presentMd?.url || artwork?.thumbs?.md?.url || null,
lgUrl: presentLg?.url || artwork?.thumbs?.lg?.url || null,
xlUrl: presentXl?.url || artwork?.thumbs?.xl?.url || null,
width: Number(artwork?.dimensions?.width || artwork?.width || 0) || null,
height: Number(artwork?.dimensions?.height || artwork?.height || 0) || null,
}
const screenshotItems = Array.isArray(artwork?.screenshots)
? artwork.screenshots.map((item, index) => ({
id: item.id || `shot-${index + 1}`,
label: item.label || `Screenshot ${index + 1}`,
thumbUrl: item.thumb_url || item.url || null,
mdUrl: item.url || item.thumb_url || null,
lgUrl: item.url || item.thumb_url || null,
xlUrl: item.url || item.thumb_url || null,
width: null,
height: null,
}))
: []
return [coverItem, ...screenshotItems].filter((item) => Boolean(item.thumbUrl || item.lgUrl || item.xlUrl))
}, [artwork, presentMd, presentLg, presentXl, presentSq])
const selectedMedia = mediaItems.find((item) => item.id === selectedMediaId) || mediaItems[0] || null
useEffect(() => {
if (!selectedMedia && mediaItems.length > 0) {
setSelectedMediaId(mediaItems[0].id)
}
}, [mediaItems, selectedMedia])
const initialAwards = artwork?.awards ?? null
return (
@@ -82,15 +121,24 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
<div id="artwork-hero-anchor" className="mx-auto w-full max-w-screen-2xl px-3 sm:px-6 lg:px-8">
<ArtworkHero
artwork={artwork}
presentMd={presentMd}
presentLg={presentLg}
presentXl={presentXl}
presentMd={selectedMedia?.mdUrl ? { url: selectedMedia.mdUrl } : presentMd}
presentLg={selectedMedia?.lgUrl ? { url: selectedMedia.lgUrl } : presentLg}
presentXl={selectedMedia?.xlUrl ? { url: selectedMedia.xlUrl } : presentXl}
mediaWidth={selectedMedia?.width ?? null}
mediaHeight={selectedMedia?.height ?? null}
mediaKey={selectedMedia?.id || 'cover'}
onOpenViewer={openViewer}
hasPrev={navState.hasPrev}
hasNext={navState.hasNext}
onPrev={navState.navigatePrev}
onNext={navState.navigateNext}
/>
<ArtworkMediaStrip
items={mediaItems}
selectedId={selectedMedia?.id || 'cover'}
onSelect={setSelectedMediaId}
/>
</div>
{/* ── Centered action bar with stat counts ────────────────────── */}
@@ -181,8 +229,8 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
isOpen={viewerOpen}
onClose={closeViewer}
artwork={artwork}
presentLg={presentLg}
presentXl={presentXl}
presentLg={selectedMedia?.lgUrl ? { url: selectedMedia.lgUrl } : presentLg}
presentXl={selectedMedia?.xlUrl ? { url: selectedMedia.xlUrl } : presentXl}
/>
</>
)

View File

@@ -564,7 +564,7 @@ export default function CollectionDashboard() {
<SummaryCard label="Archived" value={summary.archived ?? 0} icon="fa-box-archive" tone="rose" />
<SummaryCard label="Pending Submissions" value={summary.pending_submissions ?? 0} icon="fa-inbox" tone="amber" />
<SummaryCard label="Needs Review" value={summary.needs_review ?? 0} icon="fa-triangle-exclamation" tone="amber" />
<SummaryCard label="Duplicate Risk" value={summary.duplicate_risk ?? 0} icon="fa-clone" tone="rose" />
<SummaryCard label="Duplicate Risk" value={summary.duplicate_risk ?? 0} icon="fa-id-card" tone="rose" />
<SummaryCard label="Placement Blocked" value={summary.placement_blocked ?? 0} icon="fa-ban" tone="rose" />
</section>

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import { usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
import SeoHead from '../../components/seo/SeoHead'
const SEARCH_SELECT_OPTIONS = {
type: [
@@ -314,20 +315,7 @@ export default function CollectionFeaturedIndex() {
return (
<>
<Head>
<title>{seo?.title || `${title} — Skinbase Nova`}</title>
<meta name="description" content={seo?.description || description} />
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo?.robots || 'index,follow'} />
<meta property="og:title" content={seo?.title || `${title} — Skinbase Nova`} />
<meta property="og:description" content={seo?.description || description} />
{seo?.canonical ? <meta property="og:url" content={seo.canonical} /> : null}
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={seo?.title || `${title} — Skinbase Nova`} />
<meta name="twitter:description" content={seo?.description || description} />
{listSchema ? <script type="application/ld+json">{JSON.stringify(listSchema)}</script> : null}
</Head>
<SeoHead seo={seo} title={seo?.title || `${title} — Skinbase Nova`} description={seo?.description || description} jsonLd={listSchema} />
<div className="relative min-h-screen overflow-hidden pb-16">
<div

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import { usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
import SeoHead from '../../components/seo/SeoHead'
function StatCard({ icon, label, value }) {
return (
@@ -25,12 +26,7 @@ export default function CollectionSeriesShow() {
return (
<>
<Head>
<title>{seo.title || `${title} — Skinbase Nova`}</title>
<meta name="description" content={seo.description || description} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'index,follow'} />
</Head>
<SeoHead seo={seo} title={seo.title || `${title} — Skinbase Nova`} description={seo.description || description} />
<div className="relative min-h-screen overflow-hidden pb-16">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[36rem] opacity-95" style={{ background: 'radial-gradient(circle at 10% 15%, rgba(59,130,246,0.18), transparent 28%), radial-gradient(circle at 84% 18%, rgba(34,197,94,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />

View File

@@ -1,8 +1,9 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import { usePage } from '@inertiajs/react'
import ArtworkGallery from '../../components/artwork/ArtworkGallery'
import CollectionCard from '../../components/profile/collections/CollectionCard'
import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge'
import SeoHead from '../../components/seo/SeoHead'
import CommentForm from '../../components/social/CommentForm'
import CommentList from '../../components/social/CommentList'
import useWebShare from '../../hooks/useWebShare'
@@ -723,22 +724,7 @@ export default function CollectionShow() {
return (
<>
<Head>
<title>{metaTitle}</title>
{metaDescription ? <meta name="description" content={metaDescription} /> : null}
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
{seo?.robots ? <meta name="robots" content={seo.robots} /> : null}
<meta property="og:title" content={metaTitle} />
{metaDescription ? <meta property="og:description" content={metaDescription} /> : null}
{seo?.og_image ? <meta property="og:image" content={seo.og_image} /> : null}
{seo?.canonical ? <meta property="og:url" content={seo.canonical} /> : null}
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={metaTitle} />
{metaDescription ? <meta name="twitter:description" content={metaDescription} /> : null}
{seo?.og_image ? <meta name="twitter:image" content={seo.og_image} /> : null}
{collectionSchema ? <script type="application/ld+json">{JSON.stringify(collectionSchema)}</script> : null}
</Head>
<SeoHead seo={seo} title={metaTitle} description={metaDescription} jsonLd={collectionSchema} />
<div className="relative min-h-screen overflow-hidden pb-16">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[36rem] opacity-95" style={{ background: 'radial-gradient(circle at top left, rgba(56,189,248,0.18), transparent 32%), radial-gradient(circle at 82% 10%, rgba(249,115,22,0.18), transparent 26%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />

View File

@@ -721,7 +721,7 @@ export default function CollectionStaffProgramming() {
</Field>
<div className="flex items-end gap-3">
<button type="button" onClick={() => runDiagnostic('eligibility')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-4 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'eligibility' ? 'fa-circle-notch fa-spin' : 'fa-shield-check'} fa-fw`} />Eligibility</button>
<button type="button" onClick={() => runDiagnostic('duplicates')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'duplicates' ? 'fa-circle-notch fa-spin' : 'fa-clone'} fa-fw`} />Duplicates</button>
<button type="button" onClick={() => runDiagnostic('duplicates')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'duplicates' ? 'fa-circle-notch fa-spin' : 'fa-id-card'} fa-fw`} />Duplicates</button>
<button type="button" onClick={() => runDiagnostic('recommendations')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'recommendations' ? 'fa-circle-notch fa-spin' : 'fa-arrows-rotate'} fa-fw`} />Refresh</button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import { usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
import SeoHead from '../../components/seo/SeoHead'
function getCsrfToken() {
if (typeof document === 'undefined') return ''
@@ -326,20 +327,7 @@ export default function SavedCollections() {
return (
<>
<Head>
<title>{seo?.title || 'Saved Collections — Skinbase Nova'}</title>
<meta name="description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo?.robots || 'noindex,follow'} />
<meta property="og:title" content={seo?.title || 'Saved Collections — Skinbase Nova'} />
<meta property="og:description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
{seo?.canonical ? <meta property="og:url" content={seo.canonical} /> : null}
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={seo?.title || 'Saved Collections — Skinbase Nova'} />
<meta name="twitter:description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
{listSchema ? <script type="application/ld+json">{JSON.stringify(listSchema)}</script> : null}
</Head>
<SeoHead seo={seo} title={seo?.title || 'Saved Collections — Skinbase Nova'} description={seo?.description || 'Your saved collections on Skinbase Nova.'} jsonLd={listSchema} />
<div className="relative min-h-screen overflow-hidden pb-16">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react'
import { Head, usePage } from '@inertiajs/react'
import { usePage } from '@inertiajs/react'
import LeaderboardTabs from '../../components/leaderboard/LeaderboardTabs'
import LeaderboardList from '../../components/leaderboard/LeaderboardList'
import SeoHead from '../../components/seo/SeoHead'
const TYPE_TABS = [
{ value: 'creator', label: 'Creators' },
@@ -24,7 +25,7 @@ const API_BY_TYPE = {
export default function LeaderboardPage() {
const { props } = usePage()
const { initialType = 'creator', initialPeriod = 'weekly', initialData = { items: [] }, meta = {} } = props
const { initialType = 'creator', initialPeriod = 'weekly', initialData = { items: [] }, seo = {} } = props
const [type, setType] = useState(initialType)
const [period, setPeriod] = useState(initialPeriod)
@@ -70,10 +71,7 @@ export default function LeaderboardPage() {
return (
<>
<Head>
<title>{meta?.title || 'Leaderboard | Skinbase'}</title>
<meta name="description" content={meta?.description || 'Top creators, artworks, and stories on Skinbase.'} />
</Head>
<SeoHead seo={seo} title={seo?.title || 'Leaderboard — Skinbase'} description={seo?.description || 'Top creators, artworks, and stories on Skinbase.'} />
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100">
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">

View File

@@ -108,13 +108,40 @@ function positionToObjectPosition(position) {
return map[position] || '50% 50%'
}
function SectionCard({ title, description, children, actionSlot }) {
function SuccessMessage({ text, className = '' }) {
if (!text) return null
return (
<section className="rounded-xl border border-white/5 bg-white/[0.03] p-6">
<header className="flex flex-col gap-3 border-b border-white/5 pb-4 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-semibold text-white">{title}</h2>
{description ? <p className="mt-1 text-sm text-slate-400">{description}</p> : null}
<div className={`flex items-center gap-2.5 rounded-xl border border-emerald-400/25 bg-emerald-500/10 px-4 py-2.5 text-sm text-emerald-300 animate-in fade-in ${className}`}>
<i className="fa-solid fa-circle-check shrink-0 text-emerald-400" />
<span>{text}</span>
</div>
)
}
function ErrorMessage({ text, className = '' }) {
if (!text) return null
return (
<div className={`flex items-center gap-2.5 rounded-xl border border-red-400/25 bg-red-500/10 px-4 py-2.5 text-sm text-red-300 ${className}`}>
<i className="fa-solid fa-circle-exclamation shrink-0 text-red-400" />
<span>{text}</span>
</div>
)
}
function SectionCard({ title, description, icon, children, actionSlot }) {
return (
<section className="rounded-2xl border border-white/[0.06] bg-gradient-to-b from-white/[0.04] to-white/[0.02] p-6 shadow-lg shadow-black/10">
<header className="flex flex-col gap-3 border-b border-white/[0.06] pb-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-3">
{icon ? (
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent">
<i className={`${icon} text-sm`} />
</span>
) : null}
<div>
<h2 className="text-base font-semibold text-white">{title}</h2>
{description ? <p className="mt-1 text-sm text-slate-400">{description}</p> : null}
</div>
</div>
{actionSlot ? <div>{actionSlot}</div> : null}
</header>
@@ -820,6 +847,15 @@ export default function ProfileEdit() {
}
}
// Auto-dismiss success messages after 4 seconds
useEffect(() => {
if (!savedMessage.text) return
const timer = window.setTimeout(() => {
setSavedMessage({ section: '', text: '' })
}, 4000)
return () => window.clearTimeout(timer)
}, [savedMessage])
const sectionSaved = savedMessage.section === activeSection ? savedMessage.text : ''
return (
@@ -828,23 +864,15 @@ export default function ProfileEdit() {
sections={SETTINGS_SECTIONS}
activeSection={activeSection}
onSectionChange={switchSection}
dirtyMap={dirtyMap}
>
<div className="space-y-4">
<div className="flex flex-col gap-2 rounded-xl border border-white/5 bg-white/[0.02] p-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-slate-300">
Configure your account by section. Each card saves independently.
</p>
{dirtyMap[activeSection] ? (
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-xs font-medium text-amber-300">
Unsaved changes
</span>
) : null}
</div>
<div className="space-y-5">
{activeSection === 'profile' ? (
<form className="space-y-4" onSubmit={saveProfileSection}>
<SectionCard
title="Profile"
icon="fa-solid fa-user-astronaut"
description="Manage your public identity and profile presentation."
actionSlot={
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'profile'}>
@@ -854,23 +882,88 @@ export default function ProfileEdit() {
>
<div className="grid gap-6 lg:grid-cols-[260px,1fr]">
<div className="space-y-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div
className="mx-auto overflow-hidden rounded-full border border-white/15 bg-white/5"
style={{ width: 144, height: 144, minWidth: 144, minHeight: 144 }}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt="Avatar preview"
className="block h-full w-full object-cover object-center"
style={{ aspectRatio: '1 / 1', objectPosition: positionToObjectPosition(avatarPosition) }}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-slate-500">No avatar</div>
)}
<div
className={`group relative rounded-2xl border border-white/10 bg-black/20 p-5 transition-colors duration-200 ${
dragActive ? 'border-accent/50 bg-accent/5' : ''
}`}
onDragEnter={(e) => {
dragHandler(e)
setDragActive(true)
}}
onDragLeave={(e) => {
dragHandler(e)
setDragActive(false)
}}
onDragOver={dragHandler}
onDrop={handleDrop}
>
<div className="relative mx-auto" style={{ width: 144, height: 144 }}>
<div
className="overflow-hidden rounded-full border-2 border-white/10 bg-white/5 shadow-lg shadow-black/20"
style={{ width: 144, height: 144, minWidth: 144, minHeight: 144 }}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt="Avatar preview"
className="block h-full w-full object-cover object-center"
style={{ aspectRatio: '1 / 1', objectPosition: positionToObjectPosition(avatarPosition) }}
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 text-slate-500">
<i className="fa-solid fa-camera text-xl" />
<span className="text-[11px]">No avatar</span>
</div>
)}
</div>
{/* Hover overlay */}
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="absolute inset-0 flex cursor-pointer flex-col items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity duration-200 hover:opacity-100 focus-visible:opacity-100"
aria-label="Upload avatar"
>
<i className="fa-solid fa-camera-retro text-lg" />
<span className="mt-1 text-xs font-medium">Change</span>
</button>
</div>
<p className="mt-3 text-center text-xs text-slate-500">256 × 256 recommended · JPG, PNG, WEBP</p>
<input
ref={avatarInputRef}
type="file"
className="hidden"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => handleAvatarSelect(e.target.files?.[0])}
/>
<div className="mt-3 flex items-center justify-center gap-2">
<Button
type="button"
size="xs"
variant="secondary"
onClick={() => avatarInputRef.current?.click()}
leftIcon={<i className="fa-solid fa-arrow-up-from-bracket text-[10px]" />}
>
Upload
</Button>
{avatarUrl ? (
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => {
setAvatarFile(null)
setRemoveAvatar(true)
setAvatarUrl('')
}}
leftIcon={<i className="fa-solid fa-trash-can text-[10px]" />}
>
Remove
</Button>
) : null}
</div>
<p className="mt-3 text-center text-xs text-slate-400">Recommended size: 256 x 256</p>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
@@ -885,68 +978,11 @@ export default function ProfileEdit() {
hint="Applies when saving a newly selected avatar"
/>
</div>
<div
className={`rounded-xl border-2 border-dashed p-4 text-center transition ${
dragActive ? 'border-accent/60 bg-accent/10' : 'border-white/15 bg-white/[0.02]'
}`}
onDragEnter={(e) => {
dragHandler(e)
setDragActive(true)
}}
onDragLeave={(e) => {
dragHandler(e)
setDragActive(false)
}}
onDragOver={dragHandler}
onDrop={handleDrop}
>
<p className="text-sm text-white">Drag and drop avatar here</p>
<p className="mt-1 text-xs text-slate-400">JPG, PNG, WEBP up to 2 MB</p>
<input
ref={avatarInputRef}
type="file"
className="hidden"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => handleAvatarSelect(e.target.files?.[0])}
/>
<div className="mt-3 flex items-center justify-center gap-2">
<Button
type="button"
size="xs"
variant="secondary"
onClick={() => avatarInputRef.current?.click()}
>
Upload avatar
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => {
setAvatarFile(null)
setRemoveAvatar(true)
setAvatarUrl('')
}}
>
Remove avatar
</Button>
</div>
</div>
</div>
<div className="space-y-4">
{errorsBySection.profile._general ? (
<div className="rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{errorsBySection.profile._general[0]}
</div>
) : null}
{sectionSaved ? (
<div className="rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{sectionSaved}
</div>
) : null}
<ErrorMessage text={errorsBySection.profile._general?.[0]} />
<SuccessMessage text={sectionSaved} />
<TextInput
label="Display Name"
@@ -1017,6 +1053,7 @@ export default function ProfileEdit() {
<form className="space-y-4" onSubmit={saveAccountSection}>
<SectionCard
title="Account"
icon="fa-solid fa-id-badge"
description="Update your core account identity details."
actionSlot={
<Button
@@ -1030,17 +1067,8 @@ export default function ProfileEdit() {
</Button>
}
>
{errorsBySection.account._general ? (
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{errorsBySection.account._general[0]}
</div>
) : null}
{sectionSaved ? (
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{sectionSaved}
</div>
) : null}
<ErrorMessage text={errorsBySection.account._general?.[0]} className="mb-4" />
<SuccessMessage text={sectionSaved} className="mb-4" />
<div className="grid gap-4 md:grid-cols-2">
<TextInput
@@ -1068,7 +1096,7 @@ export default function ProfileEdit() {
{usernameAvailability.status !== 'idle' ? (
<p
className={`mt-4 rounded-lg border px-3 py-2 text-xs ${
className={`mt-4 flex items-center gap-2 rounded-xl border px-3 py-2 text-xs ${
usernameAvailability.status === 'available'
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-300'
: usernameAvailability.status === 'checking'
@@ -1076,11 +1104,17 @@ export default function ProfileEdit() {
: 'border-red-400/30 bg-red-500/10 text-red-300'
}`}
>
<i className={`fa-solid ${
usernameAvailability.status === 'available' ? 'fa-circle-check' :
usernameAvailability.status === 'checking' ? 'fa-spinner fa-spin' :
'fa-circle-xmark'
} shrink-0`} />
{usernameAvailability.message}
</p>
) : null}
<p className="mt-4 rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-xs text-slate-300">
<p className="mt-4 flex items-center gap-2 rounded-xl border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-xs text-slate-400">
<i className="fa-solid fa-clock shrink-0 text-slate-500" />
You can change your username once every {usernameCooldownDays} days.
</p>
@@ -1093,6 +1127,7 @@ export default function ProfileEdit() {
<form className="space-y-4" onSubmit={savePersonalSection}>
<SectionCard
title="Personal Details"
icon="fa-solid fa-address-card"
description="Optional information shown only when you decide to provide it."
actionSlot={
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'personal'}>
@@ -1100,17 +1135,8 @@ export default function ProfileEdit() {
</Button>
}
>
{errorsBySection.personal._general ? (
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{errorsBySection.personal._general[0]}
</div>
) : null}
{sectionSaved ? (
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{sectionSaved}
</div>
) : null}
<ErrorMessage text={errorsBySection.personal._general?.[0]} className="mb-4" />
<SuccessMessage text={sectionSaved} className="mb-4" />
<div className="space-y-4">
<div>
@@ -1210,6 +1236,7 @@ export default function ProfileEdit() {
<form className="space-y-4" onSubmit={saveNotificationsSection}>
<SectionCard
title="Notifications"
icon="fa-solid fa-bell"
description="Choose how and when Skinbase should notify you."
actionSlot={
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'notifications'}>
@@ -1217,30 +1244,24 @@ export default function ProfileEdit() {
</Button>
}
>
{errorsBySection.notifications._general ? (
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{errorsBySection.notifications._general[0]}
</div>
) : null}
<ErrorMessage text={errorsBySection.notifications._general?.[0]} className="mb-4" />
<SuccessMessage text={sectionSaved} className="mb-4" />
{sectionSaved ? (
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{sectionSaved}
</div>
) : null}
<div className="space-y-3">
<div className="space-y-2">
{[
['email_notifications', 'Email notifications', 'General email alerts for account activity.'],
['upload_notifications', 'Upload notifications', 'Notify me when followed creators upload.'],
['follower_notifications', 'Follower notifications', 'Notify me when someone follows me.'],
['comment_notifications', 'Comment notifications', 'Notify me about comments on my content.'],
['newsletter', 'Newsletter', 'Receive occasional community and product updates.'],
].map(([field, label, hint]) => (
<div key={field} className="flex items-center justify-between rounded-lg border border-white/5 bg-white/[0.02] px-3 py-2">
<div>
['email_notifications', 'Email notifications', 'General email alerts for account activity.', 'fa-solid fa-envelope'],
['upload_notifications', 'Upload notifications', 'Notify me when followed creators upload.', 'fa-solid fa-cloud-arrow-up'],
['follower_notifications', 'Follower notifications', 'Notify me when someone follows me.', 'fa-solid fa-user-plus'],
['comment_notifications', 'Comment notifications', 'Notify me about comments on my content.', 'fa-solid fa-comment-dots'],
['newsletter', 'Newsletter', 'Receive occasional community and product updates.', 'fa-solid fa-newspaper'],
].map(([field, label, hint, icon]) => (
<div key={field} className="flex items-center gap-3 rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3 transition-colors hover:bg-white/[0.04]">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/[0.06] text-slate-400">
<i className={`${icon} text-xs`} />
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white/90">{label}</p>
<p className="text-xs text-slate-400">{hint}</p>
<p className="text-xs text-slate-500">{hint}</p>
</div>
<Toggle
checked={!!notificationForm[field]}
@@ -1263,6 +1284,7 @@ export default function ProfileEdit() {
<form className="space-y-4" onSubmit={saveSecuritySection}>
<SectionCard
title="Security"
icon="fa-solid fa-shield-halved"
description="Update password. Additional security controls can be added here later."
actionSlot={
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'security'}>
@@ -1270,17 +1292,8 @@ export default function ProfileEdit() {
</Button>
}
>
{errorsBySection.security._general ? (
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{errorsBySection.security._general[0]}
</div>
) : null}
{sectionSaved ? (
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{sectionSaved}
</div>
) : null}
<ErrorMessage text={errorsBySection.security._general?.[0]} className="mb-4" />
<SuccessMessage text={sectionSaved} className="mb-4" />
<div className="grid max-w-2xl gap-4">
<TextInput
@@ -1318,8 +1331,11 @@ export default function ProfileEdit() {
autoComplete="new-password"
/>
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3 text-xs text-slate-400">
Future security controls: Two-factor authentication, active sessions, and login history.
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4">
<p className="flex items-center gap-2 text-xs text-slate-400">
<i className="fa-solid fa-lock text-slate-500" />
Coming soon: Two-factor authentication, active sessions, and login history.
</p>
</div>
{renderCaptchaChallenge('security')}
@@ -1329,19 +1345,35 @@ export default function ProfileEdit() {
) : null}
{activeSection === 'danger' ? (
<SectionCard
title="Danger Zone"
description="This action cannot be undone."
actionSlot={
<Button variant="danger" size="sm" onClick={() => setShowDeleteModal(true)}>
Delete Account
</Button>
}
>
<p className="text-sm text-slate-300">
Deleting your account permanently removes your artworks, comments, and profile data.
</p>
</SectionCard>
<section className="rounded-2xl border border-red-500/20 bg-gradient-to-b from-red-500/[0.06] to-red-500/[0.02] p-6 shadow-lg shadow-red-900/10">
<header className="flex flex-col gap-3 border-b border-red-500/10 pb-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-red-500/15 text-red-400">
<i className="fa-solid fa-triangle-exclamation text-sm" />
</span>
<div>
<h2 className="text-base font-semibold text-red-300">Danger Zone</h2>
<p className="mt-1 text-sm text-red-400/70">These actions are permanent and cannot be undone.</p>
</div>
</div>
</header>
<div className="pt-5">
<div className="rounded-xl border border-red-500/15 bg-red-500/[0.04] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-white">Delete account</p>
<p className="mt-0.5 text-xs text-slate-400">
Permanently removes your artworks, comments, followers, and all profile data.
</p>
</div>
<Button variant="danger" size="sm" onClick={() => setShowDeleteModal(true)}>
<i className="fa-solid fa-trash-can mr-1.5 text-xs" />
Delete Account
</Button>
</div>
</div>
</div>
</section>
) : null}
</div>

View File

@@ -0,0 +1,163 @@
import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
async function requestJson(url, method = 'POST') {
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',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Request failed')
}
return payload
}
function formatDate(value) {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Unknown'
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
export default function StudioActivity() {
const { props } = usePage()
const listing = props.listing || {}
const filters = listing.filters || {}
const items = listing.items || []
const meta = listing.meta || {}
const summary = listing.summary || {}
const typeOptions = listing.type_options || []
const moduleOptions = listing.module_options || []
const endpoints = props.endpoints || {}
const [marking, setMarking] = useState(false)
const updateFilters = (patch) => {
const next = { ...filters, ...patch }
if (patch.page == null) next.page = 1
trackStudioEvent('studio_activity_opened', {
surface: studioSurface(),
module: 'activity',
meta: patch,
})
router.get(window.location.pathname, next, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
const markAllRead = async () => {
setMarking(true)
try {
await requestJson(endpoints.markAllRead)
router.reload({ only: ['listing'] })
} catch (error) {
window.alert(error?.message || 'Unable to mark activity as read.')
} finally {
setMarking(false)
}
}
return (
<StudioLayout
title={props.title}
subtitle={props.description}
actions={
<button type="button" onClick={markAllRead} disabled={marking} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-100 disabled:opacity-50">
<i className="fa-solid fa-check-double" />
{marking ? 'Updating...' : 'Mark all read'}
</button>
}
>
<div className="space-y-6">
<section className="grid gap-4 md:grid-cols-3">
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">New since last read</div>
<div className="mt-2 text-3xl font-semibold text-white">{Number(summary.new_items || 0).toLocaleString()}</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Unread notifications</div>
<div className="mt-2 text-3xl font-semibold text-white">{Number(summary.unread_notifications || 0).toLocaleString()}</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Last inbox reset</div>
<div className="mt-2 text-base font-semibold text-white">{summary.last_read_at ? formatDate(summary.last_read_at) : 'Not yet'}</div>
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search activity</span>
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Message, actor, or module" />
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Type</span>
<select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
{typeOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
</select>
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Content type</span>
<select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
{moduleOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
</select>
</label>
<div className="flex items-end">
<button type="button" onClick={() => updateFilters({ q: '', type: 'all', module: 'all' })} className="w-full rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200">Reset</button>
</div>
</div>
</section>
<section className="space-y-4">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className={`rounded-[28px] border p-5 ${item.is_new ? 'border-sky-300/25 bg-sky-300/10' : 'border-white/10 bg-white/[0.03]'}`}>
<div className="flex gap-4">
{item.actor?.avatar_url ? (
<img src={item.actor.avatar_url} alt={item.actor.name || 'Activity actor'} className="h-12 w-12 rounded-2xl object-cover" />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-black/20 text-slate-400">
<i className="fa-solid fa-bell" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
<span>{item.module_label}</span>
<span>{formatDate(item.created_at)}</span>
{item.is_new && <span className="rounded-full bg-sky-300/20 px-2 py-1 text-sky-100">New</span>}
</div>
<h2 className="mt-2 text-lg font-semibold text-white">{item.title}</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">{item.body}</p>
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-slate-400">
{item.actor?.name && <span>{item.actor.name}</span>}
<a href={item.url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-slate-200">Open</a>
</div>
</div>
</div>
</article>
)) : <div className="rounded-[28px] border border-dashed border-white/15 px-6 py-16 text-center text-slate-400">No activity matches this filter.</div>}
</section>
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
<button type="button" disabled={(meta.current_page || 1) <= 1} onClick={() => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) })} className="rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">Previous</button>
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Page {meta.current_page || 1} of {meta.last_page || 1}</span>
<button type="button" disabled={(meta.current_page || 1) >= (meta.last_page || 1)} onClick={() => updateFilters({ page: (meta.current_page || 1) + 1 })} className="rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">Next</button>
</div>
</div>
</StudioLayout>
)
}

View File

@@ -1,48 +1,119 @@
import React from 'react'
import { usePage, Link } from '@inertiajs/react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
const kpiItems = [
{ key: 'views', label: 'Total Views', icon: 'fa-eye', color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
{ key: 'favourites', label: 'Total Favourites', icon: 'fa-heart', color: 'text-pink-400', bg: 'bg-pink-500/10' },
{ key: 'shares', label: 'Total Shares', icon: 'fa-share-nodes', color: 'text-amber-400', bg: 'bg-amber-500/10' },
{ key: 'downloads', label: 'Total Downloads', icon: 'fa-download', color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ key: 'comments', label: 'Total Comments', icon: 'fa-comment', color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ key: 'views', label: 'Views', icon: 'fa-eye', color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
{ key: 'appreciation', label: 'Reactions', icon: 'fa-heart', color: 'text-pink-400', bg: 'bg-pink-500/10' },
{ key: 'shares', label: 'Shares', icon: 'fa-share-nodes', color: 'text-amber-400', bg: 'bg-amber-500/10' },
{ key: 'saves', label: 'Saves', icon: 'fa-bookmark', color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ key: 'comments', label: 'Comments', icon: 'fa-comment', color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ key: 'followers', label: 'Followers', icon: 'fa-user-group', color: 'text-cyan-300', bg: 'bg-cyan-400/10' },
]
const performanceItems = [
{ key: 'avg_ranking', label: 'Avg Ranking Score', icon: 'fa-trophy', color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
{ key: 'avg_heat', label: 'Avg Heat Score', icon: 'fa-fire', color: 'text-orange-400', bg: 'bg-orange-500/10' },
]
const rangeOptions = [7, 14, 30, 60, 90]
const contentTypeIcons = {
skins: 'fa-layer-group',
wallpapers: 'fa-desktop',
photography: 'fa-camera',
other: 'fa-folder-open',
members: 'fa-users',
function formatShortDate(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
const contentTypeColors = {
skins: 'text-emerald-400 bg-emerald-500/10',
wallpapers: 'text-blue-400 bg-blue-500/10',
photography: 'text-amber-400 bg-amber-500/10',
other: 'text-slate-400 bg-slate-500/10',
members: 'text-purple-400 bg-purple-500/10',
function TrendChart({ title, subtitle, points, colorClass, fillClass, icon }) {
const values = (points || []).map((point) => Number(point.value || 0))
const maxValue = Math.max(...values, 1)
return (
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-white">{title}</h2>
<p className="mt-1 text-sm text-slate-400">{subtitle}</p>
</div>
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl ${fillClass} ${colorClass}`}>
<i className={`fa-solid ${icon}`} />
</div>
</div>
<div className="mt-5 flex h-52 items-end gap-2">
{(points || []).map((point) => {
const height = `${Math.max(8, Math.round((Number(point.value || 0) / maxValue) * 100))}%`
return (
<div key={point.date} className="flex min-w-0 flex-1 flex-col items-center justify-end gap-2">
<div className="text-[10px] font-medium text-slate-500">{Number(point.value || 0).toLocaleString()}</div>
<div className="flex h-full w-full items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
<div className={`w-full rounded-t-[16px] ${fillClass}`} style={{ height }} />
</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-500">{formatShortDate(point.date)}</div>
</div>
)
})}
</div>
</section>
)
}
export default function StudioAnalytics() {
const { props } = usePage()
const { totals, topArtworks, contentBreakdown, recentComments } = props
const {
totals,
topContent,
moduleBreakdown,
recentComments,
publishingTimeline,
viewsTrend,
engagementTrend,
comparison,
insightBlocks,
rangeDays,
} = props
const totalArtworksCount = (contentBreakdown || []).reduce((sum, ct) => sum + ct.count, 0)
const updateRange = (days) => {
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'analytics',
meta: {
range_days: days,
},
})
router.get(window.location.pathname, { range_days: days }, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
return (
<StudioLayout title="Analytics">
{/* KPI Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
<StudioLayout title="Analytics" subtitle="Cross-module insights for the whole creator workspace, not just artwork uploads.">
<section className="mb-6 rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(244,114,182,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Analytics window</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Performance over the last {rangeDays || 30} days</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">This view compares module output, shows views and engagement trends over time, and keeps publishing rhythm in the same window.</p>
</div>
<div className="inline-flex rounded-full border border-white/10 bg-black/20 p-1">
{rangeOptions.map((days) => (
<button
key={days}
type="button"
onClick={() => updateRange(days)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition ${Number(rangeDays || 30) === days ? 'bg-white text-slate-950' : 'text-slate-300 hover:text-white'}`}
>
{days}d
</button>
))}
</div>
</div>
</section>
<div className="grid grid-cols-2 gap-4 xl:grid-cols-6">
{kpiItems.map((item) => (
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5 hover:border-white/20 transition-all">
<div key={item.key} className="rounded-[26px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl ${item.bg} flex items-center justify-center ${item.color}`}>
<i className={`fa-solid ${item.icon}`} />
@@ -56,157 +127,184 @@ export default function StudioAnalytics() {
))}
</div>
{/* Performance Averages */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
{performanceItems.map((item) => (
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5 hover:border-white/20 transition-all">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl ${item.bg} flex items-center justify-center ${item.color}`}>
<i className={`fa-solid ${item.icon} text-lg`} />
</div>
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">{item.label}</span>
</div>
<p className="text-3xl font-bold text-white tabular-nums">
{(totals?.[item.key] ?? 0).toFixed(1)}
</p>
</div>
))}
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<TrendChart
title="Views over time"
subtitle="Cross-module reach across the current analytics window."
points={viewsTrend}
colorClass="text-emerald-300"
fillClass="bg-emerald-400/60"
icon="fa-eye"
/>
<TrendChart
title="Engagement over time"
subtitle="Combined engagement score so you can see momentum shifts, not just raw traffic."
points={engagementTrend}
colorClass="text-pink-300"
fillClass="bg-pink-400/60"
icon="fa-bolt"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* Content Breakdown */}
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-white mb-4">
<i className="fa-solid fa-chart-pie text-slate-500 mr-2" />
Content Breakdown
</h3>
{contentBreakdown?.length > 0 ? (
<div className="space-y-3">
{contentBreakdown.map((ct) => {
const pct = totalArtworksCount > 0 ? Math.round((ct.count / totalArtworksCount) * 100) : 0
const iconClass = contentTypeIcons[ct.slug] || 'fa-folder'
const colorClass = contentTypeColors[ct.slug] || 'text-slate-400 bg-slate-500/10'
const [textColor, bgColor] = colorClass.split(' ')
return (
<div key={ct.slug} className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg ${bgColor} flex items-center justify-center ${textColor} flex-shrink-0`}>
<i className={`fa-solid ${iconClass} text-xs`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-white">{ct.name}</span>
<span className="text-xs text-slate-400 tabular-nums">{ct.count}</span>
</div>
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
<div
className={`h-full rounded-full ${bgColor.replace('/10', '/40')}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Module breakdown</h2>
<div className="mt-5 space-y-3">
{(moduleBreakdown || []).map((item) => (
<div key={item.key} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 text-slate-200">
<i className={item.icon} />
<div>
<div className="font-semibold text-white">{item.label}</div>
<div className="text-xs text-slate-400">{Number(item.count || 0).toLocaleString()} items</div>
</div>
</div>
)
})}
</div>
) : (
<p className="text-sm text-slate-500 text-center py-6">No artworks categorised yet</p>
)}
</div>
<a href={item.index_url} className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Open</a>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-slate-400 md:grid-cols-4">
<div><div>Views</div><div className="mt-1 font-semibold text-white">{Number(item.views || 0).toLocaleString()}</div></div>
<div><div>Reactions</div><div className="mt-1 font-semibold text-white">{Number(item.appreciation || 0).toLocaleString()}</div></div>
<div><div>Comments</div><div className="mt-1 font-semibold text-white">{Number(item.comments || 0).toLocaleString()}</div></div>
<div><div>Shares</div><div className="mt-1 font-semibold text-white">{Number(item.shares || 0).toLocaleString()}</div></div>
</div>
</div>
))}
</div>
</section>
{/* Recent Comments */}
<div className="lg:col-span-2 bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-white mb-4">
<i className="fa-solid fa-comments text-slate-500 mr-2" />
Recent Comments
</h3>
{recentComments?.length > 0 ? (
<div className="space-y-0 divide-y divide-white/5">
{recentComments.map((c) => (
<div key={c.id} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-xs text-slate-500 flex-shrink-0">
<i className="fa-solid fa-user" />
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Publishing rhythm</h2>
<div className="mt-5 space-y-3">
{(publishingTimeline || []).map((point) => (
<div key={point.date}>
<div className="mb-1 flex items-center justify-between text-xs text-slate-400">
<span>{new Date(point.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>
<span>{point.count}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/5">
<div className="h-full rounded-full bg-sky-300/60" style={{ width: `${Math.min(100, point.count * 18)}%` }} />
</div>
</div>
))}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-[28px] 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">Module comparison</h2>
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Last {rangeDays || 30} days</span>
</div>
<div className="mt-5 space-y-4">
{(comparison || []).map((item) => {
const viewMax = Math.max(...(comparison || []).map((entry) => Number(entry.views || 0)), 1)
const engagementMax = Math.max(...(comparison || []).map((entry) => Number(entry.engagement || 0)), 1)
return (
<div key={item.key} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 text-slate-200">
<i className={item.icon} />
<div>
<div className="font-semibold text-white">{item.label}</div>
<div className="text-xs text-slate-400">{Number(item.published_count || 0).toLocaleString()} published</div>
</div>
</div>
<a href={moduleBreakdown?.find((entry) => entry.key === item.key)?.index_url} className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Open</a>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-white">
<span className="font-medium text-accent">{c.author_name}</span>
{' '}on{' '}
<span className="text-slate-300">{c.artwork_title}</span>
</p>
<p className="text-xs text-slate-500 mt-0.5 line-clamp-2">{c.body}</p>
<p className="text-[10px] text-slate-600 mt-1">{new Date(c.created_at).toLocaleDateString()}</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Views</span>
<span>{Number(item.views || 0).toLocaleString()}</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/5">
<div className="h-full rounded-full bg-emerald-400/60" style={{ width: `${Math.max(4, Math.round((Number(item.views || 0) / viewMax) * 100))}%` }} />
</div>
</div>
<div>
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Engagement</span>
<span>{Number(item.engagement || 0).toLocaleString()}</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/5">
<div className="h-full rounded-full bg-pink-400/60" style={{ width: `${Math.max(4, Math.round((Number(item.engagement || 0) / engagementMax) * 100))}%` }} />
</div>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500 text-center py-6">No comments yet</p>
)}
</div>
)
})}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Readable insights</h2>
<div className="mt-4 space-y-3 text-sm text-slate-400">
{(insightBlocks || []).map((item) => (
<a key={item.key} href={item.href} className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/[0.04] text-sky-100">
<i className={item.icon} />
</div>
<div>
<h3 className="text-sm font-semibold text-white">{item.title}</h3>
<p className="mt-2 leading-6 text-slate-400">{item.body}</p>
<span className="mt-3 inline-flex items-center gap-2 text-sm font-medium text-sky-100">{item.cta}<i className="fa-solid fa-arrow-right" /></span>
</div>
</div>
</a>
))}
</div>
</section>
</div>
{/* Top Performers Table */}
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-white mb-4">
<i className="fa-solid fa-ranking-star text-slate-500 mr-2" />
Top 10 Artworks
</h3>
{topArtworks?.length > 0 ? (
<div className="overflow-x-auto sb-scrollbar">
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Top content</h2>
<div className="mt-5 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] uppercase tracking-wider text-slate-500 border-b border-white/5">
<th className="pb-3 pr-4">#</th>
<th className="pb-3 pr-4">Artwork</th>
<tr className="border-b border-white/5 text-left text-[11px] uppercase tracking-[0.18em] text-slate-500">
<th className="pb-3 pr-4">Module</th>
<th className="pb-3 pr-4">Title</th>
<th className="pb-3 pr-4 text-right">Views</th>
<th className="pb-3 pr-4 text-right">Favs</th>
<th className="pb-3 pr-4 text-right">Shares</th>
<th className="pb-3 pr-4 text-right">Downloads</th>
<th className="pb-3 pr-4 text-right">Ranking</th>
<th className="pb-3 text-right">Heat</th>
<th className="pb-3 pr-4 text-right">Reactions</th>
<th className="pb-3 pr-4 text-right">Comments</th>
<th className="pb-3 text-right">Open</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{topArtworks.map((art, i) => (
<tr key={art.id} className="hover:bg-white/[0.02] transition-colors">
<td className="py-3 pr-4 text-slate-500 tabular-nums">{i + 1}</td>
<td className="py-3 pr-4">
<Link
href={`/studio/artworks/${art.id}/analytics`}
className="flex items-center gap-3 group"
>
{art.thumb_url && (
<img
src={art.thumb_url}
alt={art.title}
className="w-9 h-9 rounded-lg object-cover bg-nova-800 flex-shrink-0 group-hover:ring-2 ring-accent/50 transition-all"
/>
)}
<span className="text-white font-medium truncate max-w-[200px] group-hover:text-accent transition-colors">
{art.title}
</span>
</Link>
</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.views.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.favourites.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.shares.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.downloads.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-yellow-400 tabular-nums font-medium">{art.ranking_score.toFixed(1)}</td>
<td className="py-3 text-right tabular-nums">
<span className={`font-medium ${art.heat_score > 5 ? 'text-orange-400' : 'text-slate-400'}`}>
{art.heat_score.toFixed(1)}
</span>
{art.heat_score > 5 && (
<i className="fa-solid fa-fire text-orange-400 ml-1 text-[10px]" />
)}
</td>
{(topContent || []).map((item) => (
<tr key={item.id}>
<td className="py-3 pr-4 text-slate-300">{item.module_label}</td>
<td className="py-3 pr-4 text-white">{item.title}</td>
<td className="py-3 pr-4 text-right text-slate-300">{Number(item.metrics?.views || 0).toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300">{Number(item.metrics?.appreciation || 0).toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300">{Number(item.metrics?.comments || 0).toLocaleString()}</td>
<td className="py-3 text-right"><a href={item.analytics_url || item.view_url} className="text-sky-100">Open</a></td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-slate-500 text-center py-8">No published artworks with stats yet</p>
)}
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Recent comments</h2>
<div className="mt-4 space-y-3">
{(recentComments || []).map((comment) => (
<article key={comment.id} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{comment.module_label}</p>
<p className="mt-2 text-sm text-white">{comment.author_name} on {comment.item_title}</p>
<p className="mt-2 text-sm leading-6 text-slate-400">{comment.body}</p>
</article>
))}
</div>
</section>
</div>
</StudioLayout>
)

View File

@@ -1,203 +1,20 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioToolbar from '../../Components/Studio/StudioToolbar'
import StudioGridCard from '../../Components/Studio/StudioGridCard'
import StudioTable from '../../Components/Studio/StudioTable'
import BulkActionsBar from '../../Components/Studio/BulkActionsBar'
import BulkTagModal from '../../Components/Studio/BulkTagModal'
import BulkCategoryModal from '../../Components/Studio/BulkCategoryModal'
import ConfirmDangerModal from '../../Components/Studio/ConfirmDangerModal'
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
import StudioContentBrowser from '../../Components/Studio/StudioContentBrowser'
export default function StudioArchived() {
const { props } = usePage()
const { categories } = props
const [viewMode, setViewMode] = React.useState(() => localStorage.getItem('studio_view_mode') || 'grid')
const [artworks, setArtworks] = React.useState([])
const [meta, setMeta] = React.useState({ current_page: 1, last_page: 1, per_page: 24, total: 0 })
const [loading, setLoading] = React.useState(true)
const [search, setSearch] = React.useState('')
const [sort, setSort] = React.useState('created_at:desc')
const [selectedIds, setSelectedIds] = React.useState([])
const [deleteModal, setDeleteModal] = React.useState({ open: false, ids: [] })
const [tagModal, setTagModal] = React.useState({ open: false, mode: 'add' })
const [categoryModal, setCategoryModal] = React.useState({ open: false })
const searchTimer = React.useRef(null)
const perPage = viewMode === 'list' ? 50 : 24
const fetchArtworks = React.useCallback(async (page = 1) => {
setLoading(true)
try {
const params = new URLSearchParams()
params.set('page', page)
params.set('per_page', perPage)
params.set('sort', sort)
params.set('status', 'archived')
if (search) params.set('q', search)
const res = await fetch(`/api/studio/artworks?${params.toString()}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
setArtworks(data.data || [])
setMeta(data.meta || meta)
} catch (err) {
console.error('Failed to fetch:', err)
} finally {
setLoading(false)
}
}, [search, sort, perPage])
React.useEffect(() => {
clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => fetchArtworks(1), 300)
return () => clearTimeout(searchTimer.current)
}, [fetchArtworks])
const handleViewModeChange = (mode) => {
setViewMode(mode)
localStorage.setItem('studio_view_mode', mode)
}
const toggleSelect = (id) => setSelectedIds((p) => p.includes(id) ? p.filter((i) => i !== id) : [...p, id])
const selectAll = () => {
const ids = artworks.map((a) => a.id)
setSelectedIds(ids.every((id) => selectedIds.includes(id)) ? [] : ids)
}
const handleAction = async (action, artwork) => {
if (action === 'edit') { window.location.href = `/studio/artworks/${artwork.id}/edit`; return }
if (action === 'delete') { setDeleteModal({ open: true, ids: [artwork.id] }); return }
try {
await fetch(`/api/studio/artworks/${artwork.id}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action }),
})
fetchArtworks(meta.current_page)
} catch (err) { console.error(err) }
}
const executeBulk = async (action) => {
if (action === 'delete') { setDeleteModal({ open: true, ids: [...selectedIds] }); return }
if (action === 'add_tags') { setTagModal({ open: true, mode: 'add' }); return }
if (action === 'remove_tags') { setTagModal({ open: true, mode: 'remove' }); return }
if (action === 'change_category') { setCategoryModal({ open: true }); return }
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action, artwork_ids: selectedIds, params: {} }),
})
setSelectedIds([])
fetchArtworks(meta.current_page)
} catch (err) { console.error(err) }
}
const confirmBulkTags = async (tagIds) => {
const action = tagModal.mode === 'add' ? 'add_tags' : 'remove_tags'
setTagModal({ open: false, mode: 'add' })
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action, artwork_ids: selectedIds, params: { tag_ids: tagIds } }),
})
setSelectedIds([])
fetchArtworks(meta.current_page)
} catch (err) { console.error(err) }
}
const confirmBulkCategory = async (categoryId) => {
setCategoryModal({ open: false })
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action: 'change_category', artwork_ids: selectedIds, params: { category_id: categoryId } }),
})
setSelectedIds([])
fetchArtworks(meta.current_page)
} catch (err) { console.error(err) }
}
const confirmDelete = async () => {
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action: 'delete', artwork_ids: deleteModal.ids, confirm: 'DELETE' }),
})
setDeleteModal({ open: false, ids: [] })
setSelectedIds((p) => p.filter((id) => !deleteModal.ids.includes(id)))
fetchArtworks(meta.current_page)
} catch (err) { console.error(err) }
}
return (
<StudioLayout title="Archived">
<StudioToolbar
search={search}
onSearchChange={setSearch}
sort={sort}
onSortChange={setSort}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onFilterToggle={() => {}}
selectedCount={selectedIds.length}
<StudioLayout title={props.title} subtitle={props.description}>
<StudioContentBrowser
listing={props.listing}
quickCreate={props.quickCreate}
hideBucketFilter
emptyTitle="No archived content"
emptyBody="Nothing is currently hidden or archived across your creator modules."
/>
{loading && (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!loading && viewMode === 'grid' && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artworks.map((art) => (
<StudioGridCard key={art.id} artwork={art} selected={selectedIds.includes(art.id)} onSelect={toggleSelect} onAction={handleAction} />
))}
</div>
)}
{!loading && viewMode === 'list' && (
<StudioTable artworks={artworks} selectedIds={selectedIds} onSelect={toggleSelect} onSelectAll={selectAll} onAction={handleAction} onSort={setSort} currentSort={sort} />
)}
{!loading && artworks.length === 0 && (
<div className="text-center py-16">
<i className="fa-solid fa-box-archive text-4xl text-slate-600 mb-4" />
<p className="text-slate-500 text-sm">No archived artworks</p>
</div>
)}
{meta.last_page > 1 && (
<div className="flex items-center justify-center gap-2 mt-6">
{Array.from({ length: meta.last_page }, (_, i) => i + 1)
.filter((p) => p === 1 || p === meta.last_page || Math.abs(p - meta.current_page) <= 2)
.map((page, idx, arr) => (
<React.Fragment key={page}>
{idx > 0 && arr[idx - 1] !== page - 1 && <span className="text-slate-600 text-sm"></span>}
<button onClick={() => fetchArtworks(page)} className={`w-9 h-9 rounded-xl text-sm font-medium transition-all ${page === meta.current_page ? 'bg-accent text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}>{page}</button>
</React.Fragment>
))}
</div>
)}
<BulkActionsBar count={selectedIds.length} onExecute={executeBulk} onClearSelection={() => setSelectedIds([])} />
<ConfirmDangerModal open={deleteModal.open} onClose={() => setDeleteModal({ open: false, ids: [] })} onConfirm={confirmDelete} title="Permanently delete?" message={`Delete ${deleteModal.ids.length} artwork(s) permanently?`} />
<BulkTagModal open={tagModal.open} mode={tagModal.mode} onClose={() => setTagModal({ open: false, mode: 'add' })} onConfirm={confirmBulkTags} />
<BulkCategoryModal open={categoryModal.open} categories={categories} onClose={() => setCategoryModal({ open: false })} onConfirm={confirmBulkCategory} />
</StudioLayout>
)
}

View File

@@ -1,341 +1,37 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioToolbar from '../../Components/Studio/StudioToolbar'
import StudioFilters from '../../Components/Studio/StudioFilters'
import StudioGridCard from '../../Components/Studio/StudioGridCard'
import StudioTable from '../../Components/Studio/StudioTable'
import BulkActionsBar from '../../Components/Studio/BulkActionsBar'
import BulkTagModal from '../../Components/Studio/BulkTagModal'
import BulkCategoryModal from '../../Components/Studio/BulkCategoryModal'
import ConfirmDangerModal from '../../Components/Studio/ConfirmDangerModal'
import StudioContentBrowser from '../../components/Studio/StudioContentBrowser'
const VIEW_MODE_KEY = 'studio_view_mode'
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
function SummaryCard({ label, value, icon }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center gap-3 text-slate-300">
<i className={icon} />
<span className="text-sm">{label}</span>
</div>
<div className="mt-3 text-3xl font-semibold text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
export default function StudioArtworks() {
const { props } = usePage()
const { categories } = props
// State
const [viewMode, setViewMode] = useState(() => localStorage.getItem(VIEW_MODE_KEY) || 'grid')
const [artworks, setArtworks] = useState([])
const [meta, setMeta] = useState({ current_page: 1, last_page: 1, per_page: 24, total: 0 })
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [sort, setSort] = useState('created_at:desc')
const [filtersOpen, setFiltersOpen] = useState(false)
const [filters, setFilters] = useState({ status: '', category: '', performance: '', date_from: '', date_to: '', tags: [] })
const [selectedIds, setSelectedIds] = useState([])
const [deleteModal, setDeleteModal] = useState({ open: false, ids: [] })
const [tagModal, setTagModal] = useState({ open: false, mode: 'add' })
const [categoryModal, setCategoryModal] = useState({ open: false })
const searchTimer = useRef(null)
const perPage = viewMode === 'list' ? 50 : 24
// Fetch artworks from API
const fetchArtworks = useCallback(async (page = 1) => {
setLoading(true)
try {
const params = new URLSearchParams()
params.set('page', page)
params.set('per_page', perPage)
params.set('sort', sort)
if (search) params.set('q', search)
if (filters.status) params.set('status', filters.status)
if (filters.category) params.set('category', filters.category)
if (filters.performance) params.set('performance', filters.performance)
if (filters.date_from) params.set('date_from', filters.date_from)
if (filters.date_to) params.set('date_to', filters.date_to)
const res = await fetch(`/api/studio/artworks?${params.toString()}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
setArtworks(data.data || [])
setMeta(data.meta || meta)
} catch (err) {
console.error('Failed to fetch artworks:', err)
} finally {
setLoading(false)
}
}, [search, sort, filters, perPage])
// Debounced search
useEffect(() => {
clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => fetchArtworks(1), 300)
return () => clearTimeout(searchTimer.current)
}, [fetchArtworks])
// Persist view mode
const handleViewModeChange = (mode) => {
setViewMode(mode)
localStorage.setItem(VIEW_MODE_KEY, mode)
}
// Selection
const toggleSelect = (id) => {
setSelectedIds((prev) => prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id])
}
const selectAll = () => {
const allIds = artworks.map((a) => a.id)
const allSelected = allIds.every((id) => selectedIds.includes(id))
setSelectedIds(allSelected ? [] : allIds)
}
const clearSelection = () => setSelectedIds([])
// Actions
const handleAction = async (action, artwork) => {
if (action === 'edit') {
window.location.href = `/studio/artworks/${artwork.id}/edit`
return
}
if (action === 'delete') {
setDeleteModal({ open: true, ids: [artwork.id] })
return
}
// Toggle actions
try {
await fetch(`/api/studio/artworks/${artwork.id}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action }),
})
fetchArtworks(meta.current_page)
} catch (err) {
console.error('Action failed:', err)
}
}
// Bulk action execution
const executeBulk = async (action) => {
if (action === 'delete') {
setDeleteModal({ open: true, ids: [...selectedIds] })
return
}
if (action === 'add_tags') { setTagModal({ open: true, mode: 'add' }); return }
if (action === 'remove_tags') { setTagModal({ open: true, mode: 'remove' }); return }
if (action === 'change_category') { setCategoryModal({ open: true }); return }
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action, artwork_ids: selectedIds, params: {} }),
})
clearSelection()
fetchArtworks(meta.current_page)
} catch (err) {
console.error('Bulk action failed:', err)
}
}
// Confirm bulk tag action
const confirmBulkTags = async (tagIds) => {
const action = tagModal.mode === 'add' ? 'add_tags' : 'remove_tags'
setTagModal({ open: false, mode: 'add' })
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action, artwork_ids: selectedIds, params: { tag_ids: tagIds } }),
})
clearSelection()
fetchArtworks(meta.current_page)
} catch (err) {
console.error('Bulk tag action failed:', err)
}
}
// Confirm bulk category change
const confirmBulkCategory = async (categoryId) => {
setCategoryModal({ open: false })
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action: 'change_category', artwork_ids: selectedIds, params: { category_id: categoryId } }),
})
clearSelection()
fetchArtworks(meta.current_page)
} catch (err) {
console.error('Bulk category action failed:', err)
}
}
// Confirm delete
const confirmDelete = async () => {
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action: 'delete', artwork_ids: deleteModal.ids, confirm: 'DELETE' }),
})
setDeleteModal({ open: false, ids: [] })
setSelectedIds((prev) => prev.filter((id) => !deleteModal.ids.includes(id)))
fetchArtworks(meta.current_page)
} catch (err) {
console.error('Delete failed:', err)
}
}
const summary = props.summary || {}
return (
<StudioLayout title="Artworks">
{/* Toolbar */}
<StudioToolbar
search={search}
onSearchChange={setSearch}
sort={sort}
onSortChange={setSort}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onFilterToggle={() => setFiltersOpen(!filtersOpen)}
selectedCount={selectedIds.length}
/>
<div className="flex gap-4">
{/* Filters sidebar (desktop) */}
<div className="hidden lg:block">
<StudioFilters
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
filters={filters}
onFilterChange={setFilters}
categories={categories}
/>
</div>
{/* Mobile filter drawer */}
<div className="lg:hidden">
<StudioFilters
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
filters={filters}
onFilterChange={setFilters}
categories={categories}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{/* Grid view */}
{!loading && viewMode === 'grid' && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artworks.map((art) => (
<StudioGridCard
key={art.id}
artwork={art}
selected={selectedIds.includes(art.id)}
onSelect={toggleSelect}
onAction={handleAction}
/>
))}
</div>
)}
{/* List view */}
{!loading && viewMode === 'list' && (
<StudioTable
artworks={artworks}
selectedIds={selectedIds}
onSelect={toggleSelect}
onSelectAll={selectAll}
onAction={handleAction}
onSort={setSort}
currentSort={sort}
/>
)}
{/* Empty state */}
{!loading && artworks.length === 0 && (
<div className="text-center py-16">
<i className="fa-solid fa-images text-4xl text-slate-600 mb-4" />
<p className="text-slate-500 text-sm">No artworks match your criteria</p>
</div>
)}
{/* Pagination */}
{meta.last_page > 1 && (
<div className="flex items-center justify-center gap-2 mt-6">
{Array.from({ length: meta.last_page }, (_, i) => i + 1)
.filter((p) => p === 1 || p === meta.last_page || Math.abs(p - meta.current_page) <= 2)
.map((page, idx, arr) => (
<React.Fragment key={page}>
{idx > 0 && arr[idx - 1] !== page - 1 && (
<span className="text-slate-600 text-sm"></span>
)}
<button
onClick={() => fetchArtworks(page)}
className={`w-9 h-9 rounded-xl text-sm font-medium transition-all ${
page === meta.current_page
? 'bg-accent text-white'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{page}
</button>
</React.Fragment>
))}
</div>
)}
{/* Total count */}
{!loading && meta.total > 0 && (
<p className="text-center text-xs text-slate-600 mt-3">
{meta.total.toLocaleString()} artwork{meta.total !== 1 ? 's' : ''} total
</p>
)}
</div>
<StudioLayout title={props.title} subtitle={props.description}>
<div className="mb-6 grid gap-4 md:grid-cols-4">
<SummaryCard label="Artworks" value={summary.count} icon="fa-solid fa-images" />
<SummaryCard label="Drafts" value={summary.draft_count} icon="fa-solid fa-file-pen" />
<SummaryCard label="Published" value={summary.published_count} icon="fa-solid fa-rocket" />
<a href="/upload" className="rounded-[24px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em]">Upload artwork</p>
<p className="mt-3 text-sm leading-6">Start a new visual upload flow without leaving Creator Studio.</p>
</a>
</div>
{/* Bulk actions bar */}
<BulkActionsBar
count={selectedIds.length}
onExecute={executeBulk}
onClearSelection={clearSelection}
/>
{/* Delete confirmation modal */}
<ConfirmDangerModal
open={deleteModal.open}
onClose={() => setDeleteModal({ open: false, ids: [] })}
onConfirm={confirmDelete}
title="Permanently delete artworks?"
message={`This will permanently delete ${deleteModal.ids.length} artwork${deleteModal.ids.length !== 1 ? 's' : ''}. This action cannot be undone.`}
/>
{/* Bulk tag modal */}
<BulkTagModal
open={tagModal.open}
mode={tagModal.mode}
onClose={() => setTagModal({ open: false, mode: 'add' })}
onConfirm={confirmBulkTags}
/>
{/* Bulk category modal */}
<BulkCategoryModal
open={categoryModal.open}
categories={categories}
onClose={() => setCategoryModal({ open: false })}
onConfirm={confirmBulkCategory}
/>
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter />
</StudioLayout>
)
}

View File

@@ -0,0 +1,290 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
function formatDate(value) {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Unknown'
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
export default function StudioAssets() {
const { props } = usePage()
const assets = props.assets || {}
const items = assets.items || []
const summary = assets.summary || []
const highlights = assets.highlights || {}
const filters = assets.filters || {}
const meta = assets.meta || {}
const typeOptions = assets.type_options || []
const sourceOptions = assets.source_options || []
const sortOptions = assets.sort_options || []
const trackReuse = (asset, destination) => {
trackStudioEvent('studio_asset_reused', {
surface: studioSurface(),
module: 'assets',
item_module: asset.source_key || 'assets',
item_id: asset.numeric_id,
meta: {
asset_id: asset.id,
asset_type: asset.type,
destination,
},
})
}
const updateFilters = (patch) => {
const next = {
...filters,
...patch,
}
if (patch.page == null) {
next.page = 1
}
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'assets',
meta: patch,
})
router.get(window.location.pathname, next, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6">
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(34,197,94,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)] lg:p-6">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label className="space-y-2 text-sm text-slate-300 xl:col-span-2">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search assets</span>
<input
type="search"
value={filters.q || ''}
onChange={(event) => updateFilters({ q: event.target.value })}
placeholder="Title, source, or description"
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">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Type</span>
<select
value={filters.type || 'all'}
onChange={(event) => updateFilters({ type: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
>
{typeOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Source</span>
<select
value={filters.source || 'all'}
onChange={(event) => updateFilters({ source: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
>
{sourceOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
<select
value={filters.sort || 'recent'}
onChange={(event) => updateFilters({ sort: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Library volume</div>
<div className="mt-2 text-2xl font-semibold text-white">{Number(meta.total || 0).toLocaleString()}</div>
<div className="text-xs text-slate-500">creator assets available</div>
</div>
</div>
</section>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{summary.map((item) => (
<button
key={item.key}
type="button"
onClick={() => updateFilters({ type: item.key })}
className={`rounded-[24px] border p-5 text-left transition ${filters.type === item.key ? 'border-sky-300/25 bg-sky-300/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20'}`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 text-slate-200">
<i className={item.icon} />
<span className="text-sm font-medium">{item.label}</span>
</div>
<span className="text-sm font-semibold text-white">{Number(item.count || 0).toLocaleString()}</span>
</div>
</button>
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<div>
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-slate-400">
<p>
Showing <span className="font-semibold text-white">{items.length}</span> of <span className="font-semibold text-white">{Number(meta.total || 0).toLocaleString()}</span> assets
</p>
<button
type="button"
onClick={() => updateFilters({ type: 'all', source: 'all', sort: 'recent', q: '' })}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-slate-200"
>
<i className="fa-solid fa-rotate-left" />
Reset filters
</button>
</div>
{items.length > 0 ? (
<section className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{items.map((asset) => (
<article key={asset.id} className="overflow-hidden rounded-[26px] border border-white/10 bg-white/[0.03] shadow-[0_18px_50px_rgba(3,7,18,0.18)]">
<div className="relative aspect-[1.15/1] overflow-hidden bg-slate-950/70">
{asset.image_url ? (
<img src={asset.image_url} alt={asset.title} className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="flex h-full items-center justify-center text-slate-500">
<i className="fa-solid fa-photo-film text-2xl" />
</div>
)}
<div className="absolute left-4 top-4 inline-flex items-center gap-2 rounded-full border border-black/10 bg-black/45 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white backdrop-blur-md">
<span>{asset.type_label}</span>
</div>
</div>
<div className="space-y-4 p-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{asset.source_label}</p>
<h2 className="mt-1 truncate text-lg font-semibold text-white">{asset.title}</h2>
<p className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{asset.description}</p>
</div>
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-500">
<span>Used {Number(asset.usage_count || 0).toLocaleString()} times</span>
<span>Updated {formatDate(asset.created_at)}</span>
</div>
{(asset.usage_references || []).length > 0 ? (
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
{(asset.usage_references || []).slice(0, 2).map((reference) => (
<a key={`${asset.id}-${reference.href}`} href={reference.href} className="rounded-full border border-white/10 px-2.5 py-1 transition hover:border-white/20 hover:text-white">
{reference.label}
</a>
))}
</div>
) : null}
<div className="flex flex-wrap gap-2">
<a href={asset.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200">
<i className="fa-solid fa-pen-to-square" />
Manage
</a>
<a href={asset.view_url} onClick={() => trackReuse(asset, asset.view_url)} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100">
<i className="fa-solid fa-repeat" />
Reuse
</a>
</div>
</div>
</article>
))}
</section>
) : (
<section className="mt-6 rounded-[28px] border border-dashed border-white/15 bg-white/[0.02] px-6 py-16 text-center">
<h3 className="text-xl font-semibold text-white">No assets match this view</h3>
<p className="mx-auto mt-3 max-w-xl text-sm text-slate-400">Try another asset type or a broader search term. This library includes card backgrounds, story covers, collection covers, artwork previews, and profile branding.</p>
</section>
)}
</div>
<aside className="space-y-6">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-lg font-semibold text-white">Recent uploads</h2>
<div className="mt-4 space-y-3">
{(highlights.recent_uploads || []).slice(0, 5).map((asset) => (
<a key={`${asset.id}-recent`} href={asset.manage_url} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 p-3">
{asset.image_url ? <img src={asset.image_url} alt={asset.title} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 text-slate-500"><i className="fa-solid fa-photo-film" /></div>}
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{asset.title}</div>
<div className="text-xs text-slate-500">{asset.type_label}</div>
</div>
</a>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-lg font-semibold text-white">Most reused</h2>
<div className="mt-4 space-y-3">
{(highlights.most_used || []).slice(0, 5).map((asset) => (
<a key={`${asset.id}-used`} href={asset.manage_url} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/20 p-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{asset.title}</div>
<div className="text-xs text-slate-500">{asset.source_label}</div>
</div>
<span className="text-sm font-semibold text-white">{Number(asset.usage_count || 0).toLocaleString()}</span>
</a>
))}
</div>
</section>
</aside>
</div>
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
<button
type="button"
disabled={(meta.current_page || 1) <= 1}
onClick={() => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) })}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40"
>
<i className="fa-solid fa-arrow-left" />
Previous
</button>
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Page {meta.current_page || 1} of {meta.last_page || 1}</span>
<button
type="button"
disabled={(meta.current_page || 1) >= (meta.last_page || 1)}
onClick={() => updateFilters({ page: (meta.current_page || 1) + 1 })}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40"
>
Next
<i className="fa-solid fa-arrow-right" />
</button>
</div>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,134 @@
import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
async function requestJson(url, method = 'POST') {
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',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(payload?.message || 'Request failed')
return payload
}
function formatDate(value) {
if (!value) return 'Not scheduled'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Not scheduled'
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
export default function StudioCalendar() {
const { props } = usePage()
const calendar = props.calendar || {}
const filters = calendar.filters || {}
const summary = calendar.summary || {}
const [busyKey, setBusyKey] = useState(null)
const updateFilters = (patch) => {
const next = { ...filters, ...patch }
trackStudioEvent('studio_scheduled_opened', {
surface: studioSurface(),
module: next.module,
meta: patch,
})
router.get(window.location.pathname, next, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
const runAction = async (pattern, item, key) => {
const url = String(pattern || '').replace('__MODULE__', item.module).replace('__ID__', String(item.numeric_id))
setBusyKey(`${key}:${item.id}`)
try {
await requestJson(url)
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Unable to update schedule.')
} finally {
setBusyKey(null)
}
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Scheduled</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.scheduled_total || 0).toLocaleString()}</div></div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Unscheduled</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.unscheduled_total || 0).toLocaleString()}</div></div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Overloaded days</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.overloaded_days || 0).toLocaleString()}</div></div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Next publish</div><div className="mt-2 text-base font-semibold text-white">{formatDate(summary.next_publish_at)}</div></div>
</section>
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<label className="space-y-2 text-sm text-slate-300 xl:col-span-2">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search planning queue</span>
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Title or module" />
</label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">View</span><select value={filters.view || 'month'} onChange={(event) => updateFilters({ view: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.view_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Queue</span><select value={filters.status || 'scheduled'} onChange={(event) => updateFilters({ status: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.status_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
</div>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
{filters.view === 'week' ? (
<>
<h2 className="text-lg font-semibold text-white">{calendar.week?.label}</h2>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-7">
{(calendar.week?.days || []).map((day) => (
<div key={day.date} className="rounded-[22px] border border-white/10 bg-black/20 p-3">
<div className="text-sm font-semibold text-white">{day.label}</div>
<div className="mt-3 space-y-2">{day.items.length > 0 ? day.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-2xl border border-white/10 px-3 py-2 text-xs text-slate-200">{item.title}</a>) : <div className="text-xs text-slate-500">No scheduled items</div>}</div>
</div>
))}
</div>
</>
) : filters.view === 'agenda' ? (
<>
<h2 className="text-lg font-semibold text-white">Agenda</h2>
<div className="mt-4 space-y-4">{(calendar.agenda || []).map((group) => <div key={group.date} className="rounded-[22px] border border-white/10 bg-black/20 p-4"><div className="flex items-center justify-between gap-3"><div className="text-base font-semibold text-white">{group.label}</div><div className="text-xs uppercase tracking-[0.18em] text-slate-500">{group.count} items</div></div><div className="mt-3 space-y-2">{group.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 px-3 py-2 text-sm text-slate-200"><span>{item.title}</span><span className="text-xs text-slate-500">{formatDate(item.scheduled_at)}</span></a>)}</div></div>)}</div>
</>
) : (
<>
<h2 className="text-lg font-semibold text-white">{calendar.month?.label}</h2>
<div className="mt-4 grid grid-cols-7 gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((label) => <div key={label} className="px-2 py-1">{label}</div>)}</div>
<div className="mt-2 grid grid-cols-7 gap-2">{(calendar.month?.days || []).map((day) => <div key={day.date} className={`min-h-[120px] rounded-[22px] border p-3 ${day.is_current_month ? 'border-white/10 bg-black/20' : 'border-white/5 bg-black/10'}`}><div className="flex items-center justify-between gap-2"><span className={`text-sm font-semibold ${day.is_current_month ? 'text-white' : 'text-slate-500'}`}>{day.day}</span><span className="text-[10px] uppercase tracking-[0.18em] text-slate-500">{day.count}</span></div><div className="mt-3 space-y-2">{day.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-xl border border-white/10 px-2 py-1.5 text-[11px] text-slate-200">{item.title}</a>)}</div></div>)}</div>
</>
)}
</section>
<aside className="space-y-6">
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between"><h2 className="text-lg font-semibold text-white">Coverage gaps</h2><a href="/studio/drafts" className="text-sm font-medium text-sky-100">Open drafts</a></div>
<div className="mt-4 space-y-3">{(calendar.gaps || []).length > 0 ? (calendar.gaps || []).map((gap) => <div key={gap.date} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">{gap.label}</div>) : <div className="rounded-2xl border border-dashed border-white/15 px-4 py-8 text-sm text-slate-500">No empty days in the next two weeks.</div>}</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between"><h2 className="text-lg font-semibold text-white">Unscheduled queue</h2><span className="text-xs uppercase tracking-[0.18em] text-slate-500">{(calendar.unscheduled_items || []).length}</span></div>
<div className="mt-4 space-y-3">{(calendar.unscheduled_items || []).map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-sm font-semibold text-white">{item.title}</div><div className="mt-1 text-xs text-slate-500">{item.module_label} · {item.workflow?.readiness?.label || 'Needs review'}</div></a>)}</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between"><h2 className="text-lg font-semibold text-white">Upcoming actions</h2><a href="/studio/scheduled" className="text-sm font-medium text-sky-100">Open list</a></div>
<div className="mt-4 space-y-3">{(calendar.scheduled_items || []).slice(0, 5).map((item) => <div key={item.id} className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-sm font-semibold text-white">{item.title}</div><div className="mt-1 text-xs text-slate-500">{formatDate(item.scheduled_at)}</div><div className="mt-3 flex flex-wrap gap-2"><button type="button" disabled={busyKey === `publish:${item.id}`} onClick={() => runAction(props.endpoints.publishNowPattern, item, 'publish')} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100 disabled:opacity-50">Publish now</button><button type="button" disabled={busyKey === `unschedule:${item.id}`} onClick={() => runAction(props.endpoints.unschedulePattern, item, 'unschedule')} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200 disabled:opacity-50">Unschedule</button></div></div>)}</div>
</section>
</aside>
</div>
</div>
</StudioLayout>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,7 @@
import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaCardCanvasPreview from '../../components/nova-cards/NovaCardCanvasPreview'
function requestJson(url, { method = 'POST' } = {}) {
return fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
}).then(async (response) => {
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(payload?.message || 'Request failed')
return payload
})
}
import StudioContentBrowser from '../../components/Studio/StudioContentBrowser'
function StatCard({ label, value, icon }) {
return (
@@ -35,37 +19,23 @@ function StatCard({ label, value, icon }) {
export default function StudioCardsIndex() {
const { props } = usePage()
const cards = props.cards?.data || []
const stats = props.stats || {}
const endpoints = props.endpoints || {}
async function duplicateCard(cardId) {
const url = (endpoints.duplicatePattern || '').replace('__CARD__', String(cardId))
if (!url) return
const payload = await requestJson(url)
if (payload?.data?.id) {
window.location.assign((endpoints.editPattern || '/studio/cards/__CARD__/edit').replace('__CARD__', String(payload.data.id)))
}
}
const summary = props.summary || {}
return (
<StudioLayout title="Nova Cards">
<Head title="Nova Cards Studio" />
<StudioLayout title={props.title} subtitle={props.description}>
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.15),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Creation surface</p>
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Build quote cards, mood cards, and visual text art.</h2>
<p className="mt-3 text-sm leading-7 text-slate-300">Drafts autosave, templates stay structured, and every published card gets a public preview image ready for discovery and sharing.</p>
<p className="mt-3 text-sm leading-7 text-slate-300">Cards now live inside the same shared Creator Studio queue as artworks, collections, and stories, while keeping the dedicated editor and analytics flow.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={endpoints.create || '/studio/cards/create'} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
<a href="/studio/cards/create" className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i className="fa-solid fa-plus" />
New card
</Link>
<a href="/cards" className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
</a>
<a href={props.publicBrowseUrl} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-compass" />
Browse public cards
</a>
@@ -74,60 +44,14 @@ export default function StudioCardsIndex() {
</section>
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard label="All cards" value={stats.all || 0} icon="fa-layer-group" />
<StatCard label="Drafts" value={stats.drafts || 0} icon="fa-file-lines" />
<StatCard label="Processing" value={stats.processing || 0} icon="fa-wand-magic-sparkles" />
<StatCard label="Published" value={stats.published || 0} icon="fa-earth-americas" />
<StatCard label="All cards" value={summary.count || 0} icon="fa-layer-group" />
<StatCard label="Drafts" value={summary.draft_count || 0} icon="fa-file-lines" />
<StatCard label="Archived" value={summary.archived_count || 0} icon="fa-box-archive" />
<StatCard label="Published" value={summary.published_count || 0} icon="fa-earth-americas" />
</section>
<section className="mt-8">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Latest work</p>
<h3 className="mt-1 text-2xl font-semibold text-white">Your card library</h3>
</div>
</div>
{cards.length === 0 ? (
<div className="rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
<i className="fa-solid fa-rectangle-history-circle-user text-3xl" />
</div>
<h3 className="mt-5 text-2xl font-semibold text-white">No cards yet</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-slate-300">Start with a square card or jump straight into a story-sized template. Your first draft will be created automatically in the editor.</p>
<Link href={endpoints.create || '/studio/cards/create'} className="mt-6 inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i className="fa-solid fa-plus" />
Create your first card
</Link>
</div>
) : (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{cards.map((card) => (
<div key={card.id} className="group rounded-[28px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_22px_60px_rgba(2,6,23,0.22)] transition hover:-translate-y-1 hover:border-sky-300/30 hover:bg-white/[0.06]">
<a href={(endpoints.editPattern || '/studio/cards/__CARD__/edit').replace('__CARD__', String(card.id))}>
<NovaCardCanvasPreview card={card} className="w-full" />
<div className="mt-4 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="truncate text-lg font-semibold tracking-[-0.03em] text-white">{card.title}</div>
<div className="mt-1 line-clamp-2 text-sm leading-6 text-slate-300">{card.quote_text}</div>
</div>
<span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${card.status === 'published' ? 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100' : card.status === 'processing' ? 'border-amber-300/25 bg-amber-400/10 text-amber-100' : 'border-white/10 bg-white/[0.05] text-slate-200'}`}>
{card.status}
</span>
</div>
<div className="mt-4 flex items-center justify-between text-xs text-slate-400">
<span>{card.category?.name || 'Uncategorized'}</span>
<span>{card.format}</span>
</div>
</a>
<div className="mt-4 flex gap-3">
<a href={(endpoints.editPattern || '/studio/cards/__CARD__/edit').replace('__CARD__', String(card.id))} className="flex-1 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-white/[0.08]">Edit</a>
<button type="button" onClick={() => duplicateCard(card.id)} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">Duplicate</button>
</div>
</div>
))}
</div>
)}
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter emptyTitle="No cards yet" emptyBody="Create your first Nova card and it will appear here alongside your other Creator Studio content." />
</section>
</StudioLayout>
)

View File

@@ -0,0 +1,194 @@
import React from 'react'
import StudioLayout from '../../Layouts/StudioLayout'
import { usePage } from '@inertiajs/react'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
const summaryCards = [
['active_challenges', 'Active challenges', 'fa-bolt'],
['joined_challenges', 'Joined challenges', 'fa-trophy'],
['entries_submitted', 'Entries submitted', 'fa-paper-plane'],
['featured_entries', 'Featured entries', 'fa-star'],
['winner_entries', 'Winner entries', 'fa-crown'],
['cards_available', 'Challenge-ready cards', 'fa-layer-group'],
]
function formatDate(value) {
if (!value) return 'TBD'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
export default function StudioChallenges() {
const { props } = usePage()
const { summary, spotlight, activeChallenges, recentEntries, cardLeaders, reminders } = props
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid grid-cols-2 gap-4 xl:grid-cols-6">
{summaryCards.map(([key, label, icon]) => (
<div key={key} className="rounded-[26px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</span>
<i className={`fa-solid ${icon} text-sky-200`} />
</div>
<div className="mt-3 text-3xl font-semibold text-white">{Number(summary?.[key] || 0).toLocaleString()}</div>
</div>
))}
</div>
{spotlight ? (
<section className="mt-6 rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_34%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.88),_rgba(2,6,23,0.96))] p-6 shadow-[0_22px_60px_rgba(2,6,23,0.28)]">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Challenge spotlight</p>
<h2 className="mt-2 text-3xl font-semibold text-white">{spotlight.title}</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">{spotlight.prompt || spotlight.description || 'A featured challenge run is active in Nova Cards right now.'}</p>
<div className="mt-4 flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-400">
<span>{spotlight.status}</span>
<span>{spotlight.official ? 'Official' : 'Community'}</span>
<span>{spotlight.entries_count} entries</span>
<span>{spotlight.is_joined ? `${spotlight.submission_count} submitted` : 'Not joined yet'}</span>
</div>
</div>
<div className="flex flex-wrap gap-3">
<a
href={spotlight.url}
onClick={() => trackStudioEvent('studio_challenge_action_taken', {
surface: studioSurface(),
module: 'challenges',
meta: {
action: 'open_spotlight',
challenge_id: spotlight.id,
},
})}
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 transition hover:border-sky-300/35 hover:bg-sky-300/15"
>
Open challenge
</a>
<a href="/studio/cards" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Review cards</a>
</div>
</div>
</section>
) : null}
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-[28px] 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">Open and recent challenge runs</h2>
<a href="/cards/challenges" className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Public archive</a>
</div>
<div className="mt-5 space-y-3">
{(activeChallenges || []).map((challenge) => (
<a
key={challenge.id}
href={challenge.url}
onClick={() => trackStudioEvent('studio_challenge_action_taken', {
surface: studioSurface(),
module: 'challenges',
meta: {
action: 'open_challenge',
challenge_id: challenge.id,
},
})}
className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20"
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-base font-semibold text-white">{challenge.title}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{challenge.status} {challenge.official ? 'official' : 'community'} {challenge.entries_count} entries</div>
<p className="mt-3 text-sm leading-6 text-slate-400">{challenge.prompt || challenge.description || 'Challenge details are available in the public challenge view.'}</p>
</div>
<div className="text-right text-xs uppercase tracking-[0.16em] text-slate-500">
<div>{formatDate(challenge.starts_at)} start</div>
<div className="mt-2">{formatDate(challenge.ends_at)} end</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs">
<span className={`rounded-full border px-2.5 py-1 ${challenge.is_joined ? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100' : 'border-white/10 text-slate-300'}`}>{challenge.is_joined ? `${challenge.submission_count} submitted` : 'Not joined'}</span>
{challenge.featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-amber-100">Featured run</span> : null}
</div>
</a>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Workflow reminders</h2>
<div className="mt-4 space-y-3">
{(reminders || []).map((item) => (
<a key={item.title} href={item.href} className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<h3 className="text-sm font-semibold text-white">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-400">{item.body}</p>
<span className="mt-3 inline-flex items-center gap-2 text-sm font-medium text-sky-100">{item.cta}<i className="fa-solid fa-arrow-right" /></span>
</a>
))}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Recent submissions</h2>
<div className="mt-4 space-y-3">
{(recentEntries || []).map((entry) => (
<div key={entry.id} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold text-white">{entry.card.title}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{entry.challenge.title} {entry.status}</div>
</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">{formatDate(entry.submitted_at)}</div>
</div>
{entry.note ? <p className="mt-3 text-sm leading-6 text-slate-400">{entry.note}</p> : null}
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<a
href={entry.challenge.url}
onClick={() => trackStudioEvent('studio_challenge_action_taken', {
surface: studioSurface(),
module: 'challenges',
item_module: 'cards',
item_id: entry.card?.id,
meta: {
action: 'open_submission_challenge',
challenge_id: entry.challenge?.id,
entry_id: entry.id,
},
})}
className="text-sky-100"
>
Challenge
</a>
<a href={entry.card.edit_url} className="text-slate-300">Edit card</a>
<a href={entry.card.analytics_url} className="text-slate-300">Analytics</a>
</div>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Cards with challenge traction</h2>
<div className="mt-4 space-y-3">
{(cardLeaders || []).map((card) => (
<div key={card.id} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold text-white">{card.title}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{card.status} {card.challenge_entries_count} challenge entries</div>
</div>
<a href={card.edit_url} className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-100">Open</a>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-slate-400">
<div><div>Views</div><div className="mt-1 font-semibold text-white">{Number(card.views_count || 0).toLocaleString()}</div></div>
<div><div>Comments</div><div className="mt-1 font-semibold text-white">{Number(card.comments_count || 0).toLocaleString()}</div></div>
</div>
</div>
))}
</div>
</section>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,37 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioContentBrowser from '../../Components/Studio/StudioContentBrowser'
function SummaryCard({ label, value, icon }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center gap-3 text-slate-300">
<i className={icon} />
<span className="text-sm">{label}</span>
</div>
<div className="mt-3 text-3xl font-semibold text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
export default function StudioCollections() {
const { props } = usePage()
const summary = props.summary || {}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="mb-6 grid gap-4 md:grid-cols-4">
<SummaryCard label="Collections" value={summary.count} icon="fa-solid fa-layer-group" />
<SummaryCard label="Drafts" value={summary.draft_count} icon="fa-solid fa-file-pen" />
<SummaryCard label="Published" value={summary.published_count} icon="fa-solid fa-rocket" />
<a href={props.dashboardUrl} className="rounded-[24px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em]">Collection dashboard</p>
<p className="mt-3 text-sm leading-6">Open the full collection workflow surface for rules, history, and collaboration.</p>
</a>
</div>
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter />
</StudioLayout>
)
}

View File

@@ -0,0 +1,438 @@
import React, { useMemo, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
const reportReasons = [
{ value: 'spam', label: 'Spam or scam' },
{ value: 'harassment', label: 'Harassment' },
{ value: 'abuse', label: 'Abusive content' },
{ value: 'stolen', label: 'Stolen or impersonation' },
{ value: 'other', label: 'Other' },
]
function formatDate(value) {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Unknown'
return date.toLocaleString()
}
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
}
export default function StudioComments() {
const { props } = usePage()
const listing = props.listing || {}
const filters = listing.filters || {}
const items = listing.items || []
const meta = listing.meta || {}
const moduleOptions = listing.module_options || []
const endpoints = props.endpoints || {}
const [busyKey, setBusyKey] = useState(null)
const [replyFor, setReplyFor] = useState(null)
const [replyText, setReplyText] = useState('')
const [reportFor, setReportFor] = useState(null)
const [reportReason, setReportReason] = useState('spam')
const [reportDetails, setReportDetails] = useState('')
const visibleSummary = useMemo(() => {
return moduleOptions
.filter((option) => option.value !== 'all')
.map((option) => ({
...option,
count: items.filter((item) => item.module === option.value).length,
}))
}, [items, moduleOptions])
const updateFilters = (patch) => {
const next = {
...filters,
...patch,
}
if (patch.page == null) {
next.page = 1
}
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'comments',
meta: patch,
})
router.get(window.location.pathname, next, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
const buildUrl = (pattern, comment) => pattern
.replace('__MODULE__', comment.module)
.replace('__COMMENT__', String(comment.comment_id))
const submitReply = async (comment) => {
if (!replyText.trim()) {
window.alert('Reply cannot be empty.')
return
}
const key = `reply:${comment.id}`
setBusyKey(key)
try {
await requestJson(buildUrl(endpoints.replyPattern, comment), 'POST', {
content: replyText.trim(),
})
trackStudioEvent('studio_comment_replied', {
surface: studioSurface(),
module: comment.module,
item_module: comment.module,
item_id: comment.item_id,
})
setReplyFor(null)
setReplyText('')
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Unable to send reply.')
} finally {
setBusyKey(null)
}
}
const moderateComment = async (comment) => {
if (!window.confirm('Remove this comment from the conversation stream?')) {
return
}
const key = `moderate:${comment.id}`
setBusyKey(key)
try {
await requestJson(buildUrl(endpoints.moderatePattern, comment), 'DELETE')
trackStudioEvent('studio_comment_moderated', {
surface: studioSurface(),
module: comment.module,
item_module: comment.module,
item_id: comment.item_id,
})
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Unable to remove comment.')
} finally {
setBusyKey(null)
}
}
const submitReport = async (comment) => {
const key = `report:${comment.id}`
setBusyKey(key)
try {
await requestJson(buildUrl(endpoints.reportPattern, comment), 'POST', {
reason: reportReason,
details: reportDetails.trim() || null,
})
trackStudioEvent('studio_comment_reported', {
surface: studioSurface(),
module: comment.module,
item_module: comment.module,
item_id: comment.item_id,
})
setReportFor(null)
setReportReason('spam')
setReportDetails('')
window.alert('Report sent.')
} catch (error) {
window.alert(error?.message || 'Unable to report comment.')
} finally {
setBusyKey(null)
}
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
<aside className="space-y-5">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-lg font-semibold text-white">Moderation cockpit</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">Search across modules, reply without leaving Studio, and escalate suspicious comments when removal is not enough.</p>
<div className="mt-5 space-y-3">
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
<input
type="search"
value={filters.q || ''}
onChange={(event) => updateFilters({ q: event.target.value })}
placeholder="Author, item, or comment"
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">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Module</span>
<select
value={filters.module || 'all'}
onChange={(event) => updateFilters({ module: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
>
{moduleOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-lg font-semibold text-white">Visible on this page</h2>
<div className="mt-4 space-y-3">
{visibleSummary.map((item) => (
<div key={item.value} className="flex items-center justify-between rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-slate-300">
<span>{item.label}</span>
<span className="font-semibold text-white">{item.count}</span>
</div>
))}
</div>
</section>
</aside>
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-slate-400">
<p>
Showing <span className="font-semibold text-white">{items.length}</span> of <span className="font-semibold text-white">{Number(meta.total || 0).toLocaleString()}</span> comments
</p>
<p>Page {meta.current_page || 1} of {meta.last_page || 1}</p>
</div>
{items.length > 0 ? items.map((comment) => {
const replyBusy = busyKey === `reply:${comment.id}`
const moderateBusy = busyKey === `moderate:${comment.id}`
const reportBusy = busyKey === `report:${comment.id}`
return (
<article key={comment.id} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 gap-4">
{comment.author_avatar_url ? (
<img src={comment.author_avatar_url} alt={comment.author_name} className="h-12 w-12 rounded-2xl object-cover" />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-black/30 text-slate-400">
<i className="fa-solid fa-user" />
</div>
)}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">
<span>{comment.module_label}</span>
<span className="text-slate-500">{comment.time_ago || formatDate(comment.created_at)}</span>
</div>
<p className="mt-2 text-sm text-white">
<span className="font-semibold text-sky-100">{comment.author_name}</span>
{' '}on{' '}
<span className="text-slate-300">{comment.item_title || 'Untitled item'}</span>
</p>
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-slate-300">{comment.body}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 md:justify-end">
{comment.preview_url && (
<a href={comment.preview_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200">
<i className="fa-solid fa-eye" />
Preview
</a>
)}
<a href={comment.context_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200">
<i className="fa-solid fa-arrow-up-right-from-square" />
Context
</a>
{comment.reply_supported && (
<button
type="button"
onClick={() => {
setReportFor(null)
setReplyFor(replyFor === comment.id ? null : comment.id)
setReplyText('')
}}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100"
>
<i className="fa-solid fa-reply" />
Reply
</button>
)}
{comment.moderate_supported && (
<button
type="button"
onClick={() => moderateComment(comment)}
disabled={moderateBusy}
className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-1.5 text-xs text-rose-100 disabled:opacity-50"
>
<i className="fa-solid fa-trash" />
{moderateBusy ? 'Removing...' : 'Remove'}
</button>
)}
{comment.report_supported && (
<button
type="button"
onClick={() => {
setReplyFor(null)
setReportFor(reportFor === comment.id ? null : comment.id)
}}
className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1.5 text-xs text-amber-100"
>
<i className="fa-solid fa-flag" />
Report
</button>
)}
</div>
</div>
{replyFor === comment.id && (
<div className="mt-4 rounded-[22px] border border-white/10 bg-black/20 p-4">
<label className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reply as creator</label>
<textarea
value={replyText}
onChange={(event) => setReplyText(event.target.value)}
rows={4}
placeholder="Write a public reply"
className="mt-3 w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500"
/>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => submitReply(comment)}
disabled={replyBusy}
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-paper-plane" />
{replyBusy ? 'Sending...' : 'Publish reply'}
</button>
<button
type="button"
onClick={() => setReplyFor(null)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200"
>
Cancel
</button>
</div>
</div>
)}
{reportFor === comment.id && (
<div className="mt-4 rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reason</span>
<select
value={reportReason}
onChange={(event) => setReportReason(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white"
>
{reportReasons.map((reason) => (
<option key={reason.value} value={reason.value} className="bg-slate-900">
{reason.label}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Details</span>
<textarea
value={reportDetails}
onChange={(event) => setReportDetails(event.target.value)}
rows={3}
placeholder="Optional note for moderation"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500"
/>
</label>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => submitReport(comment)}
disabled={reportBusy}
className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-4 py-2 text-sm font-semibold text-amber-100 disabled:opacity-50"
>
<i className="fa-solid fa-flag" />
{reportBusy ? 'Sending...' : 'Send report'}
</button>
<button
type="button"
onClick={() => setReportFor(null)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200"
>
Cancel
</button>
</div>
</div>
)}
</article>
)
}) : (
<section className="rounded-[28px] border border-dashed border-white/15 bg-white/[0.02] px-6 py-16 text-center">
<h3 className="text-xl font-semibold text-white">No comments match this view</h3>
<p className="mx-auto mt-3 max-w-xl text-sm text-slate-400">Try another module or a broader search query.</p>
</section>
)}
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
<button
type="button"
disabled={(meta.current_page || 1) <= 1}
onClick={() => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) })}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40"
>
<i className="fa-solid fa-arrow-left" />
Previous
</button>
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Unified comments</span>
<button
type="button"
disabled={(meta.current_page || 1) >= (meta.last_page || 1)}
onClick={() => updateFilters({ page: (meta.current_page || 1) + 1 })}
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40"
>
Next
<i className="fa-solid fa-arrow-right" />
</button>
</div>
</section>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioContentBrowser from '../../Components/Studio/StudioContentBrowser'
export default function StudioContentIndex() {
const { props } = usePage()
return (
<StudioLayout title={props.title} subtitle={props.description}>
<StudioContentBrowser
listing={props.listing}
quickCreate={props.quickCreate}
emptyTitle="No content matches this filter"
emptyBody="Try broadening your search or create something new from the Studio shell."
/>
</StudioLayout>
)
}

View File

@@ -1,141 +1,536 @@
import React from 'react'
import { usePage, Link } from '@inertiajs/react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
const kpiConfig = [
{ key: 'total_artworks', label: 'Total Artworks', icon: 'fa-images', color: 'text-blue-400', link: '/studio/artworks' },
{ key: 'views_30d', label: 'Views (30d)', icon: 'fa-eye', color: 'text-emerald-400', link: null },
{ key: 'favourites_30d', label: 'Favourites (30d)', icon: 'fa-heart', color: 'text-pink-400', link: null },
{ key: 'shares_30d', label: 'Shares (30d)', icon: 'fa-share-nodes', color: 'text-amber-400', link: null },
{ key: 'followers', label: 'Followers', icon: 'fa-user-group', color: 'text-purple-400', link: null },
{ key: 'total_content', label: 'Total content', icon: 'fa-solid fa-table-cells-large' },
{ key: 'views_30d', label: 'Views', icon: 'fa-solid fa-eye' },
{ key: 'appreciation_30d', label: 'Reactions', icon: 'fa-solid fa-heart' },
{ key: 'shares_30d', label: 'Shares / Saves', icon: 'fa-solid fa-share-nodes' },
{ key: 'comments_30d', label: 'Comments', icon: 'fa-solid fa-comments' },
{ key: 'followers', label: 'Followers', icon: 'fa-solid fa-user-group' },
]
function KpiCard({ config, value }) {
const content = (
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-5 hover:border-white/20 hover:shadow-lg hover:shadow-accent/5 transition-all duration-300 cursor-pointer group">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center ${config.color} group-hover:scale-110 transition-transform`}>
<i className={`fa-solid ${config.icon}`} />
return (
<div className="rounded-[26px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_40px_rgba(2,6,23,0.18)]">
<div className="flex items-center gap-3 text-slate-300">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-300/10 text-sky-100">
<i className={config.icon} />
</div>
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">{config.label}</span>
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{config.label}</span>
</div>
<p className="text-3xl font-bold text-white tabular-nums">
{typeof value === 'number' ? value.toLocaleString() : value}
</p>
<p className="mt-4 text-3xl font-semibold text-white tabular-nums">{typeof value === 'number' ? value.toLocaleString() : value}</p>
</div>
)
if (config.link) {
return <Link href={config.link}>{content}</Link>
}
return content
}
function TopPerformerCard({ artwork }) {
function QuickCreateCard({ item }) {
return (
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-4 hover:border-white/20 hover:shadow-lg hover:shadow-accent/5 transition-all duration-300 group">
<div className="flex items-start gap-3">
{artwork.thumb_url && (
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-16 h-16 rounded-xl object-cover bg-nova-800 flex-shrink-0 group-hover:scale-105 transition-transform"
loading="lazy"
/>
)}
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold text-white truncate" title={artwork.title}>
{artwork.title}
</h4>
<div className="flex flex-wrap items-center gap-3 mt-1.5">
<span className="text-xs text-slate-400">
{artwork.favourites?.toLocaleString()}
</span>
<span className="text-xs text-slate-400">
🔗 {artwork.shares?.toLocaleString()}
</span>
</div>
{artwork.heat_score > 5 && (
<span className="inline-flex items-center gap-1 mt-2 px-2 py-0.5 rounded-md text-[10px] font-medium bg-orange-500/20 text-orange-400 border border-orange-500/30">
<i className="fa-solid fa-fire" /> Rising
</span>
)}
<a
href={item.url}
onClick={() => trackStudioEvent('studio_quick_create_used', {
surface: studioSurface(),
module: item.key,
meta: {
href: item.url,
label: item.label,
},
})}
className="rounded-[24px] border border-sky-300/20 bg-sky-300/10 p-4 text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15"
>
<div className="flex items-center gap-3">
<i className={item.icon} />
<span className="text-sm font-semibold">New {item.label}</span>
</div>
<p className="mt-3 text-sm leading-6 text-sky-100/80">Jump straight into the dedicated {item.label.toLowerCase()} creation workflow.</p>
</a>
)
}
function RecentPublishCard({ item }) {
return (
<a href={item.edit_url || item.manage_url} className="block rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{item.module_label}</p>
<h3 className="mt-2 text-base font-semibold text-white">{item.title}</h3>
<p className="mt-1 text-sm text-slate-400">Published {new Date(item.published_at || item.updated_at).toLocaleDateString()}</p>
</a>
)
}
function ContinueWorkingCard({ item }) {
return (
<a href={item.edit_url || item.manage_url} className="block rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{item.module_label}</p>
<h3 className="mt-2 text-base font-semibold text-white">{item.title}</h3>
<p className="mt-1 text-sm text-slate-400">Updated {new Date(item.updated_at || item.created_at).toLocaleDateString()}</p>
</a>
)
}
function ScheduledItemCard({ item }) {
return (
<a href={item.edit_url || item.manage_url} className="block rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{item.module_label}</p>
<h3 className="mt-2 text-base font-semibold text-white">{item.title}</h3>
<p className="mt-1 text-sm text-slate-400">Scheduled {new Date(item.scheduled_at || item.published_at).toLocaleString()}</p>
</a>
)
}
function ActivityRow({ item }) {
return (
<a href={item.url} className="block rounded-[20px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-white">{item.title}</p>
<span className="text-[11px] uppercase tracking-[0.18em] text-slate-500">{item.module_label}</span>
</div>
<p className="mt-2 text-sm text-slate-400 line-clamp-2">{item.body}</p>
</a>
)
}
function GrowthHint({ item }) {
return (
<a href={item.url} className="block rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<h3 className="text-base font-semibold text-white">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-400">{item.body}</p>
<span className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-sky-100">
{item.label}
<i className="fa-solid fa-arrow-right" />
</span>
</a>
)
}
function ChallengeWidget({ challenge }) {
return (
<a href={challenge.url} className="block rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{challenge.official ? 'Official challenge' : 'Community challenge'}</p>
<h3 className="mt-2 text-base font-semibold text-white">{challenge.title}</h3>
</div>
<span className="text-[10px] uppercase tracking-[0.16em] text-slate-500">{challenge.status}</span>
</div>
<p className="mt-2 text-sm leading-6 text-slate-400 line-clamp-3">{challenge.prompt || challenge.description}</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
<span>{Number(challenge.entries_count || 0).toLocaleString()} entries</span>
<span>{challenge.is_joined ? `${challenge.submission_count} submitted` : 'Not joined'}</span>
</div>
</a>
)
}
function FeaturedStatusCard({ item }) {
return (
<a href={item.edit_url || item.manage_url || item.view_url} className="block rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{item.module_label}</p>
<h3 className="mt-2 text-base font-semibold text-white">{item.title}</h3>
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.visibility || 'Selected for profile presentation'}</p>
</a>
)
}
function CommandCenterColumn({ title, items = [], empty, renderItem }) {
return (
<div>
<h3 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">{title}</h3>
<div className="mt-3 space-y-3">
{items.length > 0 ? items.map(renderItem) : <div className="rounded-2xl border border-dashed border-white/10 px-4 py-6 text-sm text-slate-500">{empty}</div>}
</div>
</div>
)
}
function InsightBlock({ item }) {
const toneClasses = {
positive: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100',
warning: 'border-amber-400/20 bg-amber-400/10 text-amber-100',
action: 'border-sky-400/20 bg-sky-400/10 text-sky-100',
neutral: 'border-white/10 bg-white/[0.03] text-slate-200',
}
return (
<a
href={item.href}
onClick={() => trackStudioEvent('studio_insight_clicked', {
surface: studioSurface(),
module: 'overview',
meta: {
insight_key: item.key,
href: item.href,
},
})}
className={`block rounded-[24px] border p-4 transition hover:border-white/20 ${toneClasses[item.tone] || toneClasses.neutral}`}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-black/20">
<i className={item.icon} />
</div>
<div className="min-w-0">
<h3 className="text-base font-semibold text-white">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">{item.body}</p>
<span className="mt-3 inline-flex items-center gap-2 text-sm font-medium text-inherit">
{item.cta}
<i className="fa-solid fa-arrow-right" />
</span>
</div>
</div>
</a>
)
}
function TopPerformerCard({ item }) {
return (
<article className="rounded-[26px] border border-white/10 bg-white/[0.03] p-4 transition hover:border-white/20">
<div className="flex items-start gap-3">
{item.image_url && (
<img src={item.image_url} alt={item.title} className="h-16 w-16 flex-shrink-0 rounded-2xl object-cover" loading="lazy" />
)}
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{item.module_label}</p>
<h4 className="mt-1 truncate text-base font-semibold text-white" title={item.title}>{item.title}</h4>
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.visibility}</p>
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-slate-400">
<span>{Number(item.metrics?.views || 0).toLocaleString()} views</span>
<span>{Number(item.metrics?.appreciation || 0).toLocaleString()} reactions</span>
<span>{Number(item.metrics?.comments || 0).toLocaleString()} comments</span>
</div>
</div>
</div>
<div className="mt-4 flex gap-2">
<a href={item.edit_url || item.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-100">Edit</a>
<a href={item.analytics_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-100">Analytics</a>
</div>
</article>
)
}
function RecentComment({ comment }) {
return (
<div className="flex items-start gap-3 py-3 border-b border-white/5 last:border-0">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs text-slate-400 flex-shrink-0">
<i className="fa-solid fa-comment" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-white">
<span className="font-medium text-accent">{comment.author_name}</span>
{' '}on{' '}
<span className="text-slate-300">{comment.artwork_title}</span>
</p>
<p className="text-xs text-slate-500 mt-0.5 line-clamp-2">{comment.body}</p>
<p className="text-[10px] text-slate-600 mt-1">
{new Date(comment.created_at).toLocaleDateString()}
</p>
</div>
<div className="border-b border-white/5 py-3 last:border-0">
<p className="text-sm text-white">
<span className="font-medium text-sky-100">{comment.author_name}</span>
{' '}on{' '}
<span className="text-slate-300">{comment.item_title}</span>
</p>
<p className="mt-1 text-xs text-slate-500 line-clamp-2">{comment.body}</p>
<p className="mt-1 text-[10px] text-slate-600">{new Date(comment.created_at).toLocaleDateString()}</p>
</div>
)
}
export default function StudioDashboard() {
const { props } = usePage()
const { kpis, topPerformers, recentComments } = props
const overview = props.overview || {}
const analytics = props.analytics || {}
const kpis = overview.kpis || {}
const widgetVisibility = overview.preferences?.widget_visibility || {}
const showWidget = (key) => widgetVisibility[key] !== false
return (
<StudioLayout title="Studio Overview">
{/* KPI Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
<StudioLayout title="Overview" subtitle="Create, manage, and grow across artworks, cards, collections, and stories from one shared creator operating surface.">
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
{kpiConfig.map((config) => (
<KpiCard key={config.key} config={config} value={kpis?.[config.key] ?? 0} />
))}
</div>
{/* Top Performers */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-white">Your Top Performers</h2>
<span className="text-xs text-slate-500">Last 7 days</span>
</div>
{topPerformers?.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{topPerformers.map((art) => (
<TopPerformerCard key={art.id} artwork={art} />
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Command center</h2>
<a href="/studio/calendar" className="text-sm font-medium text-sky-100">Open calendar</a>
</div>
<div className="mt-5 grid gap-5 lg:grid-cols-3">
<CommandCenterColumn
title="Publishing today"
items={overview.command_center?.publishing_today || []}
empty="Nothing is scheduled today."
renderItem={(item) => <ScheduledItemCard key={item.id} item={item} />}
/>
<CommandCenterColumn
title="Attention now"
items={overview.command_center?.attention_now || []}
empty="Inbox is quiet right now."
renderItem={(item) => <ActivityRow key={item.id} item={item} />}
/>
<CommandCenterColumn
title="Ready to schedule"
items={overview.workflow_focus?.ready_to_schedule || []}
empty="No ready drafts yet."
renderItem={(item) => <ContinueWorkingCard key={item.id} item={item} />}
/>
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Readable insights</h2>
<div className="mt-4 space-y-3">
{(overview.insight_blocks || []).map((item) => (
<InsightBlock key={item.key} item={item} />
))}
</div>
) : (
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-8 text-center">
<p className="text-slate-500 text-sm">No artworks yet. Upload your first creation!</p>
<Link
href="/upload"
className="inline-flex items-center gap-2 mt-4 px-5 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white text-sm font-semibold transition-all shadow-lg shadow-accent/25"
>
<i className="fa-solid fa-cloud-arrow-up" /> Upload
</Link>
</div>
)}
</section>
</div>
{/* Recent Comments */}
<div>
<h2 className="text-lg font-bold text-white mb-4">Recent Comments</h2>
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-4">
{recentComments?.length > 0 ? (
recentComments.map((c) => <RecentComment key={c.id} comment={c} />)
) : (
<p className="text-slate-500 text-sm text-center py-4">No comments yet</p>
)}
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
{showWidget('module_summaries') && <section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Module health</h2>
<a href="/studio/content" className="text-sm font-medium text-sky-100">Open content queue</a>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
{(overview.module_summaries || []).map((item) => (
<div key={item.key} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center gap-3 text-slate-200">
<i className={item.icon} />
<span className="text-base font-semibold text-white">{item.label}</span>
</div>
<div className="mt-4 flex items-end justify-between gap-4">
<div>
<div className="text-3xl font-semibold text-white">{Number(item.count || 0).toLocaleString()}</div>
<div className="mt-2 text-sm text-slate-400">{Number(item.published_count || 0).toLocaleString()} published</div>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-sky-200/70">{Number(item.trend_value || 0).toLocaleString()} {item.trend_label || 'recent'}</div>
</div>
<div className="text-right text-sm text-slate-400">
<div>{Number(item.draft_count || 0).toLocaleString()} drafts</div>
<div>{Number(item.archived_count || 0).toLocaleString()} archived</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<a href={item.index_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-100">Manage</a>
<a href={item.create_url} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100">{item.quick_action_label || 'Create new'}</a>
</div>
</div>
))}
</div>
</section>}
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Quick create</h2>
<span className="text-sm text-slate-500">Start with any module</span>
</div>
<div className="mt-4 grid gap-3">
{(overview.quick_create || []).map((item) => (
<QuickCreateCard key={item.key} item={item} />
))}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px_360px]">
{showWidget('active_challenges') && <section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Active challenges</h2>
<a href="/studio/challenges" onClick={() => trackStudioEvent('studio_challenge_action_taken', { surface: studioSurface(), module: 'overview', meta: { action: 'open_challenges' } })} className="text-sm font-medium text-sky-100">Open challenges</a>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-1">
{(overview.active_challenges?.items || []).map((item) => <ChallengeWidget key={item.id} challenge={item} />)}
</div>
</section>}
{showWidget('featured_status') && <section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Featured status</h2>
<a href="/studio/featured" className="text-sm font-medium text-sky-100">Manage featured</a>
</div>
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-end justify-between gap-3">
<div>
<div className="text-3xl font-semibold text-white">{overview.featured_status?.selected_count || 0}/{overview.featured_status?.target_count || 4}</div>
<div className="mt-1 text-sm text-slate-400">modules have a selected featured item</div>
</div>
<div className="text-right text-xs uppercase tracking-[0.16em] text-slate-500">{(overview.featured_status?.missing_modules || []).length} missing</div>
</div>
</div>
<div className="mt-4 space-y-3">
{(overview.featured_status?.items || []).slice(0, 3).map((item) => <FeaturedStatusCard key={item.id} item={item} />)}
</div>
</section>}
{showWidget('creator_health') && <section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Creator health</h2>
<a href="/studio/growth" className="text-sm font-medium text-sky-100">Open growth</a>
</div>
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-3xl font-semibold text-white">{overview.creator_health?.score || 0}</div>
<div className="mt-1 text-sm text-slate-400">blended workflow health score</div>
</div>
<div className="mt-4 space-y-3">
{(overview.creator_health?.checkpoints || []).map((item) => (
<a key={item.key} href={item.href} className="block rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">{item.label}</h3>
<p className="mt-2 text-sm leading-6 text-slate-400">{item.detail}</p>
</div>
<span className="text-xl font-semibold text-white">{item.score}</span>
</div>
</a>
))}
</div>
</section>}
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
{showWidget('continue_working') && <section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Continue working</h2>
<a href="/studio/drafts" className="text-sm font-medium text-sky-100">Open drafts</a>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{(overview.continue_working || []).map((item) => <ContinueWorkingCard key={item.id} item={item} />)}
</div>
</section>}
{showWidget('scheduled_items') && <section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Upcoming schedule</h2>
<a href="/studio/scheduled" className="text-sm font-medium text-sky-100">Open calendar</a>
</div>
<div className="mt-4 space-y-3">
{(overview.scheduled_items || []).slice(0, 4).map((item) => <ScheduledItemCard key={item.id} item={item} />)}
</div>
</section>}
</div>
<div className="mt-6 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">
<h2 className="text-lg font-semibold text-white">Top performers</h2>
<a href="/studio/analytics" className="text-sm font-medium text-sky-100">Open insights</a>
</div>
{overview.top_performers?.length > 0 ? (
<div className="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{overview.top_performers.map((item) => (
<TopPerformerCard key={item.id} item={item} />
))}
</div>
) : (
<div className="mt-5 rounded-[24px] border border-dashed border-white/15 px-6 py-12 text-center text-slate-400">Nothing has enough activity yet to rank here.</div>
)}
</section>
{showWidget('draft_reminders') && <section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Draft reminders</h2>
<div className="mt-4 space-y-3">
{(overview.draft_reminders || []).map((item) => (
<a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-2xl border border-white/10 bg-black/20 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{item.module_label}</p>
<h3 className="mt-2 text-base font-semibold text-white">{item.title}</h3>
<p className="mt-1 text-sm text-slate-400">Updated {new Date(item.updated_at).toLocaleDateString()}</p>
</a>
))}
</div>
</section>}
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
{showWidget('recent_activity') && <section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Recent activity</h2>
<a href="/studio/activity" className="text-sm font-medium text-sky-100">Open inbox</a>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<h3 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">Recent publishes</h3>
<div className="mt-3 space-y-3">
{(overview.recent_publishes || []).slice(0, 4).map((item) => (
<RecentPublishCard key={item.id} item={item} />
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">Recent followers</h3>
<div className="mt-3 space-y-3">
{(overview.recent_followers || []).map((follower) => (
<a key={follower.id} href={follower.profile_url} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
{follower.avatar_url ? (
<img src={follower.avatar_url} alt={follower.username} className="h-11 w-11 rounded-2xl object-cover" />
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/5 text-slate-400">
<i className="fa-solid fa-user" />
</div>
)}
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{follower.name}</div>
<div className="text-xs text-slate-400">@{follower.username}</div>
</div>
</a>
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">Inbox feed</h3>
<div className="mt-3 space-y-3">
{(overview.recent_activity || []).slice(0, 4).map((item) => (
<ActivityRow key={item.id} item={item} />
))}
</div>
</div>
</div>
</section>}
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Growth hints</h2>
<div className="mt-4 space-y-3">
{(overview.growth_hints || []).map((item) => (
<GrowthHint key={item.title} item={item} />
))}
</div>
</section>
</div>
<div className="mt-6 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">
<h2 className="text-lg font-semibold text-white">Recent comments</h2>
<a href="/studio/comments" className="text-sm font-medium text-sky-100">View all</a>
</div>
<div className="mt-4">
{(overview.recent_comments || []).length > 0 ? (
overview.recent_comments.map((comment) => <RecentComment key={comment.id} comment={comment} />)
) : (
<p className="py-6 text-center text-sm text-slate-500">No comments yet</p>
)}
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Momentum</h2>
<div className="mt-4 space-y-4">
{[
['Views', analytics.totals?.views],
['Reactions', analytics.totals?.appreciation],
['Shares', analytics.totals?.shares],
['Comments', analytics.totals?.comments],
].map(([label, value]) => (
<div key={label} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="flex items-center justify-between text-sm text-slate-300">
<span>{label}</span>
<span className="font-semibold text-white">{Number(value || 0).toLocaleString()}</span>
</div>
</div>
))}
</div>
</section>
</div>
{showWidget('stale_drafts') && <div className="mt-6 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Stale drafts</h2>
<a href="/studio/content?bucket=drafts&stale=only&module=stories" className="text-sm font-medium text-sky-100">Filter stale work</a>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-4">
{(overview.stale_drafts || []).map((item) => <ContinueWorkingCard key={item.id} item={item} />)}
</div>
</div>}
</StudioLayout>
)
}

View File

@@ -1,208 +1,20 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioToolbar from '../../Components/Studio/StudioToolbar'
import StudioGridCard from '../../Components/Studio/StudioGridCard'
import StudioTable from '../../Components/Studio/StudioTable'
import BulkActionsBar from '../../Components/Studio/BulkActionsBar'
import BulkTagModal from '../../Components/Studio/BulkTagModal'
import BulkCategoryModal from '../../Components/Studio/BulkCategoryModal'
import ConfirmDangerModal from '../../Components/Studio/ConfirmDangerModal'
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
import StudioContentBrowser from '../../Components/Studio/StudioContentBrowser'
export default function StudioDrafts() {
const { props } = usePage()
const { categories } = props
const [viewMode, setViewMode] = React.useState(() => localStorage.getItem('studio_view_mode') || 'grid')
const [artworks, setArtworks] = React.useState([])
const [meta, setMeta] = React.useState({ current_page: 1, last_page: 1, per_page: 24, total: 0 })
const [loading, setLoading] = React.useState(true)
const [search, setSearch] = React.useState('')
const [sort, setSort] = React.useState('created_at:desc')
const [selectedIds, setSelectedIds] = React.useState([])
const [deleteModal, setDeleteModal] = React.useState({ open: false, ids: [] })
const [tagModal, setTagModal] = React.useState({ open: false, mode: 'add' })
const [categoryModal, setCategoryModal] = React.useState({ open: false })
const searchTimer = React.useRef(null)
const perPage = viewMode === 'list' ? 50 : 24
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
const fetchArtworks = React.useCallback(async (page = 1) => {
setLoading(true)
try {
const params = new URLSearchParams()
params.set('page', page)
params.set('per_page', perPage)
params.set('sort', sort)
params.set('status', 'draft')
if (search) params.set('q', search)
const res = await fetch(`/api/studio/artworks?${params.toString()}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
setArtworks(data.data || [])
setMeta(data.meta || meta)
} catch (err) {
console.error('Failed to fetch:', err)
} finally {
setLoading(false)
}
}, [search, sort, perPage])
React.useEffect(() => {
clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => fetchArtworks(1), 300)
return () => clearTimeout(searchTimer.current)
}, [fetchArtworks])
const handleViewModeChange = (mode) => {
setViewMode(mode)
localStorage.setItem('studio_view_mode', mode)
}
const toggleSelect = (id) => setSelectedIds((p) => p.includes(id) ? p.filter((i) => i !== id) : [...p, id])
const selectAll = () => {
const ids = artworks.map((a) => a.id)
setSelectedIds(ids.every((id) => selectedIds.includes(id)) ? [] : ids)
}
const handleAction = async (action, artwork) => {
if (action === 'edit') { window.location.href = `/studio/artworks/${artwork.id}/edit`; return }
if (action === 'delete') { setDeleteModal({ open: true, ids: [artwork.id] }); return }
try {
await fetch(`/api/studio/artworks/${artwork.id}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action }),
})
fetchArtworks(meta.current_page)
} catch (err) { console.error(err) }
}
const executeBulk = async (action) => {
if (action === 'delete') { setDeleteModal({ open: true, ids: [...selectedIds] }); return }
if (action === 'add_tags') { setTagModal({ open: true, mode: 'add' }); return }
if (action === 'remove_tags') { setTagModal({ open: true, mode: 'remove' }); return }
if (action === 'change_category') { setCategoryModal({ open: true }); return }
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action, artwork_ids: selectedIds, params: {} }),
})
setSelectedIds([])
fetchArtworks(meta.current_page)
} catch (err) { console.error(err) }
}
const confirmBulkTags = async (tagIds) => {
const action = tagModal.mode === 'add' ? 'add_tags' : 'remove_tags'
setTagModal({ open: false, mode: 'add' })
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action, artwork_ids: selectedIds, params: { tag_ids: tagIds } }),
})
setSelectedIds([])
fetchArtworks(meta.current_page)
} catch (err) { console.error(err) }
}
const confirmBulkCategory = async (categoryId) => {
setCategoryModal({ open: false })
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action: 'change_category', artwork_ids: selectedIds, params: { category_id: categoryId } }),
})
setSelectedIds([])
fetchArtworks(meta.current_page)
} catch (err) { console.error(err) }
}
const confirmDelete = async () => {
try {
await fetch('/api/studio/artworks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ action: 'delete', artwork_ids: deleteModal.ids, confirm: 'DELETE' }),
})
setDeleteModal({ open: false, ids: [] })
setSelectedIds((p) => p.filter((id) => !deleteModal.ids.includes(id)))
fetchArtworks(meta.current_page)
} catch (err) { console.error(err) }
}
return (
<StudioLayout title="Drafts">
<StudioToolbar
search={search}
onSearchChange={setSearch}
sort={sort}
onSortChange={setSort}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onFilterToggle={() => {}}
selectedCount={selectedIds.length}
<StudioLayout title={props.title} subtitle={props.description}>
<StudioContentBrowser
listing={props.listing}
quickCreate={props.quickCreate}
hideBucketFilter
emptyTitle="No drafts waiting"
emptyBody="Every module is caught up. Create something new or switch to the main content queue."
/>
{loading && (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!loading && viewMode === 'grid' && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artworks.map((art) => (
<StudioGridCard key={art.id} artwork={art} selected={selectedIds.includes(art.id)} onSelect={toggleSelect} onAction={handleAction} />
))}
</div>
)}
{!loading && viewMode === 'list' && (
<StudioTable artworks={artworks} selectedIds={selectedIds} onSelect={toggleSelect} onSelectAll={selectAll} onAction={handleAction} onSort={setSort} currentSort={sort} />
)}
{!loading && artworks.length === 0 && (
<div className="text-center py-16">
<i className="fa-solid fa-file-pen text-4xl text-slate-600 mb-4" />
<p className="text-slate-500 text-sm">No draft artworks</p>
</div>
)}
{meta.last_page > 1 && (
<div className="flex items-center justify-center gap-2 mt-6">
{Array.from({ length: meta.last_page }, (_, i) => i + 1)
.filter((p) => p === 1 || p === meta.last_page || Math.abs(p - meta.current_page) <= 2)
.map((page, idx, arr) => (
<React.Fragment key={page}>
{idx > 0 && arr[idx - 1] !== page - 1 && <span className="text-slate-600 text-sm"></span>}
<button onClick={() => fetchArtworks(page)} className={`w-9 h-9 rounded-xl text-sm font-medium transition-all ${page === meta.current_page ? 'bg-accent text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}>{page}</button>
</React.Fragment>
))}
</div>
)}
<BulkActionsBar count={selectedIds.length} onExecute={executeBulk} onClearSelection={() => setSelectedIds([])} />
<ConfirmDangerModal open={deleteModal.open} onClose={() => setDeleteModal({ open: false, ids: [] })} onConfirm={confirmDelete} title="Permanently delete?" message={`Delete ${deleteModal.ids.length} artwork(s) permanently?`} />
<BulkTagModal open={tagModal.open} mode={tagModal.mode} onClose={() => setTagModal({ open: false, mode: 'add' })} onConfirm={confirmBulkTags} />
<BulkCategoryModal open={categoryModal.open} categories={categories} onClose={() => setCategoryModal({ open: false })} onConfirm={confirmBulkCategory} />
</StudioLayout>
)
}

View File

@@ -0,0 +1,178 @@
import React, { useEffect, useMemo, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
const modules = [
{ key: 'artworks', label: 'Artworks' },
{ key: 'cards', label: 'Cards' },
{ key: 'collections', label: 'Collections' },
{ key: 'stories', label: 'Stories' },
]
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
}
export default function StudioFeatured() {
const { props } = usePage()
const [featuredModules, setFeaturedModules] = useState(props.featuredModules || [])
const [selected, setSelected] = useState(props.selected || {})
const [saving, setSaving] = useState(false)
useEffect(() => {
setFeaturedModules(props.featuredModules || [])
setSelected(props.selected || {})
}, [props.featuredModules, props.selected])
const groupedItems = useMemo(() => {
return (props.items || []).reduce((accumulator, item) => {
const key = item.module || 'unknown'
accumulator[key] = [...(accumulator[key] || []), item]
return accumulator
}, {})
}, [props.items])
const toggleModule = (module) => {
setFeaturedModules((current) => (
current.includes(module)
? current.filter((entry) => entry !== module)
: [...current, module]
))
}
const saveSelections = async () => {
setSaving(true)
try {
await requestJson(props.endpoints.save, 'PUT', {
featured_modules: featuredModules,
featured_content: selected,
})
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Unable to save featured content.')
} finally {
setSaving(false)
}
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="mb-6 rounded-[28px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em]">Profile highlights</p>
<p className="mt-2 max-w-3xl text-sm leading-6">Choose which modules are highlighted on your profile, then assign one representative item to each active module.</p>
<button type="button" onClick={saveSelections} disabled={saving} className="mt-4 inline-flex items-center gap-2 rounded-full border border-sky-300/20 px-4 py-2 text-sm font-semibold disabled:opacity-50">
<i className="fa-solid fa-floppy-disk" />
{saving ? 'Saving...' : 'Save featured layout'}
</button>
</section>
<section className="mb-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Active modules</h2>
<div className="mt-4 flex flex-wrap gap-3">
{modules.map((module) => {
const active = featuredModules.includes(module.key)
return (
<button
key={module.key}
type="button"
onClick={() => toggleModule(module.key)}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition ${active ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-black/20 text-slate-300'}`}
>
<i className={`fa-solid ${active ? 'fa-circle-check' : 'fa-circle'}`} />
{module.label}
</button>
)
})}
</div>
</section>
<div className="space-y-6">
{modules.map((module) => {
const items = groupedItems[module.key] || []
const active = featuredModules.includes(module.key)
return (
<section key={module.key} className="rounded-[28px] 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">{module.label}</h2>
<p className="mt-1 text-sm text-slate-400">Select one featured item that represents this module on your profile.</p>
</div>
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${active ? 'bg-sky-300/10 text-sky-100' : 'bg-white/5 text-slate-500'}`}>
{active ? 'Active' : 'Hidden'}
</span>
</div>
{items.length > 0 ? (
<div className="mt-5 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{items.map((item) => {
const isSelected = Number(selected[module.key] || 0) === Number(item.numeric_id || 0)
return (
<article key={item.id} className={`overflow-hidden rounded-[28px] border ${isSelected ? 'border-sky-300/30 bg-sky-300/5' : 'border-white/10 bg-white/[0.02]'}`}>
<div className="aspect-[1.15/1] bg-slate-950/70">
{item.image_url ? (
<img src={item.image_url} alt={item.title} className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="flex h-full items-center justify-center text-slate-500">
<i className={item.module_icon || 'fa-solid fa-star'} />
</div>
)}
</div>
<div className="space-y-3 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{item.module_label}</p>
<h3 className="truncate text-lg font-semibold text-white">{item.title}</h3>
<p className="text-sm text-slate-400">{item.subtitle || item.visibility || 'Published item'}</p>
</div>
<button type="button" onClick={() => setSelected((current) => ({ ...current, [module.key]: item.numeric_id }))} className={`inline-flex h-10 w-10 items-center justify-center rounded-full border ${isSelected ? 'border-sky-300/30 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-black/20 text-slate-400'}`}>
<i className={`fa-solid ${isSelected ? 'fa-check' : 'fa-star'}`} />
</button>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-slate-400">
<div><div>Views</div><div className="mt-1 text-sm font-semibold text-white">{Number(item.metrics?.views || 0).toLocaleString()}</div></div>
<div><div>Reactions</div><div className="mt-1 text-sm font-semibold text-white">{Number(item.metrics?.appreciation || 0).toLocaleString()}</div></div>
<div><div>Comments</div><div className="mt-1 text-sm font-semibold text-white">{Number(item.metrics?.comments || 0).toLocaleString()}</div></div>
</div>
<div className="flex gap-2">
<a href={item.edit_url || item.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-100">Edit</a>
<a href={item.preview_url || item.view_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-100">Preview</a>
</div>
</div>
</article>
)
})}
</div>
) : (
<div className="mt-5 rounded-[24px] border border-dashed border-white/15 px-6 py-10 text-center text-sm text-slate-400">
No published {module.label.toLowerCase()} candidates yet.
</div>
)}
</section>
)
})}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,116 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
function SummaryCard({ label, value, icon }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center gap-3 text-slate-300">
<i className={icon} />
<span className="text-sm">{label}</span>
</div>
<div className="mt-3 text-3xl font-semibold text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
export default function StudioFollowers() {
const { props } = usePage()
const listing = props.listing || {}
const filters = listing.filters || {}
const summary = listing.summary || {}
const items = listing.items || []
const meta = listing.meta || {}
const updateQuery = (patch) => {
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'followers',
meta: {
patch,
},
})
router.get(window.location.pathname, { ...filters, ...patch }, { preserveScroll: true, preserveState: true, replace: true })
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="mb-6 grid gap-4 md:grid-cols-3">
<SummaryCard label="Total followers" value={summary.total_followers} icon="fa-solid fa-user-group" />
<SummaryCard label="Following back" value={summary.following_back} icon="fa-solid fa-arrows-rotate" />
<SummaryCard label="Not followed yet" value={summary.not_followed} icon="fa-solid fa-user-plus" />
</div>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_220px]">
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
<input value={filters.q || ''} onChange={(event) => updateQuery({ q: event.target.value, page: 1 })} placeholder="Search followers" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" />
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
<select value={filters.sort || 'recent'} onChange={(event) => updateQuery({ sort: event.target.value, page: 1 })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
{(listing.sort_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
</select>
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Relationship</span>
<select value={filters.relationship || 'all'} onChange={(event) => updateQuery({ relationship: event.target.value, page: 1 })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
{(listing.relationship_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
</select>
</label>
</div>
<div className="mt-6 space-y-3">
{items.map((item) => (
<article key={item.id} className="flex flex-col gap-4 rounded-[24px] border border-white/10 bg-black/20 p-4 md:flex-row md:items-center md:justify-between">
<a href={item.profile_url} className="flex min-w-0 items-center gap-4">
{item.avatar_url ? (
<img src={item.avatar_url} alt={item.username} className="h-14 w-14 rounded-[18px] object-cover" />
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-[18px] bg-white/5 text-slate-400"><i className="fa-solid fa-user" /></div>
)}
<div className="min-w-0">
<div className="truncate text-base font-semibold text-white">{item.name}</div>
<div className="text-sm text-slate-400">@{item.username}</div>
</div>
</a>
<div className="grid grid-cols-2 gap-4 text-sm text-slate-400 md:grid-cols-4 md:text-right">
<div>
<div>Uploads</div>
<div className="mt-1 font-semibold text-white">{Number(item.uploads_count || 0).toLocaleString()}</div>
</div>
<div>
<div>Followers</div>
<div className="mt-1 font-semibold text-white">{Number(item.followers_count || 0).toLocaleString()}</div>
</div>
<div>
<div>Followed</div>
<div className="mt-1 font-semibold text-white">{item.followed_at ? new Date(item.followed_at).toLocaleDateString() : '—'}</div>
</div>
<div>
<div>Status</div>
<div className="mt-1 font-semibold text-white">{item.is_following_back ? 'Following back' : 'Not followed'}</div>
</div>
</div>
</article>
))}
</div>
<div className="mt-6 flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
<button type="button" disabled={(meta.current_page || 1) <= 1} onClick={() => updateQuery({ page: Math.max(1, (meta.current_page || 1) - 1) })} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">
<i className="fa-solid fa-arrow-left" />
Previous
</button>
<span>Page {meta.current_page || 1} of {meta.last_page || 1}</span>
<button type="button" disabled={(meta.current_page || 1) >= (meta.last_page || 1)} onClick={() => updateQuery({ page: (meta.current_page || 1) + 1 })} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">
Next
<i className="fa-solid fa-arrow-right" />
</button>
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,279 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
const rangeOptions = [7, 14, 30, 60, 90]
const summaryCards = [
['followers', 'Followers', 'fa-user-group'],
['published_in_range', 'Published', 'fa-calendar-check'],
['engagement_actions', 'Engagement actions', 'fa-bolt'],
['profile_completion', 'Profile completion', 'fa-id-card'],
['challenge_entries', 'Challenge entries', 'fa-trophy'],
['featured_modules', 'Featured modules', 'fa-star'],
]
function formatShortDate(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function TrendBars({ title, subtitle, points, colorClass }) {
const values = (points || []).map((point) => Number(point.value || point.count || 0))
const maxValue = Math.max(...values, 1)
return (
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<p className="mt-1 text-sm text-slate-400">{subtitle}</p>
<div className="mt-5 flex h-52 items-end gap-2">
{(points || []).map((point) => {
const value = Number(point.value || point.count || 0)
const height = `${Math.max(8, Math.round((value / maxValue) * 100))}%`
return (
<div key={point.date} className="flex min-w-0 flex-1 flex-col items-center justify-end gap-2">
<div className="text-[10px] font-medium text-slate-500">{value.toLocaleString()}</div>
<div className="flex h-full w-full items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
<div className={`w-full rounded-t-[16px] ${colorClass}`} style={{ height }} />
</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-500">{formatShortDate(point.date)}</div>
</div>
)
})}
</div>
</section>
)
}
export default function StudioGrowth() {
const { props } = usePage()
const { summary, moduleFocus, checkpoints, opportunities, milestones, momentum, topContent, rangeDays } = props
const updateRange = (days) => {
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'growth',
meta: {
range_days: days,
},
})
router.get(window.location.pathname, { range_days: days }, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="mb-6 rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_34%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.88),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Growth window</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Creator growth over the last {rangeDays || 30} days</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">This view blends audience momentum, profile readiness, featured curation, and challenge participation into one operating surface.</p>
</div>
<div className="inline-flex rounded-full border border-white/10 bg-black/20 p-1">
{rangeOptions.map((days) => (
<button key={days} type="button" onClick={() => updateRange(days)} className={`rounded-full px-4 py-2 text-sm font-semibold transition ${Number(rangeDays || 30) === days ? 'bg-white text-slate-950' : 'text-slate-300 hover:text-white'}`}>
{days}d
</button>
))}
</div>
</div>
</section>
<div className="grid grid-cols-2 gap-4 xl:grid-cols-6">
{summaryCards.map(([key, label, icon]) => (
<div key={key} className="rounded-[26px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</span>
<i className={`fa-solid ${icon} text-sky-200`} />
</div>
<div className="mt-3 text-3xl font-semibold text-white">{Number(summary?.[key] || 0).toLocaleString()}</div>
</div>
))}
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<TrendBars title="Views momentum" subtitle="Cross-module reach across the current growth window." points={momentum?.views_trend || []} colorClass="bg-emerald-400/60" />
<TrendBars title="Engagement momentum" subtitle="Reactions, comments, shares, and saves translated into a cleaner direction-of-travel signal." points={momentum?.engagement_trend || []} colorClass="bg-pink-400/60" />
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Growth checkpoints</h2>
<div className="mt-5 space-y-3">
{(checkpoints || []).map((item) => (
<div key={item.key} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold text-white">{item.label}</div>
<p className="mt-2 text-sm leading-6 text-slate-400">{item.detail}</p>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-white">{item.score}</div>
<div className="text-[10px] uppercase tracking-[0.16em] text-slate-500">{item.status.replace('_', ' ')}</div>
</div>
</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/5">
<div className={`h-full rounded-full ${item.score >= 80 ? 'bg-emerald-400/70' : item.score >= 55 ? 'bg-amber-400/70' : 'bg-rose-400/70'}`} style={{ width: `${Math.max(6, item.score)}%` }} />
</div>
<a
href={item.href}
onClick={() => trackStudioEvent('studio_insight_clicked', {
surface: studioSurface(),
module: 'growth',
meta: {
insight_key: item.key,
href: item.href,
},
})}
className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-sky-100"
>
{item.cta}
<i className="fa-solid fa-arrow-right" />
</a>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Growth opportunities</h2>
<div className="mt-4 space-y-3">
{(opportunities || []).map((item) => (
<a
key={item.title}
href={item.href}
onClick={() => trackStudioEvent('studio_insight_clicked', {
surface: studioSurface(),
module: 'growth',
meta: {
insight_key: item.title,
href: item.href,
},
})}
className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20"
>
<h3 className="text-sm font-semibold text-white">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-slate-400">{item.body}</p>
<span className="mt-3 inline-flex items-center gap-2 text-sm font-medium text-sky-100">{item.cta}<i className="fa-solid fa-arrow-right" /></span>
</a>
))}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-[28px] 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">Module focus</h2>
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Share of workspace output</span>
</div>
<div className="mt-5 space-y-3">
{(moduleFocus || []).map((item) => (
<a key={item.key} href={item.href} className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 text-slate-200">
<i className={item.icon} />
<div>
<div className="font-semibold text-white">{item.label}</div>
<div className="text-xs text-slate-400">{item.published_count} published {item.draft_count} drafts</div>
</div>
</div>
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-100">Open</span>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<div className="flex items-center justify-between text-xs text-slate-400"><span>Views</span><span>{item.views.toLocaleString()}</span></div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/5"><div className="h-full rounded-full bg-emerald-400/60" style={{ width: `${Math.max(4, item.view_share)}%` }} /></div>
</div>
<div>
<div className="flex items-center justify-between text-xs text-slate-400"><span>Engagement</span><span>{item.engagement.toLocaleString()}</span></div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/5"><div className="h-full rounded-full bg-pink-400/60" style={{ width: `${Math.max(4, item.engagement_share)}%` }} /></div>
</div>
</div>
</a>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Milestones</h2>
<div className="mt-4 space-y-3">
{(milestones || []).map((item) => (
<div key={item.key} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{item.label}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.current.toLocaleString()} of {item.target.toLocaleString()}</div>
</div>
<div className="text-xl font-semibold text-white">{item.progress}%</div>
</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/5">
<div className="h-full rounded-full bg-sky-300/60" style={{ width: `${Math.max(6, item.progress)}%` }} />
</div>
</div>
))}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Publishing rhythm</h2>
<div className="mt-5 space-y-3">
{(momentum?.publishing_timeline || []).map((point) => (
<div key={point.date}>
<div className="mb-1 flex items-center justify-between text-xs text-slate-400">
<span>{formatShortDate(point.date)}</span>
<span>{point.count}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/5">
<div className="h-full rounded-full bg-sky-300/60" style={{ width: `${Math.min(100, Number(point.count || 0) * 18)}%` }} />
</div>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Top content this window</h2>
<div className="mt-4 space-y-3">
{(topContent || []).map((item) => (
<a
key={item.id}
href={item.analytics_url || item.view_url}
onClick={() => trackStudioEvent('studio_insight_clicked', {
surface: studioSurface(),
module: 'growth',
item_module: item.module_key,
item_id: item.numeric_id,
meta: {
insight_key: 'top_content',
href: item.analytics_url || item.view_url,
},
})}
className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20"
>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{item.module_label}</div>
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
<div className="mt-3 grid grid-cols-3 gap-3 text-xs text-slate-400">
<div><div>Views</div><div className="mt-1 text-sm font-semibold text-white">{Number(item.metrics?.views || 0).toLocaleString()}</div></div>
<div><div>Reactions</div><div className="mt-1 text-sm font-semibold text-white">{Number(item.metrics?.appreciation || 0).toLocaleString()}</div></div>
<div><div>Comments</div><div className="mt-1 text-sm font-semibold text-white">{Number(item.metrics?.comments || 0).toLocaleString()}</div></div>
</div>
</a>
))}
</div>
</section>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,127 @@
import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
async function requestJson(url, method = 'POST') {
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',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(payload?.message || 'Request failed')
return payload
}
function formatDate(value) {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Unknown'
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
const priorityClasses = {
high: 'border-rose-300/20 bg-rose-300/10 text-rose-100',
medium: 'border-sky-300/20 bg-sky-300/10 text-sky-100',
low: 'border-white/10 bg-white/[0.03] text-slate-300',
}
export default function StudioInbox() {
const { props } = usePage()
const inbox = props.inbox || {}
const filters = inbox.filters || {}
const items = inbox.items || []
const meta = inbox.meta || {}
const summary = inbox.summary || {}
const [marking, setMarking] = useState(false)
const updateFilters = (patch) => {
const next = { ...filters, ...patch }
if (patch.page == null) next.page = 1
router.get(window.location.pathname, next, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
const markAllRead = async () => {
setMarking(true)
try {
await requestJson(props.endpoints.markAllRead)
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Unable to mark inbox as read.')
} finally {
setMarking(false)
}
}
return (
<StudioLayout title={props.title} subtitle={props.description} actions={<button type="button" onClick={markAllRead} disabled={marking} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-100 disabled:opacity-50"><i className="fa-solid fa-check-double" />{marking ? 'Updating...' : 'Mark all read'}</button>}>
<div className="space-y-6">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Unread</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.unread_count || 0).toLocaleString()}</div></div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">High priority</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.high_priority_count || 0).toLocaleString()}</div></div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Comments</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.comment_count || 0).toLocaleString()}</div></div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Followers</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.follower_count || 0).toLocaleString()}</div></div>
</section>
<div className="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
<aside className="space-y-6">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-lg font-semibold text-white">Filters</h2>
<div className="mt-4 space-y-3">
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search</span><input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Actor, title, or module" /></label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Type</span><select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.type_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read state</span><select value={filters.read_state || 'all'} onChange={(event) => updateFilters({ read_state: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.read_state_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Priority</span><select value={filters.priority || 'all'} onChange={(event) => updateFilters({ priority: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.priority_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-lg font-semibold text-white">Attention now</h2>
<div className="mt-4 space-y-3">{(inbox.panels?.attention_now || []).map((item) => <a key={item.id} href={item.url} className="block rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-sm font-semibold text-white">{item.title}</div><div className="mt-1 text-xs text-slate-500">{item.module_label}</div></a>)}</div>
</section>
</aside>
<section className="space-y-4">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className={`rounded-[28px] border p-5 ${item.is_new ? 'border-sky-300/20 bg-sky-300/10' : 'border-white/10 bg-white/[0.03]'}`}>
<div className="flex gap-4">
{item.actor?.avatar_url ? <img src={item.actor.avatar_url} alt={item.actor.name || 'Actor'} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-black/20 text-slate-400"><i className="fa-solid fa-bell" /></div>}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
<span>{item.module_label}</span>
<span className={`inline-flex items-center rounded-full border px-2 py-1 ${priorityClasses[item.priority] || priorityClasses.low}`}>{item.priority}</span>
{item.is_new && <span className="rounded-full bg-sky-300/20 px-2 py-1 text-sky-100">Unread</span>}
</div>
<h2 className="mt-2 text-lg font-semibold text-white">{item.title}</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">{item.body}</p>
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-slate-400">
<span>{formatDate(item.created_at)}</span>
{item.actor?.name && <span>{item.actor.name}</span>}
<a href={item.url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-slate-200">Open</a>
</div>
</div>
</div>
</article>
)) : <div className="rounded-[28px] border border-dashed border-white/15 px-6 py-16 text-center text-slate-400">No inbox items match this filter.</div>}
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
<button type="button" disabled={(meta.current_page || 1) <= 1} onClick={() => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) })} className="rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">Previous</button>
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Page {meta.current_page || 1} of {meta.last_page || 1}</span>
<button type="button" disabled={(meta.current_page || 1) >= (meta.last_page || 1)} onClick={() => updateFilters({ page: (meta.current_page || 1) + 1 })} className="rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">Next</button>
</div>
</section>
</div>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,303 @@
import React, { useEffect, useState } from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
const shortcutOptions = [
{ value: '/dashboard/profile', label: 'Dashboard profile' },
{ value: '/dashboard/notifications', label: 'Notifications' },
{ value: '/dashboard/comments/received', label: 'Received comments' },
{ value: '/dashboard/followers', label: 'Followers' },
{ value: '/dashboard/following', label: 'Following' },
{ value: '/dashboard/favorites', label: 'Favorites' },
{ value: '/dashboard/artworks', label: 'Artwork dashboard' },
{ value: '/dashboard/gallery', label: 'Gallery' },
{ value: '/dashboard/awards', label: 'Awards' },
{ value: '/creator/stories', label: 'Story dashboard' },
{ value: '/studio', label: 'Creator Studio' },
]
const widgetOptions = [
{ value: 'quick_stats', label: 'Quick stats' },
{ value: 'continue_working', label: 'Continue working' },
{ value: 'scheduled_items', label: 'Scheduled items' },
{ value: 'recent_activity', label: 'Recent activity' },
{ value: 'top_performers', label: 'Top performers' },
{ value: 'draft_reminders', label: 'Draft reminders' },
{ value: 'module_summaries', label: 'Module summaries' },
{ value: 'growth_hints', label: 'Growth hints' },
{ value: 'active_challenges', label: 'Active challenges' },
{ value: 'creator_health', label: 'Creator health' },
{ value: 'featured_status', label: 'Featured status' },
{ value: 'comments_snapshot', label: 'Comments snapshot' },
{ value: 'stale_drafts', label: 'Stale drafts' },
]
const landingOptions = [
['overview', 'Overview'],
['content', 'Content'],
['drafts', 'Drafts'],
['scheduled', 'Scheduled'],
['calendar', 'Calendar'],
['inbox', 'Inbox'],
['analytics', 'Analytics'],
['growth', 'Growth'],
['challenges', 'Challenges'],
['search', 'Search'],
['preferences', 'Preferences'],
]
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
}
export default function StudioPreferences() {
const { props } = usePage()
const preferences = props.preferences || {}
const [form, setForm] = useState({
default_content_view: preferences.default_content_view || 'grid',
analytics_range_days: preferences.analytics_range_days || 30,
dashboard_shortcuts: preferences.dashboard_shortcuts || [],
draft_behavior: preferences.draft_behavior || 'resume-last',
default_landing_page: preferences.default_landing_page || 'overview',
widget_visibility: preferences.widget_visibility || {},
widget_order: preferences.widget_order || widgetOptions.map((option) => option.value),
card_density: preferences.card_density || 'comfortable',
scheduling_timezone: preferences.scheduling_timezone || '',
})
const [saving, setSaving] = useState(false)
useEffect(() => {
setForm({
default_content_view: preferences.default_content_view || 'grid',
analytics_range_days: preferences.analytics_range_days || 30,
dashboard_shortcuts: preferences.dashboard_shortcuts || [],
draft_behavior: preferences.draft_behavior || 'resume-last',
default_landing_page: preferences.default_landing_page || 'overview',
widget_visibility: preferences.widget_visibility || {},
widget_order: preferences.widget_order || widgetOptions.map((option) => option.value),
card_density: preferences.card_density || 'comfortable',
scheduling_timezone: preferences.scheduling_timezone || '',
})
}, [preferences])
const toggleShortcut = (value) => {
setForm((current) => ({
...current,
dashboard_shortcuts: current.dashboard_shortcuts.includes(value)
? current.dashboard_shortcuts.filter((entry) => entry !== value)
: [...current.dashboard_shortcuts, value].slice(0, 8),
}))
}
const toggleWidget = (value) => {
setForm((current) => ({
...current,
widget_visibility: {
...current.widget_visibility,
[value]: !(current.widget_visibility?.[value] !== false),
},
}))
}
const moveWidget = (value, direction) => {
setForm((current) => {
const items = [...current.widget_order]
const index = items.indexOf(value)
if (index < 0) return current
const nextIndex = direction === 'up' ? index - 1 : index + 1
if (nextIndex < 0 || nextIndex >= items.length) return current
const swapped = items[nextIndex]
items[nextIndex] = value
items[index] = swapped
trackStudioEvent('studio_widget_reordered', {
surface: studioSurface(),
module: 'preferences',
meta: {
widget: value,
direction,
from: index + 1,
to: nextIndex + 1,
},
})
return { ...current, widget_order: items }
})
}
const saveSettings = async () => {
setSaving(true)
try {
await requestJson(props.endpoints.save, 'PUT', form)
window.alert('Studio preferences saved.')
} catch (error) {
window.alert(error?.message || 'Unable to save Studio preferences.')
} finally {
setSaving(false)
}
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<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">Workspace preferences</h2>
<p className="mt-1 text-sm text-slate-400">Choose where Studio opens, how dense content cards feel, and which overview modules stay visible.</p>
</div>
<button type="button" onClick={saveSettings} disabled={saving} 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" />
{saving ? 'Saving...' : 'Save preferences'}
</button>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Default content view</span>
<select value={form.default_content_view} onChange={(event) => setForm((current) => ({ ...current, default_content_view: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<option value="grid" className="bg-slate-900">Grid</option>
<option value="list" className="bg-slate-900">List</option>
</select>
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Analytics date range</span>
<select value={form.analytics_range_days} onChange={(event) => setForm((current) => ({ ...current, analytics_range_days: Number(event.target.value) }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
{[7, 14, 30, 60, 90].map((days) => (
<option key={days} value={days} className="bg-slate-900">Last {days} days</option>
))}
</select>
</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">Draft behavior</span>
<select value={form.draft_behavior} onChange={(event) => setForm((current) => ({ ...current, draft_behavior: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<option value="resume-last" className="bg-slate-900">Resume the last draft I edited</option>
<option value="open-drafts" className="bg-slate-900">Open the drafts library first</option>
<option value="focus-published" className="bg-slate-900">Open published content first</option>
</select>
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Default landing page</span>
<select value={form.default_landing_page} onChange={(event) => setForm((current) => ({ ...current, default_landing_page: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
{landingOptions.map(([value, label]) => (
<option key={value} value={value} className="bg-slate-900">{label}</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Card density</span>
<select value={form.card_density} onChange={(event) => setForm((current) => ({ ...current, card_density: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<option value="comfortable" className="bg-slate-900">Comfortable</option>
<option value="compact" className="bg-slate-900">Compact</option>
</select>
</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">Scheduling timezone</span>
<input value={form.scheduling_timezone} onChange={(event) => setForm((current) => ({ ...current, scheduling_timezone: event.target.value }))} placeholder="Europe/Helsinki" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" />
</label>
</div>
<div className="mt-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-white">Dashboard shortcuts</h3>
<p className="mt-1 text-sm text-slate-400">Pin up to 8 destinations that should stay easy to reach from the wider workspace.</p>
</div>
<span className="rounded-full border border-white/10 px-3 py-1 text-xs uppercase tracking-[0.16em] text-slate-400">{form.dashboard_shortcuts.length}/8 selected</span>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{shortcutOptions.map((option) => {
const active = form.dashboard_shortcuts.includes(option.value)
return (
<button key={option.value} type="button" onClick={() => toggleShortcut(option.value)} className={`flex items-center justify-between rounded-[22px] border px-4 py-3 text-left transition ${active ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-black/20 text-slate-300'}`}>
<span>{option.label}</span>
<i className={`fa-solid ${active ? 'fa-circle-check' : 'fa-circle'} text-sm`} />
</button>
)
})}
</div>
</div>
<div className="mt-6">
<h3 className="text-base font-semibold text-white">Overview widgets</h3>
<p className="mt-1 text-sm text-slate-400">Show, hide, and prioritize dashboard sections for your daily workflow.</p>
<div className="mt-4 space-y-3">
{form.widget_order.map((widgetKey, index) => {
const option = widgetOptions.find((entry) => entry.value === widgetKey)
if (!option) return null
const enabled = form.widget_visibility?.[widgetKey] !== false
return (
<div key={widgetKey} className="flex flex-col gap-3 rounded-[22px] border border-white/10 bg-black/20 p-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold text-white">{option.label}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">Position {index + 1}</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => toggleWidget(widgetKey)} className={`rounded-full border px-3 py-1.5 text-xs ${enabled ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 text-slate-300'}`}>
{enabled ? 'Visible' : 'Hidden'}
</button>
<button type="button" onClick={() => moveWidget(widgetKey, 'up')} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-300">Up</button>
<button type="button" onClick={() => moveWidget(widgetKey, 'down')} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-300">Down</button>
</div>
</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">Related surfaces</h2>
<div className="mt-4 space-y-3">
{(props.links || []).map((link) => (
<a key={link.url} href={link.url} className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<div className="flex items-center gap-3 text-sky-100">
<i className={link.icon} />
<span className="text-base font-semibold text-white">{link.label}</span>
</div>
</a>
))}
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Preference notes</h2>
<div className="mt-4 space-y-3 text-sm text-slate-400">
<div className="rounded-[22px] border border-white/10 bg-black/20 p-4">Landing page and widget order are stored in the shared Studio preference record, so new Creator Studio surfaces can plug into the same contract without another migration.</div>
<div className="rounded-[22px] border border-white/10 bg-black/20 p-4">Analytics range and card density stay here so Analytics, Growth, and the main dashboard can stay visually consistent.</div>
</div>
</section>
</div>
</div>
</StudioLayout>
)
}

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>
)
}

View File

@@ -0,0 +1,201 @@
import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
async function requestJson(url, method = 'POST') {
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',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Request failed')
}
return payload
}
function formatDate(value) {
if (!value) return 'Not scheduled'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Not scheduled'
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
}
export default function StudioScheduled() {
const { props } = usePage()
const listing = props.listing || {}
const filters = listing.filters || {}
const summary = listing.summary || {}
const agenda = listing.agenda || []
const items = listing.items || []
const meta = listing.meta || {}
const rangeOptions = listing.range_options || []
const endpoints = props.endpoints || {}
const [busyId, setBusyId] = useState(null)
const updateFilters = (patch) => {
const next = { ...filters, ...patch }
if (patch.page == null) next.page = 1
trackStudioEvent('studio_scheduled_opened', {
surface: studioSurface(),
module: next.module,
meta: patch,
})
router.get(window.location.pathname, next, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
const actionUrl = (pattern, item) => String(pattern || '').replace('__MODULE__', item.module).replace('__ID__', item.numeric_id)
const runAction = async (item, key) => {
const url = actionUrl(key === 'publish' ? endpoints.publishNowPattern : endpoints.unschedulePattern, item)
if (!url) return
setBusyId(`${key}:${item.id}`)
try {
await requestJson(url)
trackStudioEvent(key === 'publish' ? 'studio_schedule_updated' : 'studio_schedule_cleared', {
surface: studioSurface(),
module: item.module,
item_module: item.module,
item_id: item.numeric_id,
})
router.reload({ only: ['listing', 'overview'] })
} catch (error) {
window.alert(error?.message || 'Unable to update schedule.')
} finally {
setBusyId(null)
}
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6">
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Scheduled total</div>
<div className="mt-2 text-3xl font-semibold text-white">{Number(summary.total || 0).toLocaleString()}</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 md:col-span-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Next publish slot</div>
<div className="mt-2 text-xl font-semibold text-white">{formatDate(summary.next_publish_at)}</div>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{(summary.by_module || []).map((entry) => (
<div key={entry.key} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center gap-3 text-slate-300">
<i className={entry.icon} />
<span className="text-sm font-medium text-white">{entry.label}</span>
</div>
<div className="mt-3 text-2xl font-semibold text-white">{Number(entry.count || 0).toLocaleString()}</div>
</div>
))}
</div>
</div>
<div className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Agenda</h2>
<div className="mt-4 space-y-3">
{agenda.length > 0 ? agenda.slice(0, 6).map((day) => (
<div key={day.date} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-white">{day.label}</span>
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">{day.count} items</span>
</div>
<div className="mt-2 text-sm text-slate-400">{day.items.slice(0, 2).map((item) => item.title).join(' • ')}</div>
</div>
)) : <div className="rounded-[22px] border border-dashed border-white/15 px-4 py-8 text-sm text-slate-400">No scheduled items yet.</div>}
</div>
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search scheduled work</span>
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Title or module" />
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span>
<select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
{(listing.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
</select>
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Date range</span>
<select value={filters.range || 'upcoming'} onChange={(event) => updateFilters({ range: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
{rangeOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
</select>
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Start date</span>
<input type="date" value={filters.start_date || ''} onChange={(event) => updateFilters({ range: 'custom', start_date: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" />
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">End date</span>
<input type="date" value={filters.end_date || ''} onChange={(event) => updateFilters({ range: 'custom', end_date: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" />
</label>
<div className="flex items-end">
<button type="button" onClick={() => updateFilters({ q: '', module: 'all', range: 'upcoming', start_date: '', end_date: '' })} className="w-full rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200">Reset</button>
</div>
</div>
</section>
<section className="space-y-4">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">
<span>{item.module_label}</span>
<span>{item.status}</span>
</div>
<h2 className="mt-2 text-xl font-semibold text-white">{item.title}</h2>
<div className="mt-2 flex flex-wrap items-center gap-4 text-sm text-slate-400">
<span>Scheduled for {formatDate(item.scheduled_at || item.published_at)}</span>
{item.visibility && <span>Visibility: {item.visibility}</span>}
{item.updated_at && <span>Last edited {formatDate(item.updated_at)}</span>}
{item.schedule_timezone && <span>{item.schedule_timezone}</span>}
</div>
</div>
<div className="flex flex-wrap gap-2">
<a href={item.edit_url || item.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200">Edit</a>
<a href={item.edit_url || item.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200">Reschedule</a>
{item.preview_url && <a href={item.preview_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200">Preview</a>}
<button type="button" disabled={busyId === `publish:${item.id}`} onClick={() => runAction(item, 'publish')} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm text-sky-100 disabled:opacity-50">Publish now</button>
<button type="button" disabled={busyId === `unschedule:${item.id}`} onClick={() => runAction(item, 'unschedule')} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200 disabled:opacity-50">Unschedule</button>
</div>
</div>
</article>
)) : <div className="rounded-[28px] border border-dashed border-white/15 px-6 py-16 text-center text-slate-400">No scheduled content matches this view.</div>}
</section>
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
<button type="button" disabled={(meta.current_page || 1) <= 1} onClick={() => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) })} className="rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">Previous</button>
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Page {meta.current_page || 1} of {meta.last_page || 1}</span>
<button type="button" disabled={(meta.current_page || 1) >= (meta.last_page || 1)} onClick={() => updateFilters({ page: (meta.current_page || 1) + 1 })} className="rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">Next</button>
</div>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,63 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioSearch() {
const { props } = usePage()
const search = props.search || {}
const filters = search.filters || {}
const sections = search.sections || []
const updateFilters = (patch) => {
router.get(window.location.pathname, { ...filters, ...patch }, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6">
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<label className="space-y-2 text-sm text-slate-300 xl:col-span-3"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search Studio</span><input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Search content, comments, inbox, or assets" /></label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Surface</span><select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(search.type_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(search.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
</div>
</section>
{filters.q ? (
<div className="space-y-6">
<div className="text-sm text-slate-400">Found <span className="font-semibold text-white">{Number(search.summary?.total || 0).toLocaleString()}</span> matches for <span className="font-semibold text-white">{search.summary?.query}</span></div>
{sections.length > 0 ? sections.map((section) => (
<section key={section.key} className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between gap-3"><h2 className="text-lg font-semibold text-white">{section.label}</h2><span className="text-xs uppercase tracking-[0.18em] text-slate-500">{section.count} matches</span></div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">{section.items.map((item) => <a key={item.id} href={item.href} className="block rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20"><div className="flex items-start gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/[0.04] text-sky-100"><i className={item.icon} /></div><div className="min-w-0"><div className="truncate text-base font-semibold text-white">{item.title}</div><div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{item.subtitle}</div><p className="mt-3 line-clamp-3 text-sm leading-6 text-slate-400">{item.description}</p></div></div></a>)}</div>
</section>
)) : <div className="rounded-[28px] border border-dashed border-white/15 px-6 py-16 text-center text-slate-400">No results matched this search yet.</div>}
</div>
) : (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Continue working</h2>
<div className="mt-4 grid gap-3 md:grid-cols-2">{(search.empty_state?.continue_working || []).map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-[24px] border border-white/10 bg-black/20 p-4"><div className="text-sm font-semibold text-white">{item.title}</div><div className="mt-1 text-xs text-slate-500">{item.module_label} · {item.workflow?.readiness?.label}</div></a>)}</div>
</section>
<aside className="space-y-6">
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-lg font-semibold text-white">Stale drafts</h2>
<div className="mt-4 space-y-3">{(search.empty_state?.stale_drafts || []).map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-sm font-semibold text-white">{item.title}</div><div className="mt-1 text-xs text-slate-500">{item.module_label}</div></a>)}</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-lg font-semibold text-white">Quick create</h2>
<div className="mt-4 grid gap-3">{(props.quickCreate || []).map((item) => <a key={item.key} href={item.url} className="inline-flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100"><i className={item.icon} /><span>New {item.label}</span></a>)}</div>
</section>
</aside>
</div>
)}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
import StudioLayout from '../../Layouts/StudioLayout'
import { usePage } from '@inertiajs/react'
export default function StudioSettings() {
const { props } = usePage()
return (
<StudioLayout title={props.title} subtitle={props.description}>
<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">
<h2 className="text-lg font-semibold text-white">System handoff</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">Studio now keeps creator workflow preferences in their own surface. This page stays focused on links out to adjacent dashboards and the control points that do not belong in the day-to-day workflow UI.</p>
<div className="mt-5 grid gap-3 md:grid-cols-2">
{(props.links || []).map((link) => (
<a key={link.url} href={link.url} className="rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20 hover:bg-black/30">
<div className="flex items-center gap-3 text-sky-100">
<i className={link.icon} />
<span className="text-base font-semibold text-white">{link.label}</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-400">Open the linked dashboard or settings surface without losing the Studio navigation shell as the default control plane.</p>
</a>
))}
</div>
</section>
<section className="space-y-6">
{(props.sections || []).map((section) => (
<div key={section.title} className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">{section.title}</h2>
<p className="mt-3 text-sm leading-6 text-slate-400">{section.body}</p>
<a href={section.href} className="mt-4 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 transition hover:border-sky-300/35 hover:bg-sky-300/15">
{section.cta}
<i className="fa-solid fa-arrow-right" />
</a>
</div>
))}
</section>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,36 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioContentBrowser from '../../Components/Studio/StudioContentBrowser'
export default function StudioStories() {
const { props } = usePage()
const summary = props.summary || {}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="mb-6 grid gap-4 md:grid-cols-4">
{[
['Stories', summary.count, 'fa-solid fa-feather-pointed'],
['Drafts', summary.draft_count, 'fa-solid fa-file-pen'],
['Published', summary.published_count, 'fa-solid fa-sparkles'],
].map(([label, value, icon]) => (
<div key={label} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center gap-3 text-slate-300">
<i className={icon} />
<span className="text-sm">{label}</span>
</div>
<div className="mt-3 text-3xl font-semibold text-white">{Number(value || 0).toLocaleString()}</div>
</div>
))}
<a href={props.dashboardUrl} className="rounded-[24px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em]">Story dashboard</p>
<p className="mt-3 text-sm leading-6">Jump into the existing story workspace when you need the full editor and publishing controls.</p>
</a>
</div>
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter />
</StudioLayout>
)
}