Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

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