Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 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

@@ -0,0 +1,311 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
const kpiItems = [
{ 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 rangeOptions = [7, 14, 30, 60, 90]
function formatShortDate(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
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,
topContent,
moduleBreakdown,
recentComments,
publishingTimeline,
viewsTrend,
engagementTrend,
comparison,
insightBlocks,
rangeDays,
} = props
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" 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="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}`} />
</div>
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider leading-tight">{item.label}</span>
</div>
<p className="text-3xl font-bold text-white tabular-nums">
{(totals?.[item.key] ?? 0).toLocaleString()}
</p>
</div>
))}
</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="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>
<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>
<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="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>
</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>
<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="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">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">
{(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>
</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

@@ -0,0 +1,20 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioContentBrowser from '../../Components/Studio/StudioContentBrowser'
export default function StudioArchived() {
const { props } = usePage()
return (
<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."
/>
</StudioLayout>
)
}

View File

@@ -0,0 +1,128 @@
import React from 'react'
import { usePage, Link } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
const kpiItems = [
{ key: 'views', label: 'Views', icon: 'fa-eye', color: 'text-emerald-400' },
{ key: 'favourites', label: 'Favourites', icon: 'fa-heart', color: 'text-pink-400' },
{ key: 'shares', label: 'Shares', icon: 'fa-share-nodes', color: 'text-amber-400' },
{ key: 'comments', label: 'Comments', icon: 'fa-comment', color: 'text-blue-400' },
{ key: 'downloads', label: 'Downloads', icon: 'fa-download', color: 'text-purple-400' },
]
const metricCards = [
{ key: 'ranking_score', label: 'Ranking Score', icon: 'fa-trophy', color: 'text-yellow-400' },
{ key: 'heat_score', label: 'Heat Score', icon: 'fa-fire', color: 'text-orange-400' },
{ key: 'engagement_velocity', label: 'Engagement Velocity', icon: 'fa-bolt', color: 'text-cyan-400' },
]
export default function StudioArtworkAnalytics() {
const { props } = usePage()
const { artwork, analytics } = props
return (
<StudioLayout title={`Analytics: ${artwork?.title || 'Artwork'}`}>
{/* Back link */}
<Link
href="/studio/artworks"
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white mb-6 transition-colors"
>
<i className="fa-solid fa-arrow-left" />
Back to Artworks
</Link>
{/* Artwork header */}
<div className="flex items-center gap-4 mb-8 bg-nova-900/60 border border-white/10 rounded-2xl p-4">
{artwork?.thumb_url && (
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-20 h-20 rounded-xl object-cover bg-nova-800"
/>
)}
<div>
<h2 className="text-lg font-bold text-white">{artwork?.title}</h2>
<p className="text-xs text-slate-500 mt-1">/{artwork?.slug}</p>
</div>
</div>
{/* KPI row */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
{kpiItems.map((item) => (
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5">
<div className="flex items-center gap-2 mb-2">
<i className={`fa-solid ${item.icon} ${item.color}`} />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">{item.label}</span>
</div>
<p className="text-2xl font-bold text-white tabular-nums">
{(analytics?.[item.key] ?? 0).toLocaleString()}
</p>
</div>
))}
</div>
{/* Performance metrics */}
<h3 className="text-base font-bold text-white mb-4">Performance Metrics</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
{metricCards.map((item) => (
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5">
<div className="flex items-center gap-2 mb-3">
<div className={`w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center ${item.color}`}>
<i className={`fa-solid ${item.icon} text-lg`} />
</div>
<span className="text-sm font-medium text-slate-300">{item.label}</span>
</div>
<p className="text-3xl font-bold text-white tabular-nums">
{(analytics?.[item.key] ?? 0).toFixed(1)}
</p>
</div>
))}
</div>
{/* Placeholder sections for future features */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-6">
<h4 className="text-sm font-semibold text-white mb-3">
<i className="fa-solid fa-chart-line mr-2 text-slate-500" />
Traffic Sources
</h4>
<div className="flex items-center justify-center py-8">
<div className="text-center">
<i className="fa-solid fa-chart-pie text-3xl text-slate-700 mb-3" />
<p className="text-xs text-slate-500">Coming soon</p>
<p className="text-[10px] text-slate-600 mt-1">Traffic source tracking is on the roadmap</p>
</div>
</div>
</div>
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-6">
<h4 className="text-sm font-semibold text-white mb-3">
<i className="fa-solid fa-share-from-square mr-2 text-slate-500" />
Shares by Platform
</h4>
<div className="flex items-center justify-center py-8">
<div className="text-center">
<i className="fa-solid fa-share-nodes text-3xl text-slate-700 mb-3" />
<p className="text-xs text-slate-500">Coming soon</p>
<p className="text-[10px] text-slate-600 mt-1">Platform-level share tracking coming in v2</p>
</div>
</div>
</div>
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-6 lg:col-span-2">
<h4 className="text-sm font-semibold text-white mb-3">
<i className="fa-solid fa-trophy mr-2 text-slate-500" />
Ranking History
</h4>
<div className="flex items-center justify-center py-8">
<div className="text-center">
<i className="fa-solid fa-chart-area text-3xl text-slate-700 mb-3" />
<p className="text-xs text-slate-500">Coming soon</p>
<p className="text-[10px] text-slate-600 mt-1">Historical ranking data will be tracked in a future update</p>
</div>
</div>
</div>
</div>
</StudioLayout>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,648 @@
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { usePage, Link } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import MarkdownEditor from '../../components/ui/MarkdownEditor'
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function formatBytes(bytes) {
if (!bytes) return '—'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
function getContentTypeVisualKey(slug) {
const map = { skins: 'skins', wallpapers: 'wallpapers', photography: 'photography', other: 'other', members: 'members' }
return map[slug] || 'other'
}
function buildCategoryTree(contentTypes) {
return (contentTypes || []).map((ct) => ({
...ct,
rootCategories: (ct.categories || ct.root_categories || []).map((rc) => ({
...rc,
children: rc.children || [],
})),
}))
}
export default function StudioArtworkEdit() {
const { props } = usePage()
const { artwork, contentTypes: rawContentTypes } = props
const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes])
// --- State ---
const [contentTypeId, setContentTypeId] = useState(artwork?.content_type_id || null)
const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null)
const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null)
const [title, setTitle] = useState(artwork?.title || '')
const [description, setDescription] = useState(artwork?.description || '')
const [tags, setTags] = useState(() => (artwork?.tags || []).map((t) => ({ id: t.id, name: t.name, slug: t.slug || t.name })))
const [isPublic, setIsPublic] = useState(artwork?.is_public ?? true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [errors, setErrors] = useState({})
// Tag picker state
const [tagQuery, setTagQuery] = useState('')
const [tagResults, setTagResults] = useState([])
const [tagLoading, setTagLoading] = useState(false)
const tagInputRef = useRef(null)
const tagSearchTimer = useRef(null)
// File replace state
const fileInputRef = useRef(null)
const [replacing, setReplacing] = useState(false)
const [thumbUrl, setThumbUrl] = useState(artwork?.thumb_url_lg || artwork?.thumb_url || null)
const [fileMeta, setFileMeta] = useState({
name: artwork?.file_name || '—',
size: artwork?.file_size || 0,
width: artwork?.width || 0,
height: artwork?.height || 0,
})
const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1)
const [requiresReapproval, setRequiresReapproval] = useState(artwork?.requires_reapproval ?? false)
const [changeNote, setChangeNote] = useState('')
const [showChangeNote, setShowChangeNote] = useState(false)
// Version history modal state
const [showHistory, setShowHistory] = useState(false)
const [historyData, setHistoryData] = useState(null)
const [historyLoading, setHistoryLoading] = useState(false)
const [restoring, setRestoring] = useState(null) // version id being restored
// --- Tag search ---
const searchTags = useCallback(async (q) => {
setTagLoading(true)
try {
const params = new URLSearchParams()
if (q) params.set('q', q)
const res = await fetch(`/api/studio/tags/search?${params.toString()}`, {
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
setTagResults(data || [])
} catch {
setTagResults([])
} finally {
setTagLoading(false)
}
}, [])
useEffect(() => {
clearTimeout(tagSearchTimer.current)
tagSearchTimer.current = setTimeout(() => searchTags(tagQuery), 250)
return () => clearTimeout(tagSearchTimer.current)
}, [tagQuery, searchTags])
const toggleTag = (tag) => {
setTags((prev) => {
const exists = prev.find((t) => t.id === tag.id)
return exists ? prev.filter((t) => t.id !== tag.id) : [...prev, { id: tag.id, name: tag.name, slug: tag.slug }]
})
}
const removeTag = (id) => {
setTags((prev) => prev.filter((t) => t.id !== id))
}
// --- Derived data ---
const selectedCT = contentTypes.find((ct) => ct.id === contentTypeId) || null
const rootCategories = selectedCT?.rootCategories || []
const selectedRoot = rootCategories.find((c) => c.id === categoryId) || null
const subCategories = selectedRoot?.children || []
// --- Handlers ---
const handleContentTypeChange = (id) => {
setContentTypeId(id)
setCategoryId(null)
setSubCategoryId(null)
}
const handleCategoryChange = (id) => {
setCategoryId(id)
setSubCategoryId(null)
}
const handleSave = async () => {
setSaving(true)
setSaved(false)
setErrors({})
try {
const payload = {
title,
description,
is_public: isPublic,
category_id: subCategoryId || categoryId || null,
tags: tags.map((t) => t.slug || t.name),
}
const res = await fetch(`/api/studio/artworks/${artwork.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify(payload),
})
if (res.ok) {
setSaved(true)
setTimeout(() => setSaved(false), 3000)
} else {
const data = await res.json()
if (data.errors) setErrors(data.errors)
console.error('Save failed:', data)
}
} catch (err) {
console.error('Save failed:', err)
} finally {
setSaving(false)
}
}
const handleFileReplace = async (e) => {
const file = e.target.files?.[0]
if (!file) return
setReplacing(true)
try {
const fd = new FormData()
fd.append('file', file)
if (changeNote.trim()) fd.append('change_note', changeNote.trim())
const res = await fetch(`/api/studio/artworks/${artwork.id}/replace-file`, {
method: 'POST',
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: fd,
})
const data = await res.json()
if (res.ok && data.thumb_url) {
setThumbUrl(data.thumb_url)
setFileMeta({ name: file.name, size: file.size, width: data.width || 0, height: data.height || 0 })
if (data.version_number) setVersionCount(data.version_number)
if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval)
setChangeNote('')
setShowChangeNote(false)
} else {
alert(data.error || 'File replacement failed.')
}
} catch (err) {
console.error('File replace failed:', err)
} finally {
setReplacing(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const loadVersionHistory = async () => {
setHistoryLoading(true)
setShowHistory(true)
try {
const res = await fetch(`/api/studio/artworks/${artwork.id}/versions`, {
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
setHistoryData(data)
} catch (err) {
console.error('Failed to load version history:', err)
} finally {
setHistoryLoading(false)
}
}
const handleRestoreVersion = async (versionId) => {
if (!window.confirm('Restore this version? It will be cloned as the new current version.')) return
setRestoring(versionId)
try {
const res = await fetch(`/api/studio/artworks/${artwork.id}/restore/${versionId}`, {
method: 'POST',
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
if (res.ok && data.success) {
alert(data.message)
setVersionCount((n) => n + 1)
setShowHistory(false)
} else {
alert(data.error || 'Restore failed.')
}
} catch (err) {
console.error('Restore failed:', err)
} finally {
setRestoring(null)
}
}
// --- Render ---
return (
<StudioLayout title="Edit Artwork">
<Link
href="/studio/artworks"
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white mb-6 transition-colors"
>
<i className="fa-solid fa-arrow-left" />
Back to Artworks
</Link>
<div className="max-w-3xl space-y-8">
{/* ── Uploaded Asset ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Uploaded Asset</h3>
<div className="flex items-center gap-2">
{requiresReapproval && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">
<i className="fa-solid fa-triangle-exclamation" /> Under Review
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-accent/20 text-accent border border-accent/30">
v{versionCount}
</span>
{versionCount > 1 && (
<button
type="button"
onClick={loadVersionHistory}
className="text-xs text-slate-400 hover:text-white transition-colors flex items-center gap-1"
>
<i className="fa-solid fa-clock-rotate-left text-[10px]" /> History
</button>
)}
</div>
</div>
<div className="flex items-start gap-5">
{thumbUrl ? (
<img src={thumbUrl} alt={title} className="w-32 h-32 rounded-xl object-cover bg-nova-800 flex-shrink-0" />
) : (
<div className="w-32 h-32 rounded-xl bg-nova-800 flex items-center justify-center text-slate-600 flex-shrink-0">
<i className="fa-solid fa-image text-2xl" />
</div>
)}
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm text-white font-medium truncate">{fileMeta.name}</p>
<p className="text-xs text-slate-400">{formatBytes(fileMeta.size)}</p>
{fileMeta.width > 0 && (
<p className="text-xs text-slate-400">{fileMeta.width} × {fileMeta.height} px</p>
)}
{showChangeNote && (
<textarea
value={changeNote}
onChange={(e) => setChangeNote(e.target.value)}
rows={2}
maxLength={500}
placeholder="What changed? (optional)"
className="mt-2 w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50 resize-none"
/>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileReplace} />
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={() => {
setShowChangeNote((s) => !s)
if (!showChangeNote) fileInputRef.current?.click()
}}
disabled={replacing}
className="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors disabled:opacity-50"
>
<i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} />
{replacing ? 'Replacing…' : 'Replace file'}
</button>
{showChangeNote && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={replacing}
className="inline-flex items-center gap-1.5 text-xs bg-accent/20 hover:bg-accent/30 text-accent px-2.5 py-1 rounded-lg transition-colors disabled:opacity-50"
>
<i className="fa-solid fa-upload" /> Choose file
</button>
)}
</div>
</div>
</div>
</section>
{/* ── Content Type ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Content Type</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{contentTypes.map((ct) => {
const active = ct.id === contentTypeId
const vk = getContentTypeVisualKey(ct.slug)
return (
<button
key={ct.id}
type="button"
onClick={() => handleContentTypeChange(ct.id)}
className={`relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all cursor-pointer
${active ? 'border-emerald-400/70 bg-emerald-400/15 shadow-lg shadow-emerald-400/10' : 'border-white/10 bg-white/5 hover:border-white/20'}`}
>
<img src={`/gfx/mascot_${vk}.webp`} alt={ct.name} className="w-14 h-14 object-contain" />
<span className={`text-xs font-semibold ${active ? 'text-emerald-300' : 'text-slate-300'}`}>{ct.name}</span>
{active && (
<span className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center">
<i className="fa-solid fa-check text-[10px] text-white" />
</span>
)}
</button>
)
})}
</div>
</section>
{/* ── Category ── */}
{rootCategories.length > 0 && (
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Category</h3>
<div className="flex flex-wrap gap-2">
{rootCategories.map((cat) => {
const active = cat.id === categoryId
return (
<button
key={cat.id}
type="button"
onClick={() => handleCategoryChange(cat.id)}
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
${active ? 'border-purple-600/90 bg-purple-700/35 text-purple-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
>
{cat.name}
</button>
)
})}
</div>
</div>
{/* Subcategory */}
{subCategories.length > 0 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Subcategory</h3>
<div className="flex flex-wrap gap-2">
{subCategories.map((sub) => {
const active = sub.id === subCategoryId
return (
<button
key={sub.id}
type="button"
onClick={() => setSubCategoryId(active ? null : sub.id)}
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
${active ? 'border-cyan-600/90 bg-cyan-700/35 text-cyan-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
>
{sub.name}
</button>
)
})}
</div>
</div>
)}
</section>
)}
{/* ── Basics ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-1">Basics</h3>
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
{errors.title && <p className="text-xs text-red-400 mt-1">{errors.title[0]}</p>}
</div>
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Description</label>
<MarkdownEditor
id="studio-edit-description"
value={description}
onChange={setDescription}
placeholder="Describe your artwork…"
rows={5}
error={errors.description}
/>
{errors.description && <p className="text-xs text-red-400 mt-1">{errors.description[0]}</p>}
</div>
</section>
{/* ── Tags ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Tags</h3>
{/* Search input */}
<div className="relative">
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
<input
ref={tagInputRef}
type="text"
value={tagQuery}
onChange={(e) => setTagQuery(e.target.value)}
className="w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
style={{ paddingLeft: '2.5rem' }}
placeholder="Search tags…"
/>
</div>
{/* Selected tag chips */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium bg-accent/20 text-accent"
>
{tag.name}
<button
onClick={() => removeTag(tag.id)}
className="ml-0.5 w-4 h-4 rounded-full hover:bg-white/10 flex items-center justify-center"
>
<i className="fa-solid fa-xmark text-[10px]" />
</button>
</span>
))}
</div>
)}
{/* Results list */}
<div className="max-h-48 overflow-y-auto sb-scrollbar space-y-0.5 rounded-xl bg-white/[0.02] border border-white/5 p-1">
{tagLoading && (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!tagLoading && tagResults.length === 0 && (
<p className="text-center text-sm text-slate-500 py-4">
{tagQuery ? 'No tags found' : 'Type to search tags'}
</p>
)}
{!tagLoading &&
tagResults.map((tag) => {
const isSelected = tags.some((t) => t.id === tag.id)
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
isSelected
? 'bg-accent/10 text-accent'
: 'text-slate-300 hover:bg-white/5 hover:text-white'
}`}
>
<span className="flex items-center gap-2">
<i
className={`fa-${isSelected ? 'solid fa-circle-check' : 'regular fa-circle'} text-xs ${
isSelected ? 'text-accent' : 'text-slate-500'
}`}
/>
{tag.name}
</span>
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
</button>
)
})}
</div>
<p className="text-xs text-slate-500">{tags.length}/15 tags selected</p>
{errors.tags && <p className="text-xs text-red-400">{errors.tags[0]}</p>}
</section>
{/* ── Visibility ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Visibility</h3>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={isPublic} onChange={() => setIsPublic(true)} className="text-accent focus:ring-accent/50" />
<span className="text-sm text-white">Published</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={!isPublic} onChange={() => setIsPublic(false)} className="text-accent focus:ring-accent/50" />
<span className="text-sm text-white">Draft</span>
</label>
</div>
</section>
{/* ── Actions ── */}
<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white font-semibold text-sm transition-all shadow-lg shadow-accent/25 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save changes'}
</button>
{saved && (
<span className="text-sm text-emerald-400 flex items-center gap-1">
<i className="fa-solid fa-check" /> Saved
</span>
)}
<Link
href={`/studio/artworks/${artwork?.id}/analytics`}
className="ml-auto px-4 py-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 text-sm transition-all"
>
<i className="fa-solid fa-chart-line mr-2" />
Analytics
</Link>
</div>
</div>
{/* ── Version History Modal ── */}
{showHistory && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
onClick={(e) => { if (e.target === e.currentTarget) setShowHistory(false) }}
>
<div className="bg-nova-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h2 className="text-sm font-semibold text-white flex items-center gap-2">
<i className="fa-solid fa-clock-rotate-left text-accent" />
Version History
</h2>
<button
onClick={() => setShowHistory(false)}
className="w-7 h-7 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors"
>
<i className="fa-solid fa-xmark text-xs" />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 sb-scrollbar p-4 space-y-3">
{historyLoading && (
<div className="flex items-center justify-center py-10">
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!historyLoading && historyData && historyData.versions.map((v) => (
<div
key={v.id}
className={`rounded-xl border p-4 transition-all ${
v.is_current
? 'border-accent/40 bg-accent/10'
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-white">v{v.version_number}</span>
{v.is_current && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/20 text-accent border border-accent/30">Current</span>
)}
</div>
<p className="text-[11px] text-slate-400">
{v.created_at ? new Date(v.created_at).toLocaleString() : ''}
</p>
{v.width && (
<p className="text-[11px] text-slate-400">{v.width} × {v.height} px &middot; {formatBytes(v.file_size)}</p>
)}
{v.change_note && (
<p className="text-xs text-slate-300 mt-1 italic">&ldquo;{v.change_note}&rdquo;</p>
)}
</div>
{!v.is_current && (
<button
type="button"
disabled={restoring === v.id}
onClick={() => handleRestoreVersion(v.id)}
className="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-white/5 hover:bg-accent/20 text-slate-300 hover:text-accent border border-white/10 hover:border-accent/30 transition-all disabled:opacity-50"
>
{restoring === v.id
? <><i className="fa-solid fa-spinner fa-spin" /> Restoring</>
: <><i className="fa-solid fa-rotate-left" /> Restore</>
}
</button>
)}
</div>
</div>
))}
{!historyLoading && historyData && historyData.versions.length === 0 && (
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/10">
<p className="text-xs text-slate-500">
Older versions are preserved. Restoring creates a new versionnothing is deleted.
</p>
</div>
</div>
</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 StudioArtworks() {
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="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>
<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>
)
}

View File

@@ -0,0 +1,84 @@
import React from 'react'
import { Link, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
const kpiItems = [
{ key: 'views', label: 'Views', icon: 'fa-eye', color: 'text-emerald-400' },
{ key: 'likes', label: 'Likes', icon: 'fa-heart', color: 'text-pink-400' },
{ key: 'saves', label: 'Saves', icon: 'fa-bookmark', color: 'text-amber-400' },
{ key: 'remixes', label: 'Remixes', icon: 'fa-code-branch', color: 'text-cyan-400' },
{ key: 'comments', label: 'Comments', icon: 'fa-comment', color: 'text-blue-400' },
{ key: 'challenge_entries', label: 'Challenges', icon: 'fa-trophy', color: 'text-violet-400' },
]
const secondaryItems = [
{ key: 'favorites', label: 'Favorites', icon: 'fa-star' },
{ key: 'shares', label: 'Shares', icon: 'fa-share-nodes' },
{ key: 'downloads', label: 'Downloads', icon: 'fa-download' },
]
export default function StudioCardAnalytics() {
const { props } = usePage()
const { card, analytics } = props
return (
<StudioLayout title={`Analytics: ${card?.title || 'Nova Card'}`}>
<Link href="/studio/cards" className="mb-6 inline-flex items-center gap-2 text-sm text-slate-400 transition-colors hover:text-white">
<i className="fa-solid fa-arrow-left" />
Back to Cards
</Link>
<div className="mb-8 flex items-center gap-4 rounded-2xl border border-white/10 bg-nova-900/60 p-4">
{card?.preview_url ? <img src={card.preview_url} alt={card.title} className="h-20 w-20 rounded-xl object-cover bg-nova-800" /> : null}
<div>
<h2 className="text-lg font-bold text-white">{card?.title}</h2>
<p className="mt-1 text-xs text-slate-500">/{card?.slug}</p>
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-slate-400">{card?.status} {card?.visibility}</p>
</div>
</div>
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-6">
{kpiItems.map((item) => (
<div key={item.key} className="rounded-2xl border border-white/10 bg-nova-900/60 p-5">
<div className="mb-2 flex items-center gap-2">
<i className={`fa-solid ${item.icon} ${item.color}`} />
<span className="text-xs font-medium uppercase tracking-wider text-slate-400">{item.label}</span>
</div>
<p className="text-2xl font-bold tabular-nums text-white">{(analytics?.[item.key] ?? 0).toLocaleString()}</p>
</div>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<div className="rounded-2xl border border-white/10 bg-nova-900/40 p-6">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-[0.18em] text-slate-300">Ranking signals</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Trending score</div>
<div className="mt-2 text-3xl font-bold tabular-nums text-white">{Number(analytics?.trending_score ?? 0).toFixed(2)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Last engaged</div>
<div className="mt-2 text-sm text-white">{analytics?.last_engaged_at || 'No activity yet'}</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-nova-900/40 p-6">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-[0.18em] text-slate-300">Secondary metrics</h3>
<div className="space-y-3">
{secondaryItems.map((item) => (
<div key={item.key} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="flex items-center gap-2 text-sm text-slate-300">
<i className={`fa-solid ${item.icon} text-slate-500`} />
{item.label}
</div>
<div className="text-base font-semibold tabular-nums text-white">{(analytics?.[item.key] ?? 0).toLocaleString()}</div>
</div>
))}
</div>
</div>
</div>
</StudioLayout>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioContentBrowser from '../../components/Studio/StudioContentBrowser'
function StatCard({ label, value, icon }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{label}</div>
<div className="mt-3 flex items-center gap-3">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
<i className={`fa-solid ${icon}`} />
</span>
<span className="text-3xl font-semibold tracking-[-0.04em] text-white">{value}</span>
</div>
</div>
)
}
export default function StudioCardsIndex() {
const { props } = usePage()
const summary = props.summary || {}
return (
<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">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">
<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
</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>
</div>
</div>
</section>
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<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">
<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

@@ -0,0 +1,536 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
const kpiConfig = [
{ 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 }) {
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-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{config.label}</span>
</div>
<p className="mt-4 text-3xl font-semibold text-white tabular-nums">{typeof value === 'number' ? value.toLocaleString() : value}</p>
</div>
)
}
function QuickCreateCard({ item }) {
return (
<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="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 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="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>
<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>
</section>
</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

@@ -0,0 +1,20 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioContentBrowser from '../../Components/Studio/StudioContentBrowser'
export default function StudioDrafts() {
const { props } = usePage()
return (
<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."
/>
</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,32 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupActivity() {
const { props } = usePage()
const items = Array.isArray(props.activity) ? props.activity : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-4">
{items.length > 0 ? items.map((item) => (
<div key={item.id} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold text-white">{item.headline}</h2>
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Pinned</span> : null}
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item.visibility}</span>
</div>
{item.summary ? <p className="mt-2 text-sm leading-6 text-slate-400">{item.summary}</p> : null}
<div className="mt-3 text-xs text-slate-500">{item.actor?.name || item.actor?.username || 'System'} {item.occurred_at ? new Date(item.occurred_at).toLocaleString() : 'Recently'}</div>
{item.subject?.url ? <a href={item.subject.url} className="mt-3 inline-flex text-sm font-semibold text-sky-200">Open subject</a> : null}
</div>
{props.pinPattern ? <button type="button" onClick={() => router.post(props.pinPattern.replace('__ITEM__', String(item.id)), { is_pinned: !item.is_pinned })} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-sm font-semibold text-white">{item.is_pinned ? 'Unpin' : 'Pin'}</button> : null}
</div>
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No activity yet.</div>}
</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 StudioGroupArtworks() {
const { props } = usePage()
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div 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.18em]">Group publish flow</p>
<h2 className="mt-2 text-xl font-semibold">Upload into {props.studioGroup?.name}</h2>
<a href={props.uploadUrl} className="mt-4 inline-flex rounded-full border border-sky-200/20 bg-sky-200/10 px-4 py-2 text-sm font-semibold text-sky-50">New group artwork</a>
</div>
<StudioContentBrowser listing={props.listing} quickCreate={[{ key: 'artworks', label: 'Artwork', icon: 'fa-solid fa-cloud-arrow-up', url: props.uploadUrl }]} hideModuleFilter />
</StudioLayout>
)
}

View File

@@ -0,0 +1,106 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupAssets() {
const { props } = usePage()
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
const filters = useForm({
q: props.listing?.filters?.q || '',
category: props.listing?.filters?.category || 'all',
bucket: props.listing?.filters?.bucket || 'all',
})
const form = useForm({
title: '',
description: '',
category: props.categoryOptions?.[0]?.value || 'misc',
visibility: props.visibilityOptions?.[0]?.value || 'members_only',
status: props.statusOptions?.[0]?.value || 'active',
linked_project_id: '',
is_featured: false,
file: null,
})
const submit = (event) => {
event.preventDefault()
if (!props.storeUrl) return
form.post(props.storeUrl, { forceFormData: true, preserveScroll: true })
}
const applyFilters = (event) => {
event.preventDefault()
router.get(props.studioGroup?.urls?.studio_assets || window.location.pathname, {
q: filters.data.q || undefined,
category: filters.data.category !== 'all' ? filters.data.category : undefined,
bucket: filters.data.bucket !== 'all' ? filters.data.bucket : undefined,
}, {
preserveState: true,
preserveScroll: true,
replace: true,
})
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
{props.storeUrl ? (
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-4 lg:grid-cols-6">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Asset title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none lg:col-span-2" />
<select value={form.data.category} onChange={(event) => form.setData('category', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.categoryOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<input type="file" onChange={(event) => form.setData('file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="What is this asset for?" rows={3} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="mt-4 grid gap-4 md:grid-cols-2">
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> Featured asset</label>
</div>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Upload asset</button>
</form>
) : null}
<form onSubmit={applyFilters} className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-white">Browse library</h2>
<p className="mt-1 text-sm text-slate-400">Search and filter shared assets by visibility and category.</p>
</div>
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Apply filters</button>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<input value={filters.data.q} onChange={(event) => filters.setData('q', event.target.value)} placeholder="Search title, description, or filename" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={filters.data.category} onChange={(event) => filters.setData('category', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="all">All categories</option>
{(props.categoryOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select value={filters.data.bucket} onChange={(event) => filters.setData('bucket', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="all">All visibility levels</option>
{(props.listing?.bucket_options || []).filter((option) => option.value !== 'all').map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
</form>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{items.length > 0 ? items.map((asset) => (
<div key={asset.id} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">{asset.title}</h2>
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{asset.category} {asset.visibility} {asset.status}</p>
</div>
<a href={asset.download_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-sm font-semibold text-white">Download</a>
</div>
{asset.description ? <p className="mt-3 text-sm leading-6 text-slate-400">{asset.description}</p> : null}
{props.updatePattern ? (
<button type="button" onClick={() => router.patch(props.updatePattern.replace('__ASSET__', String(asset.id)), { title: asset.title, description: asset.description || '', category: asset.category, visibility: asset.visibility, status: asset.status === 'active' ? 'archived' : 'active', linked_project_id: asset.linked_project?.id || '', is_featured: asset.is_featured })} className="mt-4 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">{asset.status === 'active' ? 'Archive' : 'Reactivate'}</button>
) : null}
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No assets yet.</div>}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,95 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupChallengeEditor() {
const { props } = usePage()
const challenge = props.challenge || null
const form = useForm({
title: challenge?.title || '',
summary: challenge?.summary || '',
description: challenge?.description || '',
visibility: challenge?.visibility || props.visibilityOptions?.[0]?.value || 'public',
participation_scope: challenge?.participation_scope || props.participationScopeOptions?.[0]?.value || 'group_only',
status: challenge?.status || props.statusOptions?.[0]?.value || 'draft',
start_at: challenge?.start_at ? challenge.start_at.slice(0, 16) : '',
end_at: challenge?.end_at ? challenge.end_at.slice(0, 16) : '',
rules_text: challenge?.rules_text || '',
submission_instructions: challenge?.submission_instructions || '',
judging_mode: challenge?.judging_mode || '',
linked_collection_id: challenge?.linked_collection?.id || '',
linked_project_id: challenge?.linked_project?.id || '',
featured_artwork_id: challenge?.featured_artwork?.id || '',
cover_file: null,
})
const attachForm = useForm({ artwork_id: '' })
const submit = (event) => {
event.preventDefault()
const options = { forceFormData: true, preserveScroll: true }
if (props.updateUrl) {
form.post(props.updateUrl, { ...options, _method: 'patch' })
return
}
form.post(props.storeUrl, options)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-4">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Challenge title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Challenge description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.participation_scope} onChange={(event) => form.setData('participation_scope', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.participationScopeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="datetime-local" value={form.data.end_at} onChange={(event) => form.setData('end_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={form.data.rules_text} onChange={(event) => form.setData('rules_text', event.target.value)} placeholder="Rules" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.submission_instructions} onChange={(event) => form.setData('submission_instructions', event.target.value)} placeholder="Submission instructions" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={form.data.judging_mode} onChange={(event) => form.setData('judging_mode', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No judging mode</option>
{(props.judgingModeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.featured_artwork_id} onChange={(event) => form.setData('featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No featured artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<button type="submit" className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Save challenge</button>
</form>
<div className="space-y-6">
{props.publishUrl ? <form onSubmit={(event) => { event.preventDefault(); form.post(props.publishUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6"><button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Publish challenge</button></form> : null}
{props.attachArtworkUrl ? (
<form onSubmit={(event) => { event.preventDefault(); attachForm.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
<select value={attachForm.data.artwork_id} onChange={(event) => attachForm.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach</button>
</form>
) : null}
</div>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupChallenges() {
const { props } = usePage()
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-slate-400">Challenges keep the group active between releases and give members a focused creative prompt.</div>
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create challenge</a> : null}
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{items.length > 0 ? items.map((challenge) => (
<a key={challenge.id} href={challenge.urls?.edit || challenge.url} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-white">{challenge.title}</h2>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{challenge.status}</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-400">{challenge.summary || 'Challenge page'}</p>
<div className="mt-4 text-xs text-slate-500">{challenge.entry_count || 0} linked entries</div>
</a>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No challenges yet.</div>}
</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 StudioGroupCollections() {
const { props } = usePage()
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div 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.18em]">Shared curation</p>
<h2 className="mt-2 text-xl font-semibold">Create collections for {props.studioGroup?.name}</h2>
<a href={props.createUrl} className="mt-4 inline-flex rounded-full border border-sky-200/20 bg-sky-200/10 px-4 py-2 text-sm font-semibold text-sky-50">New group collection</a>
</div>
<StudioContentBrowser listing={props.listing} quickCreate={[{ key: 'collections', label: 'Collection', icon: 'fa-solid fa-layer-group', url: props.createUrl }]} hideModuleFilter />
</StudioLayout>
)
}

View File

@@ -0,0 +1,235 @@
import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
function slugifyGroupValue(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 90)
}
function resolveMediaPreviewUrl(path, filesCdnUrl) {
const trimmed = String(path || '').trim()
if (!trimmed) {
return ''
}
if (trimmed.startsWith('blob:') || trimmed.startsWith('data:') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed
}
return `${String(filesCdnUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\/+/, '')}`
}
export default function StudioGroupCreate() {
const { props } = usePage()
const filesCdnUrl = props?.cdn?.files_url || ''
const avatarInputRef = useRef(null)
const bannerInputRef = useRef(null)
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
const [form, setForm] = useState({
name: '',
slug: '',
headline: '',
bio: '',
type: '',
founded_at: '',
avatar_path: '',
banner_path: '',
visibility: 'public',
membership_policy: 'invite_only',
website_url: '',
links_json: [{ label: '', url: '' }],
avatar_file: null,
banner_file: null,
})
const [avatarPreview, setAvatarPreview] = useState('')
const [bannerPreview, setBannerPreview] = useState('')
const resolvedAvatarPreview = useMemo(() => avatarPreview || resolveMediaPreviewUrl(form.avatar_path, filesCdnUrl), [avatarPreview, form.avatar_path, filesCdnUrl])
const resolvedBannerPreview = useMemo(() => bannerPreview || resolveMediaPreviewUrl(form.banner_path, filesCdnUrl), [bannerPreview, form.banner_path, filesCdnUrl])
const updateLink = (index, key, value) => {
setForm((current) => ({
...current,
links_json: current.links_json.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item),
}))
}
const addLink = () => {
setForm((current) => ({
...current,
links_json: [...current.links_json, { label: '', url: '' }],
}))
}
const removeLink = (index) => {
setForm((current) => ({
...current,
links_json: current.links_json.filter((_, itemIndex) => itemIndex !== index),
}))
}
const submit = () => {
router.post(props.endpoints?.store, {
...form,
links_json: form.links_json.filter((item) => item.label.trim() !== '' || item.url.trim() !== ''),
}, {
forceFormData: true,
})
}
const handleFileSelected = (field, setPreview) => (event) => {
const file = event.target.files?.[0] || null
setForm((current) => ({ ...current, [field]: file }))
setPreview(file ? URL.createObjectURL(file) : '')
}
const clearSelectedFile = (field, setPreview, inputRef) => {
setForm((current) => ({ ...current, [field]: null }))
setPreview('')
if (inputRef.current) {
inputRef.current.value = ''
}
}
const handleNameChange = (event) => {
const nextName = event.target.value
setForm((current) => ({
...current,
name: nextName,
slug: slugManuallyEdited ? current.slug : slugifyGroupValue(nextName),
}))
}
const handleSlugChange = (event) => {
const nextSlug = slugifyGroupValue(event.target.value)
setSlugManuallyEdited(nextSlug !== '')
setForm((current) => ({ ...current, slug: nextSlug }))
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="mx-auto mb-6 max-w-5xl">
<GroupStudioPromoCard
title="Set up the public identity before the first release"
description="A strong group page makes collaborative publishing legible: who leads the team, what kind of work you make, and why contributors should join or follow."
bullets={[
{ title: 'Headline first', body: 'Use the headline to explain what the collective publishes and what makes the group distinct.' },
{ title: 'Recruit with clarity', body: 'After creation, configure recruitment so open roles surface across search and browse experiences.' },
{ title: 'Own the presentation', body: 'Avatar, cover art, and links shape how the group appears on artworks, profile summaries, and leaderboards.' },
]}
primaryLabel="Back to groups"
primaryHref="/studio/groups"
secondaryLabel="Browse public groups"
secondaryHref="/groups"
/>
</div>
<section className="mx-auto max-w-3xl rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-5">
<label className="grid gap-2 text-sm text-slate-200">
<span>Name</span>
<input value={form.name} onChange={handleNameChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-200">
<span>Slug</span>
<input value={form.slug} onChange={handleSlugChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-200">
<span>Short description</span>
<input value={form.headline} onChange={(event) => setForm((current) => ({ ...current, headline: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-200">
<span>About</span>
<textarea value={form.bio} onChange={(event) => setForm((current) => ({ ...current, bio: event.target.value }))} rows={6} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-5 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-200">
<span>Type / category</span>
<input value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-200">
<span>Founded date</span>
<input type="date" value={form.founded_at} onChange={(event) => setForm((current) => ({ ...current, founded_at: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-200">
<span>Website</span>
<input value={form.website_url} onChange={(event) => setForm((current) => ({ ...current, website_url: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-5 md:grid-cols-2">
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Avatar / logo</span>
<div className="flex h-28 w-28 items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{resolvedAvatarPreview ? <img src={resolvedAvatarPreview} alt="Avatar preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-image text-slate-500" />}
</div>
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('avatar_file', setAvatarPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => avatarInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload avatar</button>
{form.avatar_file ? <button type="button" onClick={() => clearSelectedFile('avatar_file', setAvatarPreview, avatarInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use URL instead</button> : null}
</div>
<label className="grid gap-2 text-sm text-slate-200">
<span>Or paste an image URL</span>
<input value={form.avatar_path} onChange={(event) => setForm((current) => ({ ...current, avatar_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Cover image</span>
<div className="flex h-28 w-full items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{resolvedBannerPreview ? <img src={resolvedBannerPreview} alt="Cover preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-panorama text-slate-500" />}
</div>
<input ref={bannerInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('banner_file', setBannerPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => bannerInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload cover</button>
{form.banner_file ? <button type="button" onClick={() => clearSelectedFile('banner_file', setBannerPreview, bannerInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use URL instead</button> : null}
</div>
<label className="grid gap-2 text-sm text-slate-200">
<span>Or paste an image URL</span>
<input value={form.banner_path} onChange={(event) => setForm((current) => ({ ...current, banner_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</div>
<label className="grid gap-2 text-sm text-slate-200">
<span>Visibility</span>
<select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-200">
<span>Membership policy</span>
<select value={form.membership_policy} onChange={(event) => setForm((current) => ({ ...current, membership_policy: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(props.membershipPolicyOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-200">Links</span>
<button type="button" onClick={addLink} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Add link</button>
</div>
{form.links_json.map((item, index) => (
<div key={`link-${index}`} className="grid gap-3 md:grid-cols-[0.8fr_1.2fr_auto]">
<input value={item.label} onChange={(event) => updateLink(index, 'label', event.target.value)} placeholder="Label" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={item.url} onChange={(event) => updateLink(index, 'url', event.target.value)} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => removeLink(index)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Remove</button>
</div>
))}
</div>
<div className="flex justify-end gap-3">
<a href="/studio/groups" className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white">Cancel</a>
<button type="button" onClick={submit} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Create group</button>
</div>
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,400 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function StatCard({ 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>{label}</span></div>
<div className="mt-3 text-3xl font-semibold text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
function ContentCard({ item, fallbackLabel }) {
return (
<a href={item.manage_url || item.urls?.edit || item.edit_url || item.preview_url || item.view_url || item.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 transition hover:border-white/20">
{item.image_url ? <img src={item.image_url} alt={item.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-white/[0.03] text-slate-500"><i className="fa-solid fa-image text-2xl" /></div>}
<div className="p-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-white">{item.title}</h3>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item.status || item.event_type || fallbackLabel}</span>
</div>
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.description || item.summary || fallbackLabel}</p>
</div>
</a>
)
}
function EmptyCard({ title, description }) {
return (
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">
<p className="font-semibold text-white">{title}</p>
<p className="mt-2 leading-6">{description}</p>
</div>
)
}
function ActivityCard({ item }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-white">{item.headline}</div>
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Pinned</span> : null}
</div>
{item.summary ? <p className="mt-2 text-sm text-slate-400">{item.summary}</p> : null}
<div className="mt-2 text-xs text-slate-500">{item.actor?.name || item.actor?.username || 'System'} {item.occurred_at ? new Date(item.occurred_at).toLocaleString() : 'Recently'}</div>
{item.subject?.url ? <a href={item.subject.url} className="mt-3 inline-flex text-sm font-semibold text-sky-200">Open subject</a> : null}
</div>
)
}
export default function StudioGroupDashboard() {
const { props } = usePage()
const group = props.studioGroup
const members = Array.isArray(props.members) ? props.members : []
const dashboard = props.dashboard || {}
const draftsPendingAction = Array.isArray(props.draftsPendingAction) ? props.draftsPendingAction : []
const recentArtworks = Array.isArray(props.recentArtworks) ? props.recentArtworks : []
const recentCollections = Array.isArray(props.recentCollections) ? props.recentCollections : []
const recentPosts = Array.isArray(props.recentPosts) ? props.recentPosts : []
const recentProjects = Array.isArray(props.recentProjects) ? props.recentProjects : []
const recentReleases = Array.isArray(props.recentReleases) ? props.recentReleases : []
const recentChallenges = Array.isArray(props.recentChallenges) ? props.recentChallenges : []
const recentEvents = Array.isArray(props.recentEvents) ? props.recentEvents : []
const recentActivity = Array.isArray(props.recentActivity) ? props.recentActivity : []
const trustSignals = Array.isArray(props.trustSignals) ? props.trustSignals : []
const reputationSummary = props.reputationSummary || {}
const pendingJoinRequests = Array.isArray(props.pendingJoinRequests) ? props.pendingJoinRequests : []
const reviewQueuePreview = Array.isArray(props.reviewQueuePreview) ? props.reviewQueuePreview : []
const recruitment = props.recruitment || null
const recentHistory = Array.isArray(props.recentHistory) ? props.recentHistory : []
const roleSummary = members.reduce((summary, member) => {
const role = String(member.role || 'member')
summary[role] = (summary[role] || 0) + 1
return summary
}, {})
const quickActions = [
{ label: 'Upload artwork', href: group?.urls?.upload, icon: 'fa-solid fa-cloud-arrow-up', tone: 'sky', detail: 'Start a new group-published artwork.' },
{ label: 'Invite member', href: group?.urls?.studio_invitations, icon: 'fa-solid fa-user-plus', tone: 'emerald', detail: `Manage invites${Number(dashboard.pending_invites_count || 0) > 0 ? ` (${Number(dashboard.pending_invites_count)})` : ''}.` },
{ label: 'Review queue', href: group?.urls?.studio_review, icon: 'fa-solid fa-list-check', tone: 'amber', detail: `${Number(dashboard.pending_reviews_count || 0)} submissions waiting.` },
{ label: 'Posts', href: group?.urls?.studio_posts, icon: 'fa-solid fa-bullhorn', tone: 'violet', detail: `${Number(dashboard.published_posts_count || 0)} published posts.` },
{ label: 'Projects', href: group?.urls?.studio_projects, icon: 'fa-solid fa-diagram-project', tone: 'sky', detail: `${Number(dashboard.projects_count || 0)} total projects.` },
{ label: 'Releases', href: group?.urls?.studio_releases, icon: 'fa-solid fa-rocket', tone: 'amber', detail: `${Number(dashboard.published_releases_count || 0)} published releases.` },
{ label: 'Challenges', href: group?.urls?.studio_challenges, icon: 'fa-solid fa-trophy', tone: 'amber', detail: `${Number(dashboard.active_challenges_count || 0)} active or published.` },
{ label: 'Events', href: group?.urls?.studio_events, icon: 'fa-solid fa-calendar-day', tone: 'emerald', detail: `${Number(dashboard.events_count || 0)} scheduled or archived.` },
{ label: 'Assets', href: group?.urls?.studio_assets, icon: 'fa-solid fa-box-archive', tone: 'violet', detail: `${Number(dashboard.assets_count || 0)} shared files.` },
{ label: 'Reputation', href: group?.urls?.studio_reputation, icon: 'fa-solid fa-shield-heart', tone: 'sky', detail: `${Number(reputationSummary?.counts?.contributors || 0)} contributors tracked.` },
{ label: 'Activity', href: group?.urls?.studio_activity, icon: 'fa-solid fa-wave-square', tone: 'sky', detail: `${Number(dashboard.activity_count || 0)} feed items recorded.` },
{ label: 'Edit profile', href: group?.urls?.studio_settings, icon: 'fa-solid fa-pen-to-square', tone: 'amber', detail: 'Update headline, visuals, and links.' },
{ label: 'Recruitment', href: group?.urls?.studio_recruitment, icon: 'fa-solid fa-user-plus', tone: 'emerald', detail: recruitment?.is_recruiting ? 'Recruitment is live.' : 'Configure recruiting status.' },
{ label: 'Create collection', href: group?.urls?.collection_create, icon: 'fa-solid fa-layer-group', tone: 'violet', detail: 'Publish a new collection under this group.' },
].filter((item) => Boolean(item.href))
const toneClasses = {
sky: 'border-sky-300/20 bg-sky-300/10 text-sky-100',
emerald: 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100',
amber: 'border-amber-300/20 bg-amber-300/10 text-amber-100',
violet: 'border-violet-300/20 bg-violet-300/10 text-violet-100',
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
<StatCard label="Artworks" value={group?.counts?.artworks} icon="fa-solid fa-images" />
<StatCard label="Collections" value={group?.counts?.collections} icon="fa-solid fa-layer-group" />
<StatCard label="Followers" value={group?.counts?.followers} icon="fa-solid fa-user-group" />
<StatCard label="Active members" value={dashboard?.active_members_count || group?.counts?.members} icon="fa-solid fa-people-group" />
<StatCard label="Projects" value={dashboard?.projects_count} icon="fa-solid fa-diagram-project" />
<StatCard label="Releases" value={dashboard?.published_releases_count || dashboard?.releases_count} icon="fa-solid fa-rocket" />
<StatCard label="Assets" value={dashboard?.assets_count} icon="fa-solid fa-box-archive" />
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Quick actions</h2>
<p className="mt-1 text-sm text-slate-400">Run the most common group tasks without leaving the dashboard.</p>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{quickActions.map((action) => (
<a key={action.label} href={action.href} className={`rounded-[24px] border px-4 py-4 transition hover:translate-y-[-1px] hover:border-white/20 ${toneClasses[action.tone] || toneClasses.sky}`}>
<div className="flex items-center gap-3">
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-current/20 bg-black/10"><i className={action.icon} /></span>
<div>
<div className="text-sm font-semibold">{action.label}</div>
<div className="mt-1 text-xs opacity-80">{action.detail}</div>
</div>
</div>
</a>
))}
</div>
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-white">Pending action</h3>
<p className="mt-1 text-sm text-slate-400">Drafts and scheduled items that still need a publishing decision.</p>
</div>
<div className="text-right text-sm text-slate-300">
<div>{Number(dashboard?.draft_artworks_count || 0)} drafts</div>
<div>{Number(dashboard?.scheduled_artworks_count || 0)} scheduled</div>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{draftsPendingAction.length > 0 ? draftsPendingAction.map((artwork) => (
<ContentCard key={artwork.id} item={artwork} fallbackLabel="Draft" />
)) : <EmptyCard title="No drafts waiting" description="This group has no draft artworks waiting for review or completion right now." />}
</div>
</div>
{pendingJoinRequests.length > 0 ? (
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-white">Pending join requests</h3>
<p className="mt-1 text-sm text-slate-400">Applicants waiting for a review decision.</p>
</div>
{group?.urls?.studio_join_requests ? <a href={group.urls.studio_join_requests} className="text-sm font-semibold text-sky-200">Open queue</a> : null}
</div>
<div className="mt-4 space-y-3">
{pendingJoinRequests.map((item) => (
<div key={item.id} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="font-semibold text-white">{item.user?.name || item.user?.username}</div>
<div className="mt-1 text-sm text-slate-400">{item.desired_role_label || item.desired_role || 'Contributor'} {item.created_at ? new Date(item.created_at).toLocaleDateString() : 'New'}</div>
</div>
))}
</div>
</div>
) : null}
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-white">Members</h2>
<a href={group?.urls?.studio_members} className="text-sm font-semibold text-sky-200">Manage</a>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{Object.entries(roleSummary).map(([role, count]) => (
<div key={role} className="rounded-2xl 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.16em] text-slate-500">{role}</div>
<div className="mt-1 text-xl font-semibold text-white">{Number(count)}</div>
</div>
))}
</div>
<div className="mt-4 space-y-3">
{members.slice(0, 6).map((member) => (
<div key={member.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.username} className="h-11 w-11 rounded-2xl object-cover" /> : <div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-white">{member.user?.name || member.user?.username}</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{member.role}</div>
</div>
</div>
))}
</div>
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recruitment</div>
<div className="mt-2 text-lg font-semibold text-white">{recruitment?.is_recruiting ? (recruitment.headline || 'Recruiting is active') : 'Recruitment is off'}</div>
<p className="mt-2 text-sm text-slate-400">{recruitment?.description || 'Set open roles, skills, and contact instructions from the recruitment page.'}</p>
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Releases</h2>
<p className="mt-1 text-sm text-slate-400">Track featured drops and current release pipelines.</p>
</div>
{group?.urls?.studio_releases ? <a href={group.urls.studio_releases} className="text-sm font-semibold text-sky-200">Manage</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentReleases.length > 0 ? recentReleases.map((release) => (
<ContentCard key={release.id} item={release} fallbackLabel="Release" />
)) : <EmptyCard title="No releases yet" description="Create a release to track milestones, contributors, and publication status." />}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Projects</h2>
<p className="mt-1 text-sm text-slate-400">Recent structured releases and collaboration hubs.</p>
</div>
{group?.urls?.studio_projects ? <a href={group.urls.studio_projects} className="text-sm font-semibold text-sky-200">Manage</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentProjects.length > 0 ? recentProjects.map((project) => (
<ContentCard key={project.id} item={project} fallbackLabel="Project" />
)) : <EmptyCard title="No projects yet" description="Create a project to bundle shared assets, linked artworks, and a release state." />}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Challenges</h2>
<p className="mt-1 text-sm text-slate-400">Current creative prompts and challenge arcs.</p>
</div>
{group?.urls?.studio_challenges ? <a href={group.urls.studio_challenges} className="text-sm font-semibold text-sky-200">Manage</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentChallenges.length > 0 ? recentChallenges.map((challenge) => (
<ContentCard key={challenge.id} item={challenge} fallbackLabel="Challenge" />
)) : <EmptyCard title="No challenges yet" description="Launch a challenge to keep the group active between major releases." />}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Trust summary</h2>
<p className="mt-1 text-sm text-slate-400">Public-facing trust labels and internal contributor health snapshot.</p>
</div>
{group?.urls?.studio_reputation ? <a href={group.urls.studio_reputation} className="text-sm font-semibold text-sky-200">Open dashboard</a> : null}
</div>
<div className="mt-4 flex flex-wrap gap-2">{trustSignals.map((signal) => <span key={signal.key} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">{signal.label}</span>)}</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-slate-300"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contributors</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputationSummary?.counts?.contributors || 0)}</div></div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-slate-300"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Group badges</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputationSummary?.counts?.group_badges || 0)}</div></div>
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Contributor highlights</h2>
<p className="mt-1 text-sm text-slate-400">Recent high-trust contributors and badge unlocks.</p>
</div>
</div>
<div className="mt-4 space-y-3">{Array.isArray(reputationSummary?.top_contributors) && reputationSummary.top_contributors.length > 0 ? reputationSummary.top_contributors.slice(0, 4).map((entry) => <div key={entry.user?.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="font-semibold text-white">{entry.user?.name || entry.user?.username}</div><div className="mt-1 text-sm text-slate-400">{entry.summary || 'Contributor'}</div><div className="mt-2 text-xs text-slate-500">{entry.counts?.releases || 0} releases {entry.counts?.credited_artworks || 0} artworks</div></div>) : <EmptyCard title="No contributor signals yet" description="Release and milestone activity will populate contributor reputation here." />}</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Recent artworks</h2>
<p className="mt-1 text-sm text-slate-400">Latest published work released under this group identity.</p>
</div>
<a href={group?.urls?.studio_artworks} className="text-sm font-semibold text-sky-200">View all</a>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentArtworks.length > 0 ? recentArtworks.map((artwork) => (
<ContentCard key={artwork.id} item={artwork} fallbackLabel="Published" />
)) : <EmptyCard title="No published artworks yet" description="Publish the first group artwork to start building this feed." />}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Events</h2>
<p className="mt-1 text-sm text-slate-400">Upcoming or recently updated moments on the group timeline.</p>
</div>
{group?.urls?.studio_events ? <a href={group.urls.studio_events} className="text-sm font-semibold text-sky-200">Manage</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentEvents.length > 0 ? recentEvents.map((event) => (
<ContentCard key={event.id} item={event} fallbackLabel="Event" />
)) : <EmptyCard title="No events yet" description="Schedule a launch, stream, or milestone to start the group timeline." />}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Recent collections</h2>
<p className="mt-1 text-sm text-slate-400">Collections most recently updated in this group workspace.</p>
</div>
<a href={group?.urls?.studio_collections} className="text-sm font-semibold text-sky-200">View all</a>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentCollections.length > 0 ? recentCollections.map((collection) => (
<ContentCard key={collection.id} item={collection} fallbackLabel="Collection" />
)) : <EmptyCard title="No collections yet" description="Create a collection to organize group work into campaigns, series, or themed sets." />}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Activity feed</h2>
<p className="mt-1 text-sm text-slate-400">Pinned and recent internal or public timeline items.</p>
</div>
{group?.urls?.studio_activity ? <a href={group.urls.studio_activity} className="text-sm font-semibold text-sky-200">Open feed</a> : null}
</div>
<div className="mt-4 space-y-3">
{recentActivity.length > 0 ? recentActivity.map((item) => (
<ActivityCard key={item.id} item={item} />
)) : <EmptyCard title="No activity items yet" description="Publishing projects, events, posts, and member milestones will populate this feed." />}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Review queue</h2>
<p className="mt-1 text-sm text-slate-400">Latest artwork submissions waiting for moderation.</p>
</div>
{group?.urls?.studio_review ? <a href={group.urls.studio_review} className="text-sm font-semibold text-sky-200">Open queue</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{reviewQueuePreview.length > 0 ? reviewQueuePreview.map((item) => (
<a key={item.id} href={item.urls?.edit} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<div className="text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.group_review_status}</div>
</a>
)) : <EmptyCard title="No pending reviews" description="Contributor submissions will appear here when they are sent for review." />}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Recent posts</h2>
<p className="mt-1 text-sm text-slate-400">Announcements and updates published from the group.</p>
</div>
{group?.urls?.studio_posts ? <a href={group.urls.studio_posts} className="text-sm font-semibold text-sky-200">Manage posts</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentPosts.length > 0 ? recentPosts.map((post) => (
<a key={post.id} href={post.url} className="rounded-[24px] 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">{post.type}</div>
<div className="mt-2 text-base font-semibold text-white">{post.title}</div>
<p className="mt-2 text-sm text-slate-400">{post.excerpt || 'Open post'}</p>
</a>
)) : <EmptyCard title="No posts yet" description="Create the first group announcement to add a public news feed." />}
</div>
</section>
</div>
<section className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Recent history</h2>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{recentHistory.length > 0 ? recentHistory.map((item) => (
<div key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-sm font-semibold text-white">{item.summary || item.action_type}</div>
<div className="mt-2 text-xs text-slate-400">{item.actor?.name || item.actor?.username || 'System'} {item.created_at ? new Date(item.created_at).toLocaleString() : 'Recently'}</div>
</div>
)) : <EmptyCard title="No history yet" description="Audit events will appear here as members review requests, posts, and submissions." />}
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,82 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupEventEditor() {
const { props } = usePage()
const eventRecord = props.event || null
const form = useForm({
title: eventRecord?.title || '',
summary: eventRecord?.summary || '',
description: eventRecord?.description || '',
event_type: eventRecord?.event_type || props.typeOptions?.[0]?.value || 'launch',
visibility: eventRecord?.visibility || props.visibilityOptions?.[0]?.value || 'public',
status: eventRecord?.status || props.statusOptions?.[0]?.value || 'draft',
start_at: eventRecord?.start_at ? eventRecord.start_at.slice(0, 16) : '',
end_at: eventRecord?.end_at ? eventRecord.end_at.slice(0, 16) : '',
timezone: eventRecord?.timezone || 'UTC',
location: eventRecord?.location || '',
external_url: eventRecord?.external_url || '',
linked_project_id: eventRecord?.linked_project?.id || '',
linked_collection_id: eventRecord?.linked_collection?.id || '',
linked_challenge_id: eventRecord?.linked_challenge?.id || '',
is_featured: Boolean(eventRecord?.is_featured),
cover_file: null,
})
const submit = (event) => {
event.preventDefault()
const options = { forceFormData: true, preserveScroll: true }
if (props.updateUrl) {
form.post(props.updateUrl, { ...options, _method: 'patch' })
return
}
form.post(props.storeUrl, options)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-4">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Event title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Event description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.event_type} onChange={(event) => form.setData('event_type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.typeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="datetime-local" value={form.data.end_at} onChange={(event) => form.setData('end_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input value={form.data.timezone} onChange={(event) => form.setData('timezone', event.target.value)} placeholder="Timezone" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={form.data.location} onChange={(event) => form.setData('location', event.target.value)} placeholder="Location" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<input value={form.data.external_url} onChange={(event) => form.setData('external_url', event.target.value)} placeholder="External link" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.linked_challenge_id} onChange={(event) => form.setData('linked_challenge_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked challenge</option>
{(props.challengeOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> Featured event</label>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<button type="submit" className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Save event</button>
</form>
{props.publishUrl ? <form onSubmit={(event) => { event.preventDefault(); form.post(props.publishUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6"><button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Publish event</button></form> : null}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupEvents() {
const { props } = usePage()
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-slate-400">Events let the group announce launches, sessions, milestones, and time-based updates.</div>
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create event</a> : null}
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{items.length > 0 ? items.map((event) => (
<a key={event.id} href={event.urls?.edit || event.url} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-white">{event.title}</h2>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{event.status}</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-400">{event.summary || 'Event page'}</p>
<div className="mt-4 text-xs text-slate-500">{event.start_at ? new Date(event.start_at).toLocaleString() : 'Unscheduled'} {event.event_type}</div>
</a>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No events yet.</div>}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,129 @@
import React, { useMemo, useState } from 'react'
import { Link, router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function formatInviteTimestamp(value) {
if (!value) return null
try {
return new Date(value).toLocaleString()
} catch {
return value
}
}
export default function StudioGroupInvitations() {
const { props } = usePage()
const invitations = Array.isArray(props.invitations) ? props.invitations : []
const activeMembers = Array.isArray(props.members) ? props.members.filter((member) => member.status === 'active') : []
const [invite, setInvite] = useState({ username: '', role: 'contributor', note: '', expires_in_days: 7 })
const pendingInvites = useMemo(
() => invitations.filter((item) => item.status === 'pending'),
[invitations]
)
const revokedInvites = useMemo(
() => invitations.filter((item) => item.status === 'revoked'),
[invitations]
)
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="mb-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/75">Group invitations</p>
<h2 className="mt-2 text-xl font-semibold text-white">Invite collaborators into {props.studioGroup?.name}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-300">Pending invites stay separate from active members here, so owners and admins can review who was invited, when the invite expires, and revoke access before acceptance.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href={props.studioGroup?.urls?.studio_members} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Members</Link>
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">{pendingInvites.length} pending</span>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-[1.1fr_0.8fr_1fr_0.7fr_auto]">
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={invite.expires_in_days} onChange={(event) => setInvite((current) => ({ ...current, expires_in_days: event.target.value }))} type="number" min="1" max="30" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => router.post(props.endpoints?.invite, { ...invite, expires_in_days: Number(invite.expires_in_days || 7) || 7 })} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Send invite</button>
</div>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-white">Pending invitations</h2>
<span className="text-sm text-slate-400">{pendingInvites.length} outstanding</span>
</div>
<div className="mt-4 space-y-3">
{pendingInvites.length > 0 ? pendingInvites.map((inviteRow) => (
<article key={inviteRow.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="flex items-center gap-3">
{inviteRow.user?.avatar_url ? <img src={inviteRow.user.avatar_url} alt={inviteRow.user.name || inviteRow.user.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
<div>
<div className="font-semibold text-white">{inviteRow.user?.name || inviteRow.user?.username}</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{inviteRow.role_label || inviteRow.role}</div>
</div>
</div>
<div className="md:ml-auto flex flex-wrap items-center gap-3 text-xs text-slate-400">
{inviteRow.invited_by ? <span>Invited by {inviteRow.invited_by.name || inviteRow.invited_by.username}</span> : null}
{inviteRow.invited_at ? <span>Sent {formatInviteTimestamp(inviteRow.invited_at)}</span> : null}
{inviteRow.expires_at ? <span>Expires {formatInviteTimestamp(inviteRow.expires_at)}</span> : null}
</div>
</div>
{inviteRow.note ? <p className="mt-3 text-sm text-slate-300">{inviteRow.note}</p> : null}
<div className="mt-4 flex flex-wrap gap-2">
{inviteRow.can_revoke && inviteRow.revoke_url ? <button type="button" onClick={() => router.delete(inviteRow.revoke_url)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100">Revoke invite</button> : null}
</div>
</article>
)) : <div className="rounded-[24px] border border-dashed border-white/10 px-6 py-12 text-center text-slate-400">No pending invites for this group.</div>}
</div>
</section>
<div className="space-y-6">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-white">Recent invite history</h2>
<span className="text-sm text-slate-400">{revokedInvites.length} revoked or expired</span>
</div>
<div className="mt-4 space-y-3">
{revokedInvites.length > 0 ? revokedInvites.map((inviteRow) => (
<article key={inviteRow.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="font-semibold text-white">{inviteRow.user?.name || inviteRow.user?.username}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{inviteRow.is_expired ? 'Expired' : 'Revoked'} {inviteRow.role_label || inviteRow.role}</div>
{inviteRow.invited_at ? <p className="mt-2 text-sm text-slate-400">Originally sent {formatInviteTimestamp(inviteRow.invited_at)}</p> : null}
</article>
)) : <div className="rounded-2xl border border-dashed border-white/10 px-4 py-8 text-center text-slate-400">No recent invite history yet.</div>}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-white">Active members</h2>
<span className="text-sm text-slate-400">{activeMembers.length} active</span>
</div>
<div className="mt-4 space-y-3">
{activeMembers.slice(0, 6).map((member) => (
<div key={member.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.username} className="h-11 w-11 rounded-2xl object-cover" /> : <div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-white">{member.user?.name || member.user?.username}</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{member.role_label || member.role}</div>
</div>
</div>
))}
</div>
</section>
</div>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,96 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function HistoryList({ items }) {
if (!Array.isArray(items) || items.length === 0) {
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No recent history yet.</div>
}
return (
<div className="space-y-3">
{items.map((item) => (
<div key={item.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm font-semibold text-white">{item.summary || item.action_type}</div>
<div className="mt-1 text-xs text-slate-400">{item.actor?.name || item.actor?.username || 'System'} {item.created_at ? new Date(item.created_at).toLocaleString() : 'Recently'}</div>
</div>
))}
</div>
)
}
export default function StudioGroupJoinRequests() {
const { props } = usePage()
const listing = props.listing || {}
const items = Array.isArray(listing.items) ? listing.items : []
const approve = (request) => {
const role = window.prompt('Role to assign on approval? contributor, editor, or admin', request.desired_role || 'contributor') || request.desired_role || 'contributor'
const notes = window.prompt('Optional approval note', '') || ''
router.post(request.can_approve ? routeUrl(props.studioGroup?.urls?.studio_join_requests, request.id, 'approve') : '', { action: 'approve', role, review_notes: notes })
}
const reject = (request) => {
const notes = window.prompt('Optional rejection note', '') || ''
router.post(request.can_reject ? routeUrl(props.studioGroup?.urls?.studio_join_requests, request.id, 'reject') : '', { action: 'reject', review_notes: notes })
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Incoming requests</h2>
<p className="mt-1 text-sm text-slate-400">Approve, reject, and assign roles from one queue.</p>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{listing.filters?.bucket || 'pending'}</span>
</div>
<div className="mt-4 space-y-4">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
{item.user?.avatar_url ? <img src={item.user.avatar_url} alt={item.user.name || item.user.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
<div>
<div className="font-semibold text-white">{item.user?.name || item.user?.username}</div>
<div className="text-sm text-slate-400">Requested role: {item.desired_role_label || item.desired_role || 'Contributor'}</div>
</div>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{item.status}</span>
</div>
{item.message ? <p className="mt-4 text-sm leading-6 text-slate-300">{item.message}</p> : null}
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-400">
{item.portfolio_url ? <a href={item.portfolio_url} className="text-sky-200 underline underline-offset-4">Portfolio</a> : null}
{Array.isArray(item.skills) && item.skills.length > 0 ? <span>{item.skills.join(', ')}</span> : null}
{item.created_at ? <span>{new Date(item.created_at).toLocaleString()}</span> : null}
</div>
{item.review_notes ? <p className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-300">{item.review_notes}</p> : null}
{item.can_approve || item.can_reject ? (
<div className="mt-4 flex flex-wrap gap-2">
{item.can_approve ? <button type="button" onClick={() => approve(item)} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Approve</button> : null}
{item.can_reject ? <button type="button" onClick={() => reject(item)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Reject</button> : null}
</div>
) : null}
</article>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No join requests in this bucket.</div>}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Recent history</h2>
<p className="mt-1 text-sm text-slate-400">Audit trail for moderation-sensitive group actions.</p>
<div className="mt-4">
<HistoryList items={props.recentHistory} />
</div>
</section>
</div>
</StudioLayout>
)
}
function routeUrl(baseUrl, id, action) {
if (!baseUrl) return ''
return `${String(baseUrl).replace(/\/$/, '')}/${id}/${action}`
}

View File

@@ -0,0 +1,193 @@
import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function overrideMap(member) {
const entries = Array.isArray(member.permission_overrides) ? member.permission_overrides : []
return entries.reduce((carry, item) => {
if (!item?.key) return carry
carry[item.key] = item.is_allowed === true ? 'allow' : 'deny'
return carry
}, {})
}
function prettifyPermission(value) {
return String(value || '').replaceAll('_', ' ')
}
export default function StudioGroupMembers() {
const { props } = usePage()
const canManageMembers = Boolean(props.canManageMembers)
const [invite, setInvite] = useState({ username: '', role: 'contributor', note: '' })
const [search, setSearch] = useState('')
const [editingMemberId, setEditingMemberId] = useState(null)
const [permissionDrafts, setPermissionDrafts] = useState({})
const members = Array.isArray(props.members) ? props.members : []
const permissionOptions = Array.isArray(props.permissionOverrideOptions) ? props.permissionOverrideOptions : []
const filteredMembers = members.filter((member) => {
const haystack = `${member.user?.name || ''} ${member.user?.username || ''} ${member.role_label || member.role || ''}`.toLowerCase()
return haystack.includes(search.trim().toLowerCase())
})
const confirmTransfer = (member) => {
if (!window.confirm(`Transfer ownership of this group to ${member.user?.name || member.user?.username}? This removes your owner privileges immediately.`)) {
return
}
router.post(props.endpoints?.transferPattern.replace('__MEMBER__', String(member.id)))
}
const confirmRemoval = (member) => {
if (!window.confirm(`Remove ${member.user?.name || member.user?.username} from this group?`)) {
return
}
router.delete(props.endpoints?.deletePattern.replace('__MEMBER__', String(member.id)))
}
const openPermissionEditor = (member) => {
setEditingMemberId(member.id)
setPermissionDrafts((current) => ({ ...current, [member.id]: overrideMap(member) }))
}
const setPermissionState = (memberId, key, value) => {
setPermissionDrafts((current) => ({
...current,
[memberId]: {
...(current[memberId] || {}),
[key]: value,
},
}))
}
const savePermissions = (member) => {
const state = permissionDrafts[member.id] || {}
const payload = permissionOptions
.filter((option) => state[option.value] === 'allow' || state[option.value] === 'deny')
.map((option) => ({ key: option.value, is_allowed: state[option.value] === 'allow' }))
router.patch(props.endpoints?.permissionsPattern.replace('__MEMBER__', String(member.id)), {
permission_overrides: payload,
}, {
onSuccess: () => setEditingMemberId(null),
})
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
{canManageMembers ? (
<section className="mb-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-white">Invite member</h2>
{props.endpoints?.invitations ? <a href={props.endpoints.invitations} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Manage invitations</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_0.8fr_1fr_auto]">
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => router.post(props.endpoints?.invite, invite)} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Invite</button>
</div>
</section>
) : null}
<section 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-end md:justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Member directory</h2>
<p className="mt-1 text-sm text-slate-400">Search the current roster, then adjust roles or membership status.</p>
</div>
<label className="grid gap-2 text-sm text-slate-300 md:min-w-[260px]">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search members</span>
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Name, username, or role" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="mt-5 overflow-hidden rounded-[24px] border border-white/10">
<div className="hidden grid-cols-[minmax(0,1.5fr)_160px_120px_minmax(0,220px)] gap-3 border-b border-white/10 bg-white/[0.04] px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400 md:grid">
<span>Member</span>
<span>Role</span>
<span>Status</span>
<span>Actions</span>
</div>
<div className="divide-y divide-white/10">
{filteredMembers.map((member) => (
<article key={member.id} className="grid gap-4 px-4 py-4 md:grid-cols-[minmax(0,1.5fr)_160px_120px_minmax(0,220px)] md:items-center">
<div className="flex items-center gap-3">
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
<div>
<div className="font-semibold text-white">{member.user?.name || member.user?.username}</div>
<div className="text-sm text-slate-400">@{member.user?.username || 'member'}</div>
</div>
</div>
<div>
{canManageMembers && member.role !== 'owner' ? (
<select value={member.role} onChange={(event) => router.patch(props.endpoints?.updatePattern.replace('__MEMBER__', String(member.id)), { role: event.target.value })} className="w-full rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-white outline-none">
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
) : <span className="inline-flex rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100">{member.role === 'owner' ? 'Owner' : (member.role_label || member.role)}</span>}
{Array.isArray(member.permission_overrides) && member.permission_overrides.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{member.permission_overrides.map((permission) => (
<span key={`${permission.key}-${permission.is_allowed ? 'allow' : 'deny'}`} className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] ${permission.is_allowed ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-rose-300/20 bg-rose-400/10 text-rose-100'}`}>
{permission.is_allowed ? 'Allow' : 'Deny'} {prettifyPermission(permission.key)}
</span>
))}
</div>
) : null}
</div>
<div>
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-300">{member.status}</span>
</div>
<div className="flex flex-wrap gap-2">
{member.can_manage_permissions ? <button type="button" onClick={() => openPermissionEditor(member)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Permissions</button> : null}
{canManageMembers && member.can_transfer ? <button type="button" onClick={() => confirmTransfer(member)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Transfer</button> : null}
{canManageMembers && member.can_revoke ? <button type="button" onClick={() => confirmRemoval(member)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100">Remove</button> : null}
</div>
{editingMemberId === member.id ? (
<div className="md:col-span-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">Permission overrides</h3>
<p className="mt-1 text-xs text-slate-400">Set each advanced capability to inherit, allow, or deny.</p>
</div>
<div className="flex gap-2">
<button type="button" onClick={() => setEditingMemberId(null)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Cancel</button>
<button type="button" onClick={() => savePermissions(member)} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-2 text-sm font-semibold text-sky-100">Save</button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{permissionOptions.map((option) => {
const current = permissionDrafts[member.id]?.[option.value] || 'inherit'
return (
<div key={option.value} className="rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="text-sm font-semibold text-white">{option.label}</div>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'inherit')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'inherit' ? 'border-white/20 bg-white/[0.08] text-white' : 'border-white/10 bg-transparent text-slate-300'}`}>Inherit</button>
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'allow')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'allow' ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-white/10 bg-transparent text-slate-300'}`}>Allow</button>
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'deny')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'deny' ? 'border-rose-300/20 bg-rose-400/10 text-rose-100' : 'border-white/10 bg-transparent text-slate-300'}`}>Deny</button>
</div>
</div>
)
})}
</div>
</div>
) : null}
</article>
))}
{filteredMembers.length === 0 ? <div className="px-4 py-8 text-sm text-slate-400">No members match the current search.</div> : null}
</div>
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,64 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupPostEditor() {
const { props } = usePage()
const post = props.post || {}
const form = useForm({
type: post.type || 'announcement',
title: post.title || '',
excerpt: post.excerpt || '',
content: post.content || '',
cover_path: post.cover_url || '',
})
const submit = (event) => {
event.preventDefault()
if (props.updateUrl) {
form.patch(props.updateUrl)
return
}
form.post(props.storeUrl)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</span>
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Content</span>
<textarea value={form.data.content} onChange={(event) => form.setData('content', event.target.value)} rows={12} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Post controls</h2>
<div className="mt-5 space-y-3">
<button type="submit" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save</button>
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100">Publish</button> : null}
{props.pinUrl ? <button type="button" onClick={() => router.post(props.pinUrl)} className="w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">Toggle pinned</button> : null}
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Archive</button> : null}
</div>
</section>
</form>
</StudioLayout>
)
}

View File

@@ -0,0 +1,44 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupPosts() {
const { props } = usePage()
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Post library</h2>
<p className="mt-1 text-sm text-slate-400">Draft, publish, pin, and archive public group posts.</p>
</div>
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">New post</a> : null}
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{item.type}</div>
<h3 className="mt-2 text-lg font-semibold text-white">{item.title}</h3>
</div>
<div className="flex flex-col items-end gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{item.status}</span>
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-amber-100">Pinned</span> : null}
</div>
</div>
<p className="mt-3 text-sm leading-6 text-slate-300">{item.excerpt || item.content || 'No excerpt yet.'}</p>
<div className="mt-4 flex flex-wrap gap-2">
<a href={item.urls?.edit} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</a>
{item.urls?.public ? <a href={item.urls.public} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">View</a> : null}
</div>
</article>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No posts yet.</div>}
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,143 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function normalizeIds(values) {
return Array.from(values || []).map((option) => Number(option.value)).filter((value) => Number.isFinite(value) && value > 0)
}
export default function StudioGroupProjectEditor() {
const { props } = usePage()
const project = props.project || null
const form = useForm({
title: project?.title || '',
summary: project?.summary || '',
description: project?.description || '',
visibility: project?.visibility || props.visibilityOptions?.[0]?.value || 'public',
status: project?.status || props.statusOptions?.[0]?.value || 'planned',
start_date: project?.start_date || '',
target_date: project?.target_date || '',
lead_user_id: project?.lead?.id || '',
linked_collection_id: project?.linked_collection?.id || '',
linked_featured_artwork_id: '',
pinned_post_id: project?.pinned_post?.id || '',
member_user_ids: Array.isArray(project?.team) ? project.team.map((member) => member.id) : [],
cover_file: null,
})
const artworkAttach = useForm({ artwork_id: '' })
const assetAttach = useForm({ asset_id: '' })
const statusForm = useForm({ status: project?.status || props.statusOptions?.[0]?.value || 'planned' })
const milestoneForm = useForm({ title: '', summary: '', status: 'pending', due_date: '', owner_user_id: '', notes: '' })
const submit = (event) => {
event.preventDefault()
const options = { forceFormData: true, preserveScroll: true }
if (props.updateUrl) {
form.post(props.updateUrl, { ...options, _method: 'patch' })
return
}
form.post(props.storeUrl, options)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="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="grid gap-4">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Project title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Longer project description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="date" value={form.data.start_date} onChange={(event) => form.setData('start_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="date" value={form.data.target_date} onChange={(event) => form.setData('target_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.lead_user_id} onChange={(event) => form.setData('lead_user_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No lead</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<select multiple value={form.data.member_user_ids.map(String)} onChange={(event) => form.setData('member_user_ids', normalizeIds(event.target.selectedOptions))} className="min-h-40 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.linked_featured_artwork_id} onChange={(event) => form.setData('linked_featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No featured artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.pinned_post_id} onChange={(event) => form.setData('pinned_post_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No pinned post</option>
{(props.postOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<button type="submit" disabled={form.processing} className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">{form.processing ? 'Saving…' : 'Save project'}</button>
</section>
<div className="space-y-6">
{props.statusUrl ? (
<form onSubmit={(event) => { event.preventDefault(); statusForm.post(props.statusUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Status</h2>
<select value={statusForm.data.status} onChange={(event) => statusForm.setData('status', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Update status</button>
</form>
) : null}
{props.attachArtworkUrl ? (
<form onSubmit={(event) => { event.preventDefault(); artworkAttach.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
<select value={artworkAttach.data.artwork_id} onChange={(event) => artworkAttach.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach artwork</button>
</form>
) : null}
{props.attachAssetUrl ? (
<form onSubmit={(event) => { event.preventDefault(); assetAttach.post(props.attachAssetUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach asset</h2>
<select value={assetAttach.data.asset_id} onChange={(event) => assetAttach.setData('asset_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose asset</option>
{(props.assetOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach asset</button>
</form>
) : null}
{props.storeMilestoneUrl ? (
<form onSubmit={(event) => { event.preventDefault(); milestoneForm.post(props.storeMilestoneUrl, { preserveScroll: true, onSuccess: () => milestoneForm.reset('title', 'summary', 'due_date', 'owner_user_id', 'notes') }) }} 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">
<input value={milestoneForm.data.title} onChange={(event) => milestoneForm.setData('title', event.target.value)} placeholder="Milestone title" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-3 md:grid-cols-2">
<select value={milestoneForm.data.status} onChange={(event) => milestoneForm.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<select value={milestoneForm.data.owner_user_id} onChange={(event) => milestoneForm.setData('owner_user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No owner</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add milestone</button>
</div>
{Array.isArray(project?.milestones) && project.milestones.length > 0 ? <div className="mt-6 space-y-3">{project.milestones.map((milestone) => <div key={milestone.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="flex items-center justify-between gap-3"><div><div className="font-semibold text-white">{milestone.title}</div><div className="mt-1 text-xs text-slate-500">{milestone.owner?.name || milestone.owner?.username || 'No owner'}{milestone.due_date ? ` • due ${milestone.due_date}` : ''}</div></div><button type="button" onClick={() => router.patch(props.updateMilestonePattern.replace('__MILESTONE__', String(milestone.id)), { title: milestone.title, summary: milestone.summary || '', status: milestone.status === 'completed' ? 'active' : 'completed', due_date: milestone.due_date || '', owner_user_id: milestone.owner?.id || '', notes: milestone.notes || '' }, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white">Mark {milestone.status === 'completed' ? 'active' : 'complete'}</button></div>{milestone.summary ? <p className="mt-2 text-sm text-slate-400">{milestone.summary}</p> : null}</div>)}</div> : null}
</form>
) : null}
</div>
</form>
</StudioLayout>
)
}

View File

@@ -0,0 +1,30 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupProjects() {
const { props } = usePage()
const listing = props.listing || {}
const items = Array.isArray(listing.items) ? listing.items : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-slate-400">Projects give the group a structured place for releases, teams, and linked outputs.</div>
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create project</a> : null}
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{items.length > 0 ? items.map((project) => (
<a key={project.id} href={project.urls?.edit || project.url} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-white">{project.title}</h2>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{project.status}</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-400">{project.summary || 'Project page'}</p>
<div className="mt-4 text-xs text-slate-500">{project.counts?.artworks || 0} artworks {project.counts?.assets || 0} assets {project.counts?.team || 0} team {project.counts?.milestones || 0} milestones {project.counts?.releases || 0} releases</div>
</a>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No projects yet.</div>}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,101 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function toggleItem(list, value) {
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]
}
export default function StudioGroupRecruitment() {
const { props } = usePage()
const recruitment = props.recruitment || {}
const form = useForm({
is_recruiting: Boolean(recruitment.is_recruiting),
headline: recruitment.headline || '',
description: recruitment.description || '',
roles_json: Array.isArray(recruitment.roles) ? recruitment.roles : [],
skills_json: Array.isArray(recruitment.skills) ? recruitment.skills : [],
contact_mode: recruitment.contact_mode || 'join_request',
visibility: recruitment.visibility || 'public',
})
const submit = (event) => {
event.preventDefault()
form.patch(props.updateUrl)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Recruitment profile</h2>
<p className="mt-1 text-sm text-slate-400">Describe what the group is looking for and how applicants should reach you.</p>
</div>
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white">
<input type="checkbox" checked={form.data.is_recruiting} onChange={(event) => form.setData('is_recruiting', event.target.checked)} />
Recruiting now
</label>
</div>
<div className="mt-5 grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Headline</span>
<input value={form.data.headline} onChange={(event) => form.setData('headline', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Description</span>
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} rows={7} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Roles</span>
<div className="flex flex-wrap gap-2 rounded-[24px] border border-white/10 bg-black/20 p-4">
{(Array.isArray(props.roleOptions) ? props.roleOptions : []).map((option) => {
const selected = form.data.roles_json.includes(option.value)
return <button key={option.value} type="button" onClick={() => form.setData('roles_json', toggleItem(form.data.roles_json, option.value))} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${selected ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-white/10 bg-white/[0.03] text-slate-300'}`}>{option.label}</button>
})}
</div>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Skills</span>
<div className="flex flex-wrap gap-2 rounded-[24px] border border-white/10 bg-black/20 p-4">
{(Array.isArray(props.skillOptions) ? props.skillOptions : []).map((option) => {
const selected = form.data.skills_json.includes(option.value)
return <button key={option.value} type="button" onClick={() => form.setData('skills_json', toggleItem(form.data.skills_json, option.value))} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${selected ? 'border-sky-300/20 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-white/[0.03] text-slate-300'}`}>{option.label}</button>
})}
</div>
</label>
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Application settings</h2>
<div className="mt-5 grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contact mode</span>
<select value={form.data.contact_mode} onChange={(event) => form.setData('contact_mode', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.contactModes) ? props.contactModes : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</span>
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.visibilityOptions) ? props.visibilityOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
<p className="font-semibold text-white">Public preview</p>
<p className="mt-2">{form.data.headline || 'No headline yet.'}</p>
<p className="mt-2 text-slate-400">{form.data.description || 'Recruitment copy will show here once you add it.'}</p>
{form.data.roles_json.length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{form.data.roles_json.map((role) => <span key={role} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-white">{role}</span>)}</div> : null}
</div>
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save recruitment profile</button>
</div>
</section>
</form>
</StudioLayout>
)
}

View File

@@ -0,0 +1,154 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function toDateTimeInput(value) {
return value ? String(value).slice(0, 16) : ''
}
export default function StudioGroupReleaseEditor() {
const { props } = usePage()
const release = props.release || null
const form = useForm({
title: release?.title || '',
summary: release?.summary || '',
description: release?.description || '',
release_notes: release?.release_notes || '',
visibility: release?.visibility || props.visibilityOptions?.[0]?.value || 'public',
status: release?.status || props.statusOptions?.[0]?.value || 'draft',
current_stage: release?.current_stage || props.stageOptions?.[0]?.value || 'concept',
planned_release_at: toDateTimeInput(release?.planned_release_at),
lead_user_id: release?.lead?.id || '',
linked_project_id: release?.linked_project?.id || '',
linked_collection_id: release?.linked_collection?.id || '',
featured_artwork_id: release?.featured_artwork?.id || '',
is_featured: Boolean(release?.is_featured),
cover_file: null,
})
const stageForm = useForm({ current_stage: release?.current_stage || props.stageOptions?.[0]?.value || 'concept' })
const artworkAttach = useForm({ artwork_id: '' })
const contributorForm = useForm({ user_id: '', role_label: '' })
const milestoneForm = useForm({ title: '', summary: '', status: 'pending', due_date: '', owner_user_id: '', notes: '' })
const submit = (event) => {
event.preventDefault()
const options = { forceFormData: true, preserveScroll: true }
if (props.updateUrl) {
form.transform((data) => ({ ...data, _method: 'patch' })).post(props.updateUrl, options)
return
}
form.post(props.storeUrl, options)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="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="grid gap-4">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Release title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Release overview" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.release_notes} onChange={(event) => form.setData('release_notes', event.target.value)} placeholder="Release notes" rows={7} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.current_stage} onChange={(event) => form.setData('current_stage', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.stageOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
</div>
<input type="datetime-local" value={form.data.planned_release_at} onChange={(event) => form.setData('planned_release_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.lead_user_id} onChange={(event) => form.setData('lead_user_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No release lead</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.featured_artwork_id} onChange={(event) => form.setData('featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No featured artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
Feature this release on the public group page
</label>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button type="submit" disabled={form.processing} className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">{form.processing ? 'Saving…' : 'Save release'}</button>
{release?.url ? <a href={release.url} className="rounded-full border border-white/10 bg-black/20 px-5 py-2.5 text-sm font-semibold text-white">View public page</a> : null}
</div>
</section>
<div className="space-y-6">
{props.stageUrl ? (
<form onSubmit={(event) => { event.preventDefault(); stageForm.post(props.stageUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Stage</h2>
<select value={stageForm.data.current_stage} onChange={(event) => stageForm.setData('current_stage', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.stageOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<div className="mt-4 flex flex-wrap gap-2">
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Update stage</button>
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl, {}, { preserveScroll: true })} className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100">Publish</button> : null}
</div>
</form>
) : null}
{props.attachArtworkUrl ? (
<form onSubmit={(event) => { event.preventDefault(); artworkAttach.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
<select value={artworkAttach.data.artwork_id} onChange={(event) => artworkAttach.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach artwork</button>
</form>
) : null}
{props.attachContributorUrl ? (
<form onSubmit={(event) => { event.preventDefault(); contributorForm.post(props.attachContributorUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Contributor credit</h2>
<div className="mt-4 space-y-3">
<select value={contributorForm.data.user_id} onChange={(event) => contributorForm.setData('user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose contributor</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<input value={contributorForm.data.role_label} onChange={(event) => contributorForm.setData('role_label', event.target.value)} placeholder="Role label" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach contributor</button>
</div>
{Array.isArray(release?.contributors) && release.contributors.length > 0 ? <div className="mt-6 space-y-3">{release.contributors.map((contributor) => <div key={contributor.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><div className="font-semibold">{contributor.name || contributor.username}</div><div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{contributor.role_label || 'Contributor'}</div></div>)}</div> : null}
</form>
) : null}
{props.storeMilestoneUrl ? (
<form onSubmit={(event) => { event.preventDefault(); milestoneForm.post(props.storeMilestoneUrl, { preserveScroll: true, onSuccess: () => milestoneForm.reset('title', 'summary', 'due_date', 'owner_user_id', 'notes') }) }} 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">
<input value={milestoneForm.data.title} onChange={(event) => milestoneForm.setData('title', event.target.value)} placeholder="Milestone title" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-3 md:grid-cols-2">
<select value={milestoneForm.data.status} onChange={(event) => milestoneForm.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<select value={milestoneForm.data.owner_user_id} onChange={(event) => milestoneForm.setData('owner_user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No owner</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add milestone</button>
</div>
{Array.isArray(release?.milestones) && release.milestones.length > 0 ? <div className="mt-6 space-y-3">{release.milestones.map((milestone) => <div key={milestone.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="flex items-center justify-between gap-3"><div><div className="font-semibold text-white">{milestone.title}</div><div className="mt-1 text-xs text-slate-500">{milestone.owner?.name || milestone.owner?.username || 'No owner'}{milestone.due_date ? ` • due ${milestone.due_date}` : ''}</div></div><button type="button" onClick={() => router.patch(props.updateMilestonePattern.replace('__MILESTONE__', String(milestone.id)), { title: milestone.title, summary: milestone.summary || '', status: milestone.status === 'completed' ? 'active' : 'completed', due_date: milestone.due_date || '', owner_user_id: milestone.owner?.id || '', notes: milestone.notes || '' }, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white">Mark {milestone.status === 'completed' ? 'active' : 'complete'}</button></div>{milestone.summary ? <p className="mt-2 text-sm text-slate-400">{milestone.summary}</p> : null}</div>)}</div> : null}
</form>
) : null}
</div>
</form>
</StudioLayout>
)
}

View File

@@ -0,0 +1,47 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupReleases() {
const { props } = usePage()
const listing = props.listing || {}
const items = Array.isArray(listing.items) ? listing.items : []
const bucketOptions = Array.isArray(listing.bucket_options) ? listing.bucket_options : []
const currentBucket = listing.filters?.bucket || 'all'
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-sm text-slate-400">Track the release pipeline from draft through public launch, with milestones and contributor credits.</div>
<div className="flex items-center gap-3">
<select value={currentBucket} onChange={(event) => router.get(window.location.pathname, { bucket: event.target.value }, { preserveScroll: true, preserveState: true })} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white outline-none">
{bucketOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create release</a> : null}
</div>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{items.length > 0 ? items.map((release) => (
<div key={release.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.03]">
{release.cover_url ? <img src={release.cover_url} alt={release.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-white/[0.03] text-slate-500"><i className="fa-solid fa-rocket text-2xl" /></div>}
<div className="p-5">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.status}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.current_stage}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.visibility}</span>
</div>
<h2 className="mt-3 text-xl font-semibold text-white">{release.title}</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">{release.summary || 'Release page'}</p>
<div className="mt-4 text-xs text-slate-500">{release.counts?.artworks || 0} artworks {release.counts?.contributors || 0} contributors {release.counts?.milestones || 0} milestones</div>
<div className="mt-4 flex flex-wrap gap-2">
<a href={release.urls?.edit || release.url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Manage</a>
{release.urls?.public ? <a href={release.urls.public} className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm font-semibold text-white">View public</a> : null}
</div>
</div>
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No releases yet.</div>}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,105 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function MetricCard({ label, value }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</div>
<div className="mt-2 text-2xl font-semibold text-white">{Number(value || 0).toFixed(1)}</div>
</div>
)
}
export default function StudioGroupReputation() {
const { props } = usePage()
const reputation = props.reputation || {}
const trustSignals = Array.isArray(props.trustSignals) ? props.trustSignals : []
const metrics = props.metrics || {}
const topContributors = Array.isArray(reputation.top_contributors) ? reputation.top_contributors : []
const recentBadges = Array.isArray(reputation.recent_badges) ? reputation.recent_badges : []
const memberBadgeUnlocks = Array.isArray(reputation.member_badge_unlocks) ? reputation.member_badge_unlocks : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<MetricCard label="Freshness" value={metrics.freshness_score} />
<MetricCard label="Activity" value={metrics.activity_score} />
<MetricCard label="Release" value={metrics.release_score} />
<MetricCard label="Trust" value={metrics.trust_score} />
<MetricCard label="Collaboration" value={metrics.collaboration_score} />
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Trust signals</h2>
<p className="mt-1 text-sm text-slate-400">Public-safe labels that shape discovery and confidence.</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">{trustSignals.map((signal) => <span key={signal.key} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">{signal.label}</span>)}</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contributors</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputation.counts?.contributors || 0)}</div></div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Member badges</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputation.counts?.member_badges || 0)}</div></div>
</div>
{metrics.last_calculated_at ? <div className="mt-4 text-xs text-slate-500">Last calculated {new Date(metrics.last_calculated_at).toLocaleString()}</div> : null}
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Top contributors</h2>
<p className="mt-1 text-sm text-slate-400">Reputation summaries derived from visible collaboration history.</p>
</div>
</div>
<div className="mt-4 space-y-3">
{topContributors.length > 0 ? topContributors.map((entry) => (
<div key={entry.user?.id} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<div className="flex items-center gap-3">
{entry.user?.avatar_url ? <img src={entry.user.avatar_url} alt={entry.user?.name || entry.user?.username} className="h-11 w-11 rounded-2xl object-cover" /> : <div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate font-semibold text-white">{entry.user?.name || entry.user?.username}</div>
{entry.trusted_indicator ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Trusted</span> : null}
</div>
<div className="mt-1 text-sm text-slate-400">{entry.summary || 'Contributor'}</div>
</div>
</div>
<div className="mt-3 text-xs text-slate-500">{entry.counts?.releases || 0} releases {entry.counts?.projects || 0} projects {entry.counts?.credited_artworks || 0} artworks {entry.counts?.review_actions || 0} reviews</div>
{Array.isArray(entry.badges) && entry.badges.length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{entry.badges.map((badge) => <span key={`${entry.user?.id}-${badge.key}`} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{badge.label}</span>)}</div> : null}
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No contributor reputation signals yet.</div>}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Group badges</h2>
<div className="mt-4 space-y-3">
{recentBadges.length > 0 ? recentBadges.map((badge) => (
<div key={badge.key} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
<div className="font-semibold text-white">{badge.label}</div>
<div className="mt-2 text-sm text-slate-400">{badge.reason}</div>
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No group badges awarded yet.</div>}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Recent member badge unlocks</h2>
<div className="mt-4 space-y-3">
{memberBadgeUnlocks.length > 0 ? memberBadgeUnlocks.map((entry) => (
<div key={`${entry.user?.id}-${entry.badge?.key}`} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
<div className="font-semibold text-white">{entry.user?.name || entry.user?.username}</div>
<div className="mt-1 text-sm text-sky-200">{entry.badge?.label}</div>
<div className="mt-2 text-sm text-slate-400">{entry.badge?.reason}</div>
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No member badge unlocks yet.</div>}
</div>
</section>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,74 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function actionUrl(item, key) {
return item?.urls?.[key] || ''
}
export default function StudioGroupReviewQueue() {
const { props } = usePage()
const listing = props.listing || {}
const items = Array.isArray(listing.items) ? listing.items : []
const sendAction = (item, action) => {
const notes = window.prompt('Optional reviewer note', '') || ''
router.post(actionUrl(item, action), { action, review_notes: notes })
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Submission queue</h2>
<p className="mt-1 text-sm text-slate-400">Review artwork drafts before they publish under the group identity.</p>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{listing.filters?.bucket || 'submitted'}</span>
</div>
<div className="mt-4 space-y-4">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-start gap-4">
{item.thumb ? <img src={item.thumb} alt={item.title} className="h-24 w-24 rounded-2xl object-cover" /> : <div className="flex h-24 w-24 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-image" /></div>}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{item.group_review_status}</span>
</div>
<div className="mt-2 flex flex-wrap gap-3 text-xs text-slate-400">
{item.primary_author ? <span>Author: {item.primary_author.name || item.primary_author.username}</span> : null}
{item.uploader ? <span>Uploader: {item.uploader.name || item.uploader.username}</span> : null}
{item.submitted_at ? <span>Submitted {new Date(item.submitted_at).toLocaleString()}</span> : null}
</div>
{item.group_review_notes ? <p className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-300">{item.group_review_notes}</p> : null}
<div className="mt-4 flex flex-wrap gap-2">
<a href={item.urls?.edit} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Open draft</a>
{item.can_review ? <button type="button" onClick={() => sendAction(item, 'approve')} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Approve</button> : null}
{item.can_review ? <button type="button" onClick={() => sendAction(item, 'needs_changes')} className="rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100">Needs changes</button> : null}
{item.can_review ? <button type="button" onClick={() => sendAction(item, 'reject')} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Reject</button> : null}
</div>
</div>
</div>
</article>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No submissions in this bucket.</div>}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Recent history</h2>
<div className="mt-4 space-y-3">
{(Array.isArray(props.recentHistory) ? props.recentHistory : []).map((item) => (
<div key={item.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm font-semibold text-white">{item.summary || item.action_type}</div>
<div className="mt-1 text-xs text-slate-400">{item.actor?.name || item.actor?.username || 'System'} {item.created_at ? new Date(item.created_at).toLocaleString() : 'Recently'}</div>
</div>
))}
</div>
</section>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,187 @@
import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function resolveMediaPreviewUrl(path, filesCdnUrl) {
const trimmed = String(path || '').trim()
if (!trimmed) {
return ''
}
if (trimmed.startsWith('blob:') || trimmed.startsWith('data:') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed
}
return `${String(filesCdnUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\/+/, '')}`
}
export default function StudioGroupSettings() {
const { props } = usePage()
const group = props.studioGroup || {}
const filesCdnUrl = props?.cdn?.files_url || ''
const featuredArtworkOptions = Array.isArray(props.featuredArtworkOptions) ? props.featuredArtworkOptions : []
const avatarInputRef = useRef(null)
const bannerInputRef = useRef(null)
const [form, setForm] = useState({
name: group.name || '',
slug: group.slug || '',
headline: group.headline || '',
bio: group.bio || '',
type: group.type || '',
founded_at: group.founded_at ? String(group.founded_at).slice(0, 10) : '',
avatar_path: group.avatar_path || group.avatar_url || '',
banner_path: group.banner_path || group.banner_url || '',
visibility: group.visibility || 'public',
membership_policy: group.membership_policy || 'invite_only',
website_url: group.website_url || '',
links_json: Array.isArray(group.links) && group.links.length > 0 ? group.links : [{ label: '', url: '' }],
featured_artwork_id: group.featured_artwork_id || '',
avatar_file: null,
banner_file: null,
})
const [avatarPreview, setAvatarPreview] = useState('')
const [bannerPreview, setBannerPreview] = useState('')
const resolvedAvatarPreview = useMemo(() => avatarPreview || resolveMediaPreviewUrl(form.avatar_path || group.avatar_url, filesCdnUrl), [avatarPreview, form.avatar_path, group.avatar_url, filesCdnUrl])
const resolvedBannerPreview = useMemo(() => bannerPreview || resolveMediaPreviewUrl(form.banner_path || group.banner_url, filesCdnUrl), [bannerPreview, form.banner_path, group.banner_url, filesCdnUrl])
const selectedFeaturedArtwork = useMemo(
() => featuredArtworkOptions.find((item) => Number(item.id) === Number(form.featured_artwork_id)) || null,
[featuredArtworkOptions, form.featured_artwork_id],
)
const updateLink = (index, key, value) => {
setForm((current) => ({
...current,
links_json: current.links_json.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item),
}))
}
const addLink = () => {
setForm((current) => ({
...current,
links_json: [...current.links_json, { label: '', url: '' }],
}))
}
const removeLink = (index) => {
setForm((current) => ({
...current,
links_json: current.links_json.filter((_, itemIndex) => itemIndex !== index),
}))
}
const submit = () => {
router.post(props.endpoints?.update, {
_method: 'patch',
...form,
links_json: form.links_json.filter((item) => item.label.trim() !== '' || item.url.trim() !== ''),
}, {
forceFormData: true,
})
}
const handleFileSelected = (field, setPreview) => (event) => {
const file = event.target.files?.[0] || null
setForm((current) => ({ ...current, [field]: file }))
setPreview(file ? URL.createObjectURL(file) : '')
}
const clearSelectedFile = (field, setPreview, inputRef) => {
setForm((current) => ({ ...current, [field]: null }))
setPreview('')
if (inputRef.current) {
inputRef.current.value = ''
}
}
const archiveGroup = () => {
if (!window.confirm('Archive this group? New group publishing will stop immediately until you reopen it through admin tooling.')) {
return
}
router.post(props.endpoints?.archive)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="mx-auto max-w-3xl rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-5">
<label className="grid gap-2 text-sm text-slate-200"><span>Name</span><input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<label className="grid gap-2 text-sm text-slate-200"><span>Slug</span><input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<label className="grid gap-2 text-sm text-slate-200"><span>Short description</span><input value={form.headline} onChange={(event) => setForm((current) => ({ ...current, headline: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<label className="grid gap-2 text-sm text-slate-200"><span>About</span><textarea value={form.bio} onChange={(event) => setForm((current) => ({ ...current, bio: event.target.value }))} rows={6} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<div className="grid gap-5 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-200"><span>Type / category</span><input value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<label className="grid gap-2 text-sm text-slate-200"><span>Founded date</span><input type="date" value={form.founded_at} onChange={(event) => setForm((current) => ({ ...current, founded_at: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Website</span><input value={form.website_url} onChange={(event) => setForm((current) => ({ ...current, website_url: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<div className="grid gap-5 md:grid-cols-2">
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Avatar / logo</span>
<div className="flex h-28 w-28 items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{resolvedAvatarPreview ? <img src={resolvedAvatarPreview} alt="Avatar preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-image text-slate-500" />}
</div>
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('avatar_file', setAvatarPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => avatarInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload avatar</button>
{form.avatar_file ? <button type="button" onClick={() => clearSelectedFile('avatar_file', setAvatarPreview, avatarInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use current path</button> : null}
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Or paste an image URL</span><input value={form.avatar_path} onChange={(event) => setForm((current) => ({ ...current, avatar_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
</div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Cover image</span>
<div className="flex h-28 w-full items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{resolvedBannerPreview ? <img src={resolvedBannerPreview} alt="Cover preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-panorama text-slate-500" />}
</div>
<input ref={bannerInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('banner_file', setBannerPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => bannerInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload cover</button>
{form.banner_file ? <button type="button" onClick={() => clearSelectedFile('banner_file', setBannerPreview, bannerInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use current path</button> : null}
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Or paste an image URL</span><input value={form.banner_path} onChange={(event) => setForm((current) => ({ ...current, banner_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
</div>
</div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
<label className="grid gap-2 text-sm text-slate-200">
<span>Featured artwork</span>
<select value={form.featured_artwork_id} onChange={(event) => setForm((current) => ({ ...current, featured_artwork_id: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Use latest published artwork</option>
{featuredArtworkOptions.map((item) => <option key={item.id} value={item.id}>{item.title}</option>)}
</select>
</label>
{selectedFeaturedArtwork ? (
<div className="flex items-center gap-3 rounded-[20px] border border-white/10 bg-white/[0.04] p-3">
{selectedFeaturedArtwork.thumb ? <img src={selectedFeaturedArtwork.thumb} alt={selectedFeaturedArtwork.title} className="h-16 w-16 rounded-2xl object-cover" /> : null}
<div>
<div className="font-semibold text-white">{selectedFeaturedArtwork.title}</div>
<div className="text-sm text-slate-400">{selectedFeaturedArtwork.author || 'Group member'}</div>
</div>
</div>
) : (
<p className="text-sm text-slate-400">When this is empty, the public overview falls back to the latest published works automatically.</p>
)}
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Visibility</span><select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
<label className="grid gap-2 text-sm text-slate-200"><span>Membership policy</span><select value={form.membership_policy} onChange={(event) => setForm((current) => ({ ...current, membership_policy: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.membershipPolicyOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-200">Links</span>
<button type="button" onClick={addLink} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Add link</button>
</div>
{form.links_json.map((item, index) => (
<div key={`link-${index}`} className="grid gap-3 md:grid-cols-[0.8fr_1.2fr_auto]">
<input value={item.label} onChange={(event) => updateLink(index, 'label', event.target.value)} placeholder="Label" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={item.url} onChange={(event) => updateLink(index, 'url', event.target.value)} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => removeLink(index)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Remove</button>
</div>
))}
</div>
<div className="flex justify-between gap-3"><button type="button" onClick={archiveGroup} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Archive group</button><button type="button" onClick={submit} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Save settings</button></div>
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,91 @@
import React from 'react'
import { Link, router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
function GroupCard({ group }) {
return (
<article className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(3,7,18,0.22)]">
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-slate-900/70 text-slate-300">
{group.avatar_url ? <img src={group.avatar_url} alt={group.name} className="h-full w-full object-cover" /> : <i className="fa-solid fa-people-group" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate text-lg font-semibold text-white">{group.name}</h2>
{group.viewer?.role ? <span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100">{group.viewer.role}</span> : null}
{Number(group.pending_invites_count || 0) > 0 ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">{Number(group.pending_invites_count)} pending invite{Number(group.pending_invites_count) === 1 ? '' : 's'}</span> : null}
</div>
{group.headline ? <p className="mt-2 text-sm text-slate-300">{group.headline}</p> : null}
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
<span>{Number(group.counts?.artworks || 0).toLocaleString()} artworks</span>
<span>{Number(group.counts?.collections || 0).toLocaleString()} collections</span>
<span>{Number(group.counts?.followers || 0).toLocaleString()} followers</span>
</div>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<a href={group.urls?.studio} className="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 Studio</a>
<a href={group.urls?.studio_invitations} className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.06]">Invitations</a>
<a href={group.urls?.public} className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.06]">Public page</a>
</div>
</article>
)
}
export default function StudioGroupsIndex() {
const { props } = usePage()
const groups = Array.isArray(props.groups) ? props.groups : []
const pendingInvites = Array.isArray(props.pendingInvites) ? props.pendingInvites : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<GroupStudioPromoCard
title="Publish as a team, not just an individual"
description="Groups let you share ownership across artworks, releases, collections, reviews, and recruiting while keeping one public identity for the whole collective."
bullets={[
{ title: 'Shared publishing', body: 'Release under one name while keeping credited contributors visible across the artwork and group pages.' },
{ title: 'Team workflow', body: 'Invite reviewers, managers, and contributors into one studio space with role-based permissions.' },
{ title: 'Public discovery', body: 'Groups now appear across search, homepage modules, leaderboards, and public browse surfaces.' },
]}
primaryLabel="Create a group"
primaryHref={props.endpoints?.create}
secondaryLabel="Browse public groups"
secondaryHref="/groups"
/>
<div className="mb-6 flex items-center justify-between gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Collective publishing</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Launch and manage shared identities</h2>
</div>
<Link href={props.endpoints?.create} className="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">Create group</Link>
</div>
{pendingInvites.length > 0 ? (
<section className="mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 p-5">
<h2 className="text-lg font-semibold text-amber-50">Pending invites</h2>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{pendingInvites.map((invite) => (
<article key={invite.id} className="rounded-2xl border border-white/10 bg-black/20 p-4 text-white">
<h3 className="text-base font-semibold">{invite.group?.name}</h3>
<p className="mt-2 text-sm text-amber-50/80">Role: {invite.role}</p>
{invite.invited_by ? <p className="mt-1 text-sm text-amber-50/70">Invited by {invite.invited_by.name || invite.invited_by.username}</p> : null}
<div className="mt-4 flex gap-2">
<button type="button" onClick={() => router.post(invite.accept_url)} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100">Accept</button>
<button type="button" onClick={() => router.post(invite.decline_url)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Decline</button>
</div>
</article>
))}
</div>
</section>
) : null}
<div className="grid gap-4 xl:grid-cols-2">
{groups.length > 0 ? groups.map((group) => <GroupCard key={group.slug} group={group} />) : (
<div className="rounded-[28px] border border-dashed border-white/10 px-6 py-16 text-center text-slate-400">No groups yet. Create one to start publishing collaboratively.</div>
)}
</div>
</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,377 @@
import React, { useState } from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
if (!Array.isArray(items) || items.length === 0) {
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-3 text-xs text-slate-500">{emptyLabel}</div>
}
return (
<div className="grid gap-2">
{items.map((item) => (
<button
key={`${item.entity_type}-${item.id}`}
type="button"
onClick={() => onSelect(item)}
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-3 py-3 text-left transition hover:border-white/20"
>
{item.avatar ? <img src={item.avatar} alt={item.title} className="h-10 w-10 rounded-2xl border border-white/10 object-cover" /> : null}
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{item.title}</div>
{item.subtitle ? <div className="text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
{item.description ? <div className="mt-1 text-xs text-slate-400 line-clamp-2">{item.description}</div> : null}
</div>
</button>
))}
</div>
)
}
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-end">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={relation.entity_type} onChange={(event) => onChange(index, { ...relation, entity_type: event.target.value, entity_id: '', preview: null, query: '' })} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{relationTypeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
<div className="flex gap-2">
<input value={relation.query || ''} onChange={(event) => onChange(index, { ...relation, query: event.target.value })} placeholder="Search by name, slug, or title" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => onSearch(index)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
</div>
</label>
<button type="button" onClick={() => onRemove(index)} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Remove</button>
</div>
{relation.preview ? (
<div className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
<div className="font-semibold">Linked: {relation.preview.title}</div>
{relation.preview.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{relation.preview.subtitle}</div> : null}
</div>
) : null}
<div className="mt-4">
<SearchResultList items={results} onSelect={(item) => onChange(index, { ...relation, entity_id: item.id, preview: item, query: item.title })} emptyLabel="Search to attach a related entity." />
</div>
<label className="mt-4 grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Context label</span>
<input value={relation.context_label || ''} onChange={(event) => onChange(index, { ...relation, context_label: event.target.value })} placeholder="Featured release, Meet the creator, Join this challenge…" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
)
}
export default function StudioNewsEditor() {
const { props } = usePage()
const article = props.article || {}
const [authorResults, setAuthorResults] = useState([])
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
const [relationResults, setRelationResults] = useState({})
const form = useForm({
title: article.title || '',
slug: article.slug || '',
excerpt: article.excerpt || '',
content: article.content || '',
cover_image: article.cover_image || '',
type: article.type || (props.typeOptions?.[0]?.value || 'announcement'),
category_id: article.category_id || '',
author_id: article.author_id || props.defaultAuthor?.id || '',
editorial_status: article.editorial_status || 'draft',
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
is_featured: Boolean(article.is_featured),
is_pinned: Boolean(article.is_pinned),
tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [],
meta_title: article.meta_title || '',
meta_description: article.meta_description || '',
meta_keywords: article.meta_keywords || '',
canonical_url: article.canonical_url || '',
og_title: article.og_title || '',
og_description: article.og_description || '',
og_image: article.og_image || '',
relations: Array.isArray(article.relations) ? article.relations.map((relation) => ({
entity_type: relation.entity_type || 'group',
entity_id: relation.entity_id || '',
context_label: relation.context_label || '',
preview: relation.preview || null,
query: relation.preview?.title || '',
})) : [],
})
const submit = (event) => {
event.preventDefault()
if (props.updateUrl) {
form.patch(props.updateUrl)
return
}
form.post(props.storeUrl)
}
const searchEntities = async (type, query) => {
const url = new URL(props.entitySearchUrl, window.location.origin)
url.searchParams.set('type', type)
url.searchParams.set('q', query)
const response = await fetch(url.toString(), {
headers: {
Accept: 'application/json',
},
credentials: 'same-origin',
})
if (!response.ok) {
return []
}
const payload = await response.json()
return Array.isArray(payload.items) ? payload.items : []
}
const runAuthorSearch = async () => {
const items = await searchEntities('user', authorQuery)
setAuthorResults(items)
}
const addRelation = () => {
form.setData('relations', [
...form.data.relations,
{
entity_type: props.relationTypeOptions?.[0]?.value || 'group',
entity_id: '',
context_label: '',
preview: null,
query: '',
},
])
}
const updateRelation = (index, nextRelation) => {
form.setData('relations', form.data.relations.map((relation, relationIndex) => (relationIndex === index ? nextRelation : relation)))
}
const removeRelation = (index) => {
form.setData('relations', form.data.relations.filter((_, relationIndex) => relationIndex !== index))
setRelationResults((current) => {
const next = { ...current }
delete next[index]
return next
})
}
const runRelationSearch = async (index) => {
const relation = form.data.relations[index]
if (!relation) return
const items = await searchEntities(relation.entity_type, relation.query || '')
setRelationResults((current) => ({ ...current, [index]: items }))
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slug</span>
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} placeholder="optional-manual-slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Cover image URL or path</span>
<input value={form.data.cover_image} onChange={(event) => form.setData('cover_image', event.target.value)} placeholder="https://... or storage path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</span>
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={4} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Body</span>
<textarea value={form.data.content} onChange={(event) => form.setData('content', event.target.value)} rows={18} placeholder="Write in Markdown. Existing legacy HTML is still supported on render." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none" />
</label>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">Related entities</h2>
<p className="mt-1 text-sm text-slate-400">Attach Groups, artworks, collections, releases, projects, challenges, events, and profiles.</p>
</div>
<button type="button" onClick={addRelation} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Add relation</button>
</div>
<div className="mt-4 grid gap-4">
{form.data.relations.length > 0 ? form.data.relations.map((relation, index) => (
<RelationCard
key={`${relation.entity_type}-${index}`}
relation={relation}
index={index}
onChange={updateRelation}
onRemove={removeRelation}
onSearch={runRelationSearch}
results={relationResults[index] || []}
relationTypeOptions={Array.isArray(props.relationTypeOptions) ? props.relationTypeOptions : []}
/>
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No related entities attached yet.</div>}
</div>
</div>
</div>
</section>
<section className="space-y-6">
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Publishing</h2>
<div className="mt-5 grid gap-4">
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center gap-2 rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15"><i className="fa-regular fa-eye" />Preview article</a> : null}
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
<select value={form.data.category_id || ''} onChange={(event) => form.setData('category_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No category</option>
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => <option key={option.id} value={option.id}>{option.name}</option>)}
</select>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Workflow status</span>
<select value={form.data.editorial_status} onChange={(event) => form.setData('editorial_status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
<input type="datetime-local" value={form.data.published_at || ''} onChange={(event) => form.setData('published_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Author</div>
<div className="flex gap-2">
<input value={authorQuery} onChange={(event) => setAuthorQuery(event.target.value)} placeholder="Search for an author" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={runAuthorSearch} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
</div>
{selectedAuthor ? (
<div className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
<div className="font-semibold">Selected author: {selectedAuthor.title}</div>
{selectedAuthor.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedAuthor.subtitle}</div> : null}
</div>
) : null}
<SearchResultList items={authorResults} onSelect={(item) => {
setSelectedAuthor(item)
setAuthorQuery(item.title)
form.setData('author_id', item.id)
}} emptyLabel="Search to choose an author profile." />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tags</span>
<div className="grid gap-2 sm:grid-cols-2">
{(Array.isArray(props.tagOptions) ? props.tagOptions : []).map((tag) => {
const checked = form.data.tag_ids.includes(tag.id)
return (
<label key={tag.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input
type="checkbox"
checked={checked}
onChange={(event) => {
if (event.target.checked) {
form.setData('tag_ids', [...form.data.tag_ids, tag.id])
return
}
form.setData('tag_ids', form.data.tag_ids.filter((tagId) => tagId !== tag.id))
}}
/>
<span>{tag.name}</span>
</label>
)
})}
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
Feature on newsroom surfaces
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} />
Pin to the top of the newsroom
</label>
</div>
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">SEO &amp; social</h2>
<div className="mt-5 grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta title</span>
<input value={form.data.meta_title} onChange={(event) => form.setData('meta_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta description</span>
<textarea value={form.data.meta_description} onChange={(event) => form.setData('meta_description', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta keywords</span>
<input value={form.data.meta_keywords} onChange={(event) => form.setData('meta_keywords', event.target.value)} placeholder="creator-story, release, tutorial" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Canonical URL</span>
<input value={form.data.canonical_url} onChange={(event) => form.setData('canonical_url', event.target.value)} placeholder="https://..." className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG title</span>
<input value={form.data.og_title} onChange={(event) => form.setData('og_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG image</span>
<input value={form.data.og_image} onChange={(event) => form.setData('og_image', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG description</span>
<textarea value={form.data.og_description} onChange={(event) => form.setData('og_description', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-3">
<button type="submit" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save article</button>
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100">Publish now</button> : null}
{props.featureUrl ? <button type="button" onClick={() => router.post(props.featureUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Toggle featured</button> : null}
{props.pinUrl ? <button type="button" onClick={() => router.post(props.pinUrl)} className="w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">Toggle pinned</button> : null}
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Archive article</button> : null}
</div>
</div>
</section>
</form>
</StudioLayout>
)
}

View File

@@ -0,0 +1,164 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function formatDate(value) {
if (!value) return 'Draft'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Draft'
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function statusTone(status) {
switch (status) {
case 'published':
return 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100'
case 'scheduled':
return 'border-sky-300/20 bg-sky-400/10 text-sky-100'
case 'in_review':
return 'border-amber-300/20 bg-amber-400/10 text-amber-100'
case 'archived':
return 'border-white/10 bg-white/[0.05] text-slate-300'
default:
return 'border-white/10 bg-white/[0.05] text-slate-300'
}
}
export default function StudioNewsIndex() {
const { props } = usePage()
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
const filters = props.listing?.filters || {}
const meta = props.listing?.meta || {}
const updateFilter = (next) => {
router.get('/studio/news', {
...filters,
...next,
page: 1,
}, {
preserveState: true,
preserveScroll: true,
})
}
return (
<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.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.9))] 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">Editorial surface</p>
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Run a first-party newsroom for launches, tutorials, and community stories.</h2>
<p className="mt-3 text-sm leading-7 text-slate-300">Pinned stories drive the hero, featured pieces strengthen discovery, and related entity links keep News wired into Groups, releases, collections, and profiles.</p>
</div>
<div className="flex flex-wrap gap-3">
<a href={props.createUrl} 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 article
</a>
<a href={props.categoriesUrl} 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-tags" />
Taxonomies
</a>
</div>
</div>
</section>
<section className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px_220px_220px_auto] lg:items-center">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
<input
defaultValue={filters.q || ''}
placeholder="Search titles, excerpts, and metadata"
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
onKeyDown={(event) => {
if (event.key === 'Enter') {
updateFilter({
q: event.currentTarget.value || '',
status: filters.status || '',
type: filters.type || '',
category_id: filters.category_id || '',
})
}
}}
/>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</span>
<select
value={filters.status || ''}
onChange={(event) => updateFilter({ status: event.target.value, q: filters.q || '', type: filters.type || '', category_id: filters.category_id || '' })}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
>
<option value="">All statuses</option>
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select
value={filters.type || ''}
onChange={(event) => updateFilter({ type: event.target.value, q: filters.q || '', status: filters.status || '', category_id: filters.category_id || '' })}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
>
<option value="">All types</option>
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
<select
value={filters.category_id || ''}
onChange={(event) => updateFilter({ category_id: event.target.value, q: filters.q || '', status: filters.status || '', type: filters.type || '' })}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
>
<option value="">All categories</option>
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => (
<option key={option.id} value={option.id}>{option.name}</option>
))}
</select>
</label>
<div className="text-sm text-slate-400 lg:text-right">{Number(meta.total || 0).toLocaleString()} articles</div>
</div>
</section>
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_40px_rgba(2,6,23,0.18)]">
<div className="aspect-[16/9] bg-slate-950/60">
{item.cover_url ? <img src={item.cover_url} alt={item.title} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-slate-500"><i className="fa-solid fa-newspaper text-3xl" /></div>}
</div>
<div className="p-5">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-white/70">{item.type_label}</span>
<span className={`rounded-full border px-2.5 py-1 ${statusTone(item.editorial_status)}`}>{item.editorial_status.replaceAll('_', ' ')}</span>
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-2.5 py-1 text-amber-100">Pinned</span> : null}
{item.is_featured ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-100">Featured</span> : null}
</div>
<h3 className="mt-3 text-xl font-semibold text-white">{item.title}</h3>
<div className="mt-3 flex flex-wrap gap-3 text-sm text-slate-400">
{item.category_name ? <span>{item.category_name}</span> : null}
<span>{item.author_name}</span>
<span>{formatDate(item.published_at)}</span>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<a href={item.edit_url} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Edit</a>
<a href={item.editorial_status === 'published' ? item.public_url : item.preview_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">{item.editorial_status === 'published' ? 'View' : 'Preview'}</a>
</div>
</div>
</article>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No News articles match the current filters.</div>}
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,115 @@
import React, { useState } from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function replacePattern(pattern, token, value) {
return String(pattern || '').replace(token, String(value))
}
export default function StudioNewsTaxonomies() {
const { props } = usePage()
const [categories, setCategories] = useState(Array.isArray(props.categories) ? props.categories : [])
const [tags, setTags] = useState(Array.isArray(props.tags) ? props.tags : [])
const categoryForm = useForm({ name: '', slug: '', description: '', position: 0, is_active: true })
const tagForm = useForm({ name: '', slug: '' })
const updateCategory = (index, field, value) => {
setCategories((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)))
}
const updateTag = (index, field, value) => {
setTags((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)))
}
const saveCategory = (category) => {
router.patch(replacePattern(props.updateCategoryUrlPattern, '__CATEGORY__', category.id), category, { preserveScroll: true })
}
const saveTag = (tag) => {
router.patch(replacePattern(props.updateTagUrlPattern, '__TAG__', tag.id), tag, { preserveScroll: true })
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex flex-wrap gap-3 text-sm font-semibold">
<a href="/studio/news/categories" className={`rounded-full px-4 py-2 ${props.activeTab === 'categories' ? 'border border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border border-white/10 bg-white/[0.04] text-white'}`}>Categories</a>
<a href="/studio/news/tags" className={`rounded-full px-4 py-2 ${props.activeTab === 'tags' ? 'border border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border border-white/10 bg-white/[0.04] text-white'}`}>Tags</a>
<a href="/studio/news" className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white">Back to newsroom</a>
</div>
</section>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Categories</h2>
<p className="mt-1 text-sm text-slate-400">Stable editorial buckets for the newsroom.</p>
</div>
<span className="text-sm text-slate-500">{categories.length} total</span>
</div>
<form onSubmit={(event) => { event.preventDefault(); categoryForm.post(props.storeCategoryUrl) }} className="mt-5 grid gap-3">
<div className="grid gap-3 md:grid-cols-2">
<input value={categoryForm.data.name} onChange={(event) => categoryForm.setData('name', event.target.value)} placeholder="Category name" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={categoryForm.data.slug} onChange={(event) => categoryForm.setData('slug', event.target.value)} placeholder="optional slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={categoryForm.data.description} onChange={(event) => categoryForm.setData('description', event.target.value)} rows={3} placeholder="Description" className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="flex flex-wrap items-center gap-3">
<input type="number" value={categoryForm.data.position} onChange={(event) => categoryForm.setData('position', event.target.value)} min="0" className="w-28 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<label className="flex items-center gap-2 text-sm text-white"><input type="checkbox" checked={categoryForm.data.is_active} onChange={(event) => categoryForm.setData('is_active', event.target.checked)} /> Active</label>
<button type="submit" className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Create category</button>
</div>
</form>
<div className="mt-6 grid gap-3">
{categories.map((category, index) => (
<div key={category.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-3 md:grid-cols-2">
<input value={category.name} onChange={(event) => updateCategory(index, 'name', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={category.slug} onChange={(event) => updateCategory(index, 'slug', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={category.description || ''} onChange={(event) => updateCategory(index, 'description', event.target.value)} rows={2} className="mt-3 w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-slate-300">
<input type="number" value={category.position || 0} min="0" onChange={(event) => updateCategory(index, 'position', event.target.value)} className="w-24 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(category.is_active)} onChange={(event) => updateCategory(index, 'is_active', event.target.checked)} /> Active</label>
<span className="text-xs uppercase tracking-[0.14em] text-slate-500">{Number(category.published_count || 0).toLocaleString()} published</span>
<button type="button" onClick={() => saveCategory(category)} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Save</button>
</div>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Tags</h2>
<p className="mt-1 text-sm text-slate-400">Flexible labels for search, discovery, and internal linking.</p>
</div>
<span className="text-sm text-slate-500">{tags.length} total</span>
</div>
<form onSubmit={(event) => { event.preventDefault(); tagForm.post(props.storeTagUrl) }} className="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center">
<input value={tagForm.data.name} onChange={(event) => tagForm.setData('name', event.target.value)} placeholder="Tag name" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={tagForm.data.slug} onChange={(event) => tagForm.setData('slug', event.target.value)} placeholder="optional slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100">Create tag</button>
</form>
<div className="mt-6 grid gap-3">
{tags.map((tag, index) => (
<div key={tag.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] md:items-center">
<input value={tag.name} onChange={(event) => updateTag(index, 'name', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={tag.slug} onChange={(event) => updateTag(index, 'slug', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs uppercase tracking-[0.14em] text-slate-500">{Number(tag.published_count || 0).toLocaleString()} published</span>
<button type="button" onClick={() => saveTag(tag)} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Save</button>
</div>
</div>
))}
</div>
</section>
</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>
)
}

View File

@@ -0,0 +1,96 @@
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import StudioGroupMembers from '../StudioGroupMembers'
const { routerMock } = vi.hoisted(() => ({
routerMock: {
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}))
let pageMock = { props: {} }
vi.mock('@inertiajs/react', () => ({
usePage: () => pageMock,
router: routerMock,
}))
vi.mock('../../../Layouts/StudioLayout', () => ({
default: ({ children }) => <div>{children}</div>,
}))
describe('StudioGroupMembers permissions', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('shows management controls for owners and admins', () => {
pageMock = {
props: {
title: 'Members',
description: 'Manage members',
canManageMembers: true,
endpoints: {
invite: '/studio/groups/warp/members',
invitations: '/studio/groups/warp/invitations',
updatePattern: '/studio/groups/warp/members/__MEMBER__',
transferPattern: '/studio/groups/warp/members/__MEMBER__/transfer',
deletePattern: '/studio/groups/warp/members/__MEMBER__',
},
members: [
{
id: 1,
role: 'editor',
role_label: 'editor',
status: 'active',
can_transfer: true,
can_revoke: true,
user: { name: 'Editor User', username: 'editor-user', avatar_url: null },
},
],
},
}
render(<StudioGroupMembers />)
expect(screen.getByText('Invite member')).not.toBeNull()
expect(screen.getByRole('link', { name: /manage invitations/i })).not.toBeNull()
expect(screen.getByPlaceholderText(/name, username, or role/i)).not.toBeNull()
expect(screen.getByRole('button', { name: /transfer/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /remove/i })).not.toBeNull()
})
it('hides management controls for non-managing members', () => {
pageMock = {
props: {
title: 'Members',
description: 'Manage members',
canManageMembers: false,
endpoints: null,
members: [
{
id: 1,
role: 'editor',
role_label: 'editor',
status: 'active',
can_transfer: false,
can_revoke: false,
user: { name: 'Editor User', username: 'editor-user', avatar_url: null },
},
],
},
}
render(<StudioGroupMembers />)
expect(screen.queryByText('Invite member')).toBeNull()
expect(screen.queryByRole('link', { name: /manage invitations/i })).toBeNull()
expect(screen.queryByRole('button', { name: /transfer/i })).toBeNull()
expect(screen.queryByRole('button', { name: /remove/i })).toBeNull()
expect(screen.getByText('editor')).not.toBeNull()
})
})