Implement creator studio and upload updates
This commit is contained in:
@@ -51,7 +51,7 @@ function SidebarContent({ isActive, onNavigate }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SectionSidebar({ sections = [], activeSection, onSectionChange }) {
|
||||
function SectionSidebar({ sections = [], activeSection, onSectionChange, dirtyMap = {} }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
@@ -61,19 +61,30 @@ function SectionSidebar({ sections = [], activeSection, onSectionChange }) {
|
||||
<nav className="space-y-1 flex-1">
|
||||
{sections.map((section) => {
|
||||
const active = section.key === activeSection
|
||||
const isDirty = !!dirtyMap[section.key]
|
||||
return (
|
||||
<button
|
||||
key={section.key}
|
||||
type="button"
|
||||
onClick={() => onSectionChange?.(section.key)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
className={`group relative w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{section.icon ? <i className={`${section.icon} w-5 text-center text-base`} /> : null}
|
||||
<span>{section.label}</span>
|
||||
<span className="flex flex-col items-start gap-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
{section.label}
|
||||
{isDirty && (
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" title="Unsaved changes" />
|
||||
)}
|
||||
</span>
|
||||
{section.description && !active ? (
|
||||
<span className="text-[11px] font-normal text-slate-500 leading-tight">{section.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -82,7 +93,7 @@ function SectionSidebar({ sections = [], activeSection, onSectionChange }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsLayout({ children, title, sections = null, activeSection = null, onSectionChange = null }) {
|
||||
export default function SettingsLayout({ children, title, sections = null, activeSection = null, onSectionChange = null, dirtyMap = {} }) {
|
||||
const { url } = usePage()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const hasSectionMode = Array.isArray(sections) && sections.length > 0 && typeof onSectionChange === 'function'
|
||||
@@ -98,20 +109,27 @@ export default function SettingsLayout({ children, title, sections = null, activ
|
||||
{/* Mobile top bar */}
|
||||
<div className="lg:hidden px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
|
||||
{hasSectionMode ? (
|
||||
<label className="block">
|
||||
<span className="sr-only">Settings section</span>
|
||||
<select
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={activeSection || ''}
|
||||
onChange={(e) => onSectionChange(e.target.value)}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<option key={section.key} value={section.key} className="bg-nova-900 text-white">
|
||||
{section.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="block flex-1">
|
||||
<span className="sr-only">Settings section</span>
|
||||
<select
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-accent/50 appearance-none"
|
||||
value={activeSection || ''}
|
||||
onChange={(e) => onSectionChange(e.target.value)}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<option key={section.key} value={section.key} className="bg-nova-900 text-white">
|
||||
{section.label}{dirtyMap[section.key] ? ' •' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{dirtyMap[activeSection] ? (
|
||||
<span className="inline-flex items-center rounded-full bg-amber-400/15 px-2 py-1 text-[10px] font-semibold text-amber-300 border border-amber-400/20">
|
||||
Unsaved
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold text-white">Settings</h1>
|
||||
@@ -142,7 +160,7 @@ export default function SettingsLayout({ children, title, sections = null, activ
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden lg:flex flex-col w-64 min-h-[calc(100vh-4rem)] border-r border-white/10 bg-nova-900/60 backdrop-blur-xl p-4 pt-6 sticky top-16 self-start">
|
||||
{hasSectionMode ? (
|
||||
<SectionSidebar sections={sections} activeSection={activeSection} onSectionChange={onSectionChange} />
|
||||
<SectionSidebar sections={sections} activeSection={activeSection} onSectionChange={onSectionChange} dirtyMap={dirtyMap} />
|
||||
) : (
|
||||
<SidebarContent isActive={isActive} />
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,77 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
import { studioModule, studioSurface, trackStudioEvent } from '../utils/studioEvents'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Overview', href: '/studio', icon: 'fa-solid fa-chart-line' },
|
||||
{ label: 'Artworks', href: '/studio/artworks', icon: 'fa-solid fa-images' },
|
||||
{ label: 'Cards', href: '/studio/cards', icon: 'fa-solid fa-rectangle-history-circle-user' },
|
||||
{ label: 'Drafts', href: '/studio/artworks/drafts', icon: 'fa-solid fa-file-pen' },
|
||||
{ label: 'Archived', href: '/studio/artworks/archived', icon: 'fa-solid fa-box-archive' },
|
||||
{ label: 'Analytics', href: '/studio/analytics', icon: 'fa-solid fa-chart-pie' },
|
||||
const navGroups = [
|
||||
{
|
||||
label: 'Creator Studio',
|
||||
items: [
|
||||
{ label: 'Overview', href: '/studio', icon: 'fa-solid fa-chart-line' },
|
||||
{ label: 'Search', href: '/studio/search', icon: 'fa-solid fa-magnifying-glass' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Create',
|
||||
items: [
|
||||
{ label: 'New Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
{ label: 'New Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' },
|
||||
{ label: 'New Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' },
|
||||
{ label: 'New Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
items: [
|
||||
{ label: 'All Content', href: '/studio/content', icon: 'fa-solid fa-table-cells-large' },
|
||||
{ label: 'Artworks', href: '/studio/artworks', icon: 'fa-solid fa-images' },
|
||||
{ label: 'Cards', href: '/studio/cards', icon: 'fa-solid fa-id-card' },
|
||||
{ label: 'Collections', href: '/studio/collections', icon: 'fa-solid fa-layer-group' },
|
||||
{ label: 'Stories', href: '/studio/stories', icon: 'fa-solid fa-feather-pointed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Library',
|
||||
items: [
|
||||
{ label: 'Drafts', href: '/studio/drafts', icon: 'fa-solid fa-file-pen' },
|
||||
{ label: 'Scheduled', href: '/studio/scheduled', icon: 'fa-solid fa-calendar-days' },
|
||||
{ label: 'Calendar', href: '/studio/calendar', icon: 'fa-solid fa-calendar-range' },
|
||||
{ label: 'Archived', href: '/studio/archived', icon: 'fa-solid fa-box-archive' },
|
||||
{ label: 'Assets', href: '/studio/assets', icon: 'fa-solid fa-photo-film' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Engagement',
|
||||
items: [
|
||||
{ label: 'Inbox', href: '/studio/inbox', icon: 'fa-solid fa-inbox' },
|
||||
{ label: 'Activity', href: '/studio/activity', icon: 'fa-solid fa-bell' },
|
||||
{ label: 'Comments', href: '/studio/comments', icon: 'fa-solid fa-comments' },
|
||||
{ label: 'Followers', href: '/studio/followers', icon: 'fa-solid fa-user-group' },
|
||||
{ label: 'Challenges', href: '/studio/challenges', icon: 'fa-solid fa-trophy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Insights',
|
||||
items: [
|
||||
{ label: 'Analytics', href: '/studio/analytics', icon: 'fa-solid fa-chart-pie' },
|
||||
{ label: 'Growth', href: '/studio/growth', icon: 'fa-solid fa-chart-line' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Creator',
|
||||
items: [
|
||||
{ label: 'Profile', href: '/studio/profile', icon: 'fa-solid fa-id-card' },
|
||||
{ label: 'Featured Content', href: '/studio/featured', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
{ label: 'Preferences', href: '/studio/preferences', icon: 'fa-solid fa-sliders' },
|
||||
{ label: 'Studio Settings', href: '/studio/settings', icon: 'fa-solid fa-gear' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const quickCreateItems = [
|
||||
{ label: 'Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
{ label: 'Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' },
|
||||
{ label: 'Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' },
|
||||
{ label: 'Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' },
|
||||
]
|
||||
|
||||
function NavLink({ item, active }) {
|
||||
@@ -26,53 +90,117 @@ function NavLink({ item, active }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioLayout({ children, title }) {
|
||||
const { url, props } = usePage()
|
||||
const user = props.auth?.user
|
||||
export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
const { url } = usePage()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const pathname = url.split('?')[0]
|
||||
|
||||
useEffect(() => {
|
||||
const moduleKey = studioModule(pathname)
|
||||
const surface = studioSurface(pathname)
|
||||
|
||||
trackStudioEvent('studio_opened', {
|
||||
surface,
|
||||
module: moduleKey,
|
||||
})
|
||||
|
||||
trackStudioEvent('studio_module_opened', {
|
||||
surface,
|
||||
module: moduleKey,
|
||||
})
|
||||
}, [pathname])
|
||||
|
||||
const isActive = (href) => {
|
||||
if (href === '/studio') return url === '/studio'
|
||||
return url.startsWith(href)
|
||||
if (href === '/studio') return pathname === '/studio'
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
const handleQuickCreateClick = (item) => {
|
||||
trackStudioEvent('studio_quick_create_used', {
|
||||
surface: studioSurface(pathname),
|
||||
module: item.label.toLowerCase(),
|
||||
meta: {
|
||||
href: item.href,
|
||||
label: item.label,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-nova-900">
|
||||
{/* Mobile top bar */}
|
||||
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
|
||||
<h1 className="text-lg font-bold text-white">Studio</h1>
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="text-slate-400 hover:text-white p-2"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<i className={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
|
||||
</button>
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.12),_transparent_30%),radial-gradient(circle_at_bottom_right,_rgba(34,197,94,0.12),_transparent_35%),linear-gradient(180deg,_#06101d_0%,_#020617_45%,_#02040a_100%)]">
|
||||
<div className="sticky top-16 z-30 border-b border-white/10 bg-slate-950/80 backdrop-blur-xl lg:hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Creator Studio</p>
|
||||
<h1 className="text-lg font-bold text-white">{title || 'Creator Studio'}</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="rounded-full border border-white/10 p-2 text-slate-400 hover:text-white"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<i className={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile nav overlay */}
|
||||
{mobileOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)}>
|
||||
<div className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden" onClick={() => setMobileOpen(false)}>
|
||||
<nav
|
||||
className="absolute left-0 top-0 bottom-0 w-72 bg-nova-900 border-r border-white/10 p-4 pt-20 space-y-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute left-0 top-0 bottom-0 w-80 overflow-y-auto border-r border-white/10 bg-slate-950 p-4 pt-20"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<StudioSidebarContent isActive={isActive} onNavigate={() => setMobileOpen(false)} />
|
||||
<StudioSidebarContent isActive={isActive} onNavigate={() => setMobileOpen(false)} onQuickCreate={handleQuickCreateClick} />
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden lg:flex flex-col w-64 min-h-[calc(100vh-4rem)] border-r border-white/10 bg-nova-900/60 backdrop-blur-xl p-4 pt-6 sticky top-16 self-start">
|
||||
<StudioSidebarContent isActive={isActive} />
|
||||
<aside className="sticky top-16 hidden min-h-[calc(100vh-4rem)] w-72 self-start border-r border-white/10 bg-slate-950/55 p-4 pt-6 backdrop-blur-xl lg:flex lg:flex-col">
|
||||
<StudioSidebarContent isActive={isActive} onQuickCreate={handleQuickCreateClick} />
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-4 lg:px-8 pt-4 pb-8">
|
||||
{title && (
|
||||
<h1 className="text-2xl font-bold text-white mb-4">{title}</h1>
|
||||
)}
|
||||
<main className="min-w-0 flex-1 px-4 pb-10 pt-4 lg:px-8 lg:pt-6">
|
||||
<section className="mb-6 rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.84),_rgba(2,6,23,0.95))] p-5 shadow-[0_22px_70px_rgba(2,6,23,0.32)] lg:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Creator Studio</p>
|
||||
{title && <h1 className="mt-2 text-3xl font-semibold text-white lg:text-4xl">{title}</h1>}
|
||||
{subtitle && <p className="mt-3 max-w-2xl text-sm leading-6 text-slate-300">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
||||
{actions}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen((current) => !current)}
|
||||
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"
|
||||
>
|
||||
<i className="fa-solid fa-plus" />
|
||||
Create New
|
||||
</button>
|
||||
{createOpen && (
|
||||
<div className="absolute right-0 top-[calc(100%+0.75rem)] z-20 min-w-[220px] rounded-[24px] border border-white/10 bg-slate-950/95 p-2 shadow-[0_18px_40px_rgba(2,6,23,0.5)] backdrop-blur-xl">
|
||||
{quickCreateItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => handleQuickCreateClick(item)}
|
||||
className="flex items-center gap-3 rounded-2xl px-4 py-3 text-sm text-slate-200 transition hover:bg-white/[0.06] hover:text-white"
|
||||
>
|
||||
<i className={`${item.icon} w-5 text-center text-sky-200`} />
|
||||
<span>New {item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
@@ -80,28 +208,46 @@ export default function StudioLayout({ children, title }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StudioSidebarContent({ isActive, onNavigate }) {
|
||||
function StudioSidebarContent({ isActive, onNavigate, onQuickCreate }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500 px-4 mb-2">Creator Studio</h2>
|
||||
<div className="mb-6 rounded-[26px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Skinbase Nova</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Creator Studio</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">Create, manage, and grow from one modular workspace built for every creator surface.</p>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1 flex-1" onClick={onNavigate}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} active={isActive(item.href)} />
|
||||
<nav className="flex-1 space-y-5" onClick={onNavigate}>
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h3 className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{group.label}</h3>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.href} item={item} active={isActive(item.href)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6">
|
||||
<Link
|
||||
href="/upload"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 rounded-xl bg-sky-600 hover:bg-sky-500 text-white font-semibold text-sm transition-all duration-200 shadow-lg"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<i className="fa-solid fa-cloud-arrow-up" />
|
||||
Upload
|
||||
</Link>
|
||||
<div className="mt-6 rounded-[24px] border border-white/10 bg-[linear-gradient(135deg,_rgba(15,23,42,0.95),_rgba(12,74,110,0.4))] p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-100/70">Quick create</p>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{quickCreateItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="inline-flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-100 transition hover:border-white/20 hover:bg-white/[0.06]"
|
||||
onClick={() => {
|
||||
onQuickCreate?.(item)
|
||||
onNavigate?.()
|
||||
}}
|
||||
>
|
||||
<i className={`${item.icon} w-5 text-center text-sky-200`} />
|
||||
<span>New {item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%)' }} />
|
||||
|
||||
@@ -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%)' }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%)' }} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
163
resources/js/Pages/Studio/StudioActivity.jsx
Normal file
163
resources/js/Pages/Studio/StudioActivity.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
290
resources/js/Pages/Studio/StudioAssets.jsx
Normal file
290
resources/js/Pages/Studio/StudioAssets.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
resources/js/Pages/Studio/StudioCalendar.jsx
Normal file
134
resources/js/Pages/Studio/StudioCalendar.jsx
Normal 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
@@ -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>
|
||||
)
|
||||
|
||||
194
resources/js/Pages/Studio/StudioChallenges.jsx
Normal file
194
resources/js/Pages/Studio/StudioChallenges.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
resources/js/Pages/Studio/StudioCollections.jsx
Normal file
37
resources/js/Pages/Studio/StudioCollections.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
438
resources/js/Pages/Studio/StudioComments.jsx
Normal file
438
resources/js/Pages/Studio/StudioComments.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
resources/js/Pages/Studio/StudioContentIndex.jsx
Normal file
19
resources/js/Pages/Studio/StudioContentIndex.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
178
resources/js/Pages/Studio/StudioFeatured.jsx
Normal file
178
resources/js/Pages/Studio/StudioFeatured.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
resources/js/Pages/Studio/StudioFollowers.jsx
Normal file
116
resources/js/Pages/Studio/StudioFollowers.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
279
resources/js/Pages/Studio/StudioGrowth.jsx
Normal file
279
resources/js/Pages/Studio/StudioGrowth.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
resources/js/Pages/Studio/StudioInbox.jsx
Normal file
127
resources/js/Pages/Studio/StudioInbox.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
303
resources/js/Pages/Studio/StudioPreferences.jsx
Normal file
303
resources/js/Pages/Studio/StudioPreferences.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
393
resources/js/Pages/Studio/StudioProfile.jsx
Normal file
393
resources/js/Pages/Studio/StudioProfile.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
resources/js/Pages/Studio/StudioScheduled.jsx
Normal file
201
resources/js/Pages/Studio/StudioScheduled.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
resources/js/Pages/Studio/StudioSearch.jsx
Normal file
63
resources/js/Pages/Studio/StudioSearch.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
resources/js/Pages/Studio/StudioSettings.jsx
Normal file
43
resources/js/Pages/Studio/StudioSettings.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
resources/js/Pages/Studio/StudioStories.jsx
Normal file
36
resources/js/Pages/Studio/StudioStories.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import LinkPreviewCard from './LinkPreviewCard'
|
||||
import TagPeopleModal from './TagPeopleModal'
|
||||
|
||||
// Lazy-load the heavy emoji picker only when first opened
|
||||
// @emoji-mart/react only has a default export (the Picker); m.Picker is undefined
|
||||
const EmojiPicker = lazy(() => import('@emoji-mart/react'))
|
||||
const EmojiPicker = lazy(() => import('../common/EmojiMartPicker'))
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'public', icon: 'fa-globe', label: 'Public' },
|
||||
|
||||
597
resources/js/components/Studio/StudioContentBrowser.jsx
Normal file
597
resources/js/components/Studio/StudioContentBrowser.jsx
Normal file
@@ -0,0 +1,597 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { router } from '@inertiajs/react'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unscheduled'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Unscheduled'
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function metricValue(item, key) {
|
||||
return Number(item?.metrics?.[key] ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function readinessClasses(readiness) {
|
||||
if (!readiness) return 'border-white/15 bg-white/5 text-slate-300'
|
||||
if (readiness.can_publish && readiness.score >= readiness.max) return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
|
||||
if (readiness.can_publish) return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
|
||||
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
|
||||
}
|
||||
|
||||
function statusClasses(status) {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200'
|
||||
case 'draft':
|
||||
case 'pending_review':
|
||||
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
|
||||
case 'scheduled':
|
||||
case 'processing':
|
||||
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
|
||||
case 'archived':
|
||||
case 'hidden':
|
||||
case 'rejected':
|
||||
return 'border-white/15 bg-white/5 text-slate-300'
|
||||
default:
|
||||
return 'border-white/15 bg-white/5 text-slate-200'
|
||||
}
|
||||
}
|
||||
|
||||
function ActionLink({ href, icon, label, onClick }) {
|
||||
if (!href) return null
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06]"
|
||||
>
|
||||
<i className={`${icon} text-[11px]`} />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function RequestActionButton({ action, onExecute, busyKey }) {
|
||||
if (!action || action.type !== 'request') return null
|
||||
|
||||
const isBusy = busyKey === `${action.key}:${action.url}`
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExecute(action)}
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<i className={`${action.icon} text-[11px]`} />
|
||||
<span>{isBusy ? 'Working...' : action.label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewLink({ item }) {
|
||||
if (!item?.preview_url) return null
|
||||
|
||||
return <ActionLink href={item.preview_url} icon="fa-solid fa-eye" label="Preview" />
|
||||
}
|
||||
|
||||
function GridCard({ item, onExecuteAction, busyKey }) {
|
||||
const handleEditClick = () => {
|
||||
trackStudioEvent('studio_item_edited', {
|
||||
surface: studioSurface(),
|
||||
module: item.module,
|
||||
item_module: item.module,
|
||||
item_id: item.numeric_id,
|
||||
meta: {
|
||||
action: 'edit',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.03] shadow-[0_18px_50px_rgba(3,7,18,0.22)] transition hover:-translate-y-0.5 hover:border-white/20">
|
||||
<div className="relative aspect-[1.15/1] overflow-hidden 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 bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.18),_transparent_55%),linear-gradient(135deg,_rgba(15,23,42,0.9),_rgba(2,6,23,0.95))] text-slate-400">
|
||||
<i className={`${item.module_icon} text-3xl`} />
|
||||
</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">
|
||||
<i className={`${item.module_icon} text-[10px]`} />
|
||||
<span>{item.module_label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-1 truncate text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
|
||||
</div>
|
||||
<span className={`inline-flex shrink-0 items-center rounded-full border px-2.5 py-1 text-[11px] font-medium capitalize ${statusClasses(item.status)}`}>
|
||||
{String(item.status || 'unknown').replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.workflow?.readiness && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(item.workflow.readiness)}`}>
|
||||
{item.workflow.readiness.label}
|
||||
</span>
|
||||
{item.workflow.is_stale_draft && (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-[11px] font-medium text-amber-100">
|
||||
Stale draft
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
||||
{item.workflow.readiness.score}/{item.workflow.readiness.max} ready
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="line-clamp-2 min-h-[2.5rem] text-sm text-slate-300/90">
|
||||
{item.description || 'No description yet.'}
|
||||
</p>
|
||||
|
||||
{Array.isArray(item.workflow?.readiness?.missing) && item.workflow.readiness.missing.length > 0 && (
|
||||
<div className="rounded-2xl border border-white/5 bg-slate-950/35 p-3 text-xs text-slate-400">
|
||||
{item.workflow.readiness.missing.slice(0, 2).join(' • ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 rounded-2xl border border-white/5 bg-slate-950/40 p-3 text-xs text-slate-400">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.16em]">Views</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{metricValue(item, 'views')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.16em]">Reactions</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{metricValue(item, 'appreciation')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.16em]">Comments</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{metricValue(item, 'comments')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span>Updated {formatDate(item.updated_at)}</span>
|
||||
{item.published_at && <span>Published {formatDate(item.published_at)}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ActionLink href={item.edit_url || item.manage_url} icon="fa-solid fa-pen-to-square" label="Edit" onClick={handleEditClick} />
|
||||
<PreviewLink item={item} />
|
||||
<ActionLink href={item.analytics_url} icon="fa-solid fa-chart-line" label="Analytics" />
|
||||
<ActionLink href={item.view_url} icon="fa-solid fa-arrow-up-right-from-square" label="Open" />
|
||||
{(item.actions || []).map((action) => (
|
||||
<RequestActionButton key={`${item.id}-${action.key}`} action={{ ...action, item_id: item.numeric_id, item_module: item.module }} onExecute={onExecuteAction} busyKey={busyKey} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{Array.isArray(item.workflow?.cross_module_actions) && item.workflow.cross_module_actions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 border-t border-white/5 pt-3">
|
||||
{item.workflow.cross_module_actions.slice(0, 2).map((action) => (
|
||||
<ActionLink key={`${item.id}-${action.label}`} href={action.href} icon={action.icon} label={action.label} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function ListRow({ item, onExecuteAction, busyKey }) {
|
||||
const handleEditClick = () => {
|
||||
trackStudioEvent('studio_item_edited', {
|
||||
surface: studioSurface(),
|
||||
module: item.module,
|
||||
item_module: item.module,
|
||||
item_id: item.numeric_id,
|
||||
meta: {
|
||||
action: 'edit',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="grid gap-4 rounded-[24px] border border-white/10 bg-white/[0.03] p-4 transition hover:border-white/20 md:grid-cols-[120px_minmax(0,1fr)_auto] md:items-center">
|
||||
<div className="h-24 overflow-hidden rounded-2xl bg-slate-950/60">
|
||||
{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-400">
|
||||
<i className={`${item.module_icon} text-2xl`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
||||
<i className={`${item.module_icon} text-[10px]`} />
|
||||
{item.module_label}
|
||||
</span>
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium capitalize ${statusClasses(item.status)}`}>
|
||||
{String(item.status || 'unknown').replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
|
||||
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{item.description || 'No description yet.'}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{item.workflow?.readiness && (
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(item.workflow.readiness)}`}>
|
||||
{item.workflow.readiness.label}
|
||||
</span>
|
||||
)}
|
||||
{item.workflow?.is_stale_draft && (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-[11px] font-medium text-amber-100">
|
||||
Stale draft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-4 text-xs text-slate-500">
|
||||
<span>{metricValue(item, 'views')} views</span>
|
||||
<span>{metricValue(item, 'appreciation')} reactions</span>
|
||||
<span>{metricValue(item, 'comments')} comments</span>
|
||||
<span>Updated {formatDate(item.updated_at)}</span>
|
||||
</div>
|
||||
{Array.isArray(item.workflow?.readiness?.missing) && item.workflow.readiness.missing.length > 0 && (
|
||||
<div className="mt-3 text-xs text-slate-500">{item.workflow.readiness.missing.slice(0, 2).join(' • ')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 md:justify-end">
|
||||
<ActionLink href={item.edit_url || item.manage_url} icon="fa-solid fa-pen-to-square" label="Edit" onClick={handleEditClick} />
|
||||
<PreviewLink item={item} />
|
||||
<ActionLink href={item.analytics_url} icon="fa-solid fa-chart-line" label="Analytics" />
|
||||
<ActionLink href={item.view_url} icon="fa-solid fa-arrow-up-right-from-square" label="Open" />
|
||||
{(item.actions || []).map((action) => (
|
||||
<RequestActionButton key={`${item.id}-${action.key}`} action={{ ...action, item_id: item.numeric_id, item_module: item.module }} onExecute={onExecuteAction} busyKey={busyKey} />
|
||||
))}
|
||||
{(item.workflow?.cross_module_actions || []).slice(0, 2).map((action) => (
|
||||
<ActionLink key={`${item.id}-${action.label}`} href={action.href} icon={action.icon} label={action.label} />
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedFilterControl({ filter, onChange }) {
|
||||
if (filter.type === 'select') {
|
||||
return (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
|
||||
<select
|
||||
value={filter.value || 'all'}
|
||||
onChange={(event) => onChange(filter.key, 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 transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(filter.options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
|
||||
<input
|
||||
type="search"
|
||||
value={filter.value || ''}
|
||||
onChange={(event) => onChange(filter.key, event.target.value)}
|
||||
placeholder={filter.placeholder || filter.label}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioContentBrowser({
|
||||
listing,
|
||||
quickCreate = [],
|
||||
hideModuleFilter = false,
|
||||
hideBucketFilter = false,
|
||||
emptyTitle = 'Nothing here yet',
|
||||
emptyBody = 'Try adjusting filters or create something new.',
|
||||
}) {
|
||||
const [viewMode, setViewMode] = useState('grid')
|
||||
const [busyKey, setBusyKey] = useState(null)
|
||||
const filters = listing?.filters || {}
|
||||
const items = listing?.items || []
|
||||
const meta = listing?.meta || {}
|
||||
const advancedFilters = listing?.advanced_filters || []
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('studio-content-view')
|
||||
if (stored === 'grid' || stored === 'list') {
|
||||
setViewMode(stored)
|
||||
return
|
||||
}
|
||||
|
||||
if (listing?.default_view === 'grid' || listing?.default_view === 'list') {
|
||||
setViewMode(listing.default_view)
|
||||
}
|
||||
}, [listing?.default_view])
|
||||
|
||||
const updateQuery = (patch) => {
|
||||
const next = {
|
||||
...filters,
|
||||
...patch,
|
||||
}
|
||||
|
||||
if (patch.page == null) {
|
||||
next.page = 1
|
||||
}
|
||||
|
||||
trackStudioEvent('studio_filter_used', {
|
||||
surface: studioSurface(),
|
||||
module: filters.module || listing?.module || null,
|
||||
meta: {
|
||||
patch,
|
||||
},
|
||||
})
|
||||
|
||||
router.get(window.location.pathname, next, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
const updateView = (nextMode) => {
|
||||
setViewMode(nextMode)
|
||||
window.localStorage.setItem('studio-content-view', nextMode)
|
||||
trackStudioEvent('studio_filter_used', {
|
||||
surface: studioSurface(),
|
||||
module: filters.module || listing?.module || null,
|
||||
meta: {
|
||||
view_mode: nextMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const executeAction = async (action) => {
|
||||
if (!action?.url || action.type !== 'request') {
|
||||
return
|
||||
}
|
||||
|
||||
if (action.confirm && !window.confirm(action.confirm)) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestKey = `${action.key}:${action.url}`
|
||||
setBusyKey(requestKey)
|
||||
|
||||
try {
|
||||
const response = await fetch(action.url, {
|
||||
method: String(action.method || 'post').toUpperCase(),
|
||||
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: action.payload ? JSON.stringify(action.payload) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || 'Request failed')
|
||||
}
|
||||
|
||||
if (action.key === 'archive') {
|
||||
trackStudioEvent('studio_item_archived', {
|
||||
surface: studioSurface(),
|
||||
module: filters.module || null,
|
||||
item_module: action.item_module || null,
|
||||
item_id: action.item_id || null,
|
||||
meta: {
|
||||
action: action.key,
|
||||
url: action.url,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (action.key === 'restore') {
|
||||
trackStudioEvent('studio_item_restored', {
|
||||
surface: studioSurface(),
|
||||
module: filters.module || null,
|
||||
item_module: action.item_module || null,
|
||||
item_id: action.item_id || null,
|
||||
meta: {
|
||||
action: action.key,
|
||||
url: action.url,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (action.redirect_pattern && payload?.data?.id) {
|
||||
window.location.assign(action.redirect_pattern.replace('__ID__', String(payload.data.id)))
|
||||
return
|
||||
}
|
||||
|
||||
if (payload?.redirect) {
|
||||
window.location.assign(payload.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
router.reload({ preserveScroll: true, preserveState: true })
|
||||
} catch (error) {
|
||||
window.alert(error?.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,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 ${advancedFilters.length > 0 ? 'xl:grid-cols-5' : 'xl:grid-cols-4'}`}>
|
||||
<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) => updateQuery({ q: event.target.value })}
|
||||
placeholder="Title, description, module"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!hideModuleFilter && (
|
||||
<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) => updateQuery({ module: 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 transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(listing?.module_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!hideBucketFilter && (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</span>
|
||||
<select
|
||||
value={filters.bucket || 'all'}
|
||||
onChange={(event) => updateQuery({ bucket: 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 transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(listing?.bucket_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">Sort</span>
|
||||
<select
|
||||
value={filters.sort || 'updated_desc'}
|
||||
onChange={(event) => updateQuery({ sort: 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 transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(listing?.sort_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{advancedFilters.map((filter) => (
|
||||
<AdvancedFilterControl key={filter.key} filter={filter} onChange={(key, value) => updateQuery({ [key]: value })} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
||||
<div className="inline-flex rounded-full border border-white/10 bg-black/20 p-1">
|
||||
{[
|
||||
{ value: 'grid', icon: 'fa-solid fa-table-cells-large', label: 'Grid view' },
|
||||
{ value: 'list', icon: 'fa-solid fa-list', label: 'List view' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => updateView(option.value)}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-3 py-2 text-xs font-semibold transition ${viewMode === option.value ? 'bg-white text-slate-950' : 'text-slate-300 hover:text-white'}`}
|
||||
>
|
||||
<i className={option.icon} />
|
||||
<span className="hidden sm:inline">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{quickCreate.map((action) => (
|
||||
<a
|
||||
key={action.key}
|
||||
href={action.url}
|
||||
onClick={() => trackStudioEvent('studio_quick_create_used', {
|
||||
surface: studioSurface(),
|
||||
module: action.key,
|
||||
meta: {
|
||||
href: action.url,
|
||||
label: action.label,
|
||||
},
|
||||
})}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15"
|
||||
>
|
||||
<i className={action.icon} />
|
||||
<span>New {action.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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> items
|
||||
</p>
|
||||
<p>Page {meta.current_page || 1} of {meta.last_page || 1}</p>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
viewMode === 'grid' ? (
|
||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => <GridCard key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => <ListRow key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<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">{emptyTitle}</h3>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm text-slate-400">{emptyBody}</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={() => 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:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Creator Studio</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:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp'
|
||||
const FALLBACK_XL = 'https://files.skinbase.org/default/missing_xl.webp'
|
||||
|
||||
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, onOpenViewer, hasPrev, hasNext, onPrev, onNext }) {
|
||||
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, mediaWidth = null, mediaHeight = null, mediaKey = 'cover', onOpenViewer, hasPrev, hasNext, onPrev, onNext }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
const mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
|
||||
@@ -18,8 +18,8 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
||||
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
|
||||
const blurBackdropSrc = mdSource || lgSource || xlSource || null
|
||||
|
||||
const dbWidth = Number(artwork?.width)
|
||||
const dbHeight = Number(artwork?.height)
|
||||
const dbWidth = Number(mediaWidth ?? artwork?.width)
|
||||
const dbHeight = Number(mediaHeight ?? artwork?.height)
|
||||
const hasDbDims = dbWidth > 0 && dbHeight > 0
|
||||
|
||||
// Natural dimensions — seeded from DB if available, otherwise probed from
|
||||
@@ -28,6 +28,16 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
||||
hasDbDims ? { w: dbWidth, h: dbHeight } : null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false)
|
||||
if (hasDbDims) {
|
||||
setNaturalDims({ w: dbWidth, h: dbHeight })
|
||||
return
|
||||
}
|
||||
|
||||
setNaturalDims(null)
|
||||
}, [mediaKey, hasDbDims, dbWidth, dbHeight])
|
||||
|
||||
// Probe the xl image to discover real dimensions when DB has none
|
||||
useEffect(() => {
|
||||
if (naturalDims || !xlSource) return
|
||||
|
||||
69
resources/js/components/artwork/ArtworkMediaStrip.jsx
Normal file
69
resources/js/components/artwork/ArtworkMediaStrip.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ArtworkMediaStrip({ items = [], selectedId = null, onSelect }) {
|
||||
if (!Array.isArray(items) || items.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-4 shadow-[0_18px_60px_rgba(2,8,23,0.24)] backdrop-blur">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/45">Gallery</p>
|
||||
<p className="mt-1 text-sm text-white/60">Switch between the default cover and additional archive screenshots.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] text-white/65">
|
||||
{items.length} views
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{items.map((item) => {
|
||||
const active = item.id === selectedId
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(item.id)}
|
||||
aria-pressed={active}
|
||||
className={[
|
||||
'group shrink-0 rounded-2xl border p-2 text-left transition-all',
|
||||
active
|
||||
? 'border-sky-300/45 bg-sky-400/12 shadow-[0_0_0_1px_rgba(56,189,248,0.18)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="h-20 w-28 overflow-hidden rounded-xl bg-black/30 ring-1 ring-white/10 sm:h-24 sm:w-36">
|
||||
{item.thumbUrl ? (
|
||||
<img
|
||||
src={item.thumbUrl}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-white/30">
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 px-1">
|
||||
<span className="truncate text-xs font-medium text-white/80">{item.label}</span>
|
||||
<span className={[
|
||||
'rounded-full px-2 py-0.5 text-[10px]',
|
||||
active ? 'bg-sky-300/20 text-sky-100' : 'bg-white/10 text-white/45',
|
||||
].join(' ')}>
|
||||
{active ? 'Showing' : 'View'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -425,9 +425,7 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
? `/discover/trending`
|
||||
: '/discover/trending'
|
||||
|
||||
const similarHref = artwork?.name
|
||||
? `/search?q=${encodeURIComponent(artwork.name)}`
|
||||
: '/search'
|
||||
const similarHref = artwork?.id ? `/art/${artwork.id}/similar` : null
|
||||
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import EmojiMartPicker from '../common/EmojiMartPicker'
|
||||
|
||||
/**
|
||||
* A button that opens a floating emoji picker.
|
||||
@@ -76,7 +76,7 @@ export default function EmojiPickerButton({ onEmojiSelect, disabled = false, cla
|
||||
className="absolute bottom-full mb-2 right-0 z-50 shadow-2xl rounded-xl overflow-hidden"
|
||||
style={{ filter: 'drop-shadow(0 8px 32px rgba(0,0,0,0.6))' }}
|
||||
>
|
||||
<Picker
|
||||
<EmojiMartPicker
|
||||
data={data}
|
||||
onEmojiSelect={handleSelect}
|
||||
theme="dark"
|
||||
|
||||
116
resources/js/components/common/EmojiMartPicker.jsx
Normal file
116
resources/js/components/common/EmojiMartPicker.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
let emojiMartRegistrationPromise = null
|
||||
|
||||
function ensureEmojiMartRegistered() {
|
||||
if (!emojiMartRegistrationPromise) {
|
||||
emojiMartRegistrationPromise = import('emoji-mart')
|
||||
}
|
||||
|
||||
return emojiMartRegistrationPromise
|
||||
}
|
||||
|
||||
function applyPickerProps(element, props) {
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
element.data = props.data
|
||||
element.onEmojiSelect = props.onEmojiSelect
|
||||
element.theme = props.theme
|
||||
element.previewPosition = props.previewPosition
|
||||
element.skinTonePosition = props.skinTonePosition
|
||||
element.maxFrequentRows = props.maxFrequentRows
|
||||
element.perLine = props.perLine
|
||||
element.navPosition = props.navPosition
|
||||
element.set = props.set
|
||||
element.locale = props.locale
|
||||
element.autoFocus = props.autoFocus
|
||||
element.searchPosition = props.searchPosition
|
||||
element.dynamicWidth = props.dynamicWidth
|
||||
element.noCountryFlags = props.noCountryFlags
|
||||
}
|
||||
|
||||
export default function EmojiMartPicker({
|
||||
data,
|
||||
onEmojiSelect,
|
||||
theme = 'auto',
|
||||
previewPosition = 'bottom',
|
||||
skinTonePosition = 'preview',
|
||||
maxFrequentRows = 4,
|
||||
perLine = 9,
|
||||
navPosition = 'top',
|
||||
set = 'native',
|
||||
locale = 'en',
|
||||
autoFocus = false,
|
||||
searchPosition,
|
||||
dynamicWidth,
|
||||
noCountryFlags,
|
||||
className = '',
|
||||
}) {
|
||||
const hostRef = useRef(null)
|
||||
const pickerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
ensureEmojiMartRegistered().then(() => {
|
||||
if (cancelled || !hostRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!pickerRef.current) {
|
||||
pickerRef.current = document.createElement('em-emoji-picker')
|
||||
hostRef.current.replaceChildren(pickerRef.current)
|
||||
}
|
||||
|
||||
applyPickerProps(pickerRef.current, {
|
||||
data,
|
||||
onEmojiSelect,
|
||||
theme,
|
||||
previewPosition,
|
||||
skinTonePosition,
|
||||
maxFrequentRows,
|
||||
perLine,
|
||||
navPosition,
|
||||
set,
|
||||
locale,
|
||||
autoFocus,
|
||||
searchPosition,
|
||||
dynamicWidth,
|
||||
noCountryFlags,
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
data,
|
||||
onEmojiSelect,
|
||||
theme,
|
||||
previewPosition,
|
||||
skinTonePosition,
|
||||
maxFrequentRows,
|
||||
perLine,
|
||||
navPosition,
|
||||
set,
|
||||
locale,
|
||||
autoFocus,
|
||||
searchPosition,
|
||||
dynamicWidth,
|
||||
noCountryFlags,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hostRef.current) {
|
||||
hostRef.current.replaceChildren()
|
||||
}
|
||||
|
||||
pickerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div ref={hostRef} className={className} />
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import EmojiMartPicker from '../common/EmojiMartPicker'
|
||||
|
||||
/**
|
||||
* Emoji picker button for the forum rich-text editor.
|
||||
* Uses the same @emoji-mart/react picker as profile tweets / comments
|
||||
* Uses the same emoji-mart picker as profile tweets / comments
|
||||
* so the UI is consistent across the whole site.
|
||||
*
|
||||
* The panel is rendered through a React portal so it escapes any
|
||||
@@ -73,7 +73,7 @@ export default function EmojiPicker({ onSelect, editor }) {
|
||||
style={panelStyle}
|
||||
className="rounded-xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<Picker
|
||||
<EmojiMartPicker
|
||||
data={data}
|
||||
onEmojiSelect={handleSelect}
|
||||
theme="dark"
|
||||
|
||||
@@ -14,6 +14,82 @@ const placementStyles = {
|
||||
'bottom-right': { bottom: '12%', right: '12%' },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DraggableElement — wraps any canvas element with pointer-drag positioning.
|
||||
// Position is tracked locally during drag (avoids parent re-renders), then
|
||||
// committed to the parent via onMove(elementId, x%, y%) on pointer-up.
|
||||
// ---------------------------------------------------------------------------
|
||||
function DraggableElement({ elementId, canvasRef, freePos, savedWidth, onMove, style, className, children }) {
|
||||
const dragState = React.useRef(null)
|
||||
const [localPos, setLocalPos] = React.useState(null)
|
||||
const [localWidth, setLocalWidth] = React.useState(null)
|
||||
|
||||
function handlePointerDown(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const canvasRect = canvas.getBoundingClientRect()
|
||||
const elemRect = event.currentTarget.getBoundingClientRect()
|
||||
const offsetX = event.clientX - elemRect.left
|
||||
const offsetY = event.clientY - elemRect.top
|
||||
const startX = ((elemRect.left - canvasRect.left) / canvasRect.width) * 100
|
||||
const startY = ((elemRect.top - canvasRect.top) / canvasRect.height) * 100
|
||||
// Capture element width as % of canvas so it stays the same after going absolute.
|
||||
const widthPct = Math.round((elemRect.width / canvasRect.width) * 1000) / 10
|
||||
dragState.current = { canvasRect, offsetX, offsetY, widthPct }
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
setLocalPos({ x: startX, y: startY })
|
||||
setLocalWidth(widthPct)
|
||||
}
|
||||
|
||||
function calcPos(event) {
|
||||
const { canvasRect, offsetX, offsetY } = dragState.current
|
||||
return {
|
||||
x: Math.max(0, Math.min(94, ((event.clientX - canvasRect.left - offsetX) / canvasRect.width) * 100)),
|
||||
y: Math.max(0, Math.min(92, ((event.clientY - canvasRect.top - offsetY) / canvasRect.height) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
if (!dragState.current) return
|
||||
setLocalPos(calcPos(event))
|
||||
}
|
||||
|
||||
function handlePointerUp(event) {
|
||||
if (!dragState.current) return
|
||||
const pos = calcPos(event)
|
||||
const widthPct = dragState.current.widthPct
|
||||
dragState.current = null
|
||||
setLocalPos(null)
|
||||
setLocalWidth(null)
|
||||
onMove?.(elementId, Math.round(pos.x * 10) / 10, Math.round(pos.y * 10) / 10, widthPct)
|
||||
}
|
||||
|
||||
const isDragging = localPos !== null
|
||||
const absPos = localPos ?? freePos
|
||||
|
||||
// Width to use when the element is absolutely positioned.
|
||||
const resolvedWidth = localWidth ?? savedWidth
|
||||
|
||||
const posStyle = absPos
|
||||
? { position: 'absolute', left: `${absPos.x}%`, top: `${absPos.y}%`, zIndex: isDragging ? 50 : 10, width: resolvedWidth != null ? `${resolvedWidth}%` : undefined }
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ ...style, ...posStyle, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
|
||||
className={`${className} rounded ${isDragging ? 'ring-2 ring-sky-400/70 opacity-90' : 'ring-1 ring-transparent hover:ring-white/30'} transition-[box-shadow]`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function overlayStyle(style) {
|
||||
if (style === 'dark-strong') return 'linear-gradient(180deg, rgba(2,6,23,0.38), rgba(2,6,23,0.68))'
|
||||
if (style === 'light-soft') return 'linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.22))'
|
||||
@@ -30,9 +106,9 @@ function positionStyle(position) {
|
||||
}
|
||||
|
||||
function alignmentClass(alignment) {
|
||||
if (alignment === 'left') return 'items-start text-left'
|
||||
if (alignment === 'right') return 'items-end text-right'
|
||||
return 'items-center text-center'
|
||||
if (alignment === 'left') return 'justify-start items-start text-left'
|
||||
if (alignment === 'right') return 'justify-end items-end text-right'
|
||||
return 'justify-center items-center text-center'
|
||||
}
|
||||
|
||||
function focalPositionStyle(position) {
|
||||
@@ -76,32 +152,55 @@ function blockClass(type) {
|
||||
return 'font-semibold tracking-[-0.03em] sm:text-[1.65rem] lg:text-[2.1rem]'
|
||||
}
|
||||
|
||||
function blockStyle(type, typography, textColor, accentColor) {
|
||||
const quoteSize = Math.max(26, Math.min(typography.quote_size || 72, 120))
|
||||
function blockStyle(type, typography, textColor, accentColor, fontFamily) {
|
||||
const quoteSize = Math.max(10, Math.min(typography.quote_size || 72, 120))
|
||||
const authorSize = Math.max(14, Math.min(typography.author_size || 28, 42))
|
||||
const letterSpacing = Math.max(-1, Math.min(typography.letter_spacing || 0, 10))
|
||||
const lineHeight = Math.max(0.9, Math.min(typography.line_height || 1.2, 1.8))
|
||||
const shadowPreset = typography.shadow_preset || 'soft'
|
||||
// text_opacity: 10–100 (stored as integer percent), default 100
|
||||
const opacity = typography.text_opacity != null ? Math.max(10, Math.min(100, Number(typography.text_opacity))) / 100 : 1
|
||||
|
||||
// Convert canvas pixels (1080-normalised) to container-query width units so
|
||||
// the preview proportions always match the rendered image regardless of how
|
||||
// wide the preview panel is.
|
||||
const toCqw = (px) => `${(px / 1080 * 100).toFixed(2)}cqw`
|
||||
|
||||
const sizeMap = {
|
||||
title: toCqw(Math.max(16, quoteSize * 0.48)),
|
||||
quote: toCqw(quoteSize),
|
||||
author: toCqw(authorSize),
|
||||
source: toCqw(Math.max(12, authorSize * 0.82)),
|
||||
body: toCqw(Math.max(16, quoteSize * 0.54)),
|
||||
caption: toCqw(Math.max(12, authorSize * 0.74)),
|
||||
}
|
||||
|
||||
const fontSize = sizeMap[type] ?? sizeMap.quote
|
||||
|
||||
const font = fontFamily ? { fontFamily } : {}
|
||||
|
||||
if (type === 'title') {
|
||||
return { color: accentColor, letterSpacing: `${Math.max(letterSpacing, 0) / 10}em`, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: accentColor, fontSize, letterSpacing: `${Math.max(letterSpacing, 0) / 10}em`, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
if (type === 'author' || type === 'source') {
|
||||
return { color: accentColor, fontSize: `${authorSize / 4}px`, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: accentColor, fontSize, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
if (type === 'body' || type === 'caption') {
|
||||
return { color: textColor, lineHeight, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: textColor, fontSize, lineHeight, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
return { color: textColor, fontSize: `${quoteSize / 4}px`, lineHeight, letterSpacing: `${letterSpacing / 12}px`, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: textColor, fontSize, lineHeight, letterSpacing: `${letterSpacing / 12}px`, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
export default function NovaCardCanvasPreview({ card, fonts = [], className = '', editable = false, onElementMove = null, renderMode = false }) {
|
||||
const canvasRef = React.useRef(null)
|
||||
const project = card?.project_json || {}
|
||||
const layout = project.layout || {}
|
||||
const typography = project.typography || {}
|
||||
const resolvedFont = fonts.find((f) => f.key === (typography.font_preset || 'modern-sans'))
|
||||
const fontFamily = resolvedFont?.family || null
|
||||
const background = project.background || {}
|
||||
const backgroundImage = card?.background_image?.processed_url
|
||||
const colors = Array.isArray(background.gradient_colors) && background.gradient_colors.length >= 2
|
||||
@@ -113,39 +212,30 @@ export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
? background.solid_color || '#111827'
|
||||
: `linear-gradient(180deg, ${colors[0]}, ${colors[1]})`
|
||||
|
||||
const textBlocks = resolveTextBlocks(card, project)
|
||||
const allTextBlocks = resolveTextBlocks(card, project)
|
||||
// Blocks with a saved free position are rendered absolutely; others stay in normal flow.
|
||||
const flowTextBlocks = allTextBlocks.filter((b) => b.pos_x == null || b.pos_y == null)
|
||||
const freeTextBlocks = allTextBlocks.filter((b) => b.pos_x != null && b.pos_y != null)
|
||||
|
||||
const decorations = Array.isArray(project.decorations) ? project.decorations : []
|
||||
const assetItems = Array.isArray(project.assets?.items) ? project.assets.items : []
|
||||
const textColor = typography.text_color || '#ffffff'
|
||||
const accentColor = typography.accent_color || textColor
|
||||
const maxWidth = layout.max_width === 'compact' ? '62%' : layout.max_width === 'wide' ? '88%' : '76%'
|
||||
const layoutMaxWidth = layout.max_width === 'compact' ? '62%' : layout.max_width === 'wide' ? '88%' : '76%'
|
||||
const maxWidth = typography.quote_width != null ? `${typography.quote_width}%` : layoutMaxWidth
|
||||
const padding = layout.padding === 'tight' ? '8%' : layout.padding === 'airy' ? '14%' : '11%'
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-[28px] border border-white/10 bg-slate-950 shadow-[0_30px_80px_rgba(2,6,23,0.35)] ${className}`} style={{ aspectRatio: aspectRatios[card?.format || 'square'] || aspectRatios.square }}>
|
||||
<div ref={canvasRef} data-card-canvas className={`relative overflow-hidden bg-slate-950 [container-type:inline-size] ${renderMode ? '' : 'rounded-[28px] border border-white/10 shadow-[0_30px_80px_rgba(2,6,23,0.35)]'} ${className}`} style={{ aspectRatio: aspectRatios[card?.format || 'square'] || aspectRatios.square }}>
|
||||
<div className="absolute inset-0" style={{ background: backgroundStyle, filter: background.type === 'upload' && Number(background.blur_level || 0) > 0 ? `blur(${Math.max(Number(background.blur_level) / 8, 0)}px)` : undefined }} />
|
||||
<div className="absolute inset-0" style={{ background: overlayStyle(background.overlay_style), opacity: Math.max(0, Math.min(Number(background.opacity || 50), 100)) / 100 }} />
|
||||
<div className="absolute left-4 top-4 rounded-full border border-white/10 bg-black/25 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80 backdrop-blur">
|
||||
{(card?.format || 'square').replace('-', ' ')}
|
||||
</div>
|
||||
|
||||
{decorations.slice(0, 6).map((decoration, index) => {
|
||||
const placement = placementStyles[decoration.placement] || placementStyles['top-right']
|
||||
return (
|
||||
<div
|
||||
key={`${decoration.key || decoration.glyph || 'dec'}-${index}`}
|
||||
className="absolute text-white/85 drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
style={{
|
||||
...placement,
|
||||
color: accentColor,
|
||||
fontSize: `${Math.max(18, Math.min(decoration.size || 28, 64))}px`,
|
||||
}}
|
||||
>
|
||||
{decoration.glyph || '✦'}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!renderMode && (
|
||||
<div className="absolute left-4 top-4 rounded-full border border-white/10 bg-black/25 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80 backdrop-blur">
|
||||
{(card?.format || 'square').replace('-', ' ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset items (non-draggable) */}
|
||||
{assetItems.slice(0, 6).map((item, index) => {
|
||||
if (item?.type === 'frame') {
|
||||
const top = index % 2 === 0 ? '10%' : '88%'
|
||||
@@ -160,7 +250,7 @@ export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
top: `${12 + ((index % 3) * 18)}%`,
|
||||
left: `${10 + (Math.floor(index / 3) * 72)}%`,
|
||||
color: accentColor,
|
||||
fontSize: `${Math.max(18, Math.min(item.size || 26, 56))}px`,
|
||||
fontSize: `${(Math.max(18, Math.min(item.size || 26, 56)) / 1080 * 100).toFixed(2)}cqw`,
|
||||
}}
|
||||
>
|
||||
{item.glyph || item.label || '✦'}
|
||||
@@ -168,20 +258,122 @@ export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Flow-positioned text blocks */}
|
||||
<div className={`relative flex h-full w-full ${alignmentClass(layout.alignment)}`} style={{ padding, ...positionStyle(layout.position) }}>
|
||||
<div className="flex w-full flex-col gap-4" style={{ maxWidth }}>
|
||||
{textBlocks.map((block, index) => {
|
||||
{flowTextBlocks.map((block, index) => {
|
||||
const type = block?.type || 'body'
|
||||
const text = type === 'author' ? `— ${block.text}` : block.text
|
||||
const defStyle = blockStyle(type, typography, textColor, accentColor, fontFamily)
|
||||
const blockCls = blockClass(type)
|
||||
const blockKey = `${block.key || type}-${index}`
|
||||
|
||||
if (editable) {
|
||||
return (
|
||||
<DraggableElement
|
||||
key={blockKey}
|
||||
elementId={`block:${block.key || type}`}
|
||||
canvasRef={canvasRef}
|
||||
freePos={null}
|
||||
onMove={onElementMove}
|
||||
style={defStyle}
|
||||
className={blockCls}
|
||||
>
|
||||
{text}
|
||||
</DraggableElement>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${block.key || type}-${index}`} style={blockStyle(type, typography, textColor, accentColor)} className={blockClass(type)}>
|
||||
<div key={blockKey} style={defStyle} className={blockCls}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Absolutely-positioned text blocks (dragged out of flow) */}
|
||||
{freeTextBlocks.map((block, index) => {
|
||||
const type = block?.type || 'body'
|
||||
const text = type === 'author' ? `— ${block.text}` : block.text
|
||||
const defStyle = blockStyle(type, typography, textColor, accentColor, fontFamily)
|
||||
const blockCls = blockClass(type)
|
||||
const blockKey = `free-${block.key || type}-${index}`
|
||||
|
||||
if (editable) {
|
||||
return (
|
||||
<DraggableElement
|
||||
key={blockKey}
|
||||
elementId={`block:${block.key || type}`}
|
||||
canvasRef={canvasRef}
|
||||
freePos={{ x: block.pos_x, y: block.pos_y }}
|
||||
savedWidth={block.pos_width ?? null}
|
||||
onMove={onElementMove}
|
||||
style={defStyle}
|
||||
className={blockCls}
|
||||
>
|
||||
{text}
|
||||
</DraggableElement>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={blockKey}
|
||||
style={{ ...defStyle, position: 'absolute', left: `${block.pos_x}%`, top: `${block.pos_y}%`, ...(block.pos_width != null ? { width: `${block.pos_width}%` } : {}) }}
|
||||
className={blockCls}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Decorations — rendered last so they sit on top of all text content */}
|
||||
{decorations.slice(0, 6).map((decoration, index) => {
|
||||
const hasFreePos = decoration.pos_x != null && decoration.pos_y != null
|
||||
const placementPos = placementStyles[decoration.placement] || placementStyles['top-right']
|
||||
const decOpacity = decoration.opacity != null ? Math.max(10, Math.min(100, Number(decoration.opacity))) / 100 : 0.85
|
||||
const decStyle = {
|
||||
color: accentColor,
|
||||
fontSize: `${(Math.max(18, Math.min(decoration.size || 28, 64)) / 1080 * 100).toFixed(2)}cqw`,
|
||||
opacity: decOpacity,
|
||||
zIndex: 20,
|
||||
}
|
||||
const decKey = `${decoration.key || decoration.glyph || 'dec'}-${index}`
|
||||
const decContent = decoration.glyph || '✦'
|
||||
|
||||
if (editable) {
|
||||
const freePos = hasFreePos ? { x: decoration.pos_x, y: decoration.pos_y } : null
|
||||
const absStyle = hasFreePos
|
||||
? { position: 'absolute', left: `${decoration.pos_x}%`, top: `${decoration.pos_y}%` }
|
||||
: { position: 'absolute', ...placementPos }
|
||||
return (
|
||||
<DraggableElement
|
||||
key={decKey}
|
||||
elementId={`decoration:${index}`}
|
||||
canvasRef={canvasRef}
|
||||
freePos={freePos}
|
||||
savedWidth={null}
|
||||
onMove={onElementMove}
|
||||
style={{ ...absStyle, ...decStyle }}
|
||||
className="drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
>
|
||||
{decContent}
|
||||
</DraggableElement>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={decKey}
|
||||
className="absolute drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
style={{ ...placementPos, ...decStyle }}
|
||||
>
|
||||
{decContent}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
|
||||
export default function NovaCardFontPicker({ fonts = [], selectedKey = null, onSelect }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3">
|
||||
{fonts.map((font) => {
|
||||
const active = selectedKey === font.key
|
||||
return (
|
||||
@@ -10,10 +10,20 @@ export default function NovaCardFontPicker({ fonts = [], selectedKey = null, onS
|
||||
key={font.key}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(font)}
|
||||
className={`rounded-[22px] border p-4 text-left transition ${active ? 'border-sky-300/35 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.05]'}`}
|
||||
className={`rounded-[22px] border p-4 text-left transition ${active ? 'border-sky-300/35 bg-sky-400/12 ring-1 ring-sky-400/20 text-white' : 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.05]'}`}
|
||||
>
|
||||
<div className="text-lg font-semibold tracking-[-0.03em]" style={{ fontFamily: font.family }}>{font.label}</div>
|
||||
<div className="mt-2 text-sm text-slate-400">{font.recommended_use}</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500 mb-2">{font.label}</div>
|
||||
<div className="text-[1.65rem] font-semibold leading-tight text-white" style={{ fontFamily: font.family, fontWeight: font.weight || 600 }}>
|
||||
The quick brown fox
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-400 leading-relaxed" style={{ fontFamily: font.family }}>
|
||||
{font.recommended_use}
|
||||
</div>
|
||||
{active && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 rounded-full border border-sky-400/25 bg-sky-500/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-sky-300">
|
||||
Active
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
66
resources/js/components/seo/SeoHead.jsx
Normal file
66
resources/js/components/seo/SeoHead.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import { Head } from '@inertiajs/react'
|
||||
|
||||
function normalizeJsonLd(input) {
|
||||
if (!input) return []
|
||||
return (Array.isArray(input) ? input : [input]).filter((schema) => schema && typeof schema === 'object')
|
||||
}
|
||||
|
||||
export default function SeoHead({ seo = {}, title = null, description = null, jsonLd = null }) {
|
||||
const metaTitle = seo?.title || title || 'Skinbase'
|
||||
const metaDescription = seo?.description || description || ''
|
||||
const canonical = seo?.canonical || null
|
||||
const robots = seo?.robots || null
|
||||
const prev = seo?.prev || null
|
||||
const next = seo?.next || null
|
||||
const ogTitle = seo?.og_title || metaTitle
|
||||
const ogDescription = seo?.og_description || metaDescription
|
||||
const ogUrl = seo?.og_url || canonical
|
||||
const ogType = seo?.og_type || 'website'
|
||||
const ogImage = seo?.og_image || null
|
||||
const keywords = seo?.keywords || null
|
||||
const twitterCard = seo?.twitter_card || (ogImage ? 'summary_large_image' : 'summary')
|
||||
const twitterTitle = seo?.twitter_title || ogTitle
|
||||
const twitterDescription = seo?.twitter_description || ogDescription
|
||||
const twitterImage = seo?.twitter_image || ogImage || null
|
||||
const schemas = [...normalizeJsonLd(seo?.json_ld), ...normalizeJsonLd(jsonLd)]
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title>{metaTitle}</title>
|
||||
{metaDescription ? <meta head-key="description" name="description" content={metaDescription} /> : null}
|
||||
{keywords ? <meta head-key="keywords" name="keywords" content={keywords} /> : null}
|
||||
{robots ? <meta head-key="robots" name="robots" content={robots} /> : null}
|
||||
{canonical ? <link head-key="canonical" rel="canonical" href={canonical} /> : null}
|
||||
{prev ? <link head-key="prev" rel="prev" href={prev} /> : null}
|
||||
{next ? <link head-key="next" rel="next" href={next} /> : null}
|
||||
|
||||
<meta head-key="og:site_name" property="og:site_name" content={seo?.og_site_name || 'Skinbase'} />
|
||||
<meta head-key="og:type" property="og:type" content={ogType} />
|
||||
<meta head-key="og:title" property="og:title" content={ogTitle} />
|
||||
{ogDescription ? <meta head-key="og:description" property="og:description" content={ogDescription} /> : null}
|
||||
{ogUrl ? <meta head-key="og:url" property="og:url" content={ogUrl} /> : null}
|
||||
{ogImage ? <meta head-key="og:image" property="og:image" content={ogImage} /> : null}
|
||||
{seo?.og_image_alt ? <meta head-key="og:image:alt" property="og:image:alt" content={seo.og_image_alt} /> : null}
|
||||
|
||||
<meta head-key="twitter:card" name="twitter:card" content={twitterCard} />
|
||||
<meta head-key="twitter:title" name="twitter:title" content={twitterTitle} />
|
||||
{twitterDescription ? <meta head-key="twitter:description" name="twitter:description" content={twitterDescription} /> : null}
|
||||
{twitterImage ? <meta head-key="twitter:image" name="twitter:image" content={twitterImage} /> : null}
|
||||
|
||||
{schemas.map((schema, index) => {
|
||||
const schemaType = typeof schema?.['@type'] === 'string' ? schema['@type'] : 'schema'
|
||||
|
||||
return (
|
||||
<script
|
||||
key={`jsonld-${schemaType}-${index}`}
|
||||
head-key={`jsonld-${schemaType}-${index}`}
|
||||
type="application/ld+json"
|
||||
>
|
||||
{JSON.stringify(schema)}
|
||||
</script>
|
||||
)
|
||||
})}
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
146
resources/js/components/upload/ArchiveScreenshotPicker.jsx
Normal file
146
resources/js/components/upload/ArchiveScreenshotPicker.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
function getScreenshotName(item, fallbackIndex) {
|
||||
if (item && typeof item === 'object' && typeof item.name === 'string' && item.name.trim()) {
|
||||
return item.name.trim()
|
||||
}
|
||||
|
||||
return `Screenshot ${fallbackIndex + 1}`
|
||||
}
|
||||
|
||||
function resolveScreenshotSource(item) {
|
||||
if (!item) return { src: null, revoke: null }
|
||||
|
||||
if (typeof item === 'string') {
|
||||
return { src: item, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof item === 'object') {
|
||||
if (typeof item.preview === 'string' && item.preview) {
|
||||
return { src: item.preview, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof item.src === 'string' && item.src) {
|
||||
return { src: item.src, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof item.url === 'string' && item.url) {
|
||||
return { src: item.url, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof File !== 'undefined' && item instanceof File) {
|
||||
const objectUrl = URL.createObjectURL(item)
|
||||
return {
|
||||
src: objectUrl,
|
||||
revoke: () => URL.revokeObjectURL(objectUrl),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { src: null, revoke: null }
|
||||
}
|
||||
|
||||
export default function ArchiveScreenshotPicker({
|
||||
screenshots = [],
|
||||
selectedIndex = 0,
|
||||
onSelect,
|
||||
compact = false,
|
||||
title = 'Screenshots',
|
||||
description = 'Choose which screenshot should be used as the default preview.',
|
||||
}) {
|
||||
const [resolvedScreenshots, setResolvedScreenshots] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = []
|
||||
const next = (Array.isArray(screenshots) ? screenshots : []).map((item, index) => {
|
||||
const { src, revoke } = resolveScreenshotSource(item)
|
||||
if (revoke) cleanup.push(revoke)
|
||||
|
||||
return {
|
||||
src,
|
||||
alt: getScreenshotName(item, index),
|
||||
}
|
||||
}).filter((item) => Boolean(item.src))
|
||||
|
||||
setResolvedScreenshots(next)
|
||||
|
||||
return () => {
|
||||
cleanup.forEach((revoke) => revoke())
|
||||
}
|
||||
}, [screenshots])
|
||||
|
||||
const normalizedIndex = useMemo(() => {
|
||||
if (resolvedScreenshots.length === 0) return 0
|
||||
if (!Number.isFinite(selectedIndex)) return 0
|
||||
return Math.min(Math.max(0, Math.floor(selectedIndex)), resolvedScreenshots.length - 1)
|
||||
}, [resolvedScreenshots.length, selectedIndex])
|
||||
|
||||
const selectedScreenshot = resolvedScreenshots[normalizedIndex] ?? null
|
||||
|
||||
if (!selectedScreenshot) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-3' : 'space-y-4'}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">{title}</p>
|
||||
<p className="mt-1 text-xs text-white/55">{description}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-emerald-300/30 bg-emerald-400/10 px-2.5 py-1 text-[11px] text-emerald-100">
|
||||
Default preview
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={compact ? 'overflow-hidden rounded-2xl border border-white/10 bg-black/25' : 'overflow-hidden rounded-3xl border border-white/10 bg-black/25'}>
|
||||
<img
|
||||
src={selectedScreenshot.src}
|
||||
alt={selectedScreenshot.alt}
|
||||
className={compact ? 'h-40 w-full object-cover' : 'h-56 w-full object-cover sm:h-72'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={compact ? 'grid grid-cols-4 gap-2' : 'grid grid-cols-2 gap-3 sm:grid-cols-4'}>
|
||||
{resolvedScreenshots.map((item, index) => {
|
||||
const isSelected = index === normalizedIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${item.src}-${index}`}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(index)}
|
||||
aria-label={`Use ${item.alt} as default screenshot`}
|
||||
className={[
|
||||
'group relative overflow-hidden rounded-2xl border text-left transition',
|
||||
isSelected
|
||||
? 'border-emerald-300/45 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.22)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
|
||||
].join(' ')}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<img
|
||||
src={item.src}
|
||||
alt={item.alt}
|
||||
className={compact ? 'h-16 w-full object-cover' : 'h-20 w-full object-cover'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
|
||||
<span className="truncate text-[11px] text-white/70">{item.alt}</span>
|
||||
<span className={[
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-[10px]',
|
||||
isSelected ? 'bg-emerald-300/20 text-emerald-100' : 'bg-white/10 text-white/45',
|
||||
].join(' ')}>
|
||||
{isSelected ? 'Default' : 'Use'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import ArchiveScreenshotPicker from './ArchiveScreenshotPicker'
|
||||
import ReadinessChecklist from './ReadinessChecklist'
|
||||
import SchedulePublishPicker from './SchedulePublishPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
@@ -36,6 +37,8 @@ export default function PublishPanel({
|
||||
primaryPreviewUrl = null,
|
||||
isArchive = false,
|
||||
screenshots = [],
|
||||
selectedScreenshotIndex = 0,
|
||||
onSelectedScreenshotChange,
|
||||
// Metadata
|
||||
metadata = {},
|
||||
// Readiness
|
||||
@@ -64,8 +67,6 @@ export default function PublishPanel({
|
||||
}) {
|
||||
const pill = STATUS_PILL[machineState] ?? null
|
||||
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
|
||||
const hasAnyPreview = hasPreview || (isArchive && screenshots.length > 0)
|
||||
const previewSrc = hasPreview ? primaryPreviewUrl : (screenshots[0]?.preview ?? screenshots[0] ?? null)
|
||||
|
||||
const title = String(metadata.title || '').trim()
|
||||
const hasTitle = Boolean(title)
|
||||
@@ -126,9 +127,9 @@ export default function PublishPanel({
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0 h-[72px] w-[72px] overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 flex items-center justify-center">
|
||||
{previewSrc ? (
|
||||
{hasPreview ? (
|
||||
<img
|
||||
src={previewSrc}
|
||||
src={primaryPreviewUrl}
|
||||
alt="Artwork preview"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
@@ -162,6 +163,19 @@ export default function PublishPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.03] p-3">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
compact
|
||||
title="Preview screenshot"
|
||||
description="Choose which screenshot should represent this archive in the publish panel."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-white/8" />
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function UploadWizard({
|
||||
// ── File + screenshot state ───────────────────────────────────────────────
|
||||
const [primaryFile, setPrimaryFile] = useState(null)
|
||||
const [screenshots, setScreenshots] = useState([])
|
||||
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0)
|
||||
|
||||
// ── Metadata state ────────────────────────────────────────────────────────
|
||||
const [metadata, setMetadata] = useState(initialMetadata)
|
||||
@@ -112,6 +113,18 @@ export default function UploadWizard({
|
||||
|
||||
const canStartUpload = isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(screenshots) || screenshots.length === 0) {
|
||||
setSelectedScreenshotIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedScreenshotIndex((prev) => {
|
||||
if (!Number.isFinite(prev) || prev < 0) return 0
|
||||
return Math.min(prev, screenshots.length - 1)
|
||||
})
|
||||
}, [screenshots])
|
||||
|
||||
// ── Machine hook ──────────────────────────────────────────────────────────
|
||||
const {
|
||||
machine,
|
||||
@@ -124,6 +137,8 @@ export default function UploadWizard({
|
||||
clearPolling,
|
||||
} = useUploadMachine({
|
||||
primaryFile,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
canStartUpload,
|
||||
primaryType,
|
||||
isArchive,
|
||||
@@ -322,6 +337,7 @@ export default function UploadWizard({
|
||||
resetMachine()
|
||||
setPrimaryFile(null)
|
||||
setScreenshots([])
|
||||
setSelectedScreenshotIndex(0)
|
||||
setMetadata(initialMetadata)
|
||||
setIsUploadLocked(false)
|
||||
hasAutoAdvancedRef.current = false
|
||||
@@ -408,9 +424,11 @@ export default function UploadWizard({
|
||||
onPrimaryFileChange={setPrimaryFile}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
screenshotErrors={screenshotErrors}
|
||||
screenshotPerFileErrors={screenshotPerFileErrors}
|
||||
onScreenshotsChange={setScreenshots}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
machine={machine}
|
||||
/>
|
||||
)
|
||||
@@ -425,6 +443,8 @@ export default function UploadWizard({
|
||||
isArchive={isArchive}
|
||||
fileMetadata={fileMetadata}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
contentTypes={contentTypes}
|
||||
metadata={metadata}
|
||||
metadataErrors={metadataErrors}
|
||||
@@ -456,6 +476,8 @@ export default function UploadWizard({
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
fileMetadata={fileMetadata}
|
||||
metadata={metadata}
|
||||
canPublish={canPublish}
|
||||
@@ -607,6 +629,8 @@ export default function UploadWizard({
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
metadata={metadata}
|
||||
machineState={machine.state}
|
||||
uploadReady={uploadReady}
|
||||
@@ -689,6 +713,8 @@ export default function UploadWizard({
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
metadata={metadata}
|
||||
machineState={machine.state}
|
||||
uploadReady={uploadReady}
|
||||
|
||||
@@ -68,6 +68,15 @@ function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (url === '/api/tags/popular' || String(url).startsWith('/api/tags/search')) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unhandled GET ${url}`))
|
||||
}),
|
||||
}
|
||||
@@ -112,6 +121,20 @@ async function completeStep1ToReady() {
|
||||
})
|
||||
}
|
||||
|
||||
async function completeRequiredDetails({ title = 'My Art', mature = false } = {}) {
|
||||
await act(async () => {
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), title)
|
||||
await userEvent.click(screen.getByRole('button', { name: /art .* open/i }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: /root .* choose/i }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: /sub .* choose/i }))
|
||||
await userEvent.type(screen.getByLabelText(/search or add tags/i), 'fantasy{enter}')
|
||||
if (mature) {
|
||||
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
|
||||
}
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
})
|
||||
}
|
||||
|
||||
describe('UploadWizard step flow', () => {
|
||||
let originalImage
|
||||
let originalScrollTo
|
||||
@@ -216,6 +239,43 @@ describe('UploadWizard step flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the selected archive screenshot as the preview upload source', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 312 })
|
||||
|
||||
await uploadPrimary(new File(['zip'], 'bundle.zip', { type: 'application/zip' }))
|
||||
await uploadScreenshot(new File(['shot-1'], 'shot-1.png', { type: 'image/png' }))
|
||||
await uploadScreenshot(new File(['shot-2'], 'shot-2.png', { type: 'image/png' }))
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /use shot-2\.png as default screenshot/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Default').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.post).toHaveBeenCalledWith(
|
||||
'/api/uploads/finish',
|
||||
expect.objectContaining({
|
||||
file_name: 'shot-2.png',
|
||||
archive_file_name: 'bundle.zip',
|
||||
additional_screenshot_sessions: [
|
||||
expect.objectContaining({
|
||||
file_name: 'shot-1.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('allows navigation back to completed previous step', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 304 })
|
||||
@@ -227,7 +287,7 @@ describe('UploadWizard step flow', () => {
|
||||
await act(async () => {
|
||||
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
|
||||
})
|
||||
expect(await screen.findByText(/upload your artwork file/i)).not.toBeNull()
|
||||
expect(await screen.findByRole('heading', { level: 2, name: /upload your artwork/i })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('triggers scroll-to-top behavior on step change', async () => {
|
||||
@@ -253,11 +313,9 @@ describe('UploadWizard step flow', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
|
||||
|
||||
await completeRequiredDetails({ title: 'My Art' })
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'My Art')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
|
||||
})
|
||||
|
||||
@@ -284,14 +342,10 @@ describe('UploadWizard step flow', () => {
|
||||
await completeStep1ToReady()
|
||||
|
||||
await screen.findByText(/artwork details/i)
|
||||
const titleInput = screen.getByPlaceholderText(/give your artwork a clear title/i)
|
||||
|
||||
await completeRequiredDetails({ title: 'Mature Piece', mature: true })
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(titleInput, 'Mature Piece')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
|
||||
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
|
||||
})
|
||||
|
||||
@@ -361,7 +415,7 @@ describe('UploadWizard step flow', () => {
|
||||
const dropzoneButton = screen.getByTestId('upload-dropzone')
|
||||
expect(dropzoneButton.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
expect(screen.getByText(/file is locked after upload\. reset to change\./i)).not.toBeNull()
|
||||
expect(screen.getByText(/file is locked after upload starts\. reset to change the file\./i)).not.toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /reset upload/i }))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
import UploadDropzone from '../UploadDropzone'
|
||||
import ScreenshotUploader from '../ScreenshotUploader'
|
||||
|
||||
@@ -22,9 +23,11 @@ export default function Step1FileUpload({
|
||||
// Archive screenshots
|
||||
isArchive,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
screenshotErrors,
|
||||
screenshotPerFileErrors,
|
||||
onScreenshotsChange,
|
||||
onSelectedScreenshotChange,
|
||||
// Machine state (passed for potential future use)
|
||||
machine,
|
||||
}) {
|
||||
@@ -95,6 +98,19 @@ export default function Step1FileUpload({
|
||||
onFilesChange={onScreenshotsChange}
|
||||
/>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 sm:p-5">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
compact
|
||||
title="Choose default screenshot"
|
||||
description="Pick the screenshot that should be uploaded as the archive preview before you start the upload."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Subtle what-happens-next hints (shown only before a file is picked) */}
|
||||
{!fileSelected && (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
import UploadSidebar from '../UploadSidebar'
|
||||
import { getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils'
|
||||
|
||||
@@ -17,6 +18,8 @@ export default function Step2Details({
|
||||
isArchive,
|
||||
fileMetadata,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
onSelectedScreenshotChange,
|
||||
// Content type + category
|
||||
contentTypes,
|
||||
metadata,
|
||||
@@ -167,6 +170,18 @@ export default function Step2Details({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/8 pt-5">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
title="Archive screenshots"
|
||||
description="All selected screenshots are shown here. Pick the one that should become the main preview thumbnail."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Combined: Content type → Category → Subcategory ─────────────────── */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { motion, useReducedMotion } from 'framer-motion'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
|
||||
function stripHtml(value) {
|
||||
return String(value || '')
|
||||
@@ -45,6 +46,8 @@ export default function Step3Publish({
|
||||
primaryPreviewUrl,
|
||||
isArchive,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
onSelectedScreenshotChange,
|
||||
fileMetadata,
|
||||
// Metadata
|
||||
metadata,
|
||||
@@ -161,6 +164,18 @@ export default function Step3Publish({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/8 pt-5">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
title="Archive preview"
|
||||
description="This screenshot will be used as the default preview once the archive is published."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Visibility selector ────────────────────────────────────────── */}
|
||||
|
||||
@@ -50,6 +50,9 @@ function mountAll() {
|
||||
});
|
||||
}
|
||||
|
||||
// Expose for late/async hydration (e.g. AJAX-loaded galleries)
|
||||
window.__hydrateMasonryGalleries = mountAll;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mountAll);
|
||||
} else {
|
||||
|
||||
@@ -100,11 +100,14 @@ export function isReadyToPublishStatus(status) {
|
||||
* @param {boolean} opts.isArchive
|
||||
* @param {number|null} opts.initialDraftId
|
||||
* @param {object} opts.metadata { title, description, tags, rightsAccepted, ... }
|
||||
* @param {number} [opts.selectedScreenshotIndex]
|
||||
* @param {number} [opts.chunkSize]
|
||||
* @param {function} [opts.onArtworkCreated] called with artworkId after draft creation
|
||||
*/
|
||||
export default function useUploadMachine({
|
||||
primaryFile,
|
||||
screenshots = [],
|
||||
selectedScreenshotIndex = 0,
|
||||
canStartUpload,
|
||||
primaryType,
|
||||
isArchive,
|
||||
@@ -119,6 +122,8 @@ export default function useUploadMachine({
|
||||
const pollingTimerRef = useRef(null)
|
||||
const requestControllersRef = useRef(new Set())
|
||||
const publishLockRef = useRef(false)
|
||||
const archiveSessionRef = useRef({ sessionId: null, uploadToken: null })
|
||||
const additionalScreenshotSessionsRef = useRef([])
|
||||
|
||||
// Resolved artwork id (draft) created at the start of the upload
|
||||
const resolvedArtworkIdRef = useRef(
|
||||
@@ -150,6 +155,22 @@ export default function useUploadMachine({
|
||||
requestControllersRef.current.clear()
|
||||
}, [])
|
||||
|
||||
const clearArchiveSession = useCallback(() => {
|
||||
archiveSessionRef.current = { sessionId: null, uploadToken: null }
|
||||
}, [])
|
||||
|
||||
const setArchiveSession = useCallback((sessionId, uploadToken) => {
|
||||
archiveSessionRef.current = { sessionId, uploadToken }
|
||||
}, [])
|
||||
|
||||
const clearAdditionalScreenshotSessions = useCallback(() => {
|
||||
additionalScreenshotSessionsRef.current = []
|
||||
}, [])
|
||||
|
||||
const setAdditionalScreenshotSessions = useCallback((sessions) => {
|
||||
additionalScreenshotSessionsRef.current = Array.isArray(sessions) ? sessions : []
|
||||
}, [])
|
||||
|
||||
// ── Polling ────────────────────────────────────────────────────────────────
|
||||
const clearPolling = useCallback(() => {
|
||||
if (pollingTimerRef.current) {
|
||||
@@ -204,11 +225,98 @@ export default function useUploadMachine({
|
||||
}, POLL_INTERVAL_MS)
|
||||
}, [clearPolling, pollProcessing])
|
||||
|
||||
const initUploadSession = useCallback(async () => {
|
||||
const initController = registerController()
|
||||
try {
|
||||
const initResponse = await window.axios.post(
|
||||
uploadEndpoints.init(),
|
||||
{ client: 'web' },
|
||||
{ signal: initController.signal }
|
||||
)
|
||||
|
||||
const sessionId = initResponse?.data?.session_id
|
||||
const uploadToken = initResponse?.data?.upload_token
|
||||
if (!sessionId || !uploadToken) {
|
||||
throw new Error('Upload session initialization returned an invalid payload.')
|
||||
}
|
||||
|
||||
return { sessionId, uploadToken }
|
||||
} finally {
|
||||
unregisterController(initController)
|
||||
}
|
||||
}, [registerController, unregisterController])
|
||||
|
||||
const uploadSingleFile = useCallback(async (sessionId, uploadToken, file, uploadedBaseBytes, combinedTotalBytes) => {
|
||||
let uploadedForFile = 0
|
||||
const totalSize = file.size
|
||||
|
||||
while (uploadedForFile < totalSize) {
|
||||
const nextOffset = Math.min(uploadedForFile + effectiveChunkSize, totalSize)
|
||||
const blob = file.slice(uploadedForFile, nextOffset)
|
||||
|
||||
const payload = new FormData()
|
||||
payload.append('session_id', sessionId)
|
||||
payload.append('offset', String(uploadedForFile))
|
||||
payload.append('chunk_size', String(blob.size))
|
||||
payload.append('total_size', String(totalSize))
|
||||
payload.append('upload_token', uploadToken)
|
||||
payload.append('chunk', blob)
|
||||
|
||||
const chunkController = registerController()
|
||||
try {
|
||||
await window.axios.post(uploadEndpoints.chunk(), payload, {
|
||||
signal: chunkController.signal,
|
||||
headers: { 'X-Upload-Token': uploadToken },
|
||||
})
|
||||
} finally {
|
||||
unregisterController(chunkController)
|
||||
}
|
||||
|
||||
uploadedForFile = nextOffset
|
||||
const totalUploaded = uploadedBaseBytes + uploadedForFile
|
||||
const progress = Math.max(1, Math.min(95, toPercent(totalUploaded, combinedTotalBytes)))
|
||||
dispatchMachine({ type: 'UPLOAD_PROGRESS', progress })
|
||||
}
|
||||
|
||||
return uploadedBaseBytes + totalSize
|
||||
}, [effectiveChunkSize, registerController, unregisterController])
|
||||
|
||||
const cancelUploadSession = useCallback(async (sessionId, uploadToken) => {
|
||||
if (!sessionId) return
|
||||
|
||||
await window.axios.post(
|
||||
uploadEndpoints.cancel(),
|
||||
{ session_id: sessionId, upload_token: uploadToken || undefined },
|
||||
{ headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined }
|
||||
)
|
||||
}, [])
|
||||
|
||||
// ── Core upload flow ───────────────────────────────────────────────────────
|
||||
const runUploadFlow = useCallback(async () => {
|
||||
if (!primaryFile || !canStartUpload) return
|
||||
|
||||
const normalizedScreenshotIndex = Number.isFinite(selectedScreenshotIndex)
|
||||
? Math.max(0, Math.floor(selectedScreenshotIndex))
|
||||
: 0
|
||||
const previewFile = isArchive
|
||||
? (screenshots[normalizedScreenshotIndex] || screenshots[0] || null)
|
||||
: primaryFile
|
||||
const archiveFile = isArchive ? primaryFile : null
|
||||
const additionalScreenshotFiles = isArchive
|
||||
? screenshots.filter((file, index) => index !== normalizedScreenshotIndex && Boolean(file))
|
||||
: []
|
||||
if (!previewFile) {
|
||||
const message = isArchive
|
||||
? 'Archive uploads require at least one screenshot before upload can start.'
|
||||
: 'A preview image is required before upload can start.'
|
||||
dispatchMachine({ type: 'ERROR', error: message })
|
||||
onNotice?.({ type: 'error', message })
|
||||
return
|
||||
}
|
||||
|
||||
clearPolling()
|
||||
clearArchiveSession()
|
||||
clearAdditionalScreenshotSessions()
|
||||
dispatchMachine({ type: 'INIT_START' })
|
||||
emitUploadEvent('upload_start', {
|
||||
file_name: primaryFile.name,
|
||||
@@ -217,13 +325,20 @@ export default function useUploadMachine({
|
||||
is_archive: isArchive,
|
||||
})
|
||||
|
||||
let activePrimarySessionId = null
|
||||
let activePrimaryUploadToken = null
|
||||
let activeArchiveSessionId = null
|
||||
let activeArchiveUploadToken = null
|
||||
let activeAdditionalScreenshotSessions = []
|
||||
|
||||
try {
|
||||
// 1. Create or reuse the artwork draft
|
||||
let artworkIdForUpload = resolvedArtworkIdRef.current
|
||||
if (!artworkIdForUpload) {
|
||||
const titleSourceFile = archiveFile || primaryFile || previewFile
|
||||
const derivedTitle =
|
||||
String(metadata.title || '').trim() ||
|
||||
String(primaryFile.name || '').replace(/\.[^.]+$/, '') ||
|
||||
String(titleSourceFile?.name || '').replace(/\.[^.]+$/, '') ||
|
||||
'Untitled upload'
|
||||
|
||||
const draftResponse = await window.axios.post('/api/artworks', {
|
||||
@@ -246,50 +361,49 @@ export default function useUploadMachine({
|
||||
}
|
||||
|
||||
// 2. Init upload session
|
||||
const initController = registerController()
|
||||
const initResponse = await window.axios.post(
|
||||
uploadEndpoints.init(),
|
||||
{ client: 'web' },
|
||||
{ signal: initController.signal }
|
||||
)
|
||||
unregisterController(initController)
|
||||
|
||||
const sessionId = initResponse?.data?.session_id
|
||||
const uploadToken = initResponse?.data?.upload_token
|
||||
if (!sessionId || !uploadToken) {
|
||||
throw new Error('Upload session initialization returned an invalid payload.')
|
||||
}
|
||||
const { sessionId, uploadToken } = await initUploadSession()
|
||||
activePrimarySessionId = sessionId
|
||||
activePrimaryUploadToken = uploadToken
|
||||
|
||||
dispatchMachine({ type: 'INIT_SUCCESS', sessionId, uploadToken })
|
||||
dispatchMachine({ type: 'UPLOAD_START' })
|
||||
|
||||
// 3. Chunked upload
|
||||
let uploaded = 0
|
||||
const totalSize = primaryFile.size
|
||||
const combinedTotalBytes = previewFile.size + (archiveFile?.size || 0) + additionalScreenshotFiles.reduce((sum, file) => sum + (file?.size || 0), 0)
|
||||
let uploadedBytes = await uploadSingleFile(sessionId, uploadToken, previewFile, 0, combinedTotalBytes)
|
||||
|
||||
while (uploaded < totalSize) {
|
||||
const nextOffset = Math.min(uploaded + effectiveChunkSize, totalSize)
|
||||
const blob = primaryFile.slice(uploaded, nextOffset)
|
||||
let archiveSessionId = null
|
||||
let archiveUploadToken = null
|
||||
|
||||
const payload = new FormData()
|
||||
payload.append('session_id', sessionId)
|
||||
payload.append('offset', String(uploaded))
|
||||
payload.append('chunk_size', String(blob.size))
|
||||
payload.append('total_size', String(totalSize))
|
||||
payload.append('upload_token', uploadToken)
|
||||
payload.append('chunk', blob)
|
||||
if (archiveFile) {
|
||||
const archiveSession = await initUploadSession()
|
||||
archiveSessionId = archiveSession.sessionId
|
||||
archiveUploadToken = archiveSession.uploadToken
|
||||
activeArchiveSessionId = archiveSessionId
|
||||
activeArchiveUploadToken = archiveUploadToken
|
||||
setArchiveSession(archiveSessionId, archiveUploadToken)
|
||||
|
||||
const chunkController = registerController()
|
||||
const chunkResponse = await window.axios.post(uploadEndpoints.chunk(), payload, {
|
||||
signal: chunkController.signal,
|
||||
headers: { 'X-Upload-Token': uploadToken },
|
||||
uploadedBytes = await uploadSingleFile(archiveSessionId, archiveUploadToken, archiveFile, uploadedBytes, combinedTotalBytes)
|
||||
}
|
||||
|
||||
const additionalScreenshotSessions = []
|
||||
for (const screenshotFile of additionalScreenshotFiles) {
|
||||
const screenshotSession = await initUploadSession()
|
||||
additionalScreenshotSessions.push({
|
||||
sessionId: screenshotSession.sessionId,
|
||||
uploadToken: screenshotSession.uploadToken,
|
||||
fileName: String(screenshotFile?.name || ''),
|
||||
})
|
||||
unregisterController(chunkController)
|
||||
activeAdditionalScreenshotSessions = additionalScreenshotSessions
|
||||
setAdditionalScreenshotSessions(additionalScreenshotSessions)
|
||||
|
||||
const receivedBytes = Number(chunkResponse?.data?.received_bytes ?? nextOffset)
|
||||
uploaded = Math.max(nextOffset, Number.isFinite(receivedBytes) ? receivedBytes : nextOffset)
|
||||
const progress = chunkResponse?.data?.progress ?? toPercent(uploaded, totalSize)
|
||||
dispatchMachine({ type: 'UPLOAD_PROGRESS', progress })
|
||||
uploadedBytes = await uploadSingleFile(
|
||||
screenshotSession.sessionId,
|
||||
screenshotSession.uploadToken,
|
||||
screenshotFile,
|
||||
uploadedBytes,
|
||||
combinedTotalBytes,
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Finish + start processing
|
||||
@@ -302,7 +416,13 @@ export default function useUploadMachine({
|
||||
session_id: sessionId,
|
||||
upload_token: uploadToken,
|
||||
artwork_id: artworkIdForUpload,
|
||||
file_name: String(primaryFile?.name || ''),
|
||||
file_name: String(previewFile?.name || ''),
|
||||
archive_session_id: archiveSessionId,
|
||||
archive_file_name: archiveFile ? String(archiveFile?.name || '') : undefined,
|
||||
additional_screenshot_sessions: additionalScreenshotSessions.map((item) => ({
|
||||
session_id: item.sessionId,
|
||||
file_name: item.fileName,
|
||||
})),
|
||||
},
|
||||
{ signal: finishController.signal, headers: { 'X-Upload-Token': uploadToken } }
|
||||
)
|
||||
@@ -328,6 +448,13 @@ export default function useUploadMachine({
|
||||
emitUploadEvent('upload_complete', { session_id: sessionId, artwork_id: artworkIdForUpload })
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
|
||||
|
||||
await Promise.allSettled([
|
||||
cancelUploadSession(activePrimarySessionId, activePrimaryUploadToken),
|
||||
cancelUploadSession(activeArchiveSessionId, activeArchiveUploadToken),
|
||||
...activeAdditionalScreenshotSessions.map((item) => cancelUploadSession(item.sessionId, item.uploadToken)),
|
||||
])
|
||||
|
||||
const notice = mapUploadErrorNotice(error, 'Upload failed.')
|
||||
dispatchMachine({ type: 'ERROR', error: notice.message })
|
||||
onNotice?.(notice)
|
||||
@@ -335,17 +462,21 @@ export default function useUploadMachine({
|
||||
}
|
||||
}, [
|
||||
primaryFile,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
canStartUpload,
|
||||
primaryType,
|
||||
isArchive,
|
||||
metadata,
|
||||
effectiveChunkSize,
|
||||
registerController,
|
||||
unregisterController,
|
||||
clearPolling,
|
||||
clearArchiveSession,
|
||||
startPolling,
|
||||
onArtworkCreated,
|
||||
onNotice,
|
||||
initUploadSession,
|
||||
uploadSingleFile,
|
||||
setArchiveSession,
|
||||
cancelUploadSession,
|
||||
])
|
||||
|
||||
// ── Cancel ─────────────────────────────────────────────────────────────────
|
||||
@@ -356,13 +487,18 @@ export default function useUploadMachine({
|
||||
|
||||
try {
|
||||
const { sessionId, uploadToken } = machine
|
||||
if (sessionId) {
|
||||
await window.axios.post(
|
||||
uploadEndpoints.cancel(),
|
||||
{ session_id: sessionId, upload_token: uploadToken },
|
||||
{ headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined }
|
||||
)
|
||||
}
|
||||
const archiveSessionId = archiveSessionRef.current.sessionId
|
||||
const archiveUploadToken = archiveSessionRef.current.uploadToken
|
||||
const additionalScreenshotSessions = additionalScreenshotSessionsRef.current
|
||||
|
||||
await Promise.allSettled([
|
||||
cancelUploadSession(sessionId, uploadToken),
|
||||
cancelUploadSession(archiveSessionId, archiveUploadToken),
|
||||
...additionalScreenshotSessions.map((item) => cancelUploadSession(item.sessionId, item.uploadToken)),
|
||||
])
|
||||
|
||||
clearArchiveSession()
|
||||
clearAdditionalScreenshotSessions()
|
||||
dispatchMachine({ type: 'CANCELLED' })
|
||||
onNotice?.({ type: 'warning', message: 'Upload cancelled.' })
|
||||
emitUploadEvent('upload_cancel', { session_id: machine.sessionId || null })
|
||||
@@ -372,7 +508,7 @@ export default function useUploadMachine({
|
||||
onNotice?.(notice)
|
||||
emitUploadEvent('upload_error', { stage: 'cancel', message: notice.message })
|
||||
}
|
||||
}, [machine, abortAllRequests, clearPolling, onNotice])
|
||||
}, [machine, abortAllRequests, clearPolling, onNotice, cancelUploadSession, clearArchiveSession, clearAdditionalScreenshotSessions])
|
||||
|
||||
// ── Publish ────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
@@ -460,9 +596,11 @@ export default function useUploadMachine({
|
||||
const parsed = Number(initialDraftId)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
|
||||
})()
|
||||
clearArchiveSession()
|
||||
clearAdditionalScreenshotSessions()
|
||||
publishLockRef.current = false
|
||||
dispatchMachine({ type: 'RESET_MACHINE' })
|
||||
}, [clearPolling, abortAllRequests, initialDraftId])
|
||||
}, [clearPolling, abortAllRequests, initialDraftId, clearArchiveSession, clearAdditionalScreenshotSessions])
|
||||
|
||||
// ── Retry ──────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
|
||||
26
resources/js/render-frame.jsx
Normal file
26
resources/js/render-frame.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* render-frame.jsx — standalone React entry for Playwright card rendering.
|
||||
*
|
||||
* Reads card data from window globals injected by NovaCardRenderFrameController
|
||||
* and mounts just the NovaCardCanvasPreview component with no editor chrome.
|
||||
* The Playwright script screenshots [data-card-canvas] after this mounts.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import NovaCardCanvasPreview from './components/nova-cards/NovaCardCanvasPreview'
|
||||
|
||||
const card = window.__NOVA_CARD__ || {}
|
||||
const fonts = window.__NOVA_CARD_FONTS__ || []
|
||||
|
||||
const mount = document.getElementById('card-render-mount')
|
||||
if (mount) {
|
||||
createRoot(mount).render(
|
||||
<NovaCardCanvasPreview
|
||||
card={card}
|
||||
fonts={fonts}
|
||||
editable={false}
|
||||
renderMode={true}
|
||||
className="w-full"
|
||||
/>,
|
||||
)
|
||||
}
|
||||
@@ -5,25 +5,61 @@ import { createInertiaApp } from '@inertiajs/react'
|
||||
|
||||
// Eagerly import all Studio pages
|
||||
import StudioDashboard from './Pages/Studio/StudioDashboard'
|
||||
import StudioCalendar from './Pages/Studio/StudioCalendar'
|
||||
import StudioContentIndex from './Pages/Studio/StudioContentIndex'
|
||||
import StudioArtworks from './Pages/Studio/StudioArtworks'
|
||||
import StudioDrafts from './Pages/Studio/StudioDrafts'
|
||||
import StudioInbox from './Pages/Studio/StudioInbox'
|
||||
import StudioScheduled from './Pages/Studio/StudioScheduled'
|
||||
import StudioArchived from './Pages/Studio/StudioArchived'
|
||||
import StudioArtworkAnalytics from './Pages/Studio/StudioArtworkAnalytics'
|
||||
import StudioArtworkEdit from './Pages/Studio/StudioArtworkEdit'
|
||||
import StudioAnalytics from './Pages/Studio/StudioAnalytics'
|
||||
import StudioChallenges from './Pages/Studio/StudioChallenges'
|
||||
import StudioSearch from './Pages/Studio/StudioSearch'
|
||||
import StudioCardsIndex from './Pages/Studio/StudioCardsIndex'
|
||||
import StudioCardEditor from './Pages/Studio/StudioCardEditor'
|
||||
import StudioCardAnalytics from './Pages/Studio/StudioCardAnalytics'
|
||||
import StudioCollections from './Pages/Studio/StudioCollections'
|
||||
import StudioStories from './Pages/Studio/StudioStories'
|
||||
import StudioAssets from './Pages/Studio/StudioAssets'
|
||||
import StudioActivity from './Pages/Studio/StudioActivity'
|
||||
import StudioComments from './Pages/Studio/StudioComments'
|
||||
import StudioFollowers from './Pages/Studio/StudioFollowers'
|
||||
import StudioGrowth from './Pages/Studio/StudioGrowth'
|
||||
import StudioPreferences from './Pages/Studio/StudioPreferences'
|
||||
import StudioProfile from './Pages/Studio/StudioProfile'
|
||||
import StudioFeatured from './Pages/Studio/StudioFeatured'
|
||||
import StudioSettings from './Pages/Studio/StudioSettings'
|
||||
|
||||
const pages = {
|
||||
'Studio/StudioDashboard': StudioDashboard,
|
||||
'Studio/StudioCalendar': StudioCalendar,
|
||||
'Studio/StudioContentIndex': StudioContentIndex,
|
||||
'Studio/StudioArtworks': StudioArtworks,
|
||||
'Studio/StudioDrafts': StudioDrafts,
|
||||
'Studio/StudioInbox': StudioInbox,
|
||||
'Studio/StudioScheduled': StudioScheduled,
|
||||
'Studio/StudioArchived': StudioArchived,
|
||||
'Studio/StudioArtworkAnalytics': StudioArtworkAnalytics,
|
||||
'Studio/StudioArtworkEdit': StudioArtworkEdit,
|
||||
'Studio/StudioAnalytics': StudioAnalytics,
|
||||
'Studio/StudioChallenges': StudioChallenges,
|
||||
'Studio/StudioSearch': StudioSearch,
|
||||
'Studio/StudioCardsIndex': StudioCardsIndex,
|
||||
'Studio/StudioCardEditor': StudioCardEditor,
|
||||
'Studio/StudioCardAnalytics': StudioCardAnalytics,
|
||||
'Studio/StudioCollections': StudioCollections,
|
||||
'Studio/StudioStories': StudioStories,
|
||||
'Studio/StudioAssets': StudioAssets,
|
||||
'Studio/StudioActivity': StudioActivity,
|
||||
'Studio/StudioComments': StudioComments,
|
||||
'Studio/StudioFollowers': StudioFollowers,
|
||||
'Studio/StudioGrowth': StudioGrowth,
|
||||
'Studio/StudioPreferences': StudioPreferences,
|
||||
'Studio/StudioProfile': StudioProfile,
|
||||
'Studio/StudioFeatured': StudioFeatured,
|
||||
'Studio/StudioSettings': StudioSettings,
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
|
||||
39
resources/js/utils/studioEvents.js
Normal file
39
resources/js/utils/studioEvents.js
Normal file
@@ -0,0 +1,39 @@
|
||||
function csrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
export function studioSurface(pathname = window.location.pathname) {
|
||||
return String(pathname || '/studio').split('?')[0] || '/studio'
|
||||
}
|
||||
|
||||
export function studioModule(pathname = window.location.pathname) {
|
||||
const segments = studioSurface(pathname).split('/').filter(Boolean)
|
||||
|
||||
if (segments[0] !== 'studio') {
|
||||
return null
|
||||
}
|
||||
|
||||
return segments[1] || 'overview'
|
||||
}
|
||||
|
||||
export async function trackStudioEvent(eventType, payload = {}) {
|
||||
try {
|
||||
await fetch('/api/studio/events', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
event_type: eventType,
|
||||
...payload,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Studio analytics hooks should never block the UI.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user