Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
@@ -11,6 +11,7 @@ const HomeTrendingForYou = lazy(() => import('./HomeTrendingForYou'))
|
||||
const HomeBecauseYouLike = lazy(() => import('./HomeBecauseYouLike'))
|
||||
const HomeSuggestedCreators = lazy(() => import('./HomeSuggestedCreators'))
|
||||
const HomeTrending = lazy(() => import('./HomeTrending'))
|
||||
const HomeRising = lazy(() => import('./HomeRising'))
|
||||
const HomeFresh = lazy(() => import('./HomeFresh'))
|
||||
const HomeCategories = lazy(() => import('./HomeCategories'))
|
||||
const HomeTags = lazy(() => import('./HomeTags'))
|
||||
@@ -25,12 +26,15 @@ function SectionFallback() {
|
||||
}
|
||||
|
||||
function GuestHomePage(props) {
|
||||
const { hero, trending, fresh, tags, creators, news } = props
|
||||
const { hero, rising, trending, fresh, tags, creators, news } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 1. Hero */}
|
||||
<HomeHero artwork={hero} isLoggedIn={false} />
|
||||
<Suspense fallback={<SectionFallback />}>
|
||||
<HomeRising items={rising} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback />}>
|
||||
<HomeTrending items={trending} />
|
||||
</Suspense>
|
||||
@@ -73,6 +77,7 @@ function AuthHomePage(props) {
|
||||
user_data,
|
||||
hero,
|
||||
from_following,
|
||||
rising,
|
||||
trending,
|
||||
fresh,
|
||||
by_tags,
|
||||
@@ -104,6 +109,11 @@ function AuthHomePage(props) {
|
||||
<HomeTrendingForYou items={by_tags} preferences={preferences} />
|
||||
</Suspense>
|
||||
|
||||
{/* Rising Now */}
|
||||
<Suspense fallback={<SectionFallback />}>
|
||||
<HomeRising items={rising} />
|
||||
</Suspense>
|
||||
|
||||
{/* 2. Global Trending Now */}
|
||||
<Suspense fallback={<SectionFallback />}>
|
||||
<HomeTrending items={trending} />
|
||||
|
||||
85
resources/js/Pages/Home/HomeRising.jsx
Normal file
85
resources/js/Pages/Home/HomeRising.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function ArtCard({ item }) {
|
||||
const username = item.author_username ? `@${item.author_username}` : null
|
||||
|
||||
return (
|
||||
<article className="min-w-[72%] snap-start sm:min-w-[44%] lg:min-w-0">
|
||||
<a
|
||||
href={item.url}
|
||||
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden bg-neutral-900">
|
||||
{/* Gloss sheen */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
|
||||
|
||||
<img
|
||||
src={item.thumb || FALLBACK}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||
/>
|
||||
|
||||
{/* Rising badge */}
|
||||
<div className="absolute left-3 top-3 z-30">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/80 px-2 py-1 text-[11px] font-bold text-white ring-1 ring-white/10 backdrop-blur-sm">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
Rising
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Top-right View badge */}
|
||||
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">View</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom info overlay */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||
<img
|
||||
src={item.author_avatar || AVATAR_FALLBACK}
|
||||
alt={item.author}
|
||||
className="w-6 h-6 rounded-full object-cover shrink-0"
|
||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||
/>
|
||||
<span className="truncate">{item.author}</span>
|
||||
{username && <span className="text-white/50 shrink-0">{username}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="sr-only">{item.title} by {item.author}</span>
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomeRising({ items }) {
|
||||
if (!Array.isArray(items) || items.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span className="text-emerald-400">🚀</span> Rising Now
|
||||
</h2>
|
||||
<a href="/discover/rising" className="text-sm text-nova-300 hover:text-white transition">
|
||||
See all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 lg:grid lg:grid-cols-5 lg:overflow-visible">
|
||||
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
|
||||
<ArtCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
213
resources/js/Pages/Studio/StudioAnalytics.jsx
Normal file
213
resources/js/Pages/Studio/StudioAnalytics.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
203
resources/js/Pages/Studio/StudioArchived.jsx
Normal file
203
resources/js/Pages/Studio/StudioArchived.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
resources/js/Pages/Studio/StudioArtworkAnalytics.jsx
Normal file
128
resources/js/Pages/Studio/StudioArtworkAnalytics.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
455
resources/js/Pages/Studio/StudioArtworkEdit.jsx
Normal file
455
resources/js/Pages/Studio/StudioArtworkEdit.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
341
resources/js/Pages/Studio/StudioArtworks.jsx
Normal file
341
resources/js/Pages/Studio/StudioArtworks.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
resources/js/Pages/Studio/StudioDashboard.jsx
Normal file
141
resources/js/Pages/Studio/StudioDashboard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
208
resources/js/Pages/Studio/StudioDrafts.jsx
Normal file
208
resources/js/Pages/Studio/StudioDrafts.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
resources/js/components/Badges/RisingBadge.jsx
Normal file
27
resources/js/components/Badges/RisingBadge.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function RisingBadge({ heatScore, rankingScore }) {
|
||||
if (!heatScore && !rankingScore) return null
|
||||
|
||||
const isRising = heatScore > 5
|
||||
const isTrending = rankingScore > 50
|
||||
|
||||
if (!isRising && !isTrending) return null
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{isRising && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-orange-500/20 text-orange-400 border border-orange-500/30">
|
||||
<i className="fa-solid fa-fire text-[10px]" />
|
||||
Rising
|
||||
</span>
|
||||
)}
|
||||
{isTrending && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-purple-500/20 text-purple-400 border border-purple-500/30">
|
||||
<i className="fa-solid fa-arrow-trend-up text-[10px]" />
|
||||
Trending
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
18
resources/js/components/Badges/StatusBadge.jsx
Normal file
18
resources/js/components/Badges/StatusBadge.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
const statusConfig = {
|
||||
published: { label: 'Published', className: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' },
|
||||
draft: { label: 'Draft', className: 'bg-amber-500/20 text-amber-400 border-amber-500/30' },
|
||||
archived: { label: 'Archived', className: 'bg-slate-500/20 text-slate-400 border-slate-500/30' },
|
||||
scheduled: { label: 'Scheduled', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }) {
|
||||
const config = statusConfig[status] || statusConfig.draft
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border ${config.className}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
77
resources/js/components/Studio/BulkActionsBar.jsx
Normal file
77
resources/js/components/Studio/BulkActionsBar.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const actions = [
|
||||
{ value: 'publish', label: 'Publish', icon: 'fa-eye', danger: false },
|
||||
{ value: 'unpublish', label: 'Unpublish (draft)', icon: 'fa-eye-slash', danger: false },
|
||||
{ value: 'archive', label: 'Archive', icon: 'fa-box-archive', danger: false },
|
||||
{ value: 'unarchive', label: 'Unarchive', icon: 'fa-rotate-left', danger: false },
|
||||
{ value: 'delete', label: 'Delete', icon: 'fa-trash', danger: true },
|
||||
{ value: 'change_category', label: 'Change category', icon: 'fa-folder', danger: false },
|
||||
{ value: 'add_tags', label: 'Add tags', icon: 'fa-tag', danger: false },
|
||||
{ value: 'remove_tags', label: 'Remove tags', icon: 'fa-tags', danger: false },
|
||||
]
|
||||
|
||||
export default function BulkActionsBar({ count, onExecute, onClearSelection }) {
|
||||
const [action, setAction] = useState('')
|
||||
|
||||
if (count === 0) return null
|
||||
|
||||
const handleExecute = () => {
|
||||
if (!action) return
|
||||
onExecute(action)
|
||||
setAction('')
|
||||
}
|
||||
|
||||
const selectedAction = actions.find((a) => a.value === action)
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-nova-900/95 backdrop-blur-xl border-t border-white/10 px-4 py-3 shadow-xl shadow-black/20">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-accent/20 text-accent text-sm font-bold">
|
||||
{count}
|
||||
</span>
|
||||
<span className="text-sm text-slate-300">
|
||||
{count === 1 ? 'artwork' : 'artworks'} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 min-w-[180px]"
|
||||
>
|
||||
<option value="" className="bg-nova-900">Choose action…</option>
|
||||
{actions.map((a) => (
|
||||
<option key={a.value} value={a.value} className="bg-nova-900">
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={!action}
|
||||
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
|
||||
action
|
||||
? selectedAction?.danger
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-accent hover:bg-accent/90 text-white'
|
||||
: 'bg-white/5 text-slate-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClearSelection}
|
||||
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
resources/js/components/Studio/BulkCategoryModal.jsx
Normal file
96
resources/js/components/Studio/BulkCategoryModal.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Modal for choosing a category in bulk.
|
||||
*
|
||||
* Props:
|
||||
* - open: boolean
|
||||
* - categories: array of content types with nested categories
|
||||
* - onClose: () => void
|
||||
* - onConfirm: (categoryId: number) => void
|
||||
*/
|
||||
export default function BulkCategoryModal({ open, categories = [], onClose, onConfirm }) {
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelectedId('')
|
||||
}, [open])
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedId) return
|
||||
onConfirm(Number(selectedId))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'Enter' && selectedId) handleConfirm()
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md bg-nova-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<i className="fa-solid fa-folder text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">Change category</h3>
|
||||
<p className="text-sm text-slate-400">Choose a category to assign to the selected artworks.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category select */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
|
||||
<select
|
||||
value={selectedId}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
className="w-full px-3 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"
|
||||
>
|
||||
<option value="" className="bg-nova-900">Select a category…</option>
|
||||
{categories.map((ct) => (
|
||||
<optgroup key={ct.id} label={ct.name}>
|
||||
{ct.categories?.map((cat) => (
|
||||
<React.Fragment key={cat.id}>
|
||||
<option value={cat.id} className="bg-nova-900">{cat.name}</option>
|
||||
{cat.children?.map((ch) => (
|
||||
<option key={ch.id} value={ch.id} className="bg-nova-900"> {ch.name}</option>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedId}
|
||||
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
|
||||
selectedId
|
||||
? 'bg-accent hover:bg-accent/90 text-white'
|
||||
: 'bg-white/5 text-slate-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Apply category
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
resources/js/components/Studio/BulkTagModal.jsx
Normal file
208
resources/js/components/Studio/BulkTagModal.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Modal for picking tags to add/remove in bulk.
|
||||
*
|
||||
* Props:
|
||||
* - open: boolean
|
||||
* - mode: 'add' | 'remove'
|
||||
* - onClose: () => void
|
||||
* - onConfirm: (tagIds: number[]) => void
|
||||
*/
|
||||
export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [selected, setSelected] = useState([]) // { id, name }
|
||||
const [loading, setLoading] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
const searchTimer = useRef(null)
|
||||
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setSelected([])
|
||||
setTimeout(() => inputRef.current?.focus(), 100)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Debounced tag search
|
||||
const searchTags = useCallback(async (q) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
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': csrfToken },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const data = await res.json()
|
||||
setResults(data || [])
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => searchTags(query), 250)
|
||||
return () => clearTimeout(searchTimer.current)
|
||||
}, [query, open, searchTags])
|
||||
|
||||
const toggleTag = (tag) => {
|
||||
setSelected((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 }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeSelected = (id) => {
|
||||
setSelected((prev) => prev.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selected.length === 0) return
|
||||
onConfirm(selected.map((t) => t.id))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isAdd = mode === 'add'
|
||||
const title = isAdd ? 'Add tags' : 'Remove tags'
|
||||
const accentColor = isAdd ? 'accent' : 'amber-500'
|
||||
const icon = isAdd ? 'fa-tag' : 'fa-tags'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg bg-nova-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full ${isAdd ? 'bg-accent/20' : 'bg-amber-500/20'} flex items-center justify-center flex-shrink-0`}>
|
||||
<i className={`fa-solid ${icon} ${isAdd ? 'text-accent' : 'text-amber-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">{title}</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
{isAdd ? 'Search and select tags to add to the selected artworks.' : 'Search and select tags to remove from the selected artworks.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(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 tags chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium ${
|
||||
isAdd ? 'bg-accent/20 text-accent' : 'bg-amber-500/20 text-amber-300'
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
onClick={() => removeSelected(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 space-y-0.5 rounded-xl bg-white/[0.02] border border-white/5 p-1">
|
||||
{loading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!loading && results.length === 0 && (
|
||||
<p className="text-center text-sm text-slate-500 py-4">
|
||||
{query ? 'No tags found' : 'Type to search tags'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
results.map((tag) => {
|
||||
const isSelected = selected.some((t) => t.id === tag.id)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
|
||||
isSelected
|
||||
? isAdd
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'bg-amber-500/10 text-amber-300'
|
||||
: '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 ? (isAdd ? 'text-accent' : 'text-amber-400') : 'text-slate-500'
|
||||
}`}
|
||||
/>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={selected.length === 0}
|
||||
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
|
||||
selected.length > 0
|
||||
? isAdd
|
||||
? 'bg-accent hover:bg-accent/90 text-white'
|
||||
: 'bg-amber-600 hover:bg-amber-700 text-white'
|
||||
: 'bg-white/5 text-slate-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isAdd ? 'Add' : 'Remove'} {selected.length > 0 ? `${selected.length} tag${selected.length !== 1 ? 's' : ''}` : 'tags'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
resources/js/components/Studio/ConfirmDangerModal.jsx
Normal file
76
resources/js/components/Studio/ConfirmDangerModal.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
|
||||
export default function ConfirmDangerModal({ open, onClose, onConfirm, title, message, confirmText = 'DELETE' }) {
|
||||
const [input, setInput] = useState('')
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInput('')
|
||||
setTimeout(() => inputRef.current?.focus(), 100)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const canConfirm = input === confirmText
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'Enter' && canConfirm) onConfirm()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md bg-nova-900 border border-red-500/30 rounded-2xl shadow-2xl shadow-red-500/10 p-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<i className="fa-solid fa-triangle-exclamation text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">{title}</h3>
|
||||
<p className="text-sm text-slate-400 mt-1">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">
|
||||
Type <span className="text-red-400 font-mono">{confirmText}</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-red-500/50 font-mono"
|
||||
placeholder={confirmText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={!canConfirm}
|
||||
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
|
||||
canConfirm
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-white/5 text-slate-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Delete permanently
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
resources/js/components/Studio/StudioFilters.jsx
Normal file
127
resources/js/components/Studio/StudioFilters.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from 'react'
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'All statuses' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
]
|
||||
|
||||
const performanceOptions = [
|
||||
{ value: '', label: 'All performance' },
|
||||
{ value: 'rising', label: 'Rising (hot)' },
|
||||
{ value: 'top', label: 'Top performers' },
|
||||
{ value: 'low', label: 'Low performers' },
|
||||
]
|
||||
|
||||
export default function StudioFilters({
|
||||
open,
|
||||
onClose,
|
||||
filters,
|
||||
onFilterChange,
|
||||
categories = [],
|
||||
}) {
|
||||
if (!open) return null
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
onFilterChange({ ...filters, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
<div className="lg:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Filter panel */}
|
||||
<div className="fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto w-72 lg:w-64 bg-nova-900 lg:bg-nova-900/40 border-r lg:border border-white/10 lg:rounded-2xl p-5 space-y-5 overflow-y-auto lg:static lg:mb-4">
|
||||
<div className="flex items-center justify-between lg:hidden">
|
||||
<h3 className="text-base font-semibold text-white">Filters</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white" aria-label="Close filters">
|
||||
<i className="fa-solid fa-xmark text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="hidden lg:block text-sm font-semibold text-slate-400 uppercase tracking-wider">Filters</h3>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Status</label>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
{statusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value} className="bg-nova-900">{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
|
||||
<select
|
||||
value={filters.category || ''}
|
||||
onChange={(e) => handleChange('category', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
<option value="" className="bg-nova-900">All categories</option>
|
||||
{categories.map((ct) => (
|
||||
<optgroup key={ct.id} label={ct.name}>
|
||||
{ct.categories?.map((cat) => (
|
||||
<React.Fragment key={cat.id}>
|
||||
<option value={cat.slug} className="bg-nova-900">{cat.name}</option>
|
||||
{cat.children?.map((ch) => (
|
||||
<option key={ch.id} value={ch.slug} className="bg-nova-900"> {ch.name}</option>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Performance */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Performance</label>
|
||||
<select
|
||||
value={filters.performance || ''}
|
||||
onChange={(e) => handleChange('performance', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
{performanceOptions.map((o) => (
|
||||
<option key={o.value} value={o.value} className="bg-nova-900">{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Date range</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.date_from || ''}
|
||||
onChange={(e) => handleChange('date_from', e.target.value)}
|
||||
className="px-2 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"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.date_to || ''}
|
||||
onChange={(e) => handleChange('date_to', e.target.value)}
|
||||
className="px-2 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear */}
|
||||
<button
|
||||
onClick={() => onFilterChange({ status: '', category: '', performance: '', date_from: '', date_to: '', tags: [] })}
|
||||
className="w-full text-center text-xs text-slate-500 hover:text-white transition-colors py-2"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
101
resources/js/components/Studio/StudioGridCard.jsx
Normal file
101
resources/js/components/Studio/StudioGridCard.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from 'react'
|
||||
import StatusBadge from '../Badges/StatusBadge'
|
||||
import RisingBadge from '../Badges/RisingBadge'
|
||||
|
||||
function getStatus(art) {
|
||||
if (art.deleted_at) return 'archived'
|
||||
if (!art.is_public) return 'draft'
|
||||
return 'published'
|
||||
}
|
||||
|
||||
function statItem(icon, value) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<span>{icon}</span>
|
||||
<span>{typeof value === 'number' ? value.toLocaleString() : value}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioGridCard({ artwork, selected, onSelect, onAction }) {
|
||||
const status = getStatus(artwork)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative bg-nova-900/60 border rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-accent/5 ${
|
||||
selected ? 'border-accent/60 ring-2 ring-accent/20' : 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
{/* Selection checkbox */}
|
||||
<label className="absolute top-3 left-3 z-10 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => onSelect(artwork.id)}
|
||||
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="relative aspect-[4/3] bg-nova-800 overflow-hidden">
|
||||
<img
|
||||
src={artwork.thumb_url}
|
||||
alt={artwork.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* Hover actions */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="absolute bottom-3 right-3 flex gap-1.5">
|
||||
<ActionBtn icon="fa-eye" title="View public" onClick={() => window.open(`/artworks/${artwork.slug}`, '_blank')} />
|
||||
<ActionBtn icon="fa-pen" title="Edit" onClick={() => onAction('edit', artwork)} />
|
||||
{status !== 'archived' ? (
|
||||
<ActionBtn icon="fa-box-archive" title="Archive" onClick={() => onAction('archive', artwork)} />
|
||||
) : (
|
||||
<ActionBtn icon="fa-rotate-left" title="Unarchive" onClick={() => onAction('unarchive', artwork)} />
|
||||
)}
|
||||
<ActionBtn icon="fa-trash" title="Delete" onClick={() => onAction('delete', artwork)} danger />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-white truncate" title={artwork.title}>
|
||||
{artwork.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge status={status} />
|
||||
<RisingBadge heatScore={artwork.heat_score} rankingScore={artwork.ranking_score} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{statItem('👁', artwork.views)}
|
||||
{statItem('❤️', artwork.favourites)}
|
||||
{statItem('🔗', artwork.shares)}
|
||||
{statItem('💬', artwork.comments)}
|
||||
{statItem('⬇', artwork.downloads)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionBtn({ icon, title, onClick, danger }) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
title={title}
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center text-sm transition-all backdrop-blur-sm ${
|
||||
danger
|
||||
? 'bg-red-500/20 text-red-400 hover:bg-red-500/40'
|
||||
: 'bg-white/10 text-white hover:bg-white/20'
|
||||
}`}
|
||||
aria-label={title}
|
||||
>
|
||||
<i className={`fa-solid ${icon}`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
144
resources/js/components/Studio/StudioTable.jsx
Normal file
144
resources/js/components/Studio/StudioTable.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react'
|
||||
import StatusBadge from '../Badges/StatusBadge'
|
||||
import RisingBadge from '../Badges/RisingBadge'
|
||||
|
||||
function getStatus(art) {
|
||||
if (art.deleted_at) return 'archived'
|
||||
if (!art.is_public) return 'draft'
|
||||
return 'published'
|
||||
}
|
||||
|
||||
export default function StudioTable({ artworks, selectedIds, onSelect, onSelectAll, onAction, onSort, currentSort }) {
|
||||
const allSelected = artworks.length > 0 && artworks.every((a) => selectedIds.includes(a.id))
|
||||
|
||||
const columns = [
|
||||
{ key: 'title', label: 'Title', sortable: false },
|
||||
{ key: 'status', label: 'Status', sortable: false },
|
||||
{ key: 'category', label: 'Category', sortable: false },
|
||||
{ key: 'created_at', label: 'Created', sortable: true, sort: 'created_at' },
|
||||
{ key: 'views', label: 'Views', sortable: true, sort: 'views' },
|
||||
{ key: 'favourites', label: 'Favs', sortable: true, sort: 'favorites_count' },
|
||||
{ key: 'shares', label: 'Shares', sortable: true, sort: 'shares_count' },
|
||||
{ key: 'comments', label: 'Comments', sortable: true, sort: 'comments_count' },
|
||||
{ key: 'downloads', label: 'Downloads', sortable: true, sort: 'downloads' },
|
||||
{ key: 'ranking_score', label: 'Rank', sortable: true, sort: 'ranking_score' },
|
||||
{ key: 'heat_score', label: 'Heat', sortable: true, sort: 'heat_score' },
|
||||
]
|
||||
|
||||
const handleSort = (col) => {
|
||||
if (!col.sortable) return
|
||||
const field = col.sort
|
||||
const [currentField, currentDir] = (currentSort || '').split(':')
|
||||
const dir = currentField === field && currentDir === 'desc' ? 'asc' : 'desc'
|
||||
onSort(`${field}:${dir}`)
|
||||
}
|
||||
|
||||
const getSortIcon = (col) => {
|
||||
if (!col.sortable) return null
|
||||
const [currentField, currentDir] = (currentSort || '').split(':')
|
||||
if (currentField !== col.sort) return <i className="fa-solid fa-sort text-slate-600 ml-1 text-[10px]" />
|
||||
return <i className={`fa-solid fa-sort-${currentDir === 'asc' ? 'up' : 'down'} text-accent ml-1 text-[10px]`} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-white/10 bg-nova-900/40">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="sticky top-0 z-10 bg-nova-900/90 backdrop-blur-sm border-b border-white/10">
|
||||
<tr>
|
||||
<th className="p-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={onSelectAll}
|
||||
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<th className="p-3 w-12"></th>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`p-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap ${col.sortable ? 'cursor-pointer hover:text-white select-none' : ''}`}
|
||||
onClick={() => handleSort(col)}
|
||||
>
|
||||
{col.label}
|
||||
{getSortIcon(col)}
|
||||
</th>
|
||||
))}
|
||||
<th className="p-3 w-20 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{artworks.map((art) => (
|
||||
<tr
|
||||
key={art.id}
|
||||
className={`transition-colors ${selectedIds.includes(art.id) ? 'bg-accent/5' : 'hover:bg-white/[0.02]'}`}
|
||||
>
|
||||
<td className="p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(art.id)}
|
||||
onChange={() => onSelect(art.id)}
|
||||
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<img
|
||||
src={art.thumb_url}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-lg object-cover bg-nova-800"
|
||||
loading="lazy"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span className="text-white font-medium truncate block max-w-[200px]" title={art.title}>{art.title}</span>
|
||||
</td>
|
||||
<td className="p-3"><StatusBadge status={getStatus(art)} /></td>
|
||||
<td className="p-3 text-slate-400">{art.category || '—'}</td>
|
||||
<td className="p-3 text-slate-400 whitespace-nowrap">{art.created_at ? new Date(art.created_at).toLocaleDateString() : '—'}</td>
|
||||
<td className="p-3 text-slate-300 tabular-nums">{art.views.toLocaleString()}</td>
|
||||
<td className="p-3 text-slate-300 tabular-nums">{art.favourites.toLocaleString()}</td>
|
||||
<td className="p-3 text-slate-300 tabular-nums">{art.shares.toLocaleString()}</td>
|
||||
<td className="p-3 text-slate-300 tabular-nums">{art.comments.toLocaleString()}</td>
|
||||
<td className="p-3 text-slate-300 tabular-nums">{art.downloads.toLocaleString()}</td>
|
||||
<td className="p-3">
|
||||
<RisingBadge heatScore={0} rankingScore={art.ranking_score} />
|
||||
<span className="text-slate-400 text-xs">{art.ranking_score.toFixed(1)}</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<RisingBadge heatScore={art.heat_score} rankingScore={0} />
|
||||
<span className="text-slate-400 text-xs">{art.heat_score.toFixed(1)}</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onAction('edit', art)}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-xs text-slate-400 hover:text-white hover:bg-white/10 transition-all"
|
||||
title="Edit"
|
||||
aria-label={`Edit ${art.title}`}
|
||||
>
|
||||
<i className="fa-solid fa-pen" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction('delete', art)}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-all"
|
||||
title="Delete"
|
||||
aria-label={`Delete ${art.title}`}
|
||||
>
|
||||
<i className="fa-solid fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{artworks.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={14} className="p-12 text-center text-slate-500">
|
||||
No artworks found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
resources/js/components/Studio/StudioToolbar.jsx
Normal file
90
resources/js/components/Studio/StudioToolbar.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react'
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'created_at:desc', label: 'Latest' },
|
||||
{ value: 'ranking_score:desc', label: 'Trending' },
|
||||
{ value: 'heat_score:desc', label: 'Rising' },
|
||||
{ value: 'views:desc', label: 'Most viewed' },
|
||||
{ value: 'favorites_count:desc', label: 'Most favourited' },
|
||||
{ value: 'shares_count:desc', label: 'Most shared' },
|
||||
{ value: 'downloads:desc', label: 'Most downloaded' },
|
||||
]
|
||||
|
||||
export default function StudioToolbar({
|
||||
search,
|
||||
onSearchChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onFilterToggle,
|
||||
selectedCount,
|
||||
onUpload,
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search title or tags…"
|
||||
style={{ paddingLeft: '3rem' }}
|
||||
className="w-full pr-4 py-2.5 rounded-xl bg-nova-900/60 border border-white/10 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => onSortChange(e.target.value)}
|
||||
className="px-3 py-2.5 rounded-xl bg-nova-900/60 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 appearance-none cursor-pointer min-w-[160px]"
|
||||
>
|
||||
{sortOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} className="bg-nova-900 text-white">
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={onFilterToggle}
|
||||
className="flex items-center gap-2 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"
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
<i className="fa-solid fa-filter" />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
</button>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center bg-nova-900/60 border border-white/10 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className={`px-3 py-2.5 text-sm transition-all ${viewMode === 'grid' ? 'bg-accent/20 text-accent' : 'text-slate-400 hover:text-white'}`}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<i className="fa-solid fa-table-cells" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className={`px-3 py-2.5 text-sm transition-all ${viewMode === 'list' ? 'bg-accent/20 text-accent' : 'text-slate-400 hover:text-white'}`}
|
||||
aria-label="List view"
|
||||
>
|
||||
<i className="fa-solid fa-list" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<a
|
||||
href="/upload"
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white font-semibold text-sm transition-all shadow-lg shadow-accent/25"
|
||||
>
|
||||
<i className="fa-solid fa-cloud-arrow-up" />
|
||||
<span className="hidden sm:inline">Upload</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export default function Topbar({ user = null }) {
|
||||
</a>
|
||||
<div className="border-t border-neutral-700" />
|
||||
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
|
||||
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
|
||||
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
|
||||
<div className="border-t border-neutral-700" />
|
||||
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"
|
||||
|
||||
31
resources/js/studio.jsx
Normal file
31
resources/js/studio.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
|
||||
// Eagerly import all Studio pages
|
||||
import StudioDashboard from './Pages/Studio/StudioDashboard'
|
||||
import StudioArtworks from './Pages/Studio/StudioArtworks'
|
||||
import StudioDrafts from './Pages/Studio/StudioDrafts'
|
||||
import StudioArchived from './Pages/Studio/StudioArchived'
|
||||
import StudioArtworkAnalytics from './Pages/Studio/StudioArtworkAnalytics'
|
||||
import StudioArtworkEdit from './Pages/Studio/StudioArtworkEdit'
|
||||
import StudioAnalytics from './Pages/Studio/StudioAnalytics'
|
||||
|
||||
const pages = {
|
||||
'Studio/StudioDashboard': StudioDashboard,
|
||||
'Studio/StudioArtworks': StudioArtworks,
|
||||
'Studio/StudioDrafts': StudioDrafts,
|
||||
'Studio/StudioArchived': StudioArchived,
|
||||
'Studio/StudioArtworkAnalytics': StudioArtworkAnalytics,
|
||||
'Studio/StudioArtworkEdit': StudioArtworkEdit,
|
||||
'Studio/StudioAnalytics': StudioAnalytics,
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
},
|
||||
})
|
||||
@@ -51,9 +51,11 @@
|
||||
.shadow-sb { box-shadow: 0 12px 30px rgba(0,0,0,.45) !important; }
|
||||
|
||||
/* Scrollbar helpers used in preview */
|
||||
.sb-scrollbar::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
.sb-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,.08); border-radius: 999px; }
|
||||
.sb-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,.15); }
|
||||
.sb-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.sb-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 999px; transition: background .2s; }
|
||||
.sb-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.25); }
|
||||
.sb-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.sb-scrollbar { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.12) transparent; }
|
||||
|
||||
/* Ensure header and dropdowns are not clipped and render above page content */
|
||||
header {
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="/upload"><i class="fa fa-upload"></i> Upload</a></li>
|
||||
<li><a href="/studio/artworks"><i class="fa fa-palette"></i> Studio</a></li>
|
||||
<li><a href="{{ route('dashboard.artworks.index') }}"><i class="fa fa-cloud"></i> Edit Artworks</a></li>
|
||||
<li role="presentation" class="divider"></li>
|
||||
<li><a href="/statistics"><i class="fa fa-cog"></i> Statistics</a></li>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="ribbon gid_{{ $art->gid_num ?? 0 }}" title="{{ $art->category_name }}"><span>{{ $art->category_name }}</span></div>
|
||||
@endif
|
||||
<a href="/art/{{ $art->id }}/{{ Str::slug($art->name ?? '') }}" class="thumb-link" title="{{ $art->name }}">
|
||||
<img src="{{ $art->thumb_url ?? '/gfx/sb_join.jpg' }}" @if(!empty($art->thumb_srcset)) srcset="{{ $art->thumb_srcset }}" @endif alt="{{ $art->name }}" class="img-responsive" loading="lazy" decoding="async">
|
||||
<img src="{{ $art->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp' }}" @if(!empty($art->thumb_srcset)) srcset="{{ $art->thumb_srcset }}" @endif alt="{{ $art->name }}" class="img-responsive" loading="lazy" decoding="async">
|
||||
</a>
|
||||
<div class="thumb-meta">
|
||||
<div class="thumb-title">{{ $art->name }}</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<td class="text-center">
|
||||
<a href="/art/{{ (int) $art->id }}" title="View">
|
||||
<img
|
||||
src="{{ $art->thumb_url ?? '/gfx/sb_join.jpg' }}"
|
||||
src="{{ $art->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp' }}"
|
||||
@if(!empty($art->thumb_srcset)) srcset="{{ $art->thumb_srcset }}" @endif
|
||||
alt="{{ $art->name ?? '' }}"
|
||||
class="img-thumbnail"
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
<a href="{{ $ar->art_url ?? ('/art/' . $ar->id) }}"
|
||||
class="group relative block overflow-hidden rounded-xl ring-1 ring-white/5 bg-black/20 shadow-md transition-all duration-200 hover:-translate-y-0.5">
|
||||
<div class="relative aspect-square overflow-hidden bg-neutral-900">
|
||||
<img src="{{ $ar->thumb_url ?? '/gfx/sb_join.jpg' }}"
|
||||
<img src="{{ $ar->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp' }}"
|
||||
alt="{{ $ar->name ?? '' }}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.06]"
|
||||
onerror="this.src='/gfx/sb_join.jpg'">
|
||||
onerror="this.src='https://files.skinbase.org/default/missing_md.webp'">
|
||||
{{-- Title overlay on hover --}}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-2 py-2
|
||||
opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
$imageObject = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'ImageObject',
|
||||
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'description' => html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'name' => (string) $artwork->title,
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'url' => $meta['canonical'],
|
||||
'contentUrl' => $meta['og_image'] ?? null,
|
||||
'thumbnailUrl' => $presentMd['url'] ?? ($meta['og_image'] ?? null),
|
||||
@@ -53,8 +53,8 @@
|
||||
$creativeWork = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CreativeWork',
|
||||
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'description' => html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'name' => (string) $artwork->title,
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'url' => $meta['canonical'],
|
||||
'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
|
||||
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@foreach($artworks as $art)
|
||||
<div class="bg-panel p-3 rounded">
|
||||
<a href="/art/{{ $art->id }}/{{ Illuminate\Support\Str::slug($art->title ?? 'art') }}">
|
||||
<img src="{{ $art->thumbUrl('md') ?? '/gfx/sb_join.jpg' }}" alt="{{ $art->title }}" class="w-full h-36 object-cover rounded" />
|
||||
<img src="{{ $art->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp' }}" alt="{{ $art->title }}" class="w-full h-36 object-cover rounded" />
|
||||
</a>
|
||||
<div class="mt-2 text-sm">
|
||||
<a class="font-medium" href="/art/{{ $art->id }}/{{ Illuminate\Support\Str::slug($art->title ?? 'art') }}">{{ $art->title }}</a>
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/trending">
|
||||
<i class="fa-solid fa-fire w-4 text-center text-sb-muted"></i>Trending
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/rising">
|
||||
<i class="fa-solid fa-rocket w-4 text-center text-sb-muted"></i>Rising
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/fresh">
|
||||
<i class="fa-solid fa-bolt w-4 text-center text-sb-muted"></i>Fresh
|
||||
</a>
|
||||
@@ -216,74 +219,37 @@
|
||||
@php
|
||||
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
|
||||
$routeDashboardGallery = Route::has('dashboard.gallery') ? route('dashboard.gallery') : '/dashboard/gallery';
|
||||
$routeDashboardArtworks = Route::has('dashboard.artworks.index') ? route('dashboard.artworks.index') : (Route::has('dashboard.artworks') ? route('dashboard.artworks') : '/dashboard/artworks');
|
||||
$routeDashboardStats = Route::has('legacy.statistics') ? route('legacy.statistics') : '/statistics';
|
||||
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
|
||||
$routeDashboardAwards = Route::has('dashboard.awards') ? route('dashboard.awards') : '/dashboard/awards';
|
||||
$routeDashboardFollowers = Route::has('dashboard.followers') ? route('dashboard.followers') : '/dashboard/followers';
|
||||
$routeDashboardFollowing = Route::has('dashboard.following') ? route('dashboard.following') : '/dashboard/following';
|
||||
$routeDashboardComments = Route::has('dashboard.comments') ? route('dashboard.comments') : '/dashboard/comments';
|
||||
$routeDashboardProfile = Route::has('dashboard.profile') ? route('dashboard.profile') : '/dashboard/profile';
|
||||
$routeEditProfile = Route::has('settings') ? route('settings') : '/settings';
|
||||
$routePublicProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername;
|
||||
@endphp
|
||||
|
||||
{{-- My Content --}}
|
||||
<div class="px-4 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">My Content</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeUpload }}">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeUpload }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-upload text-xs text-sb-muted"></i></span>
|
||||
Upload
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardGallery }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-image text-xs text-sb-muted"></i></span>
|
||||
My Gallery
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/studio/artworks">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span>
|
||||
Studio
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-heart text-xs text-sb-muted"></i></span>
|
||||
My Favorites
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardAwards }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-trophy text-xs text-sb-muted"></i></span>
|
||||
My Awards
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardStats }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-chart-line text-xs text-sb-muted"></i></span>
|
||||
Statistics
|
||||
</a>
|
||||
|
||||
{{-- Community --}}
|
||||
<div class="px-4 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Community</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowers }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-group text-xs text-sb-muted"></i></span>
|
||||
Followers
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowing }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-plus text-xs text-sb-muted"></i></span>
|
||||
Following
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardComments }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-comments text-xs text-sb-muted"></i></span>
|
||||
My Activity
|
||||
</a>
|
||||
<div class="border-t border-panel my-1"></div>
|
||||
|
||||
{{-- Account --}}
|
||||
<div class="px-4 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Account</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routePublicProfile }}">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routePublicProfile }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-eye text-xs text-sb-muted"></i></span>
|
||||
View Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeEditProfile }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-pen text-xs text-sb-muted"></i></span>
|
||||
Edit Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardProfile }}">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeEditProfile }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-cog text-xs text-sb-muted"></i></span>
|
||||
Settings
|
||||
</a>
|
||||
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
|
||||
Moderation
|
||||
</a>
|
||||
@@ -323,6 +289,7 @@
|
||||
|
||||
<div class="pt-1 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Discover</div>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/trending"><i class="fa-solid fa-fire w-4 text-center text-sb-muted"></i>Trending</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/rising"><i class="fa-solid fa-rocket w-4 text-center text-sb-muted"></i>Rising</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/fresh"><i class="fa-solid fa-bolt w-4 text-center text-sb-muted"></i>Fresh</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/top-rated"><i class="fa-solid fa-medal w-4 text-center text-sb-muted"></i>Top Rated</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
|
||||
@@ -367,16 +334,16 @@
|
||||
$mobileProfile = Route::has('profile.show') ? route('profile.show', ['username' => $mobileUsername]) : '/@'.$mobileUsername;
|
||||
@endphp
|
||||
<div class="pt-1 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">My Account</div>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/studio/artworks">
|
||||
<i class="fa-solid fa-palette w-4 text-center text-sb-muted"></i>Studio
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites' }}">
|
||||
<i class="fa-solid fa-heart w-4 text-center text-sb-muted"></i>My Favorites
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ $mobileProfile }}">
|
||||
<i class="fa-solid fa-circle-user w-4 text-center text-sb-muted"></i>View Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.awards') }}">
|
||||
<i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>My Awards
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('settings') ? route('settings') : '/settings' }}">
|
||||
<i class="fa-solid fa-pen w-4 text-center text-sb-muted"></i>Edit Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.profile') }}">
|
||||
<i class="fa-solid fa-cog w-4 text-center text-sb-muted"></i>Settings
|
||||
</a>
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
<header class="fixed inset-x-0 top-0 z-50 h-16 bg-nova border-b border-panel">
|
||||
<div class="mx-auto w-full h-full px-4 flex items-center gap-3">
|
||||
<!-- Mobile hamburger -->
|
||||
<button id="btnSidebar"
|
||||
class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<!-- bars -->
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2 pr-2">
|
||||
<img src="/gfx/sb_logo.png" alt="Skinbase.org" class="h-8 w-auto rounded-sm shadow-sm object-contain">
|
||||
<span class="sr-only">Skinbase.org</span>
|
||||
</a>
|
||||
|
||||
<!-- Left nav -->
|
||||
<nav class="hidden lg:flex items-center gap-4 text-sm text-soft">
|
||||
|
||||
<div class="relative">
|
||||
<button class="hover:text-white inline-flex items-center gap-1" data-dd="browse">
|
||||
Browse
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div id="dd-browse" class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-visible">
|
||||
<div class="rounded-lg overflow-hidden">
|
||||
<div class="px-4 dd-section">Views</div>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments mr-3 text-sb-muted"></i>Forum</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/chat"><i class="fa-solid fa-message mr-3 text-sb-muted"></i>Chat</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/sections"><i class="fa-solid fa-folder-open mr-3 text-sb-muted"></i>Browse Sections</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/uploads/latest"><i class="fa-solid fa-cloud-arrow-up mr-3 text-sb-muted"></i>Latest Uploads</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/uploads/daily"><i class="fa-solid fa-calendar-day mr-3 text-sb-muted"></i>Daily Uploads</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/today-in-history"><i class="fa-solid fa-calendar mr-3 text-sb-muted"></i>Today In History</a>
|
||||
|
||||
<div class="px-4 dd-section">Authors</div>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/interviews"><i class="fa-solid fa-microphone mr-3 text-sb-muted"></i>Interviews</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/members/photos"><i class="fa-solid fa-camera mr-3 text-sb-muted"></i>Members Photos</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/authors/top"><i class="fa-solid fa-star mr-3 text-sb-muted"></i>Top Authors</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/comments/latest"><i class="fa-solid fa-comments mr-3 text-sb-muted"></i>Latest Comments</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/comments/monthly"><i class="fa-solid fa-chart-line mr-3 text-sb-muted"></i>Monthly Commented</a>
|
||||
|
||||
<div class="px-4 dd-section">Statistics</div>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/downloads/today"><i class="fa-solid fa-download mr-3 text-sb-muted"></i>Todays Downloads</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/favourites/top"><i class="fa-solid fa-heart mr-3 text-sb-muted"></i>Top Favourites</a>
|
||||
</div> <!-- end .rounded-lg -->
|
||||
</div> <!-- end .dd-browse -->
|
||||
</div> <!-- end .relative -->
|
||||
|
||||
<div class="relative">
|
||||
<button class="hover:text-white inline-flex items-center gap-1" data-dd="cats">
|
||||
Explore
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dd-cats"
|
||||
class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all mr-3 text-sb-muted"></i>All Artworks</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/photography"><i class="fa-solid fa-camera mr-3 text-sb-muted"></i>Photography</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/wallpapers"><i class="fa-solid fa-desktop mr-3 text-sb-muted"></i>Wallpapers</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/skins"><i class="fa-solid fa-layer-group mr-3 text-sb-muted"></i>Skins</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/other"><i class="fa-solid fa-folder-open mr-3 text-sb-muted"></i>Other</a>
|
||||
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/featured-artworks"><i class="fa-solid fa-star mr-3 text-sb-muted"></i>Featured Artwork</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="w-full max-w-lg">
|
||||
<div id="topbar-search-root"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@auth
|
||||
<!-- Right icon counters (authenticated users) -->
|
||||
<div class="hidden md:flex items-center gap-3 text-soft">
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M12 21s-7-4.4-9-9a5.5 5.5 0 0 1 9-6 5.5 5.5 0 0 1 9 6c-2 4.6-9 9-9 9z" />
|
||||
</svg>
|
||||
<span
|
||||
class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $uploadCount ?? 0 }}</span>
|
||||
</button>
|
||||
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M4 4h16v14H5.2L4 19.2V4z" />
|
||||
<path d="M4 6l8 6 8-6" />
|
||||
</svg>
|
||||
<span
|
||||
class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $favCount ?? 0 }}</span>
|
||||
</button>
|
||||
|
||||
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
|
||||
<path d="M13.7 21a2 2 0 01-3.4 0" />
|
||||
</svg>
|
||||
<span
|
||||
class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $msgCount ?? 0 }}</span>
|
||||
</button>
|
||||
|
||||
<!-- User dropdown -->
|
||||
<div class="relative">
|
||||
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user">
|
||||
@php
|
||||
$toolbarUserId = (int) ($userId ?? Auth::id() ?? 0);
|
||||
$toolbarAvatarHash = $avatarHash ?? optional(Auth::user())->profile->avatar_hash ?? null;
|
||||
@endphp
|
||||
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
|
||||
src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
|
||||
alt="{{ $displayName ?? 'User' }}" />
|
||||
<span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div id="dd-user"
|
||||
class="hidden absolute right-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
|
||||
@php
|
||||
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$routeDashboardUpload = Route::has('dashboard.upload') ? route('dashboard.upload') : route('upload');
|
||||
$routeDashboardGallery = Route::has('dashboard.gallery') ? route('dashboard.gallery') : '/dashboard/gallery';
|
||||
$routeDashboardArtworks = Route::has('dashboard.artworks') ? route('dashboard.artworks') : (Route::has('dashboard.artworks.index') ? route('dashboard.artworks.index') : '/dashboard/artworks');
|
||||
$routeDashboardStats = Route::has('dashboard.stats') ? route('dashboard.stats') : (Route::has('legacy.statistics') ? route('legacy.statistics') : '/dashboard/stats');
|
||||
$routeDashboardFollowers = Route::has('dashboard.followers') ? route('dashboard.followers') : '/dashboard/followers';
|
||||
$routeDashboardFollowing = Route::has('dashboard.following') ? route('dashboard.following') : '/dashboard/following';
|
||||
$routeDashboardComments = Route::has('dashboard.comments') ? route('dashboard.comments') : '/dashboard/comments';
|
||||
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
|
||||
$routeDashboardProfile = Route::has('dashboard.profile') ? route('dashboard.profile') : (Route::has('profile.edit') ? route('profile.edit') : '/dashboard/profile');
|
||||
$routePublicProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername;
|
||||
@endphp
|
||||
|
||||
<div class="px-4 dd-section">My Account</div>
|
||||
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardUpload }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-upload text-sb-muted"></i></span>
|
||||
Upload
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardGallery }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-image text-sb-muted"></i></span>
|
||||
My Gallery
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardArtworks }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-pencil text-sb-muted"></i></span>
|
||||
Edit Artworks
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardStats }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-chart-line text-sb-muted"></i></span>
|
||||
Statistics
|
||||
</a>
|
||||
|
||||
|
||||
<div class="px-4 dd-section">Community</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowers }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-user-group text-sb-muted"></i></span>
|
||||
Followers
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowing }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-user-plus text-sb-muted"></i></span>
|
||||
Following
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardComments }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-comments text-sb-muted"></i></span>
|
||||
Comments
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-heart text-sb-muted"></i></span>
|
||||
Favourites
|
||||
</a>
|
||||
|
||||
|
||||
<div class="px-4 dd-section">Community</div>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routePublicProfile }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-eye text-sb-muted"></i></span>
|
||||
View My Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardProfile }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-cog text-sb-muted"></i></span>
|
||||
Edit Profile
|
||||
</a>
|
||||
|
||||
<div class="px-4 dd-section">System</div>
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-user-shield text-sb-muted"></i></span>
|
||||
Username Moderation
|
||||
</a>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full text-left flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
|
||||
class="fa-solid fa-sign-out text-sb-muted"></i></span>
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<!-- Guest: show simple Join / Sign in links -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<a href="/register"
|
||||
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Join</a>
|
||||
<a href="/login"
|
||||
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Sign in</a>
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- MOBILE MENU -->
|
||||
<div class="hidden fixed top-16 left-0 right-0 bg-neutral-950 border-b border-neutral-800 p-4" id="mobileMenu">
|
||||
<div class="space-y-2">
|
||||
@guest
|
||||
<a class="block py-2 border-b border-neutral-900" href="/signup">Join</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/login">Sign in</a>
|
||||
@endguest
|
||||
<a class="block py-2 border-b border-neutral-900" href="/browse">All Artworks</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/photography">Photography</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/wallpapers">Wallpapers</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/skins">Skins</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/other">Other</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/featured-artworks">Featured</a>
|
||||
<a class="block py-2 border-b border-neutral-900" href="/forum">Forum</a>
|
||||
@auth
|
||||
@php
|
||||
$toolbarMobileUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$toolbarMobileProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarMobileUsername]) : '/@'.$toolbarMobileUsername;
|
||||
@endphp
|
||||
<a class="block py-2 border-b border-neutral-900" href="{{ $toolbarMobileProfile }}">Profile</a>
|
||||
@else
|
||||
<a class="block py-2 border-b border-neutral-900" href="/profile">Profile</a>
|
||||
@endauth
|
||||
@auth
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
<a class="block py-2 border-b border-neutral-900" href="{{ route('admin.usernames.moderation') }}">Username Moderation</a>
|
||||
@endif
|
||||
@endauth
|
||||
<a class="block py-2" href="/settings">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
18
resources/views/studio.blade.php
Normal file
18
resources/views/studio.blade.php
Normal file
@@ -0,0 +1,18 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@vite(['resources/js/studio.jsx'])
|
||||
<style>
|
||||
body.page-studio main { padding-top: 4rem; }
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.body.classList.add('page-studio')
|
||||
})
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@inertia
|
||||
@endsection
|
||||
@@ -28,6 +28,7 @@
|
||||
@php
|
||||
$sections = [
|
||||
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
|
||||
'rising' => ['label' => 'Rising', 'icon' => 'fa-rocket'],
|
||||
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'],
|
||||
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'],
|
||||
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'],
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
@php
|
||||
$card = (object) [
|
||||
'url' => url('/art/' . ($art->id ?? '') . '/' . \Illuminate\Support\Str::slug($art->name ?? '')),
|
||||
'thumb' => $art->thumb_url ?? '/gfx/sb_join.jpg',
|
||||
'thumb' => $art->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'name' => $art->name ?? '',
|
||||
'uname' => $art->uname ?? 'Unknown',
|
||||
|
||||
Reference in New Issue
Block a user