Wire admin studio SSR and search infrastructure
This commit is contained in:
159
resources/js/Layouts/AdminLayout.jsx
Normal file
159
resources/js/Layouts/AdminLayout.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
|
||||
const adminNavGroups = [
|
||||
{
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/moderation', icon: 'fa-solid fa-gauge-high', exact: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'People',
|
||||
items: [
|
||||
{ label: 'All Users', href: '/moderation/users', icon: 'fa-solid fa-users' },
|
||||
{ label: 'Staff', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved' },
|
||||
{ label: 'Moderators', href: '/moderation/users?role=moderator', icon: 'fa-solid fa-user-shield' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
items: [
|
||||
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed' },
|
||||
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' },
|
||||
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
|
||||
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge' },
|
||||
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ label: 'Settings', href: '/moderation/settings', icon: 'fa-solid fa-gear' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function NavLink({ item, active }) {
|
||||
const cls = `flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-rose-500/20 text-rose-300 shadow-sm shadow-rose-500/10'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`
|
||||
|
||||
return (
|
||||
<Link href={item.href} className={cls}>
|
||||
<i className={`${item.icon} w-5 text-center text-base`} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({ pathname }) {
|
||||
const isActive = (item) => {
|
||||
if (item.exact) return pathname === item.href
|
||||
return pathname.startsWith(item.href.split('?')[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-64 flex-col overflow-y-auto border-r border-white/[0.07] bg-[rgba(10,14,22,0.98)] px-3 py-6">
|
||||
{/* Brand */}
|
||||
<div className="mb-8 px-3">
|
||||
<Link href="/moderation" className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-rose-500/20">
|
||||
<i className="fa-solid fa-shield-halved text-sm text-rose-400" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-400/80">Skinbase</p>
|
||||
<p className="text-sm font-bold text-white">Admin Panel</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav groups */}
|
||||
<nav className="flex-1 space-y-6">
|
||||
{adminNavGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<p className="mb-1.5 px-4 text-[10px] font-bold uppercase tracking-[0.2em] text-slate-600">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.href} item={item} active={isActive(item)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer links */}
|
||||
<div className="mt-6 space-y-0.5 border-t border-white/[0.06] pt-4">
|
||||
<Link
|
||||
href="/studio"
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-slate-500 transition hover:bg-white/5 hover:text-slate-300"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left w-5 text-center text-base" />
|
||||
<span>Back to Studio</span>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children, title, subtitle }) {
|
||||
const { url } = usePage()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const pathname = url.split('?')[0]
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[radial-gradient(ellipse_at_top,_rgba(239,68,68,0.08),_transparent_40%),linear-gradient(180deg,#060a12_0%,#020409_100%)]">
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:flex lg:w-64 lg:flex-shrink-0">
|
||||
<div className="fixed inset-y-0 left-0 w-64">
|
||||
<Sidebar pathname={pathname} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile header */}
|
||||
<div className="fixed inset-x-0 top-0 z-40 flex items-center justify-between border-b border-white/10 bg-[rgba(10,14,22,0.97)] px-4 py-3 backdrop-blur-xl lg:hidden">
|
||||
<Link href="/moderation" className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-shield-halved text-rose-400" />
|
||||
<span className="text-sm font-bold text-white">Admin Panel</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="rounded-full border border-white/10 p-2 text-slate-400 hover:text-white"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<i className={mobileOpen ? 'fa-solid fa-xmark' : 'fa-solid fa-bars'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-30 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)} />
|
||||
<div className="absolute left-0 top-0 h-full w-72 pt-14">
|
||||
<Sidebar pathname={pathname} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col lg:pl-64">
|
||||
<main className="flex-1 px-6 py-8 pt-20 lg:pt-8">
|
||||
{(title || subtitle) && (
|
||||
<div className="mb-8">
|
||||
{title && <h1 className="text-2xl font-bold text-white">{title}</h1>}
|
||||
{subtitle && <p className="mt-1 text-sm text-slate-400">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -174,6 +174,95 @@ function NavLink({ item, active }) {
|
||||
)
|
||||
}
|
||||
|
||||
function studioContextSummary(option) {
|
||||
if (!option) return ''
|
||||
if (option.kind === 'personal') return option.description || 'Your creator workspace'
|
||||
|
||||
return [option.roleLabel, option.description].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function studioContextInitials(label) {
|
||||
const initials = String(label || '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((segment) => segment.charAt(0).toUpperCase())
|
||||
.join('')
|
||||
|
||||
return initials || 'SB'
|
||||
}
|
||||
|
||||
function StudioContextAvatar({ option, size = 'md' }) {
|
||||
const sizeClass = size === 'lg' ? 'h-11 w-11 text-sm' : 'h-9 w-9 text-xs'
|
||||
|
||||
if (option?.avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={option.avatarUrl}
|
||||
alt={option.label}
|
||||
className={`${sizeClass} rounded-2xl object-cover ring-1 ring-white/10 shadow-[0_10px_24px_rgba(15,23,42,0.25)]`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const toneClass = option?.kind === 'personal'
|
||||
? 'border-sky-300/25 bg-sky-300/12 text-sky-100'
|
||||
: 'border-amber-300/20 bg-amber-300/10 text-amber-100'
|
||||
|
||||
return (
|
||||
<span className={`${sizeClass} inline-flex items-center justify-center rounded-2xl border font-semibold uppercase tracking-[0.16em] ${toneClass}`}>
|
||||
{option?.kind === 'personal' ? <i className="fa-solid fa-user text-[12px]" /> : studioContextInitials(option?.label)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StudioContextOptionContent({ option, expanded = false }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<StudioContextAvatar option={option} size={expanded ? 'lg' : 'md'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate font-semibold ${expanded ? 'text-[15px]' : 'text-sm'} text-white`}>
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="truncate text-xs text-slate-400">
|
||||
{studioContextSummary(option)}
|
||||
</div>
|
||||
</div>
|
||||
{expanded && option.kind === 'group' && option.status ? (
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">
|
||||
{String(option.status).replace(/_/g, ' ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildStudioContextOptions(currentGroup, studioGroups, userLabel, userAvatarUrl) {
|
||||
return [
|
||||
{
|
||||
value: '',
|
||||
label: 'Personal studio',
|
||||
description: userLabel || 'Your creator workspace',
|
||||
avatarUrl: userAvatarUrl || null,
|
||||
group: 'Workspace',
|
||||
kind: 'personal',
|
||||
},
|
||||
...studioGroups.map((group) => ({
|
||||
value: group.slug,
|
||||
label: group.name,
|
||||
description: Number(group.artworks_count || 0) > 0
|
||||
? `${Number(group.artworks_count || 0).toLocaleString()} artwork${Number(group.artworks_count || 0) === 1 ? '' : 's'}`
|
||||
: 'Group workspace',
|
||||
roleLabel: group.role_label || 'Member',
|
||||
status: group.status,
|
||||
avatarUrl: group.avatar_url || null,
|
||||
group: 'Groups',
|
||||
kind: 'group',
|
||||
isCurrent: currentGroup?.slug === group.slug,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
const { url, props } = usePage()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
@@ -181,9 +270,12 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
const pathname = url.split('?')[0]
|
||||
const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : []
|
||||
const currentGroup = props.studioGroup || null
|
||||
const userLabel = props.auth?.user?.name || props.auth?.user?.username || 'Your creator workspace'
|
||||
const userAvatarUrl = props.auth?.user?.avatar_url || null
|
||||
const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator)
|
||||
const canManageWorlds = canManageNews
|
||||
const isStaff = Boolean(props.auth?.user?.is_staff)
|
||||
const studioContextOptions = buildStudioContextOptions(currentGroup, studioGroups, userLabel, userAvatarUrl)
|
||||
|
||||
const navGroups = baseNavGroups.map((group) => {
|
||||
if ((!canManageNews && !canManageWorlds) || group.label !== 'Content') {
|
||||
@@ -324,6 +416,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
<StudioSidebarContent
|
||||
currentGroup={currentGroup}
|
||||
studioGroups={studioGroups}
|
||||
studioContextOptions={studioContextOptions}
|
||||
navGroups={navGroups}
|
||||
quickCreateItems={quickCreateItems}
|
||||
isActive={isActive}
|
||||
@@ -341,6 +434,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
<StudioSidebarContent
|
||||
currentGroup={currentGroup}
|
||||
studioGroups={studioGroups}
|
||||
studioContextOptions={studioContextOptions}
|
||||
navGroups={navGroups}
|
||||
quickCreateItems={quickCreateItems}
|
||||
isActive={isActive}
|
||||
@@ -361,7 +455,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
||||
{studioGroups.length > 0 ? <ContextSwitcher currentGroup={currentGroup} studioGroups={studioGroups} onContextChange={handleContextChange} /> : null}
|
||||
{studioGroups.length > 0 ? <ContextSwitcher currentGroup={currentGroup} studioContextOptions={studioContextOptions} onContextChange={handleContextChange} /> : null}
|
||||
{actions}
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -399,42 +493,45 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ContextSwitcher({ currentGroup, studioGroups, onContextChange }) {
|
||||
function ContextSwitcher({ currentGroup, studioContextOptions, onContextChange }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-slate-200">
|
||||
<i className="fa-solid fa-people-group text-sky-200" />
|
||||
<div className="inline-flex items-center gap-3 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.88),rgba(7,14,28,0.94))] p-2 pr-3 text-sm text-slate-200 shadow-[0_18px_40px_rgba(2,6,23,0.24)]">
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-[20px] border border-sky-300/14 bg-sky-300/10 text-sky-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]">
|
||||
<i className="fa-solid fa-people-group text-sm" />
|
||||
</span>
|
||||
<NovaSelect
|
||||
value={currentGroup?.slug || ''}
|
||||
onChange={(value) => onContextChange?.(value)}
|
||||
options={[
|
||||
{ value: '', label: 'Personal studio' },
|
||||
...studioGroups.map((group) => ({ value: group.slug, label: group.name })),
|
||||
]}
|
||||
searchable={false}
|
||||
options={studioContextOptions}
|
||||
searchable={true}
|
||||
searchPlaceholder="Search studios or groups…"
|
||||
renderOption={(option) => <StudioContextOptionContent option={option} expanded />}
|
||||
renderValue={(option) => <StudioContextOptionContent option={option} />}
|
||||
className="min-w-[280px] border-white/10 bg-white/[0.03] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:border-white/20"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCreateItems, isActive, isStaff, onNavigate, onQuickCreate, onContextChange }) {
|
||||
function StudioSidebarContent({ currentGroup, studioContextOptions, navGroups, quickCreateItems, isActive, isStaff, onNavigate, onQuickCreate, onContextChange }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 rounded-[26px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Skinbase Nova</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Creator Studio</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">Create, manage, and grow from one modular workspace built for every creator surface.</p>
|
||||
{studioGroups.length > 0 ? (
|
||||
{studioContextOptions.length > 1 ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Context</p>
|
||||
<NovaSelect
|
||||
value={currentGroup?.slug || ''}
|
||||
onChange={(value) => onContextChange?.(value)}
|
||||
className="mt-2"
|
||||
options={[
|
||||
{ value: '', label: 'Personal studio' },
|
||||
...studioGroups.map((group) => ({ value: group.slug, label: group.name })),
|
||||
]}
|
||||
searchable={false}
|
||||
options={studioContextOptions}
|
||||
searchable={true}
|
||||
searchPlaceholder="Search studios or groups…"
|
||||
renderOption={(option) => <StudioContextOptionContent option={option} expanded />}
|
||||
renderValue={(option) => <StudioContextOptionContent option={option} />}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user