Studio: make grid checkbox rectangular and commit table changes

This commit is contained in:
2026-03-01 08:43:48 +01:00
parent 211dc58884
commit e3ca845a6d
89 changed files with 7323 additions and 475 deletions

View File

@@ -0,0 +1,213 @@
import React from 'react'
import { usePage, Link } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
const kpiItems = [
{ key: 'views', label: 'Total Views', icon: 'fa-eye', color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
{ key: 'favourites', label: 'Total Favourites', icon: 'fa-heart', color: 'text-pink-400', bg: 'bg-pink-500/10' },
{ key: 'shares', label: 'Total Shares', icon: 'fa-share-nodes', color: 'text-amber-400', bg: 'bg-amber-500/10' },
{ key: 'downloads', label: 'Total Downloads', icon: 'fa-download', color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ key: 'comments', label: 'Total Comments', icon: 'fa-comment', color: 'text-blue-400', bg: 'bg-blue-500/10' },
]
const performanceItems = [
{ key: 'avg_ranking', label: 'Avg Ranking Score', icon: 'fa-trophy', color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
{ key: 'avg_heat', label: 'Avg Heat Score', icon: 'fa-fire', color: 'text-orange-400', bg: 'bg-orange-500/10' },
]
const contentTypeIcons = {
skins: 'fa-layer-group',
wallpapers: 'fa-desktop',
photography: 'fa-camera',
other: 'fa-folder-open',
members: 'fa-users',
}
const contentTypeColors = {
skins: 'text-emerald-400 bg-emerald-500/10',
wallpapers: 'text-blue-400 bg-blue-500/10',
photography: 'text-amber-400 bg-amber-500/10',
other: 'text-slate-400 bg-slate-500/10',
members: 'text-purple-400 bg-purple-500/10',
}
export default function StudioAnalytics() {
const { props } = usePage()
const { totals, topArtworks, contentBreakdown, recentComments } = props
const totalArtworksCount = (contentBreakdown || []).reduce((sum, ct) => sum + ct.count, 0)
return (
<StudioLayout title="Analytics">
{/* KPI Cards */}
<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 hover:border-white/20 transition-all">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl ${item.bg} flex items-center justify-center ${item.color}`}>
<i className={`fa-solid ${item.icon}`} />
</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>
{/* Performance Averages */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
{performanceItems.map((item) => (
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5 hover:border-white/20 transition-all">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl ${item.bg} flex items-center justify-center ${item.color}`}>
<i className={`fa-solid ${item.icon} text-lg`} />
</div>
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">{item.label}</span>
</div>
<p className="text-3xl font-bold text-white tabular-nums">
{(totals?.[item.key] ?? 0).toFixed(1)}
</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* Content Breakdown */}
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-white mb-4">
<i className="fa-solid fa-chart-pie text-slate-500 mr-2" />
Content Breakdown
</h3>
{contentBreakdown?.length > 0 ? (
<div className="space-y-3">
{contentBreakdown.map((ct) => {
const pct = totalArtworksCount > 0 ? Math.round((ct.count / totalArtworksCount) * 100) : 0
const iconClass = contentTypeIcons[ct.slug] || 'fa-folder'
const colorClass = contentTypeColors[ct.slug] || 'text-slate-400 bg-slate-500/10'
const [textColor, bgColor] = colorClass.split(' ')
return (
<div key={ct.slug} className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg ${bgColor} flex items-center justify-center ${textColor} flex-shrink-0`}>
<i className={`fa-solid ${iconClass} text-xs`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-white">{ct.name}</span>
<span className="text-xs text-slate-400 tabular-nums">{ct.count}</span>
</div>
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
<div
className={`h-full rounded-full ${bgColor.replace('/10', '/40')}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
)
})}
</div>
) : (
<p className="text-sm text-slate-500 text-center py-6">No artworks categorised yet</p>
)}
</div>
{/* Recent Comments */}
<div className="lg:col-span-2 bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-white mb-4">
<i className="fa-solid fa-comments text-slate-500 mr-2" />
Recent Comments
</h3>
{recentComments?.length > 0 ? (
<div className="space-y-0 divide-y divide-white/5">
{recentComments.map((c) => (
<div key={c.id} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-xs text-slate-500 flex-shrink-0">
<i className="fa-solid fa-user" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-white">
<span className="font-medium text-accent">{c.author_name}</span>
{' '}on{' '}
<span className="text-slate-300">{c.artwork_title}</span>
</p>
<p className="text-xs text-slate-500 mt-0.5 line-clamp-2">{c.body}</p>
<p className="text-[10px] text-slate-600 mt-1">{new Date(c.created_at).toLocaleDateString()}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500 text-center py-6">No comments yet</p>
)}
</div>
</div>
{/* Top Performers Table */}
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-white mb-4">
<i className="fa-solid fa-ranking-star text-slate-500 mr-2" />
Top 10 Artworks
</h3>
{topArtworks?.length > 0 ? (
<div className="overflow-x-auto sb-scrollbar">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] uppercase tracking-wider text-slate-500 border-b border-white/5">
<th className="pb-3 pr-4">#</th>
<th className="pb-3 pr-4">Artwork</th>
<th className="pb-3 pr-4 text-right">Views</th>
<th className="pb-3 pr-4 text-right">Favs</th>
<th className="pb-3 pr-4 text-right">Shares</th>
<th className="pb-3 pr-4 text-right">Downloads</th>
<th className="pb-3 pr-4 text-right">Ranking</th>
<th className="pb-3 text-right">Heat</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{topArtworks.map((art, i) => (
<tr key={art.id} className="hover:bg-white/[0.02] transition-colors">
<td className="py-3 pr-4 text-slate-500 tabular-nums">{i + 1}</td>
<td className="py-3 pr-4">
<Link
href={`/studio/artworks/${art.id}/analytics`}
className="flex items-center gap-3 group"
>
{art.thumb_url && (
<img
src={art.thumb_url}
alt={art.title}
className="w-9 h-9 rounded-lg object-cover bg-nova-800 flex-shrink-0 group-hover:ring-2 ring-accent/50 transition-all"
/>
)}
<span className="text-white font-medium truncate max-w-[200px] group-hover:text-accent transition-colors">
{art.title}
</span>
</Link>
</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.views.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.favourites.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.shares.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.downloads.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-yellow-400 tabular-nums font-medium">{art.ranking_score.toFixed(1)}</td>
<td className="py-3 text-right tabular-nums">
<span className={`font-medium ${art.heat_score > 5 ? 'text-orange-400' : 'text-slate-400'}`}>
{art.heat_score.toFixed(1)}
</span>
{art.heat_score > 5 && (
<i className="fa-solid fa-fire text-orange-400 ml-1 text-[10px]" />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-slate-500 text-center py-8">No published artworks with stats yet</p>
)}
</div>
</StudioLayout>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,455 @@
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { usePage, Link } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
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.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,
})
// --- 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)
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 })
} else {
console.error('File replace failed:', data)
}
} catch (err) {
console.error('File replace failed:', err)
} finally {
setReplacing(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
// --- 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">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Uploaded Asset</h3>
<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>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileReplace} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={replacing}
className="mt-2 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>
</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>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={5}
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 resize-y"
/>
{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>
</StudioLayout>
)
}

View File

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

View File

@@ -0,0 +1,141 @@
import React from 'react'
import { usePage, Link } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
const kpiConfig = [
{ key: 'total_artworks', label: 'Total Artworks', icon: 'fa-images', color: 'text-blue-400', link: '/studio/artworks' },
{ key: 'views_30d', label: 'Views (30d)', icon: 'fa-eye', color: 'text-emerald-400', link: null },
{ key: 'favourites_30d', label: 'Favourites (30d)', icon: 'fa-heart', color: 'text-pink-400', link: null },
{ key: 'shares_30d', label: 'Shares (30d)', icon: 'fa-share-nodes', color: 'text-amber-400', link: null },
{ key: 'followers', label: 'Followers', icon: 'fa-user-group', color: 'text-purple-400', link: null },
]
function KpiCard({ config, value }) {
const content = (
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-5 hover:border-white/20 hover:shadow-lg hover:shadow-accent/5 transition-all duration-300 cursor-pointer group">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center ${config.color} group-hover:scale-110 transition-transform`}>
<i className={`fa-solid ${config.icon}`} />
</div>
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">{config.label}</span>
</div>
<p className="text-3xl font-bold text-white tabular-nums">
{typeof value === 'number' ? value.toLocaleString() : value}
</p>
</div>
)
if (config.link) {
return <Link href={config.link}>{content}</Link>
}
return content
}
function TopPerformerCard({ artwork }) {
return (
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-4 hover:border-white/20 hover:shadow-lg hover:shadow-accent/5 transition-all duration-300 group">
<div className="flex items-start gap-3">
{artwork.thumb_url && (
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-16 h-16 rounded-xl object-cover bg-nova-800 flex-shrink-0 group-hover:scale-105 transition-transform"
loading="lazy"
/>
)}
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold text-white truncate" title={artwork.title}>
{artwork.title}
</h4>
<div className="flex flex-wrap items-center gap-3 mt-1.5">
<span className="text-xs text-slate-400">
{artwork.favourites?.toLocaleString()}
</span>
<span className="text-xs text-slate-400">
🔗 {artwork.shares?.toLocaleString()}
</span>
</div>
{artwork.heat_score > 5 && (
<span className="inline-flex items-center gap-1 mt-2 px-2 py-0.5 rounded-md text-[10px] font-medium bg-orange-500/20 text-orange-400 border border-orange-500/30">
<i className="fa-solid fa-fire" /> Rising
</span>
)}
</div>
</div>
</div>
)
}
function RecentComment({ comment }) {
return (
<div className="flex items-start gap-3 py-3 border-b border-white/5 last:border-0">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs text-slate-400 flex-shrink-0">
<i className="fa-solid fa-comment" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-white">
<span className="font-medium text-accent">{comment.author_name}</span>
{' '}on{' '}
<span className="text-slate-300">{comment.artwork_title}</span>
</p>
<p className="text-xs text-slate-500 mt-0.5 line-clamp-2">{comment.body}</p>
<p className="text-[10px] text-slate-600 mt-1">
{new Date(comment.created_at).toLocaleDateString()}
</p>
</div>
</div>
)
}
export default function StudioDashboard() {
const { props } = usePage()
const { kpis, topPerformers, recentComments } = props
return (
<StudioLayout title="Studio Overview">
{/* KPI Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
{kpiConfig.map((config) => (
<KpiCard key={config.key} config={config} value={kpis?.[config.key] ?? 0} />
))}
</div>
{/* Top Performers */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-white">Your Top Performers</h2>
<span className="text-xs text-slate-500">Last 7 days</span>
</div>
{topPerformers?.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{topPerformers.map((art) => (
<TopPerformerCard key={art.id} artwork={art} />
))}
</div>
) : (
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-8 text-center">
<p className="text-slate-500 text-sm">No artworks yet. Upload your first creation!</p>
<Link
href="/upload"
className="inline-flex items-center gap-2 mt-4 px-5 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white text-sm font-semibold transition-all shadow-lg shadow-accent/25"
>
<i className="fa-solid fa-cloud-arrow-up" /> Upload
</Link>
</div>
)}
</div>
{/* Recent Comments */}
<div>
<h2 className="text-lg font-bold text-white mb-4">Recent Comments</h2>
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-4">
{recentComments?.length > 0 ? (
recentComments.map((c) => <RecentComment key={c.id} comment={c} />)
) : (
<p className="text-slate-500 text-sm text-center py-4">No comments yet</p>
)}
</div>
</div>
</StudioLayout>
)
}

View File

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