Implement creator studio and upload updates

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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