Save workspace changes
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Profile', href: '/dashboard/profile', icon: 'fa-solid fa-user' },
|
||||
// Future: { label: 'Notifications', href: '/dashboard/notifications', icon: 'fa-solid fa-bell' },
|
||||
// Future: { label: 'Privacy', href: '/dashboard/privacy', icon: 'fa-solid fa-shield-halved' },
|
||||
]
|
||||
|
||||
function NavLink({ item, active, onClick }) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className={`${item.icon} w-5 text-center text-base`} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ isActive, onNavigate }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500 px-4 mb-2">Settings</h2>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1 flex-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} active={isActive(item.href)} onClick={onNavigate} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6 space-y-2">
|
||||
<Link
|
||||
href="/studio"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<i className="fa-solid fa-palette w-5 text-center" />
|
||||
Creator Studio
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionSidebar({ sections = [], activeSection, onSectionChange, dirtyMap = {} }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500 px-4 mb-2">Settings</h2>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1 flex-1">
|
||||
{sections.map((section) => {
|
||||
const active = section.key === activeSection
|
||||
const isDirty = !!dirtyMap[section.key]
|
||||
return (
|
||||
<button
|
||||
key={section.key}
|
||||
type="button"
|
||||
onClick={() => onSectionChange?.(section.key)}
|
||||
className={`group relative w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{section.icon ? <i className={`${section.icon} w-5 text-center text-base`} /> : null}
|
||||
<span className="flex flex-col items-start gap-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
{section.label}
|
||||
{isDirty && (
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" title="Unsaved changes" />
|
||||
)}
|
||||
</span>
|
||||
{section.description && !active ? (
|
||||
<span className="text-[11px] font-normal text-slate-500 leading-tight">{section.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsLayout({ children, title, sections = null, activeSection = null, onSectionChange = null, dirtyMap = {} }) {
|
||||
const { url } = usePage()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const hasSectionMode = Array.isArray(sections) && sections.length > 0 && typeof onSectionChange === 'function'
|
||||
|
||||
const isActive = (href) => url.startsWith(href)
|
||||
|
||||
const currentSection = hasSectionMode
|
||||
? sections.find((section) => section.key === activeSection)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-nova-900">
|
||||
{/* Mobile top bar */}
|
||||
<div className="lg:hidden px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
|
||||
{hasSectionMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="block flex-1">
|
||||
<span className="sr-only">Settings section</span>
|
||||
<select
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-accent/50 appearance-none"
|
||||
value={activeSection || ''}
|
||||
onChange={(e) => onSectionChange(e.target.value)}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<option key={section.key} value={section.key} className="bg-nova-900 text-white">
|
||||
{section.label}{dirtyMap[section.key] ? ' •' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{dirtyMap[activeSection] ? (
|
||||
<span className="inline-flex items-center rounded-full bg-amber-400/15 px-2 py-1 text-[10px] font-semibold text-amber-300 border border-amber-400/20">
|
||||
Unsaved
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold text-white">Settings</h1>
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="text-slate-400 hover:text-white p-2"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<i className={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile nav overlay (legacy mode only) */}
|
||||
{!hasSectionMode && mobileOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)}>
|
||||
<nav
|
||||
className="absolute left-0 top-0 bottom-0 w-72 bg-nova-900 border-r border-white/10 p-4 pt-20 space-y-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SidebarContent isActive={isActive} onNavigate={() => setMobileOpen(false)} />
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden lg:flex flex-col w-64 min-h-[calc(100vh-4rem)] border-r border-white/10 bg-nova-900/60 backdrop-blur-xl p-4 pt-6 sticky top-16 self-start">
|
||||
{hasSectionMode ? (
|
||||
<SectionSidebar sections={sections} activeSection={activeSection} onSectionChange={onSectionChange} dirtyMap={dirtyMap} />
|
||||
) : (
|
||||
<SidebarContent isActive={isActive} />
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-4 lg:px-8 pt-4 pb-8 max-w-5xl">
|
||||
{title && (
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">{title}</h1>
|
||||
{currentSection?.description ? (
|
||||
<p className="text-sm text-slate-400 mt-1">{currentSection.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
import { studioModule, studioSurface, trackStudioEvent } from '../utils/studioEvents'
|
||||
|
||||
const baseNavGroups = [
|
||||
{
|
||||
label: 'Creator Studio',
|
||||
items: [
|
||||
{ label: 'Overview', href: '/studio', icon: 'fa-solid fa-chart-line' },
|
||||
{ label: 'Search', href: '/studio/search', icon: 'fa-solid fa-magnifying-glass' },
|
||||
{ label: 'Groups', href: '/studio/groups', icon: 'fa-solid fa-people-group' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Create',
|
||||
items: [
|
||||
{ label: 'New Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
{ label: 'New Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' },
|
||||
{ label: 'New Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' },
|
||||
{ label: 'New Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
items: [
|
||||
{ label: 'All Content', href: '/studio/content', icon: 'fa-solid fa-table-cells-large' },
|
||||
{ label: 'Artworks', href: '/studio/artworks', icon: 'fa-solid fa-images' },
|
||||
{ label: 'Cards', href: '/studio/cards', icon: 'fa-solid fa-id-card' },
|
||||
{ label: 'Collections', href: '/studio/collections', icon: 'fa-solid fa-layer-group' },
|
||||
{ label: 'Stories', href: '/studio/stories', icon: 'fa-solid fa-feather-pointed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Library',
|
||||
items: [
|
||||
{ label: 'Drafts', href: '/studio/drafts', icon: 'fa-solid fa-file-pen' },
|
||||
{ label: 'Scheduled', href: '/studio/scheduled', icon: 'fa-solid fa-calendar-days' },
|
||||
{ label: 'Calendar', href: '/studio/calendar', icon: 'fa-solid fa-calendar-range' },
|
||||
{ label: 'Archived', href: '/studio/archived', icon: 'fa-solid fa-box-archive' },
|
||||
{ label: 'Assets', href: '/studio/assets', icon: 'fa-solid fa-photo-film' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Engagement',
|
||||
items: [
|
||||
{ label: 'Inbox', href: '/studio/inbox', icon: 'fa-solid fa-inbox' },
|
||||
{ label: 'Activity', href: '/studio/activity', icon: 'fa-solid fa-bell' },
|
||||
{ label: 'Comments', href: '/studio/comments', icon: 'fa-solid fa-comments' },
|
||||
{ label: 'Followers', href: '/studio/followers', icon: 'fa-solid fa-user-group' },
|
||||
{ label: 'Challenges', href: '/studio/challenges', icon: 'fa-solid fa-trophy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Insights',
|
||||
items: [
|
||||
{ label: 'Analytics', href: '/studio/analytics', icon: 'fa-solid fa-chart-pie' },
|
||||
{ label: 'Growth', href: '/studio/growth', icon: 'fa-solid fa-chart-line' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Creator',
|
||||
items: [
|
||||
{ label: 'Profile', href: '/studio/profile', icon: 'fa-solid fa-id-card' },
|
||||
{ label: 'Featured Content', href: '/studio/featured', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
{ label: 'Preferences', href: '/studio/preferences', icon: 'fa-solid fa-sliders' },
|
||||
{ label: 'Studio Settings', href: '/studio/settings', icon: 'fa-solid fa-gear' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const baseQuickCreateItems = [
|
||||
{ label: 'Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
{ label: 'Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' },
|
||||
{ label: 'Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' },
|
||||
{ label: 'Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' },
|
||||
]
|
||||
|
||||
const STUDIO_CONTEXT_STORAGE_KEY = 'sb.studio.last-context'
|
||||
const RESTORABLE_STUDIO_PATHS = ['/studio', '/studio/artworks', '/studio/collections', '/studio/settings']
|
||||
|
||||
function supportsStudioContextRestore(pathname) {
|
||||
return RESTORABLE_STUDIO_PATHS.includes(pathname)
|
||||
}
|
||||
|
||||
function studioRouteKeyForPath(pathname) {
|
||||
if (pathname === '/studio/artworks' || pathname.endsWith('/artworks')) return 'studio_artworks_url'
|
||||
if (pathname === '/studio/collections' || pathname.endsWith('/collections')) return 'studio_collections_url'
|
||||
if (pathname === '/studio/settings' || pathname.endsWith('/settings')) return 'studio_settings_url'
|
||||
if (pathname.endsWith('/members')) return 'studio_members_url'
|
||||
if (pathname.endsWith('/invitations')) return 'studio_invitations_url'
|
||||
|
||||
return 'studio_url'
|
||||
}
|
||||
|
||||
function nestedRouteKeyFor(topLevelRouteKey) {
|
||||
return topLevelRouteKey.replace(/_url$/, '')
|
||||
}
|
||||
|
||||
function groupStudioUrlForPath(group, pathname) {
|
||||
if (!group) return '/studio'
|
||||
|
||||
const routeKey = studioRouteKeyForPath(pathname)
|
||||
const nestedRouteKey = nestedRouteKeyFor(routeKey)
|
||||
|
||||
return group[routeKey] || group.urls?.[nestedRouteKey] || group.studio_url || group.urls?.studio || '/studio'
|
||||
}
|
||||
|
||||
function personalStudioUrlForPath(pathname) {
|
||||
if (pathname === '/studio/artworks' || pathname.endsWith('/artworks')) return '/studio/artworks'
|
||||
if (pathname === '/studio/collections' || pathname.endsWith('/collections')) return '/studio/collections'
|
||||
if (pathname === '/studio/settings' || pathname.endsWith('/settings')) return '/studio/settings'
|
||||
|
||||
return '/studio'
|
||||
}
|
||||
|
||||
function persistStudioContext(slug) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(STUDIO_CONTEXT_STORAGE_KEY, slug || '')
|
||||
} catch {
|
||||
// Ignore storage failures so Studio navigation keeps working.
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedStudioContext() {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
try {
|
||||
return window.sessionStorage.getItem(STUDIO_CONTEXT_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToStudioUrl(targetUrl) {
|
||||
if (typeof window === 'undefined' || !targetUrl) return
|
||||
|
||||
if (typeof window.location?.assign === 'function') {
|
||||
window.location.assign(targetUrl)
|
||||
return
|
||||
}
|
||||
|
||||
window.location.href = targetUrl
|
||||
}
|
||||
|
||||
function NavLink({ item, active }) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className={`${item.icon} w-5 text-center text-base`} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
const { url, props } = usePage()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const pathname = url.split('?')[0]
|
||||
const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : []
|
||||
const currentGroup = props.studioGroup || null
|
||||
const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator)
|
||||
|
||||
const navGroups = baseNavGroups.map((group) => {
|
||||
if (!canManageNews || group.label !== 'Content') {
|
||||
return group
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
items: [
|
||||
...group.items,
|
||||
{ label: 'News', href: '/studio/news', icon: 'fa-solid fa-newspaper' },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const quickCreateItems = (canManageNews
|
||||
? [...baseQuickCreateItems, { label: 'News Article', href: '/studio/news/create', icon: 'fa-solid fa-newspaper' }]
|
||||
: baseQuickCreateItems
|
||||
).map((item) => {
|
||||
if (currentGroup?.urls && item.label === 'Artwork') {
|
||||
return { ...item, href: currentGroup.urls?.studio_artworks ? `/upload?group=${currentGroup.slug}` : item.href }
|
||||
}
|
||||
|
||||
if (currentGroup?.urls && item.label === 'Collection') {
|
||||
return { ...item, href: `/settings/collections/create?group=${currentGroup.slug}` }
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const moduleKey = studioModule(pathname)
|
||||
const surface = studioSurface(pathname)
|
||||
|
||||
trackStudioEvent('studio_opened', {
|
||||
surface,
|
||||
module: moduleKey,
|
||||
})
|
||||
|
||||
trackStudioEvent('studio_module_opened', {
|
||||
surface,
|
||||
module: moduleKey,
|
||||
})
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentGroup?.slug) return
|
||||
|
||||
persistStudioContext(currentGroup.slug)
|
||||
}, [currentGroup?.slug])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGroup || !supportsStudioContextRestore(pathname)) return
|
||||
|
||||
const storedSlug = readPersistedStudioContext()
|
||||
if (!storedSlug) return
|
||||
|
||||
const nextGroup = studioGroups.find((group) => group.slug === storedSlug)
|
||||
if (!nextGroup) {
|
||||
persistStudioContext('')
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = groupStudioUrlForPath(nextGroup, pathname)
|
||||
|
||||
if (targetUrl && targetUrl !== pathname) {
|
||||
navigateToStudioUrl(targetUrl)
|
||||
}
|
||||
}, [currentGroup, pathname, studioGroups])
|
||||
|
||||
const isActive = (href) => {
|
||||
if (href === '/studio') return pathname === '/studio'
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
const handleQuickCreateClick = (item) => {
|
||||
trackStudioEvent('studio_quick_create_used', {
|
||||
surface: studioSurface(pathname),
|
||||
module: item.label.toLowerCase(),
|
||||
meta: {
|
||||
href: item.href,
|
||||
label: item.label,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleContextChange = (nextSlug) => {
|
||||
persistStudioContext(nextSlug)
|
||||
|
||||
const nextGroup = studioGroups.find((group) => group.slug === nextSlug)
|
||||
const targetUrl = nextGroup
|
||||
? groupStudioUrlForPath(nextGroup, pathname)
|
||||
: personalStudioUrlForPath(pathname)
|
||||
|
||||
if (targetUrl !== pathname) {
|
||||
navigateToStudioUrl(targetUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.12),_transparent_30%),radial-gradient(circle_at_bottom_right,_rgba(34,197,94,0.12),_transparent_35%),linear-gradient(180deg,_#06101d_0%,_#020617_45%,_#02040a_100%)]">
|
||||
<div className="sticky top-16 z-30 border-b border-white/10 bg-slate-950/80 backdrop-blur-xl lg:hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Creator Studio</p>
|
||||
<h1 className="text-lg font-bold text-white">{title || 'Creator Studio'}</h1>
|
||||
</div>
|
||||
<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={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden" onClick={() => setMobileOpen(false)}>
|
||||
<nav
|
||||
className="absolute left-0 top-0 bottom-0 w-80 overflow-y-auto border-r border-white/10 bg-slate-950 p-4 pt-20"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<StudioSidebarContent
|
||||
currentGroup={currentGroup}
|
||||
studioGroups={studioGroups}
|
||||
navGroups={navGroups}
|
||||
quickCreateItems={quickCreateItems}
|
||||
isActive={isActive}
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
onQuickCreate={handleQuickCreateClick}
|
||||
onContextChange={handleContextChange}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
<aside className="sticky top-16 hidden min-h-[calc(100vh-4rem)] w-72 self-start border-r border-white/10 bg-slate-950/55 p-4 pt-6 backdrop-blur-xl lg:flex lg:flex-col">
|
||||
<StudioSidebarContent
|
||||
currentGroup={currentGroup}
|
||||
studioGroups={studioGroups}
|
||||
navGroups={navGroups}
|
||||
quickCreateItems={quickCreateItems}
|
||||
isActive={isActive}
|
||||
onQuickCreate={handleQuickCreateClick}
|
||||
onContextChange={handleContextChange}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1 px-4 pb-10 pt-4 lg:px-8 lg:pt-6">
|
||||
<section className="mb-6 rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.84),_rgba(2,6,23,0.95))] p-5 shadow-[0_22px_70px_rgba(2,6,23,0.32)] lg:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Creator Studio</p>
|
||||
{title && <h1 className="mt-2 text-3xl font-semibold text-white lg:text-4xl">{title}</h1>}
|
||||
{subtitle && <p className="mt-3 max-w-2xl text-sm leading-6 text-slate-300">{subtitle}</p>}
|
||||
{currentGroup ? <p className="mt-4 inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Group context: {currentGroup.name}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
||||
{studioGroups.length > 0 ? <ContextSwitcher currentGroup={currentGroup} studioGroups={studioGroups} onContextChange={handleContextChange} /> : null}
|
||||
{actions}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen((current) => !current)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15"
|
||||
>
|
||||
<i className="fa-solid fa-plus" />
|
||||
Create New
|
||||
</button>
|
||||
{createOpen && (
|
||||
<div className="absolute right-0 top-[calc(100%+0.75rem)] z-20 min-w-[220px] rounded-[24px] border border-white/10 bg-slate-950/95 p-2 shadow-[0_18px_40px_rgba(2,6,23,0.5)] backdrop-blur-xl">
|
||||
{quickCreateItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => handleQuickCreateClick(item)}
|
||||
className="flex items-center gap-3 rounded-2xl px-4 py-3 text-sm text-slate-200 transition hover:bg-white/[0.06] hover:text-white"
|
||||
>
|
||||
<i className={`${item.icon} w-5 text-center text-sky-200`} />
|
||||
<span>New {item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextSwitcher({ currentGroup, studioGroups, onContextChange }) {
|
||||
return (
|
||||
<label 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" />
|
||||
<select
|
||||
value={currentGroup?.slug || ''}
|
||||
onChange={(event) => onContextChange?.(event.target.value)}
|
||||
className="bg-transparent text-sm text-white outline-none"
|
||||
>
|
||||
<option value="" className="bg-slate-950 text-white">Personal studio</option>
|
||||
{studioGroups.map((group) => (
|
||||
<option key={group.slug} value={group.slug} className="bg-slate-950 text-white">
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCreateItems, isActive, 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 ? (
|
||||
<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>
|
||||
<select
|
||||
value={currentGroup?.slug || ''}
|
||||
onChange={(event) => onContextChange?.(event.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="">Personal studio</option>
|
||||
{studioGroups.map((group) => (
|
||||
<option key={group.slug} value={group.slug}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-5" onClick={onNavigate}>
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h3 className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{group.label}</h3>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.href} item={item} active={isActive(item.href)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 rounded-[24px] border border-white/10 bg-[linear-gradient(135deg,_rgba(15,23,42,0.95),_rgba(12,74,110,0.4))] p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-100/70">Quick create</p>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{quickCreateItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="inline-flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-100 transition hover:border-white/20 hover:bg-white/[0.06]"
|
||||
onClick={() => {
|
||||
onQuickCreate?.(item)
|
||||
onNavigate?.()
|
||||
}}
|
||||
>
|
||||
<i className={`${item.icon} w-5 text-center text-sky-200`} />
|
||||
<span>New {item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import StudioLayout from '../StudioLayout'
|
||||
|
||||
let pageMock = { url: '/studio', props: {} }
|
||||
const originalLocation = window.location
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
Link: ({ href, children, ...props }) => <a href={href} {...props}>{children}</a>,
|
||||
usePage: () => pageMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/studioEvents', () => ({
|
||||
studioModule: () => 'overview',
|
||||
studioSurface: () => '/studio',
|
||||
trackStudioEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('StudioLayout group context persistence', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
assign: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => null)
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the last selected group context on supported personal studio routes', async () => {
|
||||
pageMock = {
|
||||
url: '/studio',
|
||||
props: {
|
||||
studio_groups: [
|
||||
{
|
||||
slug: 'warp-collective',
|
||||
name: 'Warp Collective',
|
||||
studio_url: '/studio/groups/warp-collective',
|
||||
},
|
||||
],
|
||||
studioGroup: null,
|
||||
},
|
||||
}
|
||||
|
||||
Storage.prototype.getItem.mockImplementation((key) => (key === 'sb.studio.last-context' ? 'warp-collective' : null))
|
||||
|
||||
render(
|
||||
<StudioLayout title="Studio" subtitle="Overview">
|
||||
<div>Body</div>
|
||||
</StudioLayout>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.assign).toHaveBeenCalledWith('/studio/groups/warp-collective')
|
||||
})
|
||||
})
|
||||
|
||||
it('stores the selected group slug and navigates into that group context', async () => {
|
||||
pageMock = {
|
||||
url: '/studio',
|
||||
props: {
|
||||
studio_groups: [
|
||||
{
|
||||
slug: 'warp-collective',
|
||||
name: 'Warp Collective',
|
||||
studio_url: '/studio/groups/warp-collective',
|
||||
},
|
||||
],
|
||||
studioGroup: null,
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<StudioLayout title="Studio" subtitle="Overview">
|
||||
<div>Body</div>
|
||||
</StudioLayout>,
|
||||
)
|
||||
|
||||
const [contextSwitcher] = screen.getAllByRole('combobox')
|
||||
await userEvent.selectOptions(contextSwitcher, 'warp-collective')
|
||||
|
||||
expect(Storage.prototype.setItem).toHaveBeenCalledWith('sb.studio.last-context', 'warp-collective')
|
||||
await waitFor(() => {
|
||||
expect(window.location.assign).toHaveBeenCalledWith('/studio/groups/warp-collective')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import AdminUploadQueue from '../../components/admin/AdminUploadQueue'
|
||||
|
||||
export default function UploadQueuePage() {
|
||||
return <AdminUploadQueue />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import AdminUsernameQueue from '../../components/admin/AdminUsernameQueue'
|
||||
|
||||
export default function UsernameQueuePage() {
|
||||
return <AdminUsernameQueue />
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function SimilarArtworksHeader({ artwork }) {
|
||||
if (!artwork) return null
|
||||
|
||||
const title = artwork.title || 'Artwork'
|
||||
const artworkUrl = artwork.url || '#'
|
||||
const authorName = artwork.author_name || 'Artist'
|
||||
const authorHref = artwork.author_profile_url || (artwork.author_username ? `/@${artwork.author_username}` : null)
|
||||
const browseHref = artwork.browse_url || (artwork.content_type_slug ? `/${artwork.content_type_slug}` : '/explore')
|
||||
const thumbUrl = artwork.thumb_lg || artwork.thumb_md || null
|
||||
const thumbSrcSet = artwork.thumb_srcset || undefined
|
||||
const tags = Array.isArray(artwork.tag_slugs) ? artwork.tag_slugs.filter(Boolean) : []
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_28%),linear-gradient(145deg,rgba(8,17,29,0.96),rgba(11,20,34,0.94))] shadow-[0_28px_80px_rgba(2,6,23,0.34)]">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(249,115,22,0.12),transparent_24%),radial-gradient(circle_at_bottom_left,rgba(59,130,246,0.12),transparent_30%)]" />
|
||||
<div className="relative grid gap-6 p-5 md:p-7 xl:grid-cols-[280px_minmax(0,1fr)] xl:items-center">
|
||||
<a
|
||||
href={artworkUrl}
|
||||
className="group relative overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_40px_rgba(2,6,23,0.28)]"
|
||||
>
|
||||
<div className="aspect-[5/4] overflow-hidden">
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
srcSet={thumbSrcSet}
|
||||
sizes="(min-width: 1280px) 280px, (min-width: 768px) 40vw, 100vw"
|
||||
alt={title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-white/[0.04] text-sm text-slate-500">
|
||||
Preview unavailable
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/55 via-transparent to-transparent" />
|
||||
</a>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200">
|
||||
<span className="h-2 w-2 rounded-full bg-sky-300 shadow-[0_0_12px_rgba(125,211,252,0.85)]" />
|
||||
Visual discovery
|
||||
</div>
|
||||
|
||||
<h1 className="mt-4 max-w-4xl text-3xl font-semibold leading-tight tracking-[-0.04em] text-white md:text-4xl xl:text-5xl">
|
||||
Artworks similar to{' '}
|
||||
<a href={artworkUrl} className="underline decoration-white/15 underline-offset-4 transition hover:decoration-sky-300">
|
||||
{title}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-300">
|
||||
Browse visually related artworks, compare style cues, and jump back into the original piece whenever you need context.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2.5 text-sm text-slate-300">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5">
|
||||
{artwork.author_avatar ? (
|
||||
<img
|
||||
src={artwork.author_avatar}
|
||||
alt={authorName}
|
||||
className="h-5 w-5 rounded-full object-cover ring-1 ring-white/15"
|
||||
/>
|
||||
) : null}
|
||||
{authorHref ? (
|
||||
<a href={authorHref} className="font-medium text-white/85 transition hover:text-white">
|
||||
{authorName}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium text-white/85">{authorName}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{artwork.category_name ? (
|
||||
<span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-medium text-slate-300">
|
||||
{artwork.category_name}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{artwork.content_type_name ? (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1.5 text-xs font-medium text-amber-100">
|
||||
{artwork.content_type_name}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{tags.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{tags.map((tagSlug) => (
|
||||
<span
|
||||
key={tagSlug}
|
||||
className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-xs font-medium text-slate-300 transition hover:border-sky-300/30 hover:bg-sky-400/10 hover:text-white"
|
||||
>
|
||||
#{tagSlug}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a
|
||||
href={artworkUrl}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.06] px-4 py-2.5 text-sm font-semibold text-white/85 transition hover:bg-white/[0.1] hover:text-white"
|
||||
>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to artwork
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={browseHref}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 hover:text-white"
|
||||
>
|
||||
Browse {artwork.content_type_name || 'artworks'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import axios from 'axios'
|
||||
import ArtworkHero from '../components/artwork/ArtworkHero'
|
||||
import ArtworkMediaStrip from '../components/artwork/ArtworkMediaStrip'
|
||||
import ArtworkMeta from '../components/artwork/ArtworkMeta'
|
||||
import ArtworkAwards from '../components/artwork/ArtworkAwards'
|
||||
import ArtworkTags from '../components/artwork/ArtworkTags'
|
||||
import ArtworkDescription from '../components/artwork/ArtworkDescription'
|
||||
import ArtworkEvolutionPanel from '../components/artwork/ArtworkEvolutionPanel'
|
||||
import ArtworkComments from '../components/artwork/ArtworkComments'
|
||||
import ArtworkActionBar from '../components/artwork/ArtworkActionBar'
|
||||
import ArtworkDetailsPanel from '../components/artwork/ArtworkDetailsPanel'
|
||||
import CreatorSpotlight from '../components/artwork/CreatorSpotlight'
|
||||
import ArtworkRecommendationsRails from '../components/artwork/ArtworkRecommendationsRails'
|
||||
import ArtworkNavigator from '../components/viewer/ArtworkNavigator'
|
||||
import ArtworkViewer from '../components/viewer/ArtworkViewer'
|
||||
import ReactionBar from '../components/comments/ReactionBar'
|
||||
import GroupSummaryPanel from '../components/groups/GroupSummaryPanel'
|
||||
|
||||
function publisherToGroupSummary(publisher) {
|
||||
if (!publisher || publisher.type !== 'group') return null
|
||||
|
||||
return {
|
||||
id: publisher.id,
|
||||
name: publisher.name,
|
||||
slug: publisher.slug,
|
||||
headline: publisher.headline,
|
||||
avatar_url: publisher.avatar_url,
|
||||
counts: {
|
||||
followers: publisher.followers_count || 0,
|
||||
artworks: 0,
|
||||
members: 0,
|
||||
},
|
||||
trust_signals: [],
|
||||
urls: {
|
||||
public: publisher.profile_url,
|
||||
follow: publisher.follow_url,
|
||||
unfollow: publisher.unfollow_url,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [], groupSummary: initialGroupSummary = null }) {
|
||||
const [viewerOpen, setViewerOpen] = useState(false)
|
||||
const [showMatureArtwork, setShowMatureArtwork] = useState(false)
|
||||
const openViewer = useCallback(() => setViewerOpen(true), [])
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), [])
|
||||
|
||||
// Navigable state — updated on client-side navigation
|
||||
const [artwork, setArtwork] = useState(initialArtwork)
|
||||
const [liveStats, setLiveStats] = useState(initialArtwork?.stats || {})
|
||||
|
||||
const handleStatsChange = useCallback((delta) => {
|
||||
setLiveStats(prev => {
|
||||
const next = { ...prev }
|
||||
Object.entries(delta).forEach(([key, val]) => {
|
||||
next[key] = Math.max(0, (Number(next[key]) || 0) + val)
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const [presentMd, setPresentMd] = useState(initialMd)
|
||||
const [presentLg, setPresentLg] = useState(initialLg)
|
||||
const [presentXl, setPresentXl] = useState(initialXl)
|
||||
const [presentSq, setPresentSq] = useState(initialSq)
|
||||
const [related, setRelated] = useState(initialRelated)
|
||||
const [comments, setComments] = useState(initialComments)
|
||||
const [canonicalUrl, setCanonicalUrl] = useState(initialCanonical)
|
||||
const [groupSummary, setGroupSummary] = useState(initialGroupSummary || publisherToGroupSummary(initialArtwork?.publisher))
|
||||
const [selectedMediaId, setSelectedMediaId] = useState('cover')
|
||||
|
||||
// Nav arrow state — populated by ArtworkNavigator once neighbors resolve
|
||||
const [navState, setNavState] = useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null })
|
||||
|
||||
// Artwork-level reactions
|
||||
const [reactionTotals, setReactionTotals] = useState(null)
|
||||
useEffect(() => {
|
||||
if (!artwork?.id) return
|
||||
axios
|
||||
.get(`/api/artworks/${artwork.id}/reactions`)
|
||||
.then(({ data }) => setReactionTotals(data.totals ?? {}))
|
||||
.catch(() => setReactionTotals({}))
|
||||
}, [artwork?.id])
|
||||
|
||||
/**
|
||||
* Called by ArtworkNavigator after a successful no-reload navigation.
|
||||
* data = ArtworkResource JSON from /api/artworks/{id}/page
|
||||
*/
|
||||
const handleNavigate = useCallback((data) => {
|
||||
setArtwork(data)
|
||||
setLiveStats(data.stats || {})
|
||||
setPresentMd(data.thumbs?.md ?? null)
|
||||
setPresentLg(data.thumbs?.lg ?? null)
|
||||
setPresentXl(data.thumbs?.xl ?? null)
|
||||
setPresentSq(data.thumbs?.sq ?? null)
|
||||
setRelated([]) // cleared on navigation; user can scroll down for related
|
||||
setComments([]) // cleared; per-page server data
|
||||
setCanonicalUrl(data.canonical_url ?? window.location.href)
|
||||
setGroupSummary(data.group_summary ?? publisherToGroupSummary(data.publisher))
|
||||
setSelectedMediaId('cover')
|
||||
setViewerOpen(false) // close viewer when navigating away
|
||||
setShowMatureArtwork(false)
|
||||
}, [])
|
||||
|
||||
if (!artwork) return null
|
||||
|
||||
const requiresInterstitial = Boolean(artwork?.maturity?.requires_interstitial) && !showMatureArtwork
|
||||
|
||||
if (requiresInterstitial) {
|
||||
return (
|
||||
<main className="pb-24 pt-8 lg:pb-12 lg:pt-10">
|
||||
<div className="mx-auto w-full max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<section className="rounded-[32px] border border-amber-300/20 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.34)] md:p-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-amber-200/80">Content warning</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{artwork?.maturity?.warning_title || 'Mature content warning'}</h1>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-200/90">{artwork?.maturity?.warning_message || 'This artwork may contain mature material. Continue only if you want to view it.'}</p>
|
||||
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-4 text-sm text-slate-300">
|
||||
<div className="font-semibold text-white">{artwork.title}</div>
|
||||
<div className="mt-1">by {artwork?.publisher?.name || artwork?.user?.name || 'Artist'}</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMatureArtwork(true)}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/25 bg-amber-400/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/18"
|
||||
>
|
||||
<i className="fa-solid fa-eye" />
|
||||
Show artwork
|
||||
</button>
|
||||
<a
|
||||
href={artwork?.publisher?.profile_url || '/discover/trending'}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
Leave this page
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const coverItem = {
|
||||
id: 'cover',
|
||||
label: 'Cover art',
|
||||
thumbUrl: presentSq?.url || presentMd?.url || presentLg?.url || artwork?.thumbs?.sq?.url || artwork?.thumbs?.md?.url || null,
|
||||
mdUrl: presentMd?.url || artwork?.thumbs?.md?.url || null,
|
||||
lgUrl: presentLg?.url || artwork?.thumbs?.lg?.url || null,
|
||||
xlUrl: presentXl?.url || artwork?.thumbs?.xl?.url || null,
|
||||
width: Number(artwork?.dimensions?.width || artwork?.width || 0) || null,
|
||||
height: Number(artwork?.dimensions?.height || artwork?.height || 0) || null,
|
||||
}
|
||||
|
||||
const screenshotItems = Array.isArray(artwork?.screenshots)
|
||||
? artwork.screenshots.map((item, index) => ({
|
||||
id: item.id || `shot-${index + 1}`,
|
||||
label: item.label || `Screenshot ${index + 1}`,
|
||||
thumbUrl: item.thumb_url || item.url || null,
|
||||
mdUrl: item.url || item.thumb_url || null,
|
||||
lgUrl: item.url || item.thumb_url || null,
|
||||
xlUrl: item.url || item.thumb_url || null,
|
||||
width: null,
|
||||
height: null,
|
||||
}))
|
||||
: []
|
||||
|
||||
const mediaItems = [coverItem, ...screenshotItems].filter((item) => Boolean(item.thumbUrl || item.lgUrl || item.xlUrl))
|
||||
|
||||
const selectedMedia = mediaItems.find((item) => item.id === selectedMediaId) || mediaItems[0] || null
|
||||
|
||||
const initialAwards = artwork?.medals ?? artwork?.awards ?? null
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="pb-24 pt-6 lg:pb-12 lg:pt-8">
|
||||
{/* ── Hero ────────────────────────────────────────────────────── */}
|
||||
<div id="artwork-hero-anchor" className="mx-auto w-full max-w-screen-2xl px-3 sm:px-6 lg:px-8">
|
||||
<ArtworkHero
|
||||
artwork={artwork}
|
||||
presentMd={selectedMedia?.mdUrl ? { url: selectedMedia.mdUrl } : presentMd}
|
||||
presentLg={selectedMedia?.lgUrl ? { url: selectedMedia.lgUrl } : presentLg}
|
||||
presentXl={selectedMedia?.xlUrl ? { url: selectedMedia.xlUrl } : presentXl}
|
||||
mediaWidth={selectedMedia?.width ?? null}
|
||||
mediaHeight={selectedMedia?.height ?? null}
|
||||
mediaKey={selectedMedia?.id || 'cover'}
|
||||
onOpenViewer={openViewer}
|
||||
hasPrev={navState.hasPrev}
|
||||
hasNext={navState.hasNext}
|
||||
onPrev={navState.navigatePrev}
|
||||
onNext={navState.navigateNext}
|
||||
/>
|
||||
|
||||
<ArtworkMediaStrip
|
||||
items={mediaItems}
|
||||
selectedId={selectedMedia?.id || 'cover'}
|
||||
onSelect={setSelectedMediaId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Centered action bar with stat counts ────────────────────── */}
|
||||
<div className="mx-auto mt-5 w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
|
||||
<ArtworkActionBar
|
||||
artwork={artwork}
|
||||
stats={liveStats}
|
||||
canonicalUrl={canonicalUrl}
|
||||
onStatsChange={handleStatsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Two-column content ──────────────────────────────────────── */}
|
||||
<div className="mx-auto mt-8 w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
{/* LEFT COLUMN — main content */}
|
||||
<div className="relative z-10 min-w-0 space-y-5">
|
||||
{/* Title + author + breadcrumbs */}
|
||||
<ArtworkMeta artwork={artwork} />
|
||||
|
||||
{/* Description */}
|
||||
<ArtworkDescription artwork={artwork} />
|
||||
|
||||
{/* Artwork evolution */}
|
||||
<ArtworkEvolutionPanel evolution={artwork?.evolution} />
|
||||
|
||||
{/* Artwork reactions */}
|
||||
{reactionTotals !== null && (
|
||||
<section className="relative z-20 overflow-visible rounded-[28px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.14),transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-5 py-5 shadow-[0_22px_55px_rgba(0,0,0,0.26)] backdrop-blur-xl sm:px-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="max-w-xl">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent/80">Artwork Reactions</div>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-[-0.02em] text-white">Make this artwork feel alive</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-white/55">Drop a reaction so other people instantly see whether this piece hits with love, fire, wow, or a quick clap.</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:shrink-0">
|
||||
<ReactionBar
|
||||
entityType="artwork"
|
||||
entityId={artwork.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isAuthenticated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tags & categories */}
|
||||
<ArtworkTags artwork={artwork} />
|
||||
|
||||
{/* Comments */}
|
||||
<ArtworkComments
|
||||
artworkId={artwork.id}
|
||||
comments={comments}
|
||||
isLoggedIn={isAuthenticated}
|
||||
loginUrl="/login"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN — sidebar */}
|
||||
<aside className="space-y-5 lg:sticky lg:top-6 lg:self-start">
|
||||
{/* Creator card */}
|
||||
<CreatorSpotlight artwork={artwork} presentSq={presentSq} related={related} />
|
||||
|
||||
{groupSummary ? <GroupSummaryPanel group={groupSummary} artwork={artwork} /> : null}
|
||||
|
||||
{/* Details (collapsible) */}
|
||||
<ArtworkDetailsPanel artwork={artwork} stats={liveStats} />
|
||||
|
||||
{/* Medals */}
|
||||
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Full-width recommendation rails ─────────────────────────── */}
|
||||
<div className="mt-14 w-full max-w-screen-2xl mx-auto">
|
||||
<ArtworkRecommendationsRails artwork={artwork} related={related} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */}
|
||||
<ArtworkNavigator
|
||||
artworkId={artwork.id}
|
||||
onNavigate={handleNavigate}
|
||||
onOpenViewer={openViewer}
|
||||
onReady={setNavState}
|
||||
/>
|
||||
|
||||
{/* Fullscreen viewer modal */}
|
||||
<ArtworkViewer
|
||||
isOpen={viewerOpen}
|
||||
onClose={closeViewer}
|
||||
artwork={artwork}
|
||||
presentLg={selectedMedia?.lgUrl ? { url: selectedMedia.lgUrl } : presentLg}
|
||||
presentXl={selectedMedia?.xlUrl ? { url: selectedMedia.xlUrl } : presentXl}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-mount if the Blade view provided data attributes
|
||||
const el = document.getElementById('artwork-page')
|
||||
if (el) {
|
||||
const parse = (key, fallback = null) => {
|
||||
try {
|
||||
return JSON.parse(el.dataset[key] || 'null') ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const root = createRoot(el)
|
||||
root.render(
|
||||
<ArtworkPage
|
||||
artwork={parse('artwork')}
|
||||
related={parse('related', [])}
|
||||
presentMd={parse('presentMd')}
|
||||
presentLg={parse('presentLg')}
|
||||
presentXl={parse('presentXl')}
|
||||
presentSq={parse('presentSq')}
|
||||
canonicalUrl={parse('canonical', '')}
|
||||
isAuthenticated={parse('isAuthenticated', false)}
|
||||
groupSummary={parse('groupSummary')}
|
||||
comments={parse('comments', [])}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtworkPage
|
||||
@@ -0,0 +1,456 @@
|
||||
import React, { startTransition, useDeferredValue, useEffect, useRef, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import CategoryCard from '../components/category/CategoryCard'
|
||||
import Pagination from '../components/forum/Pagination'
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'popular', label: 'Popular' },
|
||||
{ value: 'az', label: 'A-Z' },
|
||||
{ value: 'artworks', label: 'Most artworks' },
|
||||
]
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat()
|
||||
|
||||
function LoadingGrid() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="aspect-[4/5] animate-pulse rounded-2xl border border-white/8 bg-white/[0.04]" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ query }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-dashed border-white/14 bg-black/20 px-6 py-14 text-center backdrop-blur-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-white/35">No matching categories</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">Nothing matched "{query}"</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-white/58">
|
||||
Try a shorter term or switch sorting to browse the full category directory again.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorState({ onRetry }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-rose-400/20 bg-rose-500/8 px-6 py-14 text-center shadow-[0_30px_70px_rgba(0,0,0,0.2)]">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-rose-200/70">Unable to load categories</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">The directory API did not respond cleanly.</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-white/58">
|
||||
Refresh the list and try again. If this persists, the API route or cache payload needs inspection.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-6 inline-flex items-center justify-center rounded-full border border-rose-300/35 bg-rose-400/12 px-5 py-3 text-sm font-semibold text-rose-100 transition hover:border-rose-200/55 hover:bg-rose-400/20"
|
||||
>
|
||||
Retry request
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getInitialPage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 1
|
||||
}
|
||||
|
||||
const rawPage = Number(new URL(window.location.href).searchParams.get('page') || 1)
|
||||
|
||||
if (!Number.isFinite(rawPage) || rawPage < 1) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return Math.floor(rawPage)
|
||||
}
|
||||
|
||||
function getInitialSort() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'popular'
|
||||
}
|
||||
|
||||
const sort = new URL(window.location.href).searchParams.get('sort') || 'popular'
|
||||
return SORT_OPTIONS.some((option) => option.value === sort) ? sort : 'popular'
|
||||
}
|
||||
|
||||
function getInitialSearchQuery() {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return new URL(window.location.href).searchParams.get('q') || ''
|
||||
}
|
||||
|
||||
function syncQueryState({ page, sort, query }) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
if (page <= 1) {
|
||||
url.searchParams.delete('page')
|
||||
} else {
|
||||
url.searchParams.set('page', String(page))
|
||||
}
|
||||
|
||||
if (sort === 'popular') {
|
||||
url.searchParams.delete('sort')
|
||||
} else {
|
||||
url.searchParams.set('sort', sort)
|
||||
}
|
||||
|
||||
if (query.trim() === '') {
|
||||
url.searchParams.delete('q')
|
||||
} else {
|
||||
url.searchParams.set('q', query)
|
||||
}
|
||||
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories', pageDescription = '' }) {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [popularCategories, setPopularCategories] = useState([])
|
||||
const [meta, setMeta] = useState({ current_page: 1, last_page: 1, per_page: PAGE_SIZE, total: 0 })
|
||||
const [summary, setSummary] = useState({ total_categories: 0, total_artworks: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState(() => getInitialSearchQuery())
|
||||
const [sort, setSort] = useState(() => getInitialSort())
|
||||
const [currentPage, setCurrentPage] = useState(() => getInitialPage())
|
||||
const deferredQuery = useDeferredValue(searchQuery)
|
||||
const sentinelRef = useRef(null)
|
||||
|
||||
const loadCategories = async ({ signal, page, query, activeSort, append = false }) => {
|
||||
if (append) {
|
||||
setLoadingMore(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
setError(false)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(PAGE_SIZE),
|
||||
sort: activeSort,
|
||||
})
|
||||
|
||||
if (query.trim() !== '') {
|
||||
params.set('q', query.trim())
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiUrl}?${params.toString()}`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load categories')
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
const nextCategories = Array.isArray(payload?.data) ? payload.data : []
|
||||
|
||||
setCategories((previous) => {
|
||||
if (!append) {
|
||||
return nextCategories
|
||||
}
|
||||
|
||||
const seenIds = new Set(previous.map((category) => category.id))
|
||||
const merged = [...previous]
|
||||
|
||||
nextCategories.forEach((category) => {
|
||||
if (!seenIds.has(category.id)) {
|
||||
merged.push(category)
|
||||
}
|
||||
})
|
||||
|
||||
return merged
|
||||
})
|
||||
setPopularCategories(Array.isArray(payload?.popular_categories) ? payload.popular_categories : [])
|
||||
setMeta(payload?.meta || { current_page: 1, last_page: 1, per_page: PAGE_SIZE, total: 0 })
|
||||
setSummary(payload?.summary || { total_categories: 0, total_artworks: 0 })
|
||||
|
||||
if ((payload?.meta?.current_page ?? page) !== currentPage) {
|
||||
setCurrentPage(payload?.meta?.current_page ?? page)
|
||||
}
|
||||
} catch (requestError) {
|
||||
if (requestError?.name !== 'AbortError') {
|
||||
setError(true)
|
||||
}
|
||||
} finally {
|
||||
if (!signal?.aborted || signal === undefined) {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
void loadCategories({
|
||||
signal: controller.signal,
|
||||
page: currentPage,
|
||||
query: deferredQuery,
|
||||
activeSort: sort,
|
||||
append: false,
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [apiUrl, deferredQuery, sort])
|
||||
|
||||
useEffect(() => {
|
||||
syncQueryState({ page: currentPage, sort, query: deferredQuery })
|
||||
}, [currentPage, deferredQuery, sort])
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setCategories([])
|
||||
setCurrentPage(page)
|
||||
|
||||
void loadCategories({
|
||||
page,
|
||||
query: deferredQuery,
|
||||
activeSort: sort,
|
||||
append: false,
|
||||
})
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
const hasMore = meta.current_page < meta.last_page
|
||||
|
||||
if (!sentinel || loading || loadingMore || error || !hasMore) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const firstEntry = entries[0]
|
||||
|
||||
if (!firstEntry?.isIntersecting) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextPage = meta.current_page + 1
|
||||
setCurrentPage(nextPage)
|
||||
void loadCategories({
|
||||
page: nextPage,
|
||||
query: deferredQuery,
|
||||
activeSort: sort,
|
||||
append: true,
|
||||
})
|
||||
}, { rootMargin: '320px 0px' })
|
||||
|
||||
observer.observe(sentinel)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [deferredQuery, error, loading, loadingMore, meta.current_page, meta.last_page, sort])
|
||||
|
||||
const handleRetry = () => {
|
||||
void loadCategories({
|
||||
page: currentPage,
|
||||
query: deferredQuery,
|
||||
activeSort: sort,
|
||||
append: false,
|
||||
})
|
||||
}
|
||||
|
||||
const loadedCount = categories.length
|
||||
const showingStart = loadedCount > 0 ? 1 : 0
|
||||
const showingEnd = loadedCount
|
||||
const hasMorePages = meta.current_page < meta.last_page
|
||||
|
||||
return (
|
||||
<div className="pb-24 text-white">
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="absolute inset-x-0 top-0 h-[28rem] bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.12),transparent_38%),radial-gradient(circle_at_top_right,rgba(249,115,22,0.14),transparent_34%)]" />
|
||||
<div className="relative w-full px-6 pb-8 pt-14 sm:px-8 sm:pt-20 xl:px-10 2xl:px-14 lg:pt-24">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1.2fr)_20rem] lg:items-end">
|
||||
<div>
|
||||
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-white/50 backdrop-blur-sm">
|
||||
Category directory
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl lg:text-6xl">
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<p className="mt-5 max-w-2xl text-base leading-8 text-white/62 sm:text-lg">
|
||||
{pageDescription || 'Browse all wallpapers, skins, themes and digital art categories'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-1">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/22 p-5 backdrop-blur-md shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-white/40">Categories</p>
|
||||
<p className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{numberFormatter.format(summary.total_categories)}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/22 p-5 backdrop-blur-md shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-white/40">Artworks indexed</p>
|
||||
<p className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{numberFormatter.format(summary.total_artworks)}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/22 p-5 backdrop-blur-md shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-white/40">View</p>
|
||||
<p className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Grid</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 rounded-[30px] border border-white/10 bg-black/25 p-4 shadow-[0_30px_80px_rgba(0,0,0,0.25)] backdrop-blur-xl sm:p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-center">
|
||||
<label className="relative block">
|
||||
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white/35">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-5 w-5">
|
||||
<path fillRule="evenodd" d="M8.5 3a5.5 5.5 0 1 0 3.473 9.765l3.63 3.63a.75.75 0 1 0 1.06-1.06l-3.63-3.63A5.5 5.5 0 0 0 8.5 3Zm-4 5.5a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
startTransition(() => {
|
||||
setSearchQuery(value)
|
||||
setCurrentPage(1)
|
||||
})
|
||||
}}
|
||||
placeholder="Search categories"
|
||||
aria-label="Search categories"
|
||||
className="h-14 w-full rounded-2xl border border-white/10 bg-white/[0.04] pl-12 pr-4 text-sm text-white placeholder:text-white/28 focus:border-cyan-300/45 focus:outline-none focus:ring-2 focus:ring-cyan-300/15"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-white/38">Sort by</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
setSort(event.target.value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
aria-label="Sort categories"
|
||||
className="h-14 w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 text-sm text-white focus:border-orange-300/45 focus:outline-none focus:ring-2 focus:ring-orange-300/12"
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-950 text-white">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="w-full px-6 sm:px-8 xl:px-10 2xl:px-14">
|
||||
{!loading && !error && deferredQuery.trim() === '' && popularCategories.length > 0 && (
|
||||
<div className="mb-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_24px_60px_rgba(0,0,0,0.18)] backdrop-blur-sm">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Popular categories</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Start with the busiest destinations</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{popularCategories.map((category) => (
|
||||
<a
|
||||
key={category.id}
|
||||
href={category.url}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-white/72 transition hover:border-white/20 hover:bg-white/[0.05] hover:text-white"
|
||||
>
|
||||
<span>{category.name}</span>
|
||||
<span className="text-white/38">{numberFormatter.format(category.artwork_count)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Directory results</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">
|
||||
{numberFormatter.format(meta.total)} categories visible
|
||||
</h2>
|
||||
</div>
|
||||
{!loading && !error && meta.total > 0 ? (
|
||||
<p className="text-sm text-white/52">
|
||||
Showing {numberFormatter.format(showingStart)} to {numberFormatter.format(showingEnd)} of {numberFormatter.format(meta.total)} categories.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-white/52">
|
||||
Browse all wallpapers, skins, themes and digital art categories.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <LoadingGrid />}
|
||||
{!loading && error && <ErrorState onRetry={handleRetry} />}
|
||||
{!loading && !error && meta.total === 0 && <EmptyState query={deferredQuery} />}
|
||||
|
||||
{!loading && !error && meta.total > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{categories.map((category, index) => (
|
||||
<CategoryCard key={category.id} category={category} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div ref={sentinelRef} className="h-6 w-full" aria-hidden="true" />
|
||||
|
||||
{loadingMore && (
|
||||
<div className="mt-6 flex items-center justify-center gap-3 rounded-2xl border border-white/8 bg-black/18 px-4 py-4 text-sm text-white/56 backdrop-blur-sm">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-cyan-300" />
|
||||
Loading more categories
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10 flex flex-col items-center justify-center gap-3 rounded-[24px] border border-white/8 bg-black/18 px-4 py-5 backdrop-blur-sm">
|
||||
<p className="text-sm text-white/46">
|
||||
Loaded through page {numberFormatter.format(meta.current_page)} of {numberFormatter.format(meta.last_page)}
|
||||
</p>
|
||||
<Pagination meta={meta} onPageChange={handlePageChange} />
|
||||
{hasMorePages && (
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-white/28">
|
||||
Scroll to load the next page automatically
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mountElement = document.getElementById('categories-page-root')
|
||||
|
||||
if (mountElement) {
|
||||
let props = {}
|
||||
|
||||
try {
|
||||
const propsElement = document.getElementById('categories-page-props')
|
||||
props = propsElement ? JSON.parse(propsElement.textContent || '{}') : {}
|
||||
} catch {
|
||||
props = {}
|
||||
}
|
||||
|
||||
createRoot(mountElement).render(<CategoriesPage {...props} />)
|
||||
}
|
||||
|
||||
export default CategoriesPage
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
|
||||
function MetricCard({ label, value, delta, icon }) {
|
||||
return (
|
||||
<div className="rounded-[26px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">{label}</div>
|
||||
<i className={`fa-solid ${icon} text-slate-500`} />
|
||||
</div>
|
||||
<div className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
|
||||
<div className="mt-2 text-sm text-slate-300">{Number(delta || 0).toLocaleString()} in the selected range</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineChart({ timeline }) {
|
||||
const safeTimeline = Array.isArray(timeline) ? timeline : []
|
||||
const maxValue = safeTimeline.reduce((largest, item) => Math.max(largest, item.views || 0, item.likes || 0, item.saves || 0), 0) || 1
|
||||
|
||||
return (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Timeline</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Engagement trend</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{safeTimeline.length} days</span>
|
||||
</div>
|
||||
|
||||
{safeTimeline.length ? (
|
||||
<div className="mt-6 flex h-64 items-end gap-2 overflow-x-auto rounded-[24px] border border-white/10 bg-slate-950/40 px-4 py-5">
|
||||
{safeTimeline.map((point) => (
|
||||
<div key={point.date} className="flex min-w-[32px] flex-1 flex-col items-center justify-end gap-2">
|
||||
<div className="flex h-full w-full items-end gap-1">
|
||||
<div className="w-1/3 rounded-t-full bg-sky-300/80" style={{ height: `${Math.max(6, ((point.views || 0) / maxValue) * 100)}%` }} title={`Views: ${point.views || 0}`} />
|
||||
<div className="w-1/3 rounded-t-full bg-emerald-300/75" style={{ height: `${Math.max(6, ((point.likes || 0) / maxValue) * 100)}%` }} title={`Likes: ${point.likes || 0}`} />
|
||||
<div className="w-1/3 rounded-t-full bg-amber-300/75" style={{ height: `${Math.max(6, ((point.saves || 0) / maxValue) * 100)}%` }} title={`Saves: ${point.saves || 0}`} />
|
||||
</div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{String(point.date || '').slice(5)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">Analytics are enabled, but there are not enough daily snapshots yet to render a timeline.</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-full bg-sky-300/80" />Views</span>
|
||||
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-full bg-emerald-300/75" />Likes</span>
|
||||
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-full bg-amber-300/75" />Saves</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionAnalytics() {
|
||||
const { props } = usePage()
|
||||
const collection = props.collection || {}
|
||||
const analytics = props.analytics || {}
|
||||
const totals = analytics.totals || {}
|
||||
const range = analytics.range || {}
|
||||
const topArtworks = Array.isArray(analytics.top_artworks) ? analytics.top_artworks : []
|
||||
const seo = props.seo || {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || `${collection.title || 'Collection'} Analytics — Skinbase Nova`}</title>
|
||||
<meta name="description" content={seo.description || 'Collection analytics overview.'} />
|
||||
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
<meta name="robots" content={seo.robots || 'noindex,follow'} />
|
||||
</Head>
|
||||
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[32rem] opacity-95" style={{ background: 'radial-gradient(circle at 14% 14%, rgba(56,189,248,0.18), transparent 26%), radial-gradient(circle at 86% 18%, rgba(16,185,129,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
||||
{props.dashboardUrl ? <a href={props.dashboardUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white"><i className="fa-solid fa-arrow-left fa-fw text-[11px]" />Dashboard</a> : null}
|
||||
{props.historyUrl ? <a href={props.historyUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-timeline fa-fw text-[11px]" />History</a> : null}
|
||||
{collection.manage_url ? <a href={collection.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white"><i className="fa-solid fa-pen-to-square fa-fw text-[11px]" />Manage</a> : null}
|
||||
</div>
|
||||
|
||||
<section className="mt-6 rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Performance</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{collection.title || 'Collection analytics'}</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
Review activity velocity, audience response, and the artworks carrying the most discovery value over the last {range.days || 30} days.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<MetricCard label="Views" value={totals.views} delta={range.views_delta} icon="fa-eye" />
|
||||
<MetricCard label="Likes" value={totals.likes} delta={range.likes_delta} icon="fa-heart" />
|
||||
<MetricCard label="Follows" value={totals.follows} delta={range.follows_delta} icon="fa-bell" />
|
||||
<MetricCard label="Saves" value={totals.saves} delta={range.saves_delta} icon="fa-bookmark" />
|
||||
<MetricCard label="Comments" value={totals.comments} delta={range.comments_delta} icon="fa-comments" />
|
||||
<MetricCard label="Submissions" value={totals.submissions} delta={totals.submissions} icon="fa-inbox" />
|
||||
</section>
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
<TimelineChart timeline={analytics.timeline} />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Artworks</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Top artwork drivers</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{topArtworks.length}</span>
|
||||
</div>
|
||||
|
||||
{topArtworks.length ? (
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{topArtworks.map((artwork) => (
|
||||
<div key={artwork.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-slate-950/40">
|
||||
<div className="aspect-square bg-slate-950/60">
|
||||
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-image text-3xl" /></div>}
|
||||
</div>
|
||||
<div className="space-y-2 p-4">
|
||||
<div className="truncate text-sm font-semibold text-white">{artwork.title}</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-slate-300">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Views: {Number(artwork.views || 0).toLocaleString()}</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Favs: {Number(artwork.favourites || 0).toLocaleString()}</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Shares: {Number(artwork.shares || 0).toLocaleString()}</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Rank: {Number(artwork.ranking_score || 0).toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">Attach or publish more artworks before artwork-level ranking can be surfaced here.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+674
@@ -0,0 +1,674 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
|
||||
const DEFAULT_SEARCH_FILTERS = {
|
||||
q: '',
|
||||
type: '',
|
||||
visibility: '',
|
||||
lifecycle_state: '',
|
||||
workflow_state: '',
|
||||
health_state: '',
|
||||
placement_eligibility: '',
|
||||
}
|
||||
|
||||
const DEFAULT_BULK_FORM = {
|
||||
action: 'archive',
|
||||
campaign_key: '',
|
||||
campaign_label: '',
|
||||
lifecycle_state: 'archived',
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
function titleize(value) {
|
||||
return String(value || '')
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function buildSearchUrl(baseUrl, filters) {
|
||||
const url = new URL(baseUrl, window.location.origin)
|
||||
|
||||
Object.entries(filters || {}).forEach(([key, value]) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
url.searchParams.set(key, String(value))
|
||||
})
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function fetchSearchResults(baseUrl, filters) {
|
||||
const response = await fetch(buildSearchUrl(baseUrl, filters), {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Search failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, icon, tone = 'sky' }) {
|
||||
const toneClasses = {
|
||||
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
|
||||
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
|
||||
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
|
||||
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<div className={`inline-flex h-12 w-12 items-center justify-center rounded-2xl border ${toneClasses[tone] || toneClasses.sky}`}>
|
||||
<i className={`fa-solid ${icon}`} />
|
||||
</div>
|
||||
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">{label}</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionStrip({ title, eyebrow, collections, emptyLabel, endpoints }) {
|
||||
function resolve(pattern, collectionId) {
|
||||
return pattern ? pattern.replace('__COLLECTION__', String(collectionId)) : null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.24)] backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">{title}</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{collections.length}</span>
|
||||
</div>
|
||||
|
||||
{collections.length ? (
|
||||
<div className="mt-6 grid gap-5 xl:grid-cols-2">
|
||||
{collections.map((collection) => (
|
||||
<div key={collection.id} className="space-y-3">
|
||||
<CollectionCard collection={collection} isOwner />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resolve(endpoints?.managePattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.managePattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]">
|
||||
<i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Manage
|
||||
</a>
|
||||
) : null}
|
||||
{resolve(endpoints?.analyticsPattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.analyticsPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
||||
<i className="fa-solid fa-chart-column fa-fw text-[10px]" />Analytics
|
||||
</a>
|
||||
) : null}
|
||||
{resolve(endpoints?.historyPattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.historyPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]">
|
||||
<i className="fa-solid fa-timeline fa-fw text-[10px]" />History
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">{emptyLabel}</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function WarningList({ warnings, endpoints }) {
|
||||
function resolve(pattern, collectionId) {
|
||||
return pattern ? pattern.replace('__COLLECTION__', String(collectionId)) : null
|
||||
}
|
||||
|
||||
if (!warnings.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.24)] backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Health</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Warnings and blockers</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{warnings.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
{warnings.map((warning) => (
|
||||
<div key={warning.collection_id} className="rounded-[24px] border border-white/10 bg-[#0d1726] p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-white">{warning.title}</p>
|
||||
<p className="mt-2 text-sm text-slate-300">State: {warning.health?.health_state || 'unknown'}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-xs font-semibold text-amber-100">
|
||||
{warning.health?.health_score ?? 'n/a'}
|
||||
</span>
|
||||
</div>
|
||||
{Array.isArray(warning.health?.flags) && warning.health.flags.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{warning.health.flags.map((flag) => (
|
||||
<span key={flag} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-300">{flag}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{resolve(endpoints?.managePattern, warning.collection_id) ? <a href={resolve(endpoints.managePattern, warning.collection_id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Manage</a> : null}
|
||||
{resolve(endpoints?.healthPattern, warning.collection_id) ? <a href={resolve(endpoints.healthPattern, warning.collection_id)} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/15"><i className="fa-solid fa-shield-heart fa-fw text-[10px]" />Health JSON</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchField({ label, value, onChange, children }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</span>
|
||||
{children || (
|
||||
<input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function BulkActionsPanel({
|
||||
selectedCount,
|
||||
totalCount,
|
||||
form,
|
||||
onFormChange,
|
||||
onApply,
|
||||
onClear,
|
||||
onToggleAll,
|
||||
busy,
|
||||
error,
|
||||
notice,
|
||||
}) {
|
||||
if (!selectedCount && !notice && !error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 rounded-[26px] border border-white/10 bg-[#0d1726] p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Bulk actions</p>
|
||||
<h3 className="mt-1 text-lg font-semibold text-white">Apply safe actions to selected collections</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<button type="button" onClick={onToggleAll} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-slate-200 transition hover:bg-white/[0.07]">
|
||||
{selectedCount === totalCount && totalCount > 0 ? 'Clear visible' : 'Select visible'}
|
||||
</button>
|
||||
{selectedCount ? (
|
||||
<button type="button" onClick={onClear} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-slate-200 transition hover:bg-white/[0.07]">
|
||||
Clear selection
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-sky-100">{selectedCount} selected</span>
|
||||
</div>
|
||||
|
||||
{(notice || error) ? (
|
||||
<div className={`mt-4 rounded-2xl px-4 py-3 text-sm ${error ? 'border border-rose-300/20 bg-rose-400/10 text-rose-100' : 'border border-emerald-300/20 bg-emerald-400/10 text-emerald-100'}`}>
|
||||
{error || notice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-4">
|
||||
<SearchField label="Action">
|
||||
<select value={form.action} onChange={(event) => onFormChange('action', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="archive">Archive</option>
|
||||
<option value="assign_campaign">Assign campaign</option>
|
||||
<option value="update_lifecycle">Update lifecycle</option>
|
||||
<option value="request_ai_review">Request AI review</option>
|
||||
<option value="mark_editorial_review">Mark editorial review</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
{form.action === 'assign_campaign' ? (
|
||||
<>
|
||||
<SearchField label="Campaign key">
|
||||
<input value={form.campaign_key} onChange={(event) => onFormChange('campaign_key', event.target.value)} placeholder="spring-launch" className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40" />
|
||||
</SearchField>
|
||||
<SearchField label="Campaign label">
|
||||
<input value={form.campaign_label} onChange={(event) => onFormChange('campaign_label', event.target.value)} placeholder="Spring Launch" className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40" />
|
||||
</SearchField>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{form.action === 'update_lifecycle' ? (
|
||||
<SearchField label="Lifecycle state">
|
||||
<select value={form.lifecycle_state} onChange={(event) => onFormChange('lifecycle_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end">
|
||||
<button type="button" onClick={onApply} disabled={busy || !selectedCount} className="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
<i className="fa-solid fa-wand-magic-sparkles fa-fw text-[12px]" />Apply action
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResults({ state, endpoints, selectedIds, onToggleSelected }) {
|
||||
function resolve(pattern, collectionId) {
|
||||
return pattern ? pattern.replace('__COLLECTION__', String(collectionId)) : null
|
||||
}
|
||||
|
||||
if (!state.hasSearched) {
|
||||
return (
|
||||
<div className="mt-6 rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">
|
||||
Run a search to slice your collection library by workflow, health, visibility, and placement readiness.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return <div className="mt-6 rounded-[26px] border border-rose-300/20 bg-rose-400/10 px-6 py-4 text-sm text-rose-100">{state.error}</div>
|
||||
}
|
||||
|
||||
if (!state.collections.length) {
|
||||
return (
|
||||
<div className="mt-6 rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">
|
||||
No collections matched the current filters.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(state.filters || {}).filter(([, value]) => value !== '' && value !== null && value !== undefined).map(([key, value]) => (
|
||||
<span key={key} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-300">
|
||||
{titleize(key)}: {value === '1' ? 'Eligible' : value === '0' ? 'Blocked' : titleize(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{state.meta ? <span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{state.meta.total || state.collections.length} results</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
{state.collections.map((collection) => (
|
||||
<div key={collection.id} className="space-y-3 rounded-[28px] border border-white/10 bg-[#0d1726] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(collection.id)}
|
||||
onChange={() => onToggleSelected(collection.id)}
|
||||
className="h-4 w-4 rounded border-white/20 bg-[#09111d] text-sky-400 focus:ring-sky-300/30"
|
||||
/>
|
||||
Select
|
||||
</label>
|
||||
</div>
|
||||
<CollectionCard collection={collection} isOwner />
|
||||
<div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||
{collection.workflow_state ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">Workflow: {titleize(collection.workflow_state)}</span> : null}
|
||||
{collection.health_state ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">Health: {titleize(collection.health_state)}</span> : null}
|
||||
{collection.placement_eligibility === true ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-emerald-100">Placement Eligible</span> : null}
|
||||
{collection.placement_eligibility === false ? <span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-rose-100">Placement Blocked</span> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resolve(endpoints?.managePattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.managePattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]">
|
||||
<i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Manage
|
||||
</a>
|
||||
) : null}
|
||||
{resolve(endpoints?.analyticsPattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.analyticsPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
||||
<i className="fa-solid fa-chart-column fa-fw text-[10px]" />Analytics
|
||||
</a>
|
||||
) : null}
|
||||
{resolve(endpoints?.historyPattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.historyPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]">
|
||||
<i className="fa-solid fa-timeline fa-fw text-[10px]" />History
|
||||
</a>
|
||||
) : null}
|
||||
{resolve(endpoints?.healthPattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.healthPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/15">
|
||||
<i className="fa-solid fa-shield-heart fa-fw text-[10px]" />Health
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionDashboard() {
|
||||
const { props } = usePage()
|
||||
const [summary, setSummary] = React.useState(props.summary || {})
|
||||
const topPerforming = Array.isArray(props.topPerforming) ? props.topPerforming : []
|
||||
const needsAttention = Array.isArray(props.needsAttention) ? props.needsAttention : []
|
||||
const expiringCampaigns = Array.isArray(props.expiringCampaigns) ? props.expiringCampaigns : []
|
||||
const healthWarnings = Array.isArray(props.healthWarnings) ? props.healthWarnings : []
|
||||
const filterOptions = props.filterOptions || {}
|
||||
const endpoints = props.endpoints || {}
|
||||
const seo = props.seo || {}
|
||||
const [searchFilters, setSearchFilters] = React.useState(DEFAULT_SEARCH_FILTERS)
|
||||
const [searchState, setSearchState] = React.useState({
|
||||
busy: false,
|
||||
error: '',
|
||||
collections: [],
|
||||
meta: null,
|
||||
filters: null,
|
||||
hasSearched: false,
|
||||
})
|
||||
const [selectedIds, setSelectedIds] = React.useState([])
|
||||
const [bulkForm, setBulkForm] = React.useState(DEFAULT_BULK_FORM)
|
||||
const [bulkState, setBulkState] = React.useState({ busy: false, error: '', notice: '' })
|
||||
|
||||
React.useEffect(() => {
|
||||
setSummary(props.summary || {})
|
||||
}, [props.summary])
|
||||
|
||||
React.useEffect(() => {
|
||||
const visibleIds = new Set((searchState.collections || []).map((collection) => Number(collection.id)))
|
||||
setSelectedIds((current) => current.filter((id) => visibleIds.has(Number(id))))
|
||||
}, [searchState.collections])
|
||||
|
||||
function updateFilter(key, value) {
|
||||
setSearchFilters((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleSearch(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!endpoints.search) {
|
||||
setSearchState({ busy: false, error: 'Search endpoint is unavailable.', collections: [], meta: null, filters: null, hasSearched: true })
|
||||
return
|
||||
}
|
||||
|
||||
setSearchState((current) => ({ ...current, busy: true, error: '', hasSearched: true }))
|
||||
|
||||
try {
|
||||
const payload = await fetchSearchResults(endpoints.search, searchFilters)
|
||||
setSearchState({
|
||||
busy: false,
|
||||
error: '',
|
||||
collections: Array.isArray(payload.collections) ? payload.collections : [],
|
||||
meta: payload.meta || null,
|
||||
filters: payload.filters || { ...searchFilters },
|
||||
hasSearched: true,
|
||||
})
|
||||
} catch (error) {
|
||||
setSearchState({ busy: false, error: error.message || 'Search failed.', collections: [], meta: null, filters: null, hasSearched: true })
|
||||
}
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
setSearchFilters(DEFAULT_SEARCH_FILTERS)
|
||||
setSearchState({ busy: false, error: '', collections: [], meta: null, filters: null, hasSearched: false })
|
||||
setSelectedIds([])
|
||||
}
|
||||
|
||||
function updateBulkForm(key, value) {
|
||||
setBulkForm((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
function toggleSelected(collectionId) {
|
||||
setSelectedIds((current) => (
|
||||
current.includes(collectionId)
|
||||
? current.filter((id) => id !== collectionId)
|
||||
: [...current, collectionId]
|
||||
))
|
||||
}
|
||||
|
||||
function toggleSelectAllVisible() {
|
||||
const visibleIds = (searchState.collections || []).map((collection) => Number(collection.id))
|
||||
if (!visibleIds.length) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedIds((current) => (
|
||||
current.length === visibleIds.length && visibleIds.every((id) => current.includes(id))
|
||||
? []
|
||||
: visibleIds
|
||||
))
|
||||
}
|
||||
|
||||
async function applyBulkAction() {
|
||||
if (!selectedIds.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!endpoints.bulkActions) {
|
||||
setBulkState({ busy: false, error: 'Bulk action endpoint is unavailable.', notice: '' })
|
||||
return
|
||||
}
|
||||
|
||||
if (bulkForm.action === 'archive' && !window.confirm(`Archive ${selectedIds.length} selected collection${selectedIds.length === 1 ? '' : 's'}?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
action: bulkForm.action,
|
||||
collection_ids: selectedIds,
|
||||
}
|
||||
|
||||
if (bulkForm.action === 'assign_campaign') {
|
||||
payload.campaign_key = bulkForm.campaign_key
|
||||
payload.campaign_label = bulkForm.campaign_label
|
||||
}
|
||||
|
||||
if (bulkForm.action === 'update_lifecycle') {
|
||||
payload.lifecycle_state = bulkForm.lifecycle_state
|
||||
}
|
||||
|
||||
setBulkState({ busy: true, error: '', notice: '' })
|
||||
|
||||
try {
|
||||
const response = await requestJson(endpoints.bulkActions, { method: 'POST', body: payload })
|
||||
const updates = new Map((Array.isArray(response.collections) ? response.collections : []).map((collection) => [Number(collection.id), collection]))
|
||||
|
||||
setSearchState((current) => ({
|
||||
...current,
|
||||
collections: (current.collections || []).map((collection) => updates.get(Number(collection.id)) || collection),
|
||||
}))
|
||||
setSummary(response.summary || summary)
|
||||
setSelectedIds([])
|
||||
setBulkState({ busy: false, error: '', notice: response.message || 'Bulk action applied.' })
|
||||
} catch (error) {
|
||||
setBulkState({ busy: false, error: error.message || 'Bulk action failed.', notice: '' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || 'Collections Dashboard — Skinbase Nova'}</title>
|
||||
<meta name="description" content={seo.description || 'Collection lifecycle and performance dashboard.'} />
|
||||
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
<meta name="robots" content={seo.robots || 'noindex,follow'} />
|
||||
</Head>
|
||||
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 12% 15%, rgba(56,189,248,0.18), transparent 28%), radial-gradient(circle at 84% 14%, rgba(245,158,11,0.16), transparent 26%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
|
||||
<section className="overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Operations</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Collections dashboard</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
A working view of collection health across lifecycle, submissions, quality, and campaign timing. Use it to decide what to publish, repair, archive, or promote next.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<SummaryCard label="Total Collections" value={summary.total ?? 0} icon="fa-layer-group" tone="sky" />
|
||||
<SummaryCard label="Drafts" value={summary.drafts ?? 0} icon="fa-file" tone="amber" />
|
||||
<SummaryCard label="Scheduled" value={summary.scheduled ?? 0} icon="fa-calendar-days" tone="emerald" />
|
||||
<SummaryCard label="Published" value={summary.published ?? 0} icon="fa-globe" tone="sky" />
|
||||
<SummaryCard label="Archived" value={summary.archived ?? 0} icon="fa-box-archive" tone="rose" />
|
||||
<SummaryCard label="Pending Submissions" value={summary.pending_submissions ?? 0} icon="fa-inbox" tone="amber" />
|
||||
<SummaryCard label="Needs Review" value={summary.needs_review ?? 0} icon="fa-triangle-exclamation" tone="amber" />
|
||||
<SummaryCard label="Duplicate Risk" value={summary.duplicate_risk ?? 0} icon="fa-id-card" tone="rose" />
|
||||
<SummaryCard label="Placement Blocked" value={summary.placement_blocked ?? 0} icon="fa-ban" tone="rose" />
|
||||
</section>
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.24)] backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Search</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Find the exact collections that need action</h2>
|
||||
</div>
|
||||
{searchState.busy ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-xs font-semibold text-sky-100">Searching...</span> : null}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="mt-6 grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
||||
<SearchField label="Query" value={searchFilters.q} onChange={(event) => updateFilter('q', event.target.value)}>
|
||||
<input value={searchFilters.q} onChange={(event) => updateFilter('q', event.target.value)} placeholder="Title, slug, or campaign" className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40" />
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Type">
|
||||
<select value={searchFilters.type} onChange={(event) => updateFilter('type', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any type</option>
|
||||
{(Array.isArray(filterOptions.types) ? filterOptions.types : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Visibility">
|
||||
<select value={searchFilters.visibility} onChange={(event) => updateFilter('visibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any visibility</option>
|
||||
{(Array.isArray(filterOptions.visibilities) ? filterOptions.visibilities : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Lifecycle">
|
||||
<select value={searchFilters.lifecycle_state} onChange={(event) => updateFilter('lifecycle_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any lifecycle</option>
|
||||
{(Array.isArray(filterOptions.lifecycleStates) ? filterOptions.lifecycleStates : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Workflow">
|
||||
<select value={searchFilters.workflow_state} onChange={(event) => updateFilter('workflow_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any workflow</option>
|
||||
{(Array.isArray(filterOptions.workflowStates) ? filterOptions.workflowStates : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Health">
|
||||
<select value={searchFilters.health_state} onChange={(event) => updateFilter('health_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any health state</option>
|
||||
{(Array.isArray(filterOptions.healthStates) ? filterOptions.healthStates : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Placement">
|
||||
<select value={searchFilters.placement_eligibility} onChange={(event) => updateFilter('placement_eligibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any placement state</option>
|
||||
<option value="1">Eligible</option>
|
||||
<option value="0">Blocked</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<div className="flex items-end gap-3 xl:col-span-1">
|
||||
<button type="submit" disabled={searchState.busy} className="inline-flex flex-1 items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
<i className="fa-solid fa-magnifying-glass fa-fw text-[12px]" />Search
|
||||
</button>
|
||||
<button type="button" onClick={resetSearch} disabled={searchState.busy} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:cursor-not-allowed disabled:opacity-60">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<BulkActionsPanel
|
||||
selectedCount={selectedIds.length}
|
||||
totalCount={(searchState.collections || []).length}
|
||||
form={bulkForm}
|
||||
onFormChange={updateBulkForm}
|
||||
onApply={applyBulkAction}
|
||||
onClear={() => setSelectedIds([])}
|
||||
onToggleAll={toggleSelectAllVisible}
|
||||
busy={bulkState.busy}
|
||||
error={bulkState.error}
|
||||
notice={bulkState.notice}
|
||||
/>
|
||||
|
||||
<SearchResults state={searchState} endpoints={endpoints} selectedIds={selectedIds} onToggleSelected={toggleSelected} />
|
||||
</section>
|
||||
|
||||
<WarningList warnings={healthWarnings} endpoints={endpoints} />
|
||||
<CollectionStrip title="Top Performing" eyebrow="Momentum" collections={topPerforming} emptyLabel="No collections have enough activity yet to rank here." endpoints={endpoints} />
|
||||
<CollectionStrip title="Needs Attention" eyebrow="Quality" collections={needsAttention} emptyLabel="No collections currently need manual intervention." endpoints={endpoints} />
|
||||
<CollectionStrip title="Expiring Campaigns" eyebrow="Timing" collections={expiringCampaigns} emptyLabel="No campaigns are approaching their sunset window." endpoints={endpoints} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
const SEARCH_SELECT_OPTIONS = {
|
||||
type: [
|
||||
{ value: 'personal', label: 'Personal' },
|
||||
{ value: 'community', label: 'Community' },
|
||||
{ value: 'editorial', label: 'Editorial' },
|
||||
],
|
||||
mode: [
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'smart', label: 'Smart' },
|
||||
],
|
||||
lifecycle_state: [
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'featured', label: 'Featured' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
],
|
||||
health_state: [
|
||||
{ value: 'healthy', label: 'Healthy' },
|
||||
{ value: 'needs_metadata', label: 'Needs metadata' },
|
||||
{ value: 'stale', label: 'Stale' },
|
||||
{ value: 'low_content', label: 'Low content' },
|
||||
{ value: 'broken_items', label: 'Broken items' },
|
||||
{ value: 'weak_cover', label: 'Weak cover' },
|
||||
{ value: 'low_engagement', label: 'Low engagement' },
|
||||
{ value: 'duplicate_risk', label: 'Duplicate risk' },
|
||||
{ value: 'merge_candidate', label: 'Merge candidate' },
|
||||
],
|
||||
sort: [
|
||||
{ value: 'trending', label: 'Trending' },
|
||||
{ value: 'recent', label: 'Recent' },
|
||||
{ value: 'quality', label: 'Quality' },
|
||||
{ value: 'evergreen', label: 'Evergreen' },
|
||||
],
|
||||
}
|
||||
|
||||
function humanizeToken(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('_', ' ')
|
||||
.replaceAll('-', ' ')
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase())
|
||||
}
|
||||
|
||||
function searchChipLabel(key, value) {
|
||||
if (!value) return null
|
||||
|
||||
const option = SEARCH_SELECT_OPTIONS[key]?.find((item) => item.value === value)
|
||||
const displayValue = option?.label || humanizeToken(value)
|
||||
|
||||
return key === 'q'
|
||||
? `Query: ${value}`
|
||||
: key === 'campaign_key'
|
||||
? `Campaign: ${displayValue}`
|
||||
: key === 'program_key'
|
||||
? `Program: ${displayValue}`
|
||||
: key === 'quality_tier'
|
||||
? `Quality Tier: ${displayValue}`
|
||||
: key === 'sort'
|
||||
? `Sort: ${displayValue}`
|
||||
: `${humanizeToken(key)}: ${displayValue}`
|
||||
}
|
||||
|
||||
function buildSearchHref(filters, omitKey = null) {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
Object.entries(filters || {}).forEach(([key, value]) => {
|
||||
if (key === omitKey) return
|
||||
if (value === null || value === undefined || value === '') return
|
||||
params.set(key, value)
|
||||
})
|
||||
|
||||
const query = params.toString()
|
||||
return query ? `/collections/search?${query}` : '/collections/search'
|
||||
}
|
||||
|
||||
function activeSearchChips(filters) {
|
||||
return Object.entries(filters || {})
|
||||
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: searchChipLabel(key, value),
|
||||
href: buildSearchHref(filters, key),
|
||||
}))
|
||||
.filter((chip) => chip.label)
|
||||
}
|
||||
|
||||
function primarySaveContext({ search, campaign, program, title, eyebrow }) {
|
||||
if (search) {
|
||||
return {
|
||||
context: 'collection_search',
|
||||
meta: {
|
||||
query: search?.filters?.q || null,
|
||||
surface_label: 'collection search',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (campaign) {
|
||||
return {
|
||||
context: 'campaign_landing',
|
||||
meta: {
|
||||
campaign_key: campaign.key,
|
||||
campaign_label: campaign.label,
|
||||
surface_label: campaign.label || 'campaign landing',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (program) {
|
||||
return {
|
||||
context: 'program_landing',
|
||||
meta: {
|
||||
program_key: program.key,
|
||||
program_label: program.label,
|
||||
surface_label: program.label || 'program landing',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (eyebrow === 'Trending') return { context: 'trending_landing', meta: { surface_label: 'trending collections' } }
|
||||
if (eyebrow === 'Editorial') return { context: 'editorial_landing', meta: { surface_label: 'editorial collections' } }
|
||||
if (eyebrow === 'Community') return { context: 'community_landing', meta: { surface_label: 'community collections' } }
|
||||
if (eyebrow === 'Seasonal') return { context: 'seasonal_landing', meta: { surface_label: 'seasonal collections' } }
|
||||
if (title === 'Recommended collections' || title === 'Collections worth exploring') return { context: 'recommended_landing', meta: { surface_label: 'recommended collections' } }
|
||||
|
||||
return {
|
||||
context: 'featured_landing',
|
||||
meta: { surface_label: 'featured collections' },
|
||||
}
|
||||
}
|
||||
|
||||
function HeroStat({ icon, label, value }) {
|
||||
return (
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.05] px-4 py-4">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
<i className={`fa-solid ${icon} text-[10px]`} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
|
||||
<i className="fa-solid fa-compass text-3xl" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-semibold text-white">No featured collections yet</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-300">
|
||||
Featured placement is reserved for public collections with a strong visual point of view. Check back once creators start pinning their best showcases.
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
|
||||
>
|
||||
<i className="fa-solid fa-house fa-fw" />
|
||||
Back to home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchPanel({ search }) {
|
||||
if (!search) return null
|
||||
|
||||
const filters = search.filters || {}
|
||||
const options = search.options || {}
|
||||
const chips = activeSearchChips(filters)
|
||||
|
||||
return (
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Filters</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Search collections</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{search?.meta?.total ?? 0} results</span>
|
||||
</div>
|
||||
<form method="GET" action="/collections/search" className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<input name="q" defaultValue={filters.q || ''} placeholder="Search title or summary" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35 xl:col-span-2" />
|
||||
<select name="type" defaultValue={filters.type || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">All types</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="editorial">Editorial</option>
|
||||
</select>
|
||||
<select name="sort" defaultValue={filters.sort || 'trending'} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="trending">Trending</option>
|
||||
<option value="recent">Recent</option>
|
||||
<option value="quality">Quality</option>
|
||||
<option value="evergreen">Evergreen</option>
|
||||
</select>
|
||||
<select name="category" defaultValue={filters.category || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any category</option>
|
||||
{(options.category || []).map((item) => (
|
||||
<option key={`category-${item.value}`} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="mode" defaultValue={filters.mode || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any curation mode</option>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="smart">Smart</option>
|
||||
</select>
|
||||
<select name="style" defaultValue={filters.style || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any style signal</option>
|
||||
{(options.style || []).map((item) => (
|
||||
<option key={`style-${item.value}`} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="lifecycle_state" defaultValue={filters.lifecycle_state || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any lifecycle</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="featured">Featured</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<select name="theme" defaultValue={filters.theme || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any theme</option>
|
||||
{(options.theme || []).map((item) => (
|
||||
<option key={`theme-${item.value}`} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="health_state" defaultValue={filters.health_state || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any quality state</option>
|
||||
<option value="healthy">Healthy</option>
|
||||
<option value="needs_metadata">Needs metadata</option>
|
||||
<option value="stale">Stale</option>
|
||||
<option value="low_content">Low content</option>
|
||||
<option value="broken_items">Broken items</option>
|
||||
<option value="weak_cover">Weak cover</option>
|
||||
<option value="low_engagement">Low engagement</option>
|
||||
<option value="duplicate_risk">Duplicate risk</option>
|
||||
<option value="merge_candidate">Merge candidate</option>
|
||||
</select>
|
||||
<select name="color" defaultValue={filters.color || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any color palette</option>
|
||||
{(options.color || []).map((item) => (
|
||||
<option key={`color-${item.value}`} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input name="campaign_key" defaultValue={filters.campaign_key || ''} placeholder="Campaign key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
|
||||
<input name="program_key" defaultValue={filters.program_key || ''} placeholder="Program key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
|
||||
<select name="quality_tier" defaultValue={filters.quality_tier || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any quality tier</option>
|
||||
{(options.quality_tier || []).map((item) => (
|
||||
<option key={`quality-tier-${item.value}`} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="md:col-span-2 xl:col-span-4 flex flex-wrap gap-3">
|
||||
<button type="submit" className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-magnifying-glass fa-fw" />Apply filters</button>
|
||||
<a href="/collections/search" className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
{chips.length ? (
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{chips.map((chip) => (
|
||||
<a key={chip.key} href={chip.href} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs font-semibold text-slate-200 transition hover:bg-white/[0.08]">
|
||||
<span>{chip.label}</span>
|
||||
<i className="fa-solid fa-xmark text-[10px] text-slate-400" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{(search?.links?.prev || search?.links?.next) ? (
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-sm">
|
||||
{search.links.prev ? <a href={search.links.prev} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-left fa-fw text-[10px]" />Previous</a> : null}
|
||||
{search.links.next ? <a href={search.links.next} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white transition hover:bg-white/[0.07]">Next<i className="fa-solid fa-arrow-right fa-fw text-[10px]" /></a> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionFeaturedIndex() {
|
||||
const { props } = usePage()
|
||||
const seo = props.seo || {}
|
||||
const eyebrow = props.eyebrow || 'Discovery'
|
||||
const title = props.title || 'Featured collections'
|
||||
const description = props.description || 'A rotating set of standout galleries from across Skinbase Nova. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.'
|
||||
const collections = Array.isArray(props.collections) ? props.collections : []
|
||||
const communityCollections = Array.isArray(props.communityCollections) ? props.communityCollections : []
|
||||
const editorialCollections = Array.isArray(props.editorialCollections) ? props.editorialCollections : []
|
||||
const recentCollections = Array.isArray(props.recentCollections) ? props.recentCollections : []
|
||||
const trendingCollections = Array.isArray(props.trendingCollections) ? props.trendingCollections : []
|
||||
const seasonalCollections = Array.isArray(props.seasonalCollections) ? props.seasonalCollections : []
|
||||
const campaign = props.campaign || null
|
||||
const program = props.program || null
|
||||
const search = props.search || null
|
||||
const smartCount = collections.filter((collection) => collection?.mode === 'smart').length
|
||||
const totalArtworks = collections.reduce((sum, collection) => sum + (collection?.artworks_count || 0), 0)
|
||||
const mainSave = primarySaveContext({ search, campaign, program, title, eyebrow })
|
||||
const listSchema = seo?.canonical ? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: seo.canonical,
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
numberOfItems: collections.length,
|
||||
itemListElement: collections.slice(0, 18).map((collection, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: collection.url,
|
||||
name: collection.title,
|
||||
})),
|
||||
},
|
||||
} : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoHead seo={seo} title={seo?.title || `${title} — Skinbase Nova`} description={seo?.description || description} jsonLd={listSchema} />
|
||||
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[38rem] opacity-95"
|
||||
style={{
|
||||
background: 'radial-gradient(circle at 12% 14%, rgba(56,189,248,0.18), transparent 28%), radial-gradient(circle at 88% 16%, rgba(249,115,22,0.18), transparent 26%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)',
|
||||
}}
|
||||
/>
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
||||
<a href="/" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
|
||||
<i className="fa-solid fa-arrow-left fa-fw text-[11px]" />
|
||||
Back to home
|
||||
</a>
|
||||
<a href="/collections/featured" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Featured</a>
|
||||
<a href="/collections/trending" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Trending</a>
|
||||
<a href="/collections/community" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Community</a>
|
||||
<a href="/collections/editorial" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Editorial</a>
|
||||
<a href="/collections/seasonal" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Seasonal</a>
|
||||
</div>
|
||||
|
||||
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.18fr)_400px] xl:items-end">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{title}</h1>
|
||||
{campaign?.badge_label ? (
|
||||
<div className="mt-4 inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
|
||||
{campaign.badge_label}
|
||||
</div>
|
||||
) : program?.promotion_tier ? (
|
||||
<div className="mt-4 inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
|
||||
Promotion tier: {program.promotion_tier}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
{description}
|
||||
</p>
|
||||
{campaign ? (
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-xs text-slate-300">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Campaign key: {campaign.key}</span>
|
||||
{campaign.event_label ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Event: {campaign.event_label}</span> : null}
|
||||
{campaign.season_key ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Season: {campaign.season_key}</span> : null}
|
||||
{Array.isArray(campaign.active_surface_keys) && campaign.active_surface_keys.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Surfaces: {campaign.active_surface_keys.join(', ')}</span> : null}
|
||||
</div>
|
||||
) : program ? (
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-xs text-slate-300">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Program key: {program.key}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Collections: {program.collections_count ?? collections.length}</span>
|
||||
{program.trust_tier ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Trust: {program.trust_tier}</span> : null}
|
||||
{Array.isArray(program.partner_labels) && program.partner_labels.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Partners: {program.partner_labels.join(', ')}</span> : null}
|
||||
{Array.isArray(program.sponsorship_labels) && program.sponsorship_labels.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Sponsors: {program.sponsorship_labels.join(', ')}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<HeroStat icon="fa-layer-group" label="Collections" value={collections.length.toLocaleString()} />
|
||||
<HeroStat icon="fa-wand-magic-sparkles" label="Smart" value={smartCount.toLocaleString()} />
|
||||
<HeroStat icon="fa-images" label="Artworks" value={totalArtworks.toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<SearchPanel search={search} />
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
{collections.length ? (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{collections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext={mainSave.context} saveContextMeta={mainSave.meta} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{communityCollections.length ? (
|
||||
<section className="mt-10">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Community</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Collaborative picks</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{communityCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="community_row" saveContextMeta={{ surface_label: 'community collections' }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{trendingCollections.length ? (
|
||||
<section className="mt-10">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Trending</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Momentum right now</h2>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{trendingCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="trending_row" saveContextMeta={{ surface_label: 'trending collections' }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{editorialCollections.length ? (
|
||||
<section className="mt-10">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Editorial</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Staff and campaign collections</h2>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{editorialCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="editorial_row" saveContextMeta={{ surface_label: 'editorial collections' }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{seasonalCollections.length ? (
|
||||
<section className="mt-10">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Seasonal</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign and event spotlights</h2>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{seasonalCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="seasonal_row" saveContextMeta={{ surface_label: 'seasonal collections' }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{recentCollections.length ? (
|
||||
<section className="mt-10 pb-8">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Recent</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Freshly published collections</h2>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{recentCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="recent_row" saveContextMeta={{ surface_label: 'recent collections' }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return 'Unknown time'
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Unknown time'
|
||||
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function FieldChanges({ label, value }) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
|
||||
const entries = Object.entries(value).slice(0, 8)
|
||||
if (!entries.length) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-slate-300">
|
||||
{entries.map(([key, fieldValue]) => (
|
||||
<div key={key} className="flex items-start justify-between gap-4 border-b border-white/5 pb-2 last:border-b-0 last:pb-0">
|
||||
<span className="font-medium text-white">{key}</span>
|
||||
<span className="max-w-[60%] truncate text-right">{Array.isArray(fieldValue) ? `${fieldValue.length} items` : String(fieldValue)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildPageUrl(pageNumber) {
|
||||
if (typeof window === 'undefined') return '#'
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('page', String(pageNumber))
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export default function CollectionHistory() {
|
||||
const { props } = usePage()
|
||||
const collection = props.collection || {}
|
||||
const history = props.history || {}
|
||||
const entries = Array.isArray(history.data) ? history.data : []
|
||||
const meta = history.meta || {}
|
||||
const seo = props.seo || {}
|
||||
const [busyId, setBusyId] = React.useState(null)
|
||||
const [notice, setNotice] = React.useState('')
|
||||
|
||||
async function handleRestore(entry) {
|
||||
if (!props.restorePattern || !entry?.can_restore) return
|
||||
|
||||
const confirmed = window.confirm(`Restore this collection state from history entry #${entry.id}?`)
|
||||
if (!confirmed) return
|
||||
|
||||
setBusyId(entry.id)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const response = await fetch(props.restorePattern.replace('__HISTORY__', String(entry.id)), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Unable to restore this history entry right now.')
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setNotice(error?.message || 'Unable to restore this history entry right now.')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || `${collection.title || 'Collection'} History — Skinbase Nova`}</title>
|
||||
<meta name="description" content={seo.description || 'Collection audit history.'} />
|
||||
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
<meta name="robots" content={seo.robots || 'noindex,follow'} />
|
||||
</Head>
|
||||
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[32rem] opacity-95" style={{ background: 'radial-gradient(circle at 14% 14%, rgba(56,189,248,0.16), transparent 26%), radial-gradient(circle at 84% 20%, rgba(244,63,94,0.14), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
||||
|
||||
<div className="mx-auto max-w-6xl px-4 pt-8 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
||||
{props.dashboardUrl ? <a href={props.dashboardUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white"><i className="fa-solid fa-arrow-left fa-fw text-[11px]" />Dashboard</a> : null}
|
||||
{props.analyticsUrl ? <a href={props.analyticsUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-chart-column fa-fw text-[11px]" />Analytics</a> : null}
|
||||
{collection.manage_url ? <a href={collection.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white"><i className="fa-solid fa-pen-to-square fa-fw text-[11px]" />Manage</a> : null}
|
||||
</div>
|
||||
|
||||
<section className="mt-6 rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Audit</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{collection.title || 'Collection history'}</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
A chronological log of lifecycle transitions, editorial changes, artwork operations, and moderation-adjacent actions for this collection.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4">
|
||||
{notice ? <div className="rounded-[24px] border border-rose-300/20 bg-rose-500/10 px-5 py-4 text-sm text-rose-100">{notice}</div> : null}
|
||||
{entries.length ? entries.map((entry) => (
|
||||
<article key={entry.id} className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{String(entry.action_type || 'updated').replace(/_/g, ' ')}</span>
|
||||
{entry.actor?.username ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">@{entry.actor.username}</span> : <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">System</span>}
|
||||
{entry.can_restore ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100">Restorable</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold text-white">{entry.summary || 'Collection updated'}</h2>
|
||||
{entry.can_restore && Array.isArray(entry.restore_fields) && entry.restore_fields.length ? <p className="mt-3 text-xs uppercase tracking-[0.18em] text-slate-400">Restores: {entry.restore_fields.join(', ')}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<div className="text-sm text-slate-400">{formatDateTime(entry.created_at)}</div>
|
||||
{props.canRestoreHistory && entry.can_restore ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRestore(entry)}
|
||||
disabled={busyId === entry.id}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-100 transition hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<i className="fa-solid fa-rotate-left fa-fw text-[10px]" />
|
||||
{busyId === entry.id ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<FieldChanges label="Before" value={entry.before} />
|
||||
<FieldChanges label="After" value={entry.after} />
|
||||
</div>
|
||||
</article>
|
||||
)) : (
|
||||
<div className="rounded-[30px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-14 text-sm text-slate-300">No audit entries have been recorded for this collection yet.</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{Number(meta.last_page || 1) > 1 ? (
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-3 rounded-[28px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">
|
||||
<div>Page {meta.current_page || 1} of {meta.last_page || 1}</div>
|
||||
<div className="flex gap-2">
|
||||
{(meta.current_page || 1) > 1 ? <a href={buildPageUrl((meta.current_page || 1) - 1)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-left fa-fw text-[10px]" />Previous</a> : null}
|
||||
{(meta.current_page || 1) < (meta.last_page || 1) ? <a href={buildPageUrl((meta.current_page || 1) + 1)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 font-semibold text-white transition hover:bg-white/[0.07]">Next<i className="fa-solid fa-arrow-right fa-fw text-[10px]" /></a> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+3304
File diff suppressed because it is too large
Load Diff
+94
@@ -0,0 +1,94 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function StatCard({ icon, label, value }) {
|
||||
return (
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.05] px-4 py-4">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
<i className={`fa-solid ${icon} text-[10px]`} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionSeriesShow() {
|
||||
const { props } = usePage()
|
||||
const seo = props.seo || {}
|
||||
const title = props.title || `Collection Series: ${props.seriesKey || ''}`
|
||||
const description = props.description || 'A connected sequence of public collections on Skinbase Nova.'
|
||||
const collections = Array.isArray(props.collections) ? props.collections : []
|
||||
const leadCollection = props.leadCollection || null
|
||||
const stats = props.stats || {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoHead seo={seo} title={seo.title || `${title} — Skinbase Nova`} description={seo.description || description} />
|
||||
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[36rem] opacity-95" style={{ background: 'radial-gradient(circle at 10% 15%, rgba(59,130,246,0.18), transparent 28%), radial-gradient(circle at 84% 18%, rgba(34,197,94,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
||||
<a href="/collections/featured" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
|
||||
<i className="fa-solid fa-arrow-left fa-fw text-[11px]" />
|
||||
Back to collections
|
||||
</a>
|
||||
{leadCollection?.url ? <a href={leadCollection.url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Lead collection</a> : null}
|
||||
</div>
|
||||
|
||||
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_400px] xl:items-end">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Series</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">{description}</p>
|
||||
{props.seriesKey ? <div className="mt-5 inline-flex rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">{props.seriesKey}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<StatCard icon="fa-layer-group" label="Collections" value={Number(stats.collections || collections.length).toLocaleString()} />
|
||||
<StatCard icon="fa-user-group" label="Creators" value={Number(stats.owners || 0).toLocaleString()} />
|
||||
<StatCard icon="fa-images" label="Artworks" value={Number(stats.artworks || 0).toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{leadCollection ? (
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Lead Entry</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Start with the opening collection</h2>
|
||||
</div>
|
||||
{stats.latest_activity_at ? <div className="text-xs uppercase tracking-[0.16em] text-slate-400">Latest activity {new Date(stats.latest_activity_at).toLocaleDateString()}</div> : null}
|
||||
</div>
|
||||
<div className="mt-5 max-w-xl">
|
||||
<CollectionCard collection={leadCollection} isOwner={false} />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-8 pb-8">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Sequence</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Public collections in order</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{collections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+963
@@ -0,0 +1,963 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import ShareToast from '../../components/ui/ShareToast'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function isoToLocalInput(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function titleize(value) {
|
||||
return String(value || '')
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function Field({ label, help, children }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-semibold text-white">{label}</span>
|
||||
{children}
|
||||
{help ? <span className="block text-xs leading-relaxed text-slate-400">{help}</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, tone = 'sky' }) {
|
||||
const toneClasses = {
|
||||
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
|
||||
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
|
||||
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<div className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClasses[tone] || toneClasses.sky}`}>{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sortAssignments(items) {
|
||||
return [...items].sort((left, right) => {
|
||||
const keyCompare = String(left.program_key || '').localeCompare(String(right.program_key || ''))
|
||||
if (keyCompare !== 0) return keyCompare
|
||||
return (Number(right.priority) || 0) - (Number(left.priority) || 0)
|
||||
})
|
||||
}
|
||||
|
||||
function buildHooksForm(collection) {
|
||||
return {
|
||||
experiment_key: collection?.experiment_key || '',
|
||||
experiment_treatment: collection?.experiment_treatment || '',
|
||||
placement_variant: collection?.placement_variant || '',
|
||||
ranking_mode_variant: collection?.ranking_mode_variant || '',
|
||||
collection_pool_version: collection?.collection_pool_version || '',
|
||||
test_label: collection?.test_label || '',
|
||||
promotion_tier: collection?.promotion_tier || '',
|
||||
partner_key: collection?.partner_key || '',
|
||||
trust_tier: collection?.trust_tier || '',
|
||||
sponsorship_state: collection?.sponsorship_state || '',
|
||||
ownership_domain: collection?.ownership_domain || '',
|
||||
commercial_review_state: collection?.commercial_review_state || '',
|
||||
legal_review_state: collection?.legal_review_state || '',
|
||||
placement_eligibility: Boolean(collection?.placement_eligibility),
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiagnostics(collection) {
|
||||
if (!collection) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
collection_id: Number(collection.id),
|
||||
workflow_state: collection.workflow_state || null,
|
||||
health_state: collection.health_state || null,
|
||||
placement_eligibility: Boolean(collection.placement_eligibility),
|
||||
experiment_key: collection.experiment_key || null,
|
||||
experiment_treatment: collection.experiment_treatment || null,
|
||||
placement_variant: collection.placement_variant || null,
|
||||
ranking_mode_variant: collection.ranking_mode_variant || null,
|
||||
collection_pool_version: collection.collection_pool_version || null,
|
||||
test_label: collection.test_label || null,
|
||||
partner_key: collection.partner_key || null,
|
||||
trust_tier: collection.trust_tier || null,
|
||||
promotion_tier: collection.promotion_tier || null,
|
||||
sponsorship_state: collection.sponsorship_state || null,
|
||||
ownership_domain: collection.ownership_domain || null,
|
||||
commercial_review_state: collection.commercial_review_state || null,
|
||||
legal_review_state: collection.legal_review_state || null,
|
||||
ranking_bucket: collection.ranking_bucket || null,
|
||||
recommendation_tier: collection.recommendation_tier || null,
|
||||
last_health_check_at: collection.last_health_check_at || null,
|
||||
last_recommendation_refresh_at: collection.last_recommendation_refresh_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
function buildProgramUrl(pattern, programKey) {
|
||||
if (!pattern || !programKey) return null
|
||||
return pattern.replace('__PROGRAM__', String(programKey))
|
||||
}
|
||||
|
||||
export default function CollectionStaffProgramming() {
|
||||
const { props } = usePage()
|
||||
const initialCollectionOptions = Array.isArray(props.collectionOptions) ? props.collectionOptions : []
|
||||
const initialAssignments = Array.isArray(props.assignments) ? props.assignments : []
|
||||
const baseProgramKeys = Array.isArray(props.programKeyOptions) ? props.programKeyOptions : []
|
||||
const initialMergeQueue = props.mergeQueue || { summary: {}, pending: [], recent: [] }
|
||||
const observabilitySummary = props.observabilitySummary || { counts: {}, watchlist: [], generated_at: null }
|
||||
const endpoints = props.endpoints || {}
|
||||
const historyPattern = props.historyPattern || ''
|
||||
const seo = props.seo || {}
|
||||
const viewer = props.viewer || {}
|
||||
const [assignments, setAssignments] = React.useState(sortAssignments(initialAssignments))
|
||||
const [collectionOverrides, setCollectionOverrides] = React.useState({})
|
||||
const collectionOptions = React.useMemo(() => initialCollectionOptions.map((collection) => collectionOverrides[collection.id] || collection), [collectionOverrides, initialCollectionOptions])
|
||||
const [mergeQueue, setMergeQueue] = React.useState(initialMergeQueue)
|
||||
const [previewCollections, setPreviewCollections] = React.useState([])
|
||||
const [diagnostics, setDiagnostics] = React.useState({
|
||||
eligibility: null,
|
||||
duplicates: null,
|
||||
recommendations: null,
|
||||
})
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [toast, setToast] = React.useState({ id: 0, visible: false, message: '', variant: 'success' })
|
||||
const [busy, setBusy] = React.useState('')
|
||||
const [queueBusy, setQueueBusy] = React.useState({})
|
||||
const [selectedCollectionId, setSelectedCollectionId] = React.useState(initialCollectionOptions[0]?.id || '')
|
||||
const [previewForm, setPreviewForm] = React.useState({
|
||||
program_key: baseProgramKeys[0] || '',
|
||||
limit: 8,
|
||||
})
|
||||
const [assignmentForm, setAssignmentForm] = React.useState({
|
||||
id: null,
|
||||
collection_id: collectionOptions[0]?.id || '',
|
||||
program_key: baseProgramKeys[0] || '',
|
||||
campaign_key: '',
|
||||
placement_scope: '',
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
priority: 0,
|
||||
notes: '',
|
||||
})
|
||||
const selectedCollection = React.useMemo(() => collectionOptions.find((collection) => String(collection.id) === String(selectedCollectionId)) || null, [collectionOptions, selectedCollectionId])
|
||||
const [hooksForm, setHooksForm] = React.useState(buildHooksForm(initialCollectionOptions[0] || null))
|
||||
const [hooksDiagnostics, setHooksDiagnostics] = React.useState(buildDiagnostics(initialCollectionOptions[0] || null))
|
||||
|
||||
const programKeyOptions = React.useMemo(() => {
|
||||
return Array.from(new Set([
|
||||
...baseProgramKeys,
|
||||
...assignments.map((assignment) => assignment.program_key).filter(Boolean),
|
||||
...collectionOptions.map((collection) => collection.program_key).filter(Boolean),
|
||||
assignmentForm.program_key || null,
|
||||
previewForm.program_key || null,
|
||||
].filter(Boolean))).sort((left, right) => String(left).localeCompare(String(right)))
|
||||
}, [assignmentForm.program_key, assignments, baseProgramKeys, collectionOptions, previewForm.program_key])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!assignmentForm.collection_id && collectionOptions[0]?.id) {
|
||||
setAssignmentForm((current) => ({ ...current, collection_id: collectionOptions[0].id }))
|
||||
}
|
||||
|
||||
if (!selectedCollectionId && collectionOptions[0]?.id) {
|
||||
setSelectedCollectionId(collectionOptions[0].id)
|
||||
}
|
||||
}, [assignmentForm.collection_id, collectionOptions, selectedCollectionId])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!assignmentForm.program_key && programKeyOptions[0]) {
|
||||
setAssignmentForm((current) => ({ ...current, program_key: programKeyOptions[0] }))
|
||||
}
|
||||
|
||||
if (!previewForm.program_key && programKeyOptions[0]) {
|
||||
setPreviewForm((current) => ({ ...current, program_key: programKeyOptions[0] }))
|
||||
}
|
||||
}, [assignmentForm.program_key, previewForm.program_key, programKeyOptions])
|
||||
|
||||
React.useEffect(() => {
|
||||
setMergeQueue(initialMergeQueue)
|
||||
}, [initialMergeQueue])
|
||||
|
||||
React.useEffect(() => {
|
||||
setHooksForm(buildHooksForm(selectedCollection))
|
||||
setHooksDiagnostics(buildDiagnostics(selectedCollection))
|
||||
}, [selectedCollection])
|
||||
|
||||
function showToast(message, variant = 'success') {
|
||||
setToast({
|
||||
id: Date.now() + Math.random(),
|
||||
visible: true,
|
||||
message,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
function resetAssignmentForm() {
|
||||
setAssignmentForm({
|
||||
id: null,
|
||||
collection_id: collectionOptions[0]?.id || '',
|
||||
program_key: programKeyOptions[0] || '',
|
||||
campaign_key: '',
|
||||
placement_scope: '',
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
priority: 0,
|
||||
notes: '',
|
||||
})
|
||||
}
|
||||
|
||||
function hydrateAssignment(assignment) {
|
||||
setAssignmentForm({
|
||||
id: assignment.id,
|
||||
collection_id: assignment.collection?.id || '',
|
||||
program_key: assignment.program_key || '',
|
||||
campaign_key: assignment.campaign_key || '',
|
||||
placement_scope: assignment.placement_scope || '',
|
||||
starts_at: isoToLocalInput(assignment.starts_at),
|
||||
ends_at: isoToLocalInput(assignment.ends_at),
|
||||
priority: assignment.priority || 0,
|
||||
notes: assignment.notes || '',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleAssignmentSubmit(event) {
|
||||
event.preventDefault()
|
||||
setBusy('assignment')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const url = assignmentForm.id
|
||||
? endpoints.updatePattern?.replace('__PROGRAM__', String(assignmentForm.id))
|
||||
: endpoints.store
|
||||
|
||||
const payload = await requestJson(url, {
|
||||
method: assignmentForm.id ? 'PATCH' : 'POST',
|
||||
body: {
|
||||
collection_id: Number(assignmentForm.collection_id),
|
||||
program_key: assignmentForm.program_key,
|
||||
campaign_key: assignmentForm.campaign_key || null,
|
||||
placement_scope: assignmentForm.placement_scope || null,
|
||||
starts_at: assignmentForm.starts_at ? new Date(assignmentForm.starts_at).toISOString() : null,
|
||||
ends_at: assignmentForm.ends_at ? new Date(assignmentForm.ends_at).toISOString() : null,
|
||||
priority: Number(assignmentForm.priority || 0),
|
||||
notes: assignmentForm.notes || null,
|
||||
},
|
||||
})
|
||||
|
||||
setAssignments((current) => sortAssignments([...current.filter((item) => item.id !== payload.assignment.id), payload.assignment]))
|
||||
setNotice(assignmentForm.id ? 'Programming assignment updated.' : 'Programming assignment created.')
|
||||
resetAssignmentForm()
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to save programming assignment.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreview(event) {
|
||||
event.preventDefault()
|
||||
setBusy('preview')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(endpoints.preview, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
program_key: previewForm.program_key,
|
||||
limit: Number(previewForm.limit || 8),
|
||||
},
|
||||
})
|
||||
|
||||
setPreviewCollections(Array.isArray(payload.collections) ? payload.collections : [])
|
||||
setNotice('Preview refreshed.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to preview this program key.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function runDiagnostic(kind) {
|
||||
const endpointMap = {
|
||||
eligibility: endpoints.refreshEligibility,
|
||||
duplicates: endpoints.duplicateScan,
|
||||
recommendations: endpoints.refreshRecommendations,
|
||||
}
|
||||
|
||||
const url = endpointMap[kind]
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(kind)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(url, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
collection_id: selectedCollectionId ? Number(selectedCollectionId) : null,
|
||||
},
|
||||
})
|
||||
|
||||
setDiagnostics((current) => ({ ...current, [kind]: payload.result || null }))
|
||||
setNotice(payload?.result?.message || `${titleize(kind)} queued.`)
|
||||
} catch (error) {
|
||||
setNotice(error.message || `Failed to run ${kind}.`)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQueueAction(kind, item) {
|
||||
const sourceId = item?.source?.id
|
||||
const targetId = item?.target?.id
|
||||
|
||||
if (!sourceId || !targetId) {
|
||||
return
|
||||
}
|
||||
|
||||
const endpointMap = {
|
||||
canonicalize: endpoints.canonicalizeCandidate,
|
||||
merge: endpoints.mergeCandidate,
|
||||
reject: endpoints.rejectCandidate,
|
||||
}
|
||||
|
||||
const confirmationMap = {
|
||||
canonicalize: `Designate "${item.target?.title || 'this collection'}" as the canonical target for "${item.source?.title || 'this collection'}"?`,
|
||||
merge: `Merge "${item.source?.title || 'this collection'}" into "${item.target?.title || 'this collection'}" from the staff queue?`,
|
||||
reject: `Mark "${item.target?.title || 'this collection'}" as not a duplicate of "${item.source?.title || 'this collection'}"?`,
|
||||
}
|
||||
|
||||
const url = endpointMap[kind]
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.confirm(confirmationMap[kind] || 'Continue?')) {
|
||||
return
|
||||
}
|
||||
|
||||
setQueueBusy((current) => ({ ...current, [item.id]: kind }))
|
||||
|
||||
try {
|
||||
const payload = await requestJson(url, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
source_collection_id: Number(sourceId),
|
||||
target_collection_id: Number(targetId),
|
||||
},
|
||||
})
|
||||
|
||||
if (payload?.mergeQueue) {
|
||||
setMergeQueue(payload.mergeQueue)
|
||||
}
|
||||
|
||||
showToast(payload?.message || `${titleize(kind)} action completed.`, 'success')
|
||||
} catch (error) {
|
||||
showToast(error.message || `Failed to ${kind} this queue item.`, 'error')
|
||||
} finally {
|
||||
setQueueBusy((current) => {
|
||||
const next = { ...current }
|
||||
delete next[item.id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHooksSubmit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!selectedCollectionId || !endpoints.metadataUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy('hooks')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(endpoints.metadataUpdate, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
collection_id: Number(selectedCollectionId),
|
||||
experiment_key: hooksForm.experiment_key || null,
|
||||
experiment_treatment: hooksForm.experiment_treatment || null,
|
||||
placement_variant: hooksForm.placement_variant || null,
|
||||
ranking_mode_variant: hooksForm.ranking_mode_variant || null,
|
||||
collection_pool_version: hooksForm.collection_pool_version || null,
|
||||
test_label: hooksForm.test_label || null,
|
||||
promotion_tier: hooksForm.promotion_tier || null,
|
||||
partner_key: viewer.isAdmin ? (hooksForm.partner_key || null) : undefined,
|
||||
trust_tier: viewer.isAdmin ? (hooksForm.trust_tier || null) : undefined,
|
||||
sponsorship_state: viewer.isAdmin ? (hooksForm.sponsorship_state || null) : undefined,
|
||||
ownership_domain: viewer.isAdmin ? (hooksForm.ownership_domain || null) : undefined,
|
||||
commercial_review_state: viewer.isAdmin ? (hooksForm.commercial_review_state || null) : undefined,
|
||||
legal_review_state: viewer.isAdmin ? (hooksForm.legal_review_state || null) : undefined,
|
||||
placement_eligibility: Boolean(hooksForm.placement_eligibility),
|
||||
},
|
||||
})
|
||||
|
||||
if (payload?.collection?.id) {
|
||||
setCollectionOverrides((current) => ({
|
||||
...current,
|
||||
[payload.collection.id]: payload.collection,
|
||||
}))
|
||||
}
|
||||
|
||||
setHooksDiagnostics(payload?.diagnostics || buildDiagnostics(payload?.collection || selectedCollection))
|
||||
setHooksForm(buildHooksForm(payload?.collection || selectedCollection))
|
||||
setNotice(payload?.message || 'Experiment and program governance hooks updated.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to update experiment and program governance hooks.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const totalPrograms = Array.from(new Set(assignments.map((assignment) => assignment.program_key).filter(Boolean))).length
|
||||
const eligibleAssignments = assignments.filter((assignment) => assignment.collection?.placement_eligibility === true).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || 'Collection Programming — Skinbase Nova'}</title>
|
||||
<meta name="description" content={seo.description || 'Staff programming tools for collections.'} />
|
||||
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
<meta name="robots" content={seo.robots || 'noindex,follow'} />
|
||||
</Head>
|
||||
|
||||
<ShareToast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
visible={toast.visible}
|
||||
variant={toast.variant}
|
||||
duration={toast.variant === 'error' ? 3200 : 2200}
|
||||
onHide={() => setToast((current) => ({ ...current, visible: false }))}
|
||||
/>
|
||||
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 18% 15%, rgba(56,189,248,0.18), transparent 28%), radial-gradient(circle at 85% 14%, rgba(132,204,22,0.16), transparent 26%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
|
||||
<section className="rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Staff Programming</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Collections programming studio</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
Manage program assignments, preview live pools, and run targeted diagnostics before collections hit discovery surfaces.
|
||||
</p>
|
||||
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
|
||||
</div>
|
||||
{endpoints.surfaces ? <a href={endpoints.surfaces} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-thumbtack fa-fw text-[11px]" />Open placement studio</a> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-5 md:grid-cols-3">
|
||||
<StatCard label="Assignments" value={assignments.length} tone="sky" />
|
||||
<StatCard label="Program Keys" value={totalPrograms} tone="amber" />
|
||||
<StatCard label="Eligible" value={eligibleAssignments} tone="emerald" />
|
||||
</section>
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-200/80">Merge Queue</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Pending duplicates and recent decisions</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">
|
||||
Review what still needs merge attention and what staff already resolved. Each row links back into the collection studio for full compare-and-confirm actions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Pending" value={mergeQueue?.summary?.pending || 0} tone="amber" />
|
||||
<StatCard label="Approved" value={mergeQueue?.summary?.approved || 0} tone="sky" />
|
||||
<StatCard label="Rejected" value={mergeQueue?.summary?.rejected || 0} tone="emerald" />
|
||||
<StatCard label="Merged" value={mergeQueue?.summary?.completed || 0} tone="sky" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<div className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-200/80">Needs Review</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white">Suggested duplicate pairs</h3>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{mergeQueue?.pending?.length || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
{(mergeQueue?.pending || []).length ? mergeQueue.pending.map((item) => {
|
||||
const activeQueueAction = queueBusy[item.id] || ''
|
||||
const cardBusy = Boolean(activeQueueAction)
|
||||
|
||||
return (
|
||||
<div key={`merge-pending-${item.id}`} className={`rounded-[24px] border border-white/10 bg-white/[0.04] p-4 transition ${cardBusy ? 'ring-1 ring-sky-300/25' : ''}`}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(item.comparison?.match_reasons || []).map((reason) => (
|
||||
<span key={reason} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{titleize(reason)}</span>
|
||||
))}
|
||||
</div>
|
||||
{cardBusy ? <span className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-sky-100"><i className="fa-solid fa-circle-notch fa-spin fa-fw text-[10px]" />Processing {titleize(activeQueueAction)}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Source</p>
|
||||
{item.source ? <CollectionCard collection={item.source} isOwner /> : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Candidate</p>
|
||||
{item.target ? <CollectionCard collection={item.target} isOwner /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 md:grid-cols-3 text-xs text-slate-400">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Shared artworks: <span className="font-semibold text-white">{item.comparison?.shared_artworks_count ?? 0}</span></div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Source count: <span className="font-semibold text-white">{item.comparison?.source_artworks_count ?? 0}</span></div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Target count: <span className="font-semibold text-white">{item.comparison?.target_artworks_count ?? 0}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.source?.manage_url ? <a href={item.source.manage_url} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15"><i className="fa-solid fa-code-compare fa-fw text-[10px]" />Review source</a> : null}
|
||||
{item.target?.manage_url ? <a href={item.target.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" />Open target</a> : null}
|
||||
{item.source?.id && historyPattern ? <a href={historyPattern.replace('__COLLECTION__', String(item.source.id))} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-timeline fa-fw text-[10px]" />History</a> : null}
|
||||
<button type="button" onClick={() => handleQueueAction('canonicalize', item)} disabled={cardBusy} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${activeQueueAction === 'canonicalize' ? 'fa-circle-notch fa-spin' : 'fa-badge-check'} fa-fw text-[10px]`} />{activeQueueAction === 'canonicalize' ? 'Canonicalizing...' : 'Canonicalize'}</button>
|
||||
<button type="button" onClick={() => handleQueueAction('merge', item)} disabled={cardBusy} className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold text-emerald-100 transition hover:bg-emerald-400/15 disabled:opacity-60"><i className={`fa-solid ${activeQueueAction === 'merge' ? 'fa-circle-notch fa-spin' : 'fa-code-merge'} fa-fw text-[10px]`} />{activeQueueAction === 'merge' ? 'Merging...' : 'Merge now'}</button>
|
||||
<button type="button" onClick={() => handleQueueAction('reject', item)} disabled={cardBusy} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${activeQueueAction === 'reject' ? 'fa-circle-notch fa-spin' : 'fa-ban'} fa-fw text-[10px]`} />{activeQueueAction === 'reject' ? 'Rejecting...' : 'Reject'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-10 text-sm text-slate-300">No pending merge candidates right now.</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Recent Decisions</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white">Canonical, reject, and merge history</h3>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{mergeQueue?.recent?.length || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
{(mergeQueue?.recent || []).length ? mergeQueue.recent.map((item) => (
|
||||
<div key={`merge-recent-${item.id}`} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{titleize(item.action_type)}</span>
|
||||
{item.summary ? <p className="mt-2 text-sm text-slate-300">{item.summary}</p> : null}
|
||||
<p className="mt-2 text-xs text-slate-500">{item.updated_at ? new Date(item.updated_at).toLocaleString() : 'Unknown time'}{item.actor?.username ? ` • @${item.actor.username}` : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2 text-sm text-slate-300">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Source</div>
|
||||
<div className="mt-1 font-semibold text-white">{item.source?.title || 'Collection'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Target</div>
|
||||
<div className="mt-1 font-semibold text-white">{item.target?.title || 'Collection'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.source?.manage_url ? <a href={item.source.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Open source</a> : null}
|
||||
{item.target?.manage_url ? <a href={item.target.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" />Open target</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-10 text-sm text-slate-300">No recent merge decisions yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<form onSubmit={handleAssignmentSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Assignment</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Program key and scope</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Collection">
|
||||
<select value={assignmentForm.collection_id} onChange={(event) => setAssignmentForm((current) => ({ ...current, collection_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">
|
||||
{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Program Key" help="Use stable internal names like discover-spring or homepage-hero.">
|
||||
<input list="program-key-options" value={assignmentForm.program_key} onChange={(event) => setAssignmentForm((current) => ({ ...current, program_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Placement Scope" help="Optional placement scope such as homepage.hero or discover.rail.">
|
||||
<input value={assignmentForm.placement_scope} onChange={(event) => setAssignmentForm((current) => ({ ...current, placement_scope: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Campaign Key">
|
||||
<input value={assignmentForm.campaign_key} onChange={(event) => setAssignmentForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Priority">
|
||||
<input type="number" min="-100" max="100" value={assignmentForm.priority} onChange={(event) => setAssignmentForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Starts At"><input type="datetime-local" value={assignmentForm.starts_at} onChange={(event) => setAssignmentForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Ends At"><input type="datetime-local" value={assignmentForm.ends_at} onChange={(event) => setAssignmentForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
</div>
|
||||
<Field label="Notes" help="Operational note for launch timing, overrides, or review context."><textarea value={assignmentForm.notes} onChange={(event) => setAssignmentForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={busy === 'assignment'} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'assignment' ? 'fa-circle-notch fa-spin' : 'fa-sliders'} fa-fw`} />{assignmentForm.id ? 'Update Assignment' : 'Save Assignment'}</button>
|
||||
{assignmentForm.id ? <button type="button" onClick={resetAssignmentForm} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
|
||||
</div>
|
||||
<datalist id="program-key-options">
|
||||
{programKeyOptions.map((option) => <option key={option} value={option} />)}
|
||||
</datalist>
|
||||
</form>
|
||||
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handlePreview} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Preview</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Inspect a live program pool</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_140px_auto]">
|
||||
<Field label="Program Key"><input list="program-key-options" value={previewForm.program_key} onChange={(event) => setPreviewForm((current) => ({ ...current, program_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
||||
<Field label="Limit"><input type="number" min="1" max="24" value={previewForm.limit} onChange={(event) => setPreviewForm((current) => ({ ...current, limit: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<div className="flex items-end"><button type="submit" disabled={busy === 'preview'} className="inline-flex h-[50px] w-full items-center justify-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'preview' ? 'fa-circle-notch fa-spin' : 'fa-binoculars'} fa-fw`} />Preview</button></div>
|
||||
</div>
|
||||
|
||||
{previewCollections.length ? (
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
{previewCollections.map((collection) => (
|
||||
<div key={collection.id} className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4">
|
||||
<CollectionCard collection={collection} isOwner />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-8 text-sm text-slate-300">Run a preview to inspect which collections currently qualify for a given program key.</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Diagnostics</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Eligibility, duplicate risk, and ranking refresh</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Operations summary</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Stale health</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.stale_health || 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Stale recommendations</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.stale_recommendations || 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Placement blocked</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.placement_blocked || 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Duplicate risk</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.duplicate_risk || 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{observabilitySummary?.generated_at ? <p className="mt-4 text-xs text-slate-400">Generated {new Date(observabilitySummary.generated_at).toLocaleString()}</p> : null}
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Watchlist</p>
|
||||
{Array.isArray(observabilitySummary?.watchlist) && observabilitySummary.watchlist.length ? (
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||
{observabilitySummary.watchlist.map((collection) => (
|
||||
<div key={`watch-${collection.id}`} className="rounded-[20px] border border-white/10 bg-white/[0.04] p-3">
|
||||
<CollectionCard collection={collection} isOwner />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="mt-3">No watchlist items are currently flagged.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Field label="Target Collection" help="Leave a selection in place to inspect one collection. Change it any time before running a diagnostic.">
|
||||
<select value={selectedCollectionId} onChange={(event) => setSelectedCollectionId(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">
|
||||
{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="flex items-end gap-3">
|
||||
<button type="button" onClick={() => runDiagnostic('eligibility')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-4 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'eligibility' ? 'fa-circle-notch fa-spin' : 'fa-shield-check'} fa-fw`} />Eligibility</button>
|
||||
<button type="button" onClick={() => runDiagnostic('duplicates')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'duplicates' ? 'fa-circle-notch fa-spin' : 'fa-id-card'} fa-fw`} />Duplicates</button>
|
||||
<button type="button" onClick={() => runDiagnostic('recommendations')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'recommendations' ? 'fa-circle-notch fa-spin' : 'fa-arrows-rotate'} fa-fw`} />Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Eligibility</p>
|
||||
{diagnostics.eligibility ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p>{diagnostics.eligibility.status === 'queued' ? `${diagnostics.eligibility.count} collection(s) queued.` : `${diagnostics.eligibility.count} collection(s) evaluated.`}</p>
|
||||
{diagnostics.eligibility.message ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{diagnostics.eligibility.message}</div> : null}
|
||||
{(diagnostics.eligibility.items || []).map((item) => <div key={item.collection_id} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{item.health_state || 'unknown'} · {item.readiness_state || 'unknown'} · {item.placement_eligibility ? 'eligible' : 'blocked'}</div>)}
|
||||
</div>
|
||||
) : <p className="mt-3">Run an eligibility refresh to verify readiness and public placement safety.</p>}
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Duplicate candidates</p>
|
||||
{diagnostics.duplicates ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p>{diagnostics.duplicates.status === 'queued' ? `${diagnostics.duplicates.count} collection(s) queued.` : `${diagnostics.duplicates.count} collection(s) with candidates.`}</p>
|
||||
{diagnostics.duplicates.message ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{diagnostics.duplicates.message}</div> : null}
|
||||
{(diagnostics.duplicates.items || []).map((item) => (
|
||||
<div key={item.collection_id} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
{item.candidates?.length ? item.candidates.map((candidate) => candidate.title).join(', ') : 'No candidates'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="mt-3">Run duplicate scan to surface overlap before programming a collection widely.</p>}
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Recommendation refresh</p>
|
||||
{diagnostics.recommendations ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p>{diagnostics.recommendations.status === 'queued' ? `${diagnostics.recommendations.count} collection(s) queued.` : `${diagnostics.recommendations.count} collection(s) refreshed.`}</p>
|
||||
{diagnostics.recommendations.message ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{diagnostics.recommendations.message}</div> : null}
|
||||
{(diagnostics.recommendations.items || []).map((item) => <div key={item.collection_id} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{titleize(item.recommendation_tier || 'unknown')} · {titleize(item.ranking_bucket || 'unknown')} · {titleize(item.search_boost_tier || 'unknown')}</div>)}
|
||||
</div>
|
||||
) : <p className="mt-3">Run a recommendation refresh to update ranking and search tiers for this collection.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form onSubmit={handleHooksSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-fuchsia-200/80">Hooks</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Experiment and program governance</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-300">Control experiment keys, promotion tiers, and staff-only program governance hooks for the selected collection without leaving the programming studio.</p>
|
||||
</div>
|
||||
{selectedCollection?.program_key && endpoints.publicProgramPattern ? (
|
||||
<a href={buildProgramUrl(endpoints.publicProgramPattern, selectedCollection.program_key)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.07]">
|
||||
<i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[11px]" />Open public program landing
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Field label="Experiment Key" help="Internal test or treatment key for cross-surface collection experiments.">
|
||||
<input value={hooksForm.experiment_key} onChange={(event) => setHooksForm((current) => ({ ...current, experiment_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Treatment" help="Variant or treatment label tied to the experiment key.">
|
||||
<input value={hooksForm.experiment_treatment} onChange={(event) => setHooksForm((current) => ({ ...current, experiment_treatment: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Placement Variant" help="Surface-specific placement variant such as homepage_a or search_dense.">
|
||||
<input value={hooksForm.placement_variant} onChange={(event) => setHooksForm((current) => ({ ...current, placement_variant: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Ranking Variant" help="Override or annotate ranking mode experiments without changing the live pool logic.">
|
||||
<input value={hooksForm.ranking_mode_variant} onChange={(event) => setHooksForm((current) => ({ ...current, ranking_mode_variant: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Pool Version" help="Snapshot or rollout version for the collection pool definition.">
|
||||
<input value={hooksForm.collection_pool_version} onChange={(event) => setHooksForm((current) => ({ ...current, collection_pool_version: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Test Label" help="Human-readable campaign or experiment label for operations and diagnostics.">
|
||||
<input value={hooksForm.test_label} onChange={(event) => setHooksForm((current) => ({ ...current, test_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} />
|
||||
</Field>
|
||||
<Field label="Promotion Tier" help="Optional internal tier for elevated or restrained programming treatment.">
|
||||
<input value={hooksForm.promotion_tier} onChange={(event) => setHooksForm((current) => ({ ...current, promotion_tier: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
|
||||
</Field>
|
||||
{viewer.isAdmin ? (
|
||||
<>
|
||||
<Field label="Partner Key" help="Admin-only internal key for trusted partner or program ownership.">
|
||||
<input value={hooksForm.partner_key} onChange={(event) => setHooksForm((current) => ({ ...current, partner_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Trust Tier" help="Admin-only trust marker used for internal partner/program review logic.">
|
||||
<input value={hooksForm.trust_tier} onChange={(event) => setHooksForm((current) => ({ ...current, trust_tier: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
|
||||
</Field>
|
||||
<Field label="Sponsorship State" help="Admin-only state for sponsored, pending, or cleared program treatment.">
|
||||
<input value={hooksForm.sponsorship_state} onChange={(event) => setHooksForm((current) => ({ ...current, sponsorship_state: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
|
||||
</Field>
|
||||
<Field label="Ownership Domain" help="Admin-only internal ownership domain such as editorial, partner, creator_program, or events.">
|
||||
<input value={hooksForm.ownership_domain} onChange={(event) => setHooksForm((current) => ({ ...current, ownership_domain: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Commercial Review" help="Admin-only commercial review status for future partner and sponsor programs.">
|
||||
<input value={hooksForm.commercial_review_state} onChange={(event) => setHooksForm((current) => ({ ...current, commercial_review_state: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
|
||||
</Field>
|
||||
<Field label="Legal Review" help="Admin-only legal review status when collections need compliance approval before wider promotion.">
|
||||
<input value={hooksForm.legal_review_state} onChange={(event) => setHooksForm((current) => ({ ...current, legal_review_state: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
|
||||
</Field>
|
||||
</>
|
||||
) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-4 text-sm text-slate-300 md:col-span-2 xl:col-span-3">Partner, sponsorship, ownership, and review metadata remain admin-only. Moderators can still manage experiment and promotion hooks here.</div>}
|
||||
</div>
|
||||
|
||||
<label className="mt-4 flex items-center gap-3 rounded-[20px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-200">
|
||||
<input type="checkbox" checked={hooksForm.placement_eligibility} onChange={(event) => setHooksForm((current) => ({ ...current, placement_eligibility: event.target.checked }))} className="h-4 w-4 rounded border-white/20 bg-white/[0.04] text-sky-400 focus:ring-sky-300/40" />
|
||||
Placement eligible override
|
||||
</label>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Experiment</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.experiment_key || selectedCollection?.experiment_key || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Treatment</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.experiment_treatment || selectedCollection?.experiment_treatment || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Placement Variant</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.placement_variant || selectedCollection?.placement_variant || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Workflow</div>
|
||||
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.workflow_state || selectedCollection?.workflow_state || 'unknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Health</div>
|
||||
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.health_state || selectedCollection?.health_state || 'unknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Recommendation Tier</div>
|
||||
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.recommendation_tier || selectedCollection?.recommendation_tier || 'unknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Ranking Bucket</div>
|
||||
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.ranking_bucket || selectedCollection?.ranking_bucket || 'unknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Ranking Variant</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.ranking_mode_variant || selectedCollection?.ranking_mode_variant || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Pool Version</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.collection_pool_version || selectedCollection?.collection_pool_version || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Test Label</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.test_label || selectedCollection?.test_label || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Promotion Tier</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.promotion_tier || selectedCollection?.promotion_tier || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Partner Key</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.partner_key || selectedCollection?.partner_key || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Trust Tier</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.trust_tier || selectedCollection?.trust_tier || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Sponsorship State</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.sponsorship_state || selectedCollection?.sponsorship_state || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Ownership Domain</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.ownership_domain || selectedCollection?.ownership_domain || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Commercial Review</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.commercial_review_state || selectedCollection?.commercial_review_state || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Legal Review</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.legal_review_state || selectedCollection?.legal_review_state || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Last Health Check</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.last_health_check_at ? new Date(hooksDiagnostics.last_health_check_at).toLocaleString() : 'Not yet'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Last Recommendation Refresh</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.last_recommendation_refresh_at ? new Date(hooksDiagnostics.last_recommendation_refresh_at).toLocaleString() : 'Not yet'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={busy === 'hooks' || !selectedCollectionId} className="inline-flex items-center gap-2 rounded-2xl border border-fuchsia-300/20 bg-fuchsia-400/10 px-5 py-3 text-sm font-semibold text-fuchsia-100 transition hover:bg-fuchsia-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'hooks' ? 'fa-circle-notch fa-spin' : 'fa-flask-vial'} fa-fw`} />Save Hooks</button>
|
||||
{selectedCollection?.manage_url ? <a href={selectedCollection.manage_url} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw" />Open collection</a> : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Assignments</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Current programming inventory</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{assignments.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
{assignments.length ? assignments.map((assignment) => (
|
||||
<div key={assignment.id} className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{assignment.program_key}</span>
|
||||
{assignment.placement_scope ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">{assignment.placement_scope}</span> : null}
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">priority {assignment.priority}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => hydrateAssignment(assignment)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit</button>
|
||||
{endpoints.managePattern ? <a href={endpoints.managePattern.replace('__COLLECTION__', String(assignment.collection?.id || ''))} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" />Manage</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div>
|
||||
{assignment.collection ? <CollectionCard collection={assignment.collection} isOwner /> : null}
|
||||
</div>
|
||||
<div className="space-y-3 text-sm text-slate-300">
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Campaign: {assignment.campaign_key || 'None'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Starts: {assignment.starts_at ? new Date(assignment.starts_at).toLocaleString() : 'Immediate'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Ends: {assignment.ends_at ? new Date(assignment.ends_at).toLocaleString() : 'Open-ended'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Placement: {assignment.collection?.placement_eligibility ? 'Eligible' : 'Blocked'}</div>
|
||||
{assignment.notes ? <div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">{assignment.notes}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : <div className="rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">No programming assignments yet. Create the first one above.</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+644
@@ -0,0 +1,644 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function isoToLocalInput(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function rulesJsonToText(rulesJson) {
|
||||
if (!rulesJson) return '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}'
|
||||
|
||||
try {
|
||||
return JSON.stringify(rulesJson, null, 2)
|
||||
} catch {
|
||||
return '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}'
|
||||
}
|
||||
}
|
||||
|
||||
function Field({ label, help, children }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-semibold text-white">{label}</span>
|
||||
{children}
|
||||
{help ? <span className="block text-xs leading-relaxed text-slate-400">{help}</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionStaffSurfaces() {
|
||||
const { props } = usePage()
|
||||
const collectionOptions = Array.isArray(props.collectionOptions) ? props.collectionOptions : []
|
||||
const [definitions, setDefinitions] = React.useState(Array.isArray(props.definitions) ? props.definitions : [])
|
||||
const [placements, setPlacements] = React.useState(Array.isArray(props.placements) ? props.placements : [])
|
||||
const [conflicts, setConflicts] = React.useState(Array.isArray(props.conflicts) ? props.conflicts : [])
|
||||
const [definitionForm, setDefinitionForm] = React.useState({
|
||||
id: null,
|
||||
surface_key: '',
|
||||
title: '',
|
||||
description: '',
|
||||
mode: 'manual',
|
||||
ranking_mode: 'ranking_score',
|
||||
max_items: 12,
|
||||
is_active: true,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
fallback_surface_key: '',
|
||||
rules_json: '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}',
|
||||
})
|
||||
const [placementForm, setPlacementForm] = React.useState({
|
||||
id: null,
|
||||
surface_key: props.surfaceKeyOptions?.[0] || '',
|
||||
collection_id: collectionOptions[0]?.id || '',
|
||||
placement_type: 'manual',
|
||||
priority: 0,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
is_active: true,
|
||||
campaign_key: '',
|
||||
notes: '',
|
||||
})
|
||||
const [batchForm, setBatchForm] = React.useState({
|
||||
collection_ids: [],
|
||||
campaign_key: '',
|
||||
campaign_label: '',
|
||||
event_label: '',
|
||||
season_key: '',
|
||||
editorial_notes: '',
|
||||
surface_key: props.surfaceKeyOptions?.[0] || '',
|
||||
placement_type: 'campaign',
|
||||
priority: 0,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
is_active: true,
|
||||
notes: '',
|
||||
})
|
||||
const [batchResult, setBatchResult] = React.useState(null)
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [busy, setBusy] = React.useState('')
|
||||
const seo = props.seo || {}
|
||||
const surfaceKeyOptions = React.useMemo(() => {
|
||||
const keys = definitions.map((definition) => definition.surface_key).filter(Boolean)
|
||||
return Array.from(new Set(keys)).sort((left, right) => String(left).localeCompare(String(right)))
|
||||
}, [definitions])
|
||||
const conflictPlacementIds = React.useMemo(() => {
|
||||
return new Set(conflicts.flatMap((conflict) => Array.isArray(conflict.placement_ids) ? conflict.placement_ids : []))
|
||||
}, [conflicts])
|
||||
|
||||
React.useEffect(() => {
|
||||
setPlacementForm((current) => {
|
||||
if (current.surface_key && surfaceKeyOptions.includes(current.surface_key)) {
|
||||
return current
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
surface_key: surfaceKeyOptions[0] || '',
|
||||
}
|
||||
})
|
||||
|
||||
setBatchForm((current) => {
|
||||
if (!current.surface_key || surfaceKeyOptions.includes(current.surface_key)) {
|
||||
return current
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
surface_key: surfaceKeyOptions[0] || '',
|
||||
}
|
||||
})
|
||||
}, [surfaceKeyOptions])
|
||||
|
||||
function resetDefinitionForm() {
|
||||
setDefinitionForm({
|
||||
id: null,
|
||||
surface_key: '',
|
||||
title: '',
|
||||
description: '',
|
||||
mode: 'manual',
|
||||
ranking_mode: 'ranking_score',
|
||||
max_items: 12,
|
||||
is_active: true,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
fallback_surface_key: '',
|
||||
rules_json: '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}',
|
||||
})
|
||||
}
|
||||
|
||||
function resetPlacementForm() {
|
||||
setPlacementForm({
|
||||
id: null,
|
||||
surface_key: surfaceKeyOptions[0] || '',
|
||||
collection_id: collectionOptions[0]?.id || '',
|
||||
placement_type: 'manual',
|
||||
priority: 0,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
is_active: true,
|
||||
campaign_key: '',
|
||||
notes: '',
|
||||
})
|
||||
}
|
||||
|
||||
function toggleBatchCollection(collectionId) {
|
||||
setBatchForm((current) => {
|
||||
const currentIds = Array.isArray(current.collection_ids) ? current.collection_ids : []
|
||||
const nextIds = currentIds.includes(collectionId)
|
||||
? currentIds.filter((id) => id !== collectionId)
|
||||
: [...currentIds, collectionId]
|
||||
|
||||
return {
|
||||
...current,
|
||||
collection_ids: nextIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDefinitionSubmit(event) {
|
||||
event.preventDefault()
|
||||
setBusy('definition')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const rulesJson = definitionForm.rules_json.trim() ? JSON.parse(definitionForm.rules_json) : null
|
||||
const url = definitionForm.id
|
||||
? props.endpoints?.definitionsUpdatePattern?.replace('__DEFINITION__', String(definitionForm.id))
|
||||
: props.endpoints?.definitionsStore
|
||||
const payload = await requestJson(url, {
|
||||
method: definitionForm.id ? 'PATCH' : 'POST',
|
||||
body: {
|
||||
...definitionForm,
|
||||
max_items: Number(definitionForm.max_items || 12),
|
||||
starts_at: definitionForm.starts_at ? new Date(definitionForm.starts_at).toISOString() : null,
|
||||
ends_at: definitionForm.ends_at ? new Date(definitionForm.ends_at).toISOString() : null,
|
||||
fallback_surface_key: definitionForm.fallback_surface_key || null,
|
||||
rules_json: rulesJson,
|
||||
},
|
||||
})
|
||||
|
||||
setDefinitions((current) => {
|
||||
const next = current.filter((definition) => definition.id !== payload.definition.id)
|
||||
return [...next, payload.definition].sort((left, right) => String(left.surface_key).localeCompare(String(right.surface_key)))
|
||||
})
|
||||
setNotice(definitionForm.id ? 'Surface definition updated.' : 'Surface definition saved.')
|
||||
resetDefinitionForm()
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to save definition.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlacementSubmit(event) {
|
||||
event.preventDefault()
|
||||
setBusy('placement')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const url = placementForm.id
|
||||
? props.endpoints?.placementsUpdatePattern?.replace('__PLACEMENT__', String(placementForm.id))
|
||||
: props.endpoints?.placementsStore
|
||||
const payload = await requestJson(url, {
|
||||
method: placementForm.id ? 'PATCH' : 'POST',
|
||||
body: {
|
||||
...placementForm,
|
||||
collection_id: Number(placementForm.collection_id),
|
||||
priority: Number(placementForm.priority || 0),
|
||||
starts_at: placementForm.starts_at ? new Date(placementForm.starts_at).toISOString() : null,
|
||||
ends_at: placementForm.ends_at ? new Date(placementForm.ends_at).toISOString() : null,
|
||||
},
|
||||
})
|
||||
|
||||
setPlacements((current) => {
|
||||
const next = current.filter((placement) => placement.id !== payload.placement.id)
|
||||
return [...next, payload.placement].sort((left, right) => {
|
||||
if (left.surface_key === right.surface_key) return (right.priority || 0) - (left.priority || 0)
|
||||
return String(left.surface_key).localeCompare(String(right.surface_key))
|
||||
})
|
||||
})
|
||||
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
|
||||
setNotice(placementForm.id ? 'Surface placement updated.' : 'Surface placement saved.')
|
||||
resetPlacementForm()
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to save placement.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchEditorial(mode) {
|
||||
setBusy(`batch-${mode}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(props.endpoints?.batchEditorial, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...batchForm,
|
||||
starts_at: batchForm.starts_at ? new Date(batchForm.starts_at).toISOString() : null,
|
||||
ends_at: batchForm.ends_at ? new Date(batchForm.ends_at).toISOString() : null,
|
||||
collection_ids: (batchForm.collection_ids || []).map((id) => Number(id)),
|
||||
priority: Number(batchForm.priority || 0),
|
||||
surface_key: batchForm.surface_key || null,
|
||||
apply: mode === 'apply',
|
||||
},
|
||||
})
|
||||
|
||||
setBatchResult(payload.plan || null)
|
||||
|
||||
if (mode === 'apply') {
|
||||
setPlacements(Array.isArray(payload.placements) ? payload.placements : [])
|
||||
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
|
||||
setNotice('Batch editorial changes applied.')
|
||||
} else {
|
||||
setNotice('Batch editorial preview generated.')
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Batch editorial tools failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateDefinition(definition) {
|
||||
setDefinitionForm({
|
||||
id: definition.id,
|
||||
surface_key: definition.surface_key || '',
|
||||
title: definition.title || '',
|
||||
description: definition.description || '',
|
||||
mode: definition.mode || 'manual',
|
||||
ranking_mode: definition.ranking_mode || 'ranking_score',
|
||||
max_items: definition.max_items || 12,
|
||||
is_active: definition.is_active !== false,
|
||||
starts_at: isoToLocalInput(definition.starts_at),
|
||||
ends_at: isoToLocalInput(definition.ends_at),
|
||||
fallback_surface_key: definition.fallback_surface_key || '',
|
||||
rules_json: rulesJsonToText(definition.rules_json),
|
||||
})
|
||||
}
|
||||
|
||||
function hydratePlacement(placement) {
|
||||
setPlacementForm({
|
||||
id: placement.id,
|
||||
surface_key: placement.surface_key || '',
|
||||
collection_id: placement.collection?.id || '',
|
||||
placement_type: placement.placement_type || 'manual',
|
||||
priority: placement.priority || 0,
|
||||
starts_at: isoToLocalInput(placement.starts_at),
|
||||
ends_at: isoToLocalInput(placement.ends_at),
|
||||
is_active: placement.is_active !== false,
|
||||
campaign_key: placement.campaign_key || '',
|
||||
notes: placement.notes || '',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDeleteDefinition(definition) {
|
||||
if (!window.confirm(`Delete surface definition "${definition.surface_key}"?`)) return
|
||||
|
||||
setBusy(`delete-definition-${definition.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const url = props.endpoints?.definitionsDeletePattern?.replace('__DEFINITION__', String(definition.id))
|
||||
await requestJson(url, { method: 'DELETE' })
|
||||
setDefinitions((current) => current.filter((item) => item.id !== definition.id))
|
||||
if (definitionForm.id === definition.id) {
|
||||
resetDefinitionForm()
|
||||
}
|
||||
setNotice('Surface definition deleted.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to delete definition.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePlacement(placement) {
|
||||
if (!window.confirm(`Delete placement for "${placement.collection?.title || 'this collection'}" on ${placement.surface_key}?`)) return
|
||||
|
||||
setBusy(`delete-placement-${placement.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const url = props.endpoints?.placementsDeletePattern?.replace('__PLACEMENT__', String(placement.id))
|
||||
const payload = await requestJson(url, { method: 'DELETE' })
|
||||
setPlacements((current) => current.filter((item) => item.id !== placement.id))
|
||||
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
|
||||
if (placementForm.id === placement.id) {
|
||||
resetPlacementForm()
|
||||
}
|
||||
setNotice('Surface placement deleted.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to delete placement.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || 'Collection Surfaces — Skinbase Nova'}</title>
|
||||
<meta name="description" content={seo.description || 'Staff tools for collection surfaces.'} />
|
||||
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
<meta name="robots" content={seo.robots || 'noindex,follow'} />
|
||||
</Head>
|
||||
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
|
||||
<section className="rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Staff Surfaces</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Collections placement studio</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
Define reusable discovery surfaces, then place eligible public collections into manual or campaign-specific slots with clear timing and notes.
|
||||
</p>
|
||||
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<form onSubmit={handleDefinitionSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Surface Definition</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Rules and ranking</h2>
|
||||
</div>
|
||||
{definitionForm.id ? <p className="mt-3 text-sm text-slate-300">Editing <span className="font-semibold text-white">{definitionForm.surface_key}</span></p> : null}
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Surface Key" help={definitionForm.id ? 'Surface keys stay stable during edits so existing placements remain attached.' : null}><input value={definitionForm.surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, surface_key: event.target.value }))} disabled={Boolean(definitionForm.id)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60" maxLength={120} /></Field>
|
||||
<Field label="Title"><input value={definitionForm.title} onChange={(event) => setDefinitionForm((current) => ({ ...current, title: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={160} /></Field>
|
||||
<Field label="Mode"><select value={definitionForm.mode} onChange={(event) => setDefinitionForm((current) => ({ ...current, mode: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="manual">Manual</option><option value="automatic">Automatic</option><option value="hybrid">Hybrid</option></select></Field>
|
||||
<Field label="Ranking"><select value={definitionForm.ranking_mode} onChange={(event) => setDefinitionForm((current) => ({ ...current, ranking_mode: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="ranking_score">Ranking score</option><option value="recent_activity">Recent activity</option><option value="quality_score">Quality score</option></select></Field>
|
||||
<Field label="Max Items"><input type="number" min="1" max="24" value={definitionForm.max_items} onChange={(event) => setDefinitionForm((current) => ({ ...current, max_items: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Starts At" help="Optional activation window for the full surface definition."><input type="datetime-local" value={definitionForm.starts_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Ends At" help="Leave blank when the surface should stay live until staff changes it."><input type="datetime-local" value={definitionForm.ends_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Fallback Surface Key" help="Optional fallback when this definition is inactive, scheduled out, or resolves no items."><input value={definitionForm.fallback_surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, fallback_surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={definitionForm.is_active} onChange={(event) => setDefinitionForm((current) => ({ ...current, is_active: event.target.checked }))} />Active</label>
|
||||
</div>
|
||||
<Field label="Description" help="Operational note for staff browsing this surface later."><textarea value={definitionForm.description} onChange={(event) => setDefinitionForm((current) => ({ ...current, description: event.target.value }))} className="mt-4 min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={400} /></Field>
|
||||
<Field label="Rules JSON" help="Supported filters include campaign, event, season, type, presentation_style, theme_token, collaboration_mode, owner_username or owner_usernames, commercial_eligible_only, analytics_enabled_only, min_quality_score, min_ranking_score, include_collection_ids, exclude_collection_ids, and featured_only."><textarea value={definitionForm.rules_json} onChange={(event) => setDefinitionForm((current) => ({ ...current, rules_json: event.target.value }))} className="mt-4 min-h-[160px] w-full rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 font-mono text-sm text-white outline-none" spellCheck={false} /></Field>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<button type="submit" disabled={busy === 'definition'} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'definition' ? 'fa-circle-notch fa-spin' : 'fa-layer-group'} fa-fw`} />{definitionForm.id ? 'Update Definition' : 'Save Definition'}</button>
|
||||
{definitionForm.id ? <button type="button" onClick={resetDefinitionForm} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form onSubmit={handlePlacementSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Surface Placement</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Manual and campaign slots</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Surface"><select value={placementForm.surface_key} onChange={(event) => setPlacementForm((current) => ({ ...current, surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">{surfaceKeyOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select></Field>
|
||||
<Field label="Collection"><select value={placementForm.collection_id} onChange={(event) => setPlacementForm((current) => ({ ...current, collection_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}</select></Field>
|
||||
<Field label="Placement Type"><select value={placementForm.placement_type} onChange={(event) => setPlacementForm((current) => ({ ...current, placement_type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="manual">Manual</option><option value="campaign">Campaign</option><option value="scheduled_override">Scheduled override</option></select></Field>
|
||||
<Field label="Priority"><input type="number" min="-100" max="100" value={placementForm.priority} onChange={(event) => setPlacementForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Starts At"><input type="datetime-local" value={placementForm.starts_at} onChange={(event) => setPlacementForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Ends At"><input type="datetime-local" value={placementForm.ends_at} onChange={(event) => setPlacementForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Campaign Key" help="Optional campaign label for reporting and grouped overrides."><input value={placementForm.campaign_key} onChange={(event) => setPlacementForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={placementForm.is_active} onChange={(event) => setPlacementForm((current) => ({ ...current, is_active: event.target.checked }))} />Active placement</label>
|
||||
</div>
|
||||
<Field label="Notes" help="Internal note for why this collection owns the slot."><textarea value={placementForm.notes} onChange={(event) => setPlacementForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<button type="submit" disabled={busy === 'placement'} className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'placement' ? 'fa-circle-notch fa-spin' : 'fa-thumbtack'} fa-fw`} />{placementForm.id ? 'Update Placement' : 'Save Placement'}</button>
|
||||
{placementForm.id ? <button type="button" onClick={resetPlacementForm} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Batch Editorial Tools</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign planning in one pass</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{batchForm.collection_ids.length} selected</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<p className="text-sm font-semibold text-white">Choose collections</p>
|
||||
<p className="mt-2 text-sm text-slate-300">The selector uses current public discovery candidates so staff can quickly prepare a seasonal or editorial run.</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{collectionOptions.map((option) => {
|
||||
const checked = batchForm.collection_ids.includes(option.id)
|
||||
return (
|
||||
<label key={option.id} className={`flex cursor-pointer items-start gap-3 rounded-[22px] border px-4 py-3 transition ${checked ? 'border-lime-300/30 bg-lime-400/10' : 'border-white/10 bg-white/[0.04] hover:bg-white/[0.07]'}`}>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleBatchCollection(option.id)} className="mt-1" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-white">{option.title}</span>
|
||||
<span className="mt-1 block text-xs text-slate-400">{option.type || 'collection'} · {option.visibility || 'public'}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<p className="text-sm font-semibold text-white">Campaign metadata</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Campaign Key"><input value={batchForm.campaign_key} onChange={(event) => setBatchForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
||||
<Field label="Campaign Label"><input value={batchForm.campaign_label} onChange={(event) => setBatchForm((current) => ({ ...current, campaign_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
|
||||
<Field label="Event Label"><input value={batchForm.event_label} onChange={(event) => setBatchForm((current) => ({ ...current, event_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
|
||||
<Field label="Season Key"><input value={batchForm.season_key} onChange={(event) => setBatchForm((current) => ({ ...current, season_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
||||
</div>
|
||||
<Field label="Editorial Notes" help="Shared context recorded on each selected collection."><textarea value={batchForm.editorial_notes} onChange={(event) => setBatchForm((current) => ({ ...current, editorial_notes: event.target.value }))} className="mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={4000} /></Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<p className="text-sm font-semibold text-white">Optional placement plan</p>
|
||||
<p className="mt-2 text-sm text-slate-300">If you set a surface, the preview shows which collections can safely be placed and which ones will be skipped.</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Surface"><select value={batchForm.surface_key} onChange={(event) => setBatchForm((current) => ({ ...current, surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="">No placement</option>{surfaceKeyOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select></Field>
|
||||
<Field label="Placement Type"><select value={batchForm.placement_type} onChange={(event) => setBatchForm((current) => ({ ...current, placement_type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="campaign">Campaign</option><option value="manual">Manual</option><option value="scheduled_override">Scheduled override</option></select></Field>
|
||||
<Field label="Priority"><input type="number" min="-100" max="100" value={batchForm.priority} onChange={(event) => setBatchForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={batchForm.is_active} onChange={(event) => setBatchForm((current) => ({ ...current, is_active: event.target.checked }))} />Active placement</label>
|
||||
<Field label="Starts At"><input type="datetime-local" value={batchForm.starts_at} onChange={(event) => setBatchForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Ends At"><input type="datetime-local" value={batchForm.ends_at} onChange={(event) => setBatchForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
</div>
|
||||
<Field label="Placement Notes"><textarea value={batchForm.notes} onChange={(event) => setBatchForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button type="button" onClick={() => handleBatchEditorial('preview')} disabled={busy === 'batch-preview'} className="inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-5 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'batch-preview' ? 'fa-circle-notch fa-spin' : 'fa-flask'} fa-fw`} />Preview Batch</button>
|
||||
<button type="button" onClick={() => handleBatchEditorial('apply')} disabled={busy === 'batch-apply'} className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'batch-apply' ? 'fa-circle-notch fa-spin' : 'fa-wand-magic-sparkles'} fa-fw`} />Apply Batch</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchResult ? (
|
||||
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Preview results</p>
|
||||
<p className="mt-1 text-sm text-slate-300">{batchResult.collections_count} collections reviewed, {batchResult.placement_eligible_count} placement-ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(batchResult.items || []).map((item) => (
|
||||
<div key={item.collection?.id} className="rounded-[22px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{item.collection?.title}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{item.collection?.visibility} · {item.collection?.lifecycle_state} · {item.collection?.moderation_status}</p>
|
||||
</div>
|
||||
{item.placement ? (
|
||||
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${item.placement.eligible ? 'border-lime-300/20 bg-lime-400/10 text-lime-100' : 'border-rose-300/20 bg-rose-400/10 text-rose-100'}`}>
|
||||
{item.placement.eligible ? `ready for ${item.placement.surface_key}` : 'placement skipped'}
|
||||
</span>
|
||||
) : <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">metadata only</span>}
|
||||
</div>
|
||||
{item.eligibility?.reasons?.length ? <p className="mt-3 text-xs text-amber-100/80">Campaign readiness: {item.eligibility.reasons.join(' ')}</p> : null}
|
||||
{item.placement?.reasons?.length ? <p className="mt-2 text-xs text-rose-100/80">Placement: {item.placement.reasons.join(' ')}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Definitions</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Registered surfaces</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{definitions.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{definitions.map((definition) => (
|
||||
<div key={definition.id} className="rounded-[24px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{definition.surface_key}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">{definition.mode}</span>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold text-white">{definition.title}</h3>
|
||||
{definition.description ? <p className="mt-2 text-sm text-slate-300">{definition.description}</p> : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{definition.ranking_mode}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">max {definition.max_items}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{definition.is_active ? 'active' : 'inactive'}</span>
|
||||
{definition.starts_at ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">starts {new Date(definition.starts_at).toLocaleString()}</span> : null}
|
||||
{definition.ends_at ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">ends {new Date(definition.ends_at).toLocaleString()}</span> : null}
|
||||
{definition.fallback_surface_key ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">fallback {definition.fallback_surface_key}</span> : null}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => hydrateDefinition(definition)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit Definition</button>
|
||||
<button type="button" onClick={() => handleDeleteDefinition(definition)} disabled={busy === `delete-definition-${definition.id}`} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `delete-definition-${definition.id}` ? 'fa-circle-notch fa-spin' : 'fa-trash'} fa-fw text-[10px]`} />Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{conflicts.length ? (
|
||||
<section className="mt-8 rounded-[32px] border border-rose-300/20 bg-rose-500/10 p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-100/80">Conflicts</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Schedule overlaps need review</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-xs font-semibold text-rose-100">{conflicts.length}</span>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{conflicts.map((conflict, index) => (
|
||||
<div key={`${conflict.surface_key}-${index}`} className="rounded-[24px] border border-rose-300/20 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100">{conflict.surface_key}</span>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-rose-50">{conflict.summary}</p>
|
||||
<p className="mt-3 text-xs text-rose-100/70">
|
||||
Window: {conflict.window?.starts_at ? new Date(conflict.window.starts_at).toLocaleString() : 'Immediate'} to {conflict.window?.ends_at ? new Date(conflict.window.ends_at).toLocaleString() : 'Open-ended'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Placements</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Active and scheduled slots</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{placements.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.id} className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">{placement.surface_key}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">{placement.placement_type}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">priority {placement.priority}</span>
|
||||
{conflictPlacementIds.has(placement.id) || placement.has_conflict ? <span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100">conflict</span> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => hydratePlacement(placement)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit</button>
|
||||
<button type="button" onClick={() => handleDeletePlacement(placement)} disabled={busy === `delete-placement-${placement.id}`} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `delete-placement-${placement.id}` ? 'fa-circle-notch fa-spin' : 'fa-trash'} fa-fw text-[10px]`} />Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div>
|
||||
{placement.collection ? <CollectionCard collection={placement.collection} isOwner /> : null}
|
||||
</div>
|
||||
<div className="space-y-3 text-sm text-slate-300">
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Starts: {placement.starts_at ? new Date(placement.starts_at).toLocaleString() : 'Immediate'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Ends: {placement.ends_at ? new Date(placement.ends_at).toLocaleString() : 'Open-ended'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Campaign: {placement.campaign_key || 'None'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Status: {placement.is_active ? 'Active' : 'Inactive'}</div>
|
||||
{placement.notes ? <div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3 text-slate-300">{placement.notes}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+723
@@ -0,0 +1,723 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.errors?.artwork_id?.[0] || payload?.errors?.is_active?.[0] || payload?.errors?.force_hero?.[0] || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function isoToLocalInput(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function localInputToIso(value) {
|
||||
if (!value) return null
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—'
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function Badge({ label, tone = 'slate' }) {
|
||||
const toneClasses = {
|
||||
slate: 'border-white/10 bg-white/10 text-slate-100',
|
||||
sky: 'border-sky-300/20 bg-sky-400/15 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/15 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/15 text-amber-100',
|
||||
rose: 'border-rose-300/20 bg-rose-400/15 text-rose-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClasses[tone] || toneClasses.slate}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, help, children }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-semibold text-white">{label}</span>
|
||||
{children}
|
||||
{help ? <span className="block text-xs leading-relaxed text-slate-400">{help}</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, tone = 'sky' }) {
|
||||
const toneClasses = {
|
||||
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
|
||||
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
|
||||
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
|
||||
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<div className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClasses[tone] || toneClasses.sky}`}>{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
artwork_id: '',
|
||||
priority: 100,
|
||||
featured_at: isoToLocalInput(new Date().toISOString()),
|
||||
expires_at: '',
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
function mapEntryToCandidate(entry) {
|
||||
if (!entry) return null
|
||||
|
||||
return {
|
||||
...entry.artwork,
|
||||
medals: entry.medals,
|
||||
eligibility: entry.eligibility,
|
||||
existing_feature_count: entry.duplicate_count,
|
||||
already_featured: entry.duplicate_count > 0,
|
||||
}
|
||||
}
|
||||
|
||||
function compareEntries(left, right, sortKey, direction) {
|
||||
const dir = direction === 'asc' ? 1 : -1
|
||||
const value = (entry) => {
|
||||
switch (sortKey) {
|
||||
case 'featured_at':
|
||||
return new Date(entry.featured_at || 0).getTime() || 0
|
||||
case 'expires_at':
|
||||
return new Date(entry.expires_at || 0).getTime() || 0
|
||||
case 'score_30d':
|
||||
return Number(entry.medals?.score_30d || 0)
|
||||
default:
|
||||
return Number(entry.priority || 0)
|
||||
}
|
||||
}
|
||||
|
||||
const leftValue = value(left)
|
||||
const rightValue = value(right)
|
||||
if (leftValue !== rightValue) {
|
||||
return (leftValue > rightValue ? 1 : -1) * dir
|
||||
}
|
||||
|
||||
const leftFeatured = new Date(left.featured_at || 0).getTime() || 0
|
||||
const rightFeatured = new Date(right.featured_at || 0).getTime() || 0
|
||||
if (leftFeatured !== rightFeatured) {
|
||||
return (leftFeatured > rightFeatured ? 1 : -1) * dir
|
||||
}
|
||||
|
||||
return Number(right.id || 0) - Number(left.id || 0)
|
||||
}
|
||||
|
||||
export default function FeaturedArtworksAdmin() {
|
||||
const { props } = usePage()
|
||||
const endpoints = props.endpoints || {}
|
||||
const capabilities = props.capabilities || {}
|
||||
const seo = props.seo || {}
|
||||
const [entries, setEntries] = React.useState(Array.isArray(props.entries) ? props.entries : [])
|
||||
const [winner, setWinner] = React.useState(props.winner || null)
|
||||
const [stats, setStats] = React.useState(props.stats || {})
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [busy, setBusy] = React.useState('')
|
||||
const [filter, setFilter] = React.useState('all')
|
||||
const [sortKey, setSortKey] = React.useState('priority')
|
||||
const [sortDirection, setSortDirection] = React.useState('desc')
|
||||
const [listQuery, setListQuery] = React.useState('')
|
||||
const [searchQuery, setSearchQuery] = React.useState('')
|
||||
const [searchResults, setSearchResults] = React.useState([])
|
||||
const [selectedArtwork, setSelectedArtwork] = React.useState(null)
|
||||
const [editingId, setEditingId] = React.useState(null)
|
||||
const [form, setForm] = React.useState(emptyForm())
|
||||
|
||||
React.useEffect(() => {
|
||||
setEntries(Array.isArray(props.entries) ? props.entries : [])
|
||||
setWinner(props.winner || null)
|
||||
setStats(props.stats || {})
|
||||
}, [props.entries, props.stats, props.winner])
|
||||
|
||||
function syncPayload(payload) {
|
||||
setEntries(Array.isArray(payload.entries) ? payload.entries : [])
|
||||
setWinner(payload.winner || null)
|
||||
setStats(payload.stats || {})
|
||||
if (payload.message) {
|
||||
setNotice(payload.message)
|
||||
}
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
setEditingId(null)
|
||||
setSelectedArtwork(null)
|
||||
setSearchResults([])
|
||||
setSearchQuery('')
|
||||
setForm(emptyForm())
|
||||
}
|
||||
|
||||
async function handleArtworkSearch(event) {
|
||||
event.preventDefault()
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
setBusy('search')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const url = `${endpoints.search}?q=${encodeURIComponent(searchQuery.trim())}`
|
||||
const payload = await requestJson(url, { method: 'GET' })
|
||||
setSearchResults(Array.isArray(payload.results) ? payload.results : [])
|
||||
if ((payload.results || []).length === 0) {
|
||||
setNotice('No artworks matched that search.')
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Artwork search failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
function chooseArtwork(artwork) {
|
||||
setSelectedArtwork(artwork)
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
artwork_id: artwork.id,
|
||||
}))
|
||||
}
|
||||
|
||||
function editEntry(entry) {
|
||||
setEditingId(entry.id)
|
||||
setSelectedArtwork(mapEntryToCandidate(entry))
|
||||
setSearchResults([])
|
||||
setSearchQuery('')
|
||||
setForm({
|
||||
artwork_id: entry.artwork_id,
|
||||
priority: entry.priority,
|
||||
featured_at: isoToLocalInput(entry.featured_at),
|
||||
expires_at: isoToLocalInput(entry.expires_at),
|
||||
is_active: Boolean(entry.is_active),
|
||||
})
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault()
|
||||
if (!editingId && !form.artwork_id) {
|
||||
setNotice('Select an artwork first.')
|
||||
return
|
||||
}
|
||||
|
||||
setBusy('submit')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(
|
||||
editingId
|
||||
? endpoints.updatePattern.replace('__FEATURE__', String(editingId))
|
||||
: endpoints.store,
|
||||
{
|
||||
method: editingId ? 'PATCH' : 'POST',
|
||||
body: {
|
||||
artwork_id: Number(form.artwork_id),
|
||||
priority: Number(form.priority || 0),
|
||||
featured_at: localInputToIso(form.featured_at),
|
||||
expires_at: localInputToIso(form.expires_at),
|
||||
is_active: Boolean(form.is_active),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
syncPayload(payload)
|
||||
resetEditor()
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to save this featured entry.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(entry) {
|
||||
setBusy(`toggle-${entry.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(endpoints.togglePattern.replace('__FEATURE__', String(entry.id)), {
|
||||
method: 'PATCH',
|
||||
})
|
||||
syncPayload(payload)
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to change active state.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(entry) {
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Delete featured entry #${entry.id}?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(`delete-${entry.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(endpoints.destroyPattern.replace('__FEATURE__', String(entry.id)), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
syncPayload(payload)
|
||||
|
||||
if (editingId === entry.id) {
|
||||
resetEditor()
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to delete this featured entry.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForceHero(entry) {
|
||||
setBusy(`force-${entry.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(endpoints.forceHeroPattern.replace('__FEATURE__', String(entry.id)), {
|
||||
method: 'PATCH',
|
||||
})
|
||||
syncPayload(payload)
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to change force hero state.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEntries = React.useMemo(() => {
|
||||
const query = listQuery.trim().toLowerCase()
|
||||
|
||||
return entries
|
||||
.filter((entry) => {
|
||||
if (filter === 'active') return Boolean(entry.is_active)
|
||||
if (filter === 'inactive') return !entry.is_active
|
||||
if (filter === 'expired') return Boolean(entry.is_expired)
|
||||
if (filter === 'winner') return Boolean(entry.is_winner)
|
||||
if (filter === 'eligible') return Boolean(entry.eligibility?.is_eligible)
|
||||
if (filter === 'ineligible') return !entry.eligibility?.is_eligible
|
||||
return true
|
||||
})
|
||||
.filter((entry) => {
|
||||
if (!query) return true
|
||||
|
||||
const haystack = [
|
||||
entry.artwork?.title,
|
||||
entry.artwork?.owner?.display_name,
|
||||
entry.artwork?.owner?.username,
|
||||
entry.artwork?.id,
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
return haystack.includes(query)
|
||||
})
|
||||
.sort((left, right) => compareEntries(left, right, sortKey, sortDirection))
|
||||
}, [entries, filter, listQuery, sortDirection, sortKey])
|
||||
|
||||
const duplicateSelection = !editingId && selectedArtwork?.already_featured
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || 'Featured Artworks'}</title>
|
||||
{seo.description ? <meta name="description" content={seo.description} /> : null}
|
||||
{seo.robots ? <meta name="robots" content={seo.robots} /> : null}
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen bg-[#07111c] text-white">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="overflow-hidden rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(245,158,11,0.14),_transparent_35%),linear-gradient(180deg,_rgba(6,14,25,0.92),_rgba(8,18,32,0.96))] p-8 shadow-[0_28px_90px_rgba(2,6,23,0.45)]">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">Featured Artworks</div>
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl">Homepage hero control, with the real winner logic exposed.</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 sm:text-base">Editors can create, update, activate, expire, and remove featured entries here. The winner summary below mirrors the public homepage selection order: priority, recent medal score, featured date, then published date.</p>
|
||||
</div>
|
||||
<div className="grid w-full max-w-xl grid-cols-2 gap-4 md:grid-cols-3">
|
||||
<StatCard label="Entries" value={stats.total || 0} tone="sky" />
|
||||
<StatCard label="Eligible" value={stats.eligible || 0} tone="emerald" />
|
||||
<StatCard label="Expired" value={stats.expired || 0} tone="amber" />
|
||||
<StatCard label="Active" value={stats.active || 0} tone="sky" />
|
||||
<StatCard label="Inactive" value={stats.inactive || 0} tone="rose" />
|
||||
<StatCard label="Not Eligible" value={stats.ineligible || 0} tone="rose" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice ? (
|
||||
<div className="rounded-2xl border border-sky-300/15 bg-sky-400/10 px-4 py-3 text-sm text-sky-50">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Current Homepage Hero</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{winner ? winner.artwork?.title : 'No eligible featured artwork'}</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-7 text-slate-300">
|
||||
{winner?.selection_reason || 'There is no active, non-expired, eligible featured artwork right now.'}
|
||||
</p>
|
||||
{winner?.is_force_hero ? (
|
||||
<div className="mt-4 max-w-2xl rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm leading-6 text-amber-50">
|
||||
Forced by editor. This artwork bypasses the normal hero winner order until Force Hero is disabled on its featured row.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{winner ? <Badge label="Winner" tone="amber" /> : <Badge label="No Winner" tone="rose" />}
|
||||
{winner?.is_force_hero ? <Badge label="Force Hero" tone="amber" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{winner ? (
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[220px_1fr]">
|
||||
<a href={winner.artwork?.canonical_url || '#'} className="overflow-hidden rounded-[24px] border border-white/10 bg-[#09121f]" target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={winner.artwork?.thumbnail?.url}
|
||||
alt={winner.artwork?.title || 'Winner preview'}
|
||||
className="h-full min-h-[180px] w-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Artist</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{winner.artwork?.owner?.display_name || 'Unknown'}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{winner.artwork?.owner?.type === 'group' ? 'Group publisher' : `@${winner.artwork?.owner?.username || ''}`}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Medal Score (30d)</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{winner.medals?.score_30d || 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Priority</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{winner.priority}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Featured Since</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{formatDateTime(winner.featured_at)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4 sm:col-span-2">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Published At</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{formatDateTime(winner.artwork?.published_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">{editingId ? 'Edit Entry' : 'Create Entry'}</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{editingId ? `Featured entry #${editingId}` : 'Add an artwork to the featured pool'}</h2>
|
||||
</div>
|
||||
{editingId ? (
|
||||
<button type="button" onClick={resetEditor} className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/5">
|
||||
Cancel edit
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!editingId ? (
|
||||
<form onSubmit={handleArtworkSearch} className="mt-6 space-y-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<Field label="Artwork selector" help="Search by artwork ID, title, slug, artist, or group. Pick a result to lock it into the form.">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
placeholder="Try an artwork ID, title, or creator"
|
||||
/>
|
||||
<button type="submit" disabled={busy === 'search'} className="rounded-2xl bg-sky-400 px-4 py-3 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{busy === 'search' ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{searchResults.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{searchResults.map((artwork) => (
|
||||
<button
|
||||
type="button"
|
||||
key={artwork.id}
|
||||
onClick={() => chooseArtwork(artwork)}
|
||||
className={`grid gap-4 rounded-2xl border p-3 text-left transition sm:grid-cols-[88px_1fr] ${selectedArtwork?.id === artwork.id ? 'border-sky-300/40 bg-sky-400/10' : 'border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.04]'}`}
|
||||
>
|
||||
<img src={artwork.thumbnail?.url} alt={artwork.title} className="h-24 w-full rounded-2xl object-cover" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-white">{artwork.title}</span>
|
||||
<span className="text-xs text-slate-400">#{artwork.id}</span>
|
||||
{artwork.already_featured ? <Badge label="Already Featured" tone="amber" /> : null}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">{artwork.owner?.display_name || 'Unknown'} • Medal Score (30d): {artwork.medals?.score_30d || 0}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(artwork.eligibility?.is_eligible ? [{ label: 'Eligible', tone: 'emerald' }] : [{ label: 'Not eligible', tone: 'rose' }]).concat(
|
||||
(artwork.eligibility?.reasons || []).map((reason) => ({
|
||||
label: reason,
|
||||
tone: reason === 'Missing preview' ? 'rose' : 'slate',
|
||||
}))
|
||||
).slice(0, 4).map((badge) => (
|
||||
<Badge key={`${artwork.id}-${badge.label}`} label={badge.label} tone={badge.tone} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{selectedArtwork ? (
|
||||
<div className="mt-6 grid gap-4 rounded-[24px] border border-white/10 bg-black/20 p-4 sm:grid-cols-[108px_1fr]">
|
||||
<img src={selectedArtwork.thumbnail?.url} alt={selectedArtwork.title || 'Artwork preview'} className="h-28 w-full rounded-2xl object-cover" />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Selected Artwork</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{selectedArtwork.title}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">#{selectedArtwork.id} • {selectedArtwork.owner?.display_name || 'Unknown'} • Medal Score (30d): {selectedArtwork.medals?.score_30d || 0}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(selectedArtwork.eligibility?.is_eligible ? [{ label: 'Currently eligible', tone: 'emerald' }] : [{ label: 'Currently ineligible', tone: 'rose' }]).concat(
|
||||
(selectedArtwork.eligibility?.reasons || []).map((reason) => ({
|
||||
label: reason,
|
||||
tone: reason === 'Missing preview' ? 'rose' : 'slate',
|
||||
}))
|
||||
).map((badge) => (
|
||||
<Badge key={`selected-${badge.label}`} label={badge.label} tone={badge.tone} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{duplicateSelection ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
|
||||
This artwork already has a featured entry. Edit the existing row instead of creating a duplicate.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Priority" help="Higher priority always wins before medal score is considered.">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.priority}
|
||||
onChange={(event) => setForm((current) => ({ ...current, priority: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Active" help="Inactive rows stay visible in admin but cannot win the homepage hero.">
|
||||
<label className="flex h-[52px] items-center gap-3 rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-slate-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(form.is_active)}
|
||||
onChange={(event) => setForm((current) => ({ ...current, is_active: event.target.checked }))}
|
||||
className="h-4 w-4 rounded border-white/20 bg-transparent text-sky-400 focus:ring-sky-300/30"
|
||||
/>
|
||||
<span>{form.is_active ? 'Active on save' : 'Inactive on save'}</span>
|
||||
</label>
|
||||
</Field>
|
||||
|
||||
<Field label="Featured Since">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.featured_at}
|
||||
onChange={(event) => setForm((current) => ({ ...current, featured_at: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Expires">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.expires_at}
|
||||
onChange={(event) => setForm((current) => ({ ...current, expires_at: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="sm:col-span-2 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy === 'submit' || (!editingId && !selectedArtwork) || duplicateSelection}
|
||||
className="rounded-2xl bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy === 'submit' ? 'Saving…' : editingId ? 'Save Changes' : 'Create Featured Entry'}
|
||||
</button>
|
||||
{editingId ? (
|
||||
<button type="button" onClick={resetEditor} className="rounded-2xl border border-white/10 px-5 py-3 text-sm font-semibold text-slate-100 transition hover:border-white/20 hover:bg-white/5">
|
||||
Reset
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Featured Pool</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Every featured row, with eligibility and winner state visible.</h2>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:w-[720px]">
|
||||
<input
|
||||
type="text"
|
||||
value={listQuery}
|
||||
onChange={(event) => setListQuery(event.target.value)}
|
||||
placeholder="Filter by title, artist, or artwork ID"
|
||||
className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
/>
|
||||
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="all">All rows</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="winner">Winner</option>
|
||||
<option value="eligible">Eligible</option>
|
||||
<option value="ineligible">Not eligible</option>
|
||||
</select>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<select value={sortKey} onChange={(event) => setSortKey(event.target.value)} className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="priority">Priority</option>
|
||||
<option value="featured_at">Featured Since</option>
|
||||
<option value="expires_at">Expires</option>
|
||||
<option value="score_30d">Medal Score (30d)</option>
|
||||
</select>
|
||||
<button type="button" onClick={() => setSortDirection((current) => current === 'desc' ? 'asc' : 'desc')} className="rounded-2xl border border-white/10 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:border-white/20 hover:bg-white/5">
|
||||
{sortDirection === 'desc' ? 'Desc' : 'Asc'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-[24px] border border-white/10">
|
||||
<div className="hidden grid-cols-[1.2fr_1fr_0.5fr_0.9fr_0.9fr_0.7fr_1.5fr_0.9fr] gap-4 border-b border-white/10 bg-black/20 px-5 py-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400 lg:grid">
|
||||
<div>Artwork</div>
|
||||
<div>Artist / Owner</div>
|
||||
<div>Priority</div>
|
||||
<div>Featured Since</div>
|
||||
<div>Expires</div>
|
||||
<div>Score (30d)</div>
|
||||
<div>Status</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-white/10">
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-slate-400">No featured entries match the current filter.</div>
|
||||
) : filteredEntries.map((entry) => (
|
||||
<div key={entry.id} className="grid gap-5 bg-white/[0.02] px-5 py-5 lg:grid-cols-[1.2fr_1fr_0.5fr_0.9fr_0.9fr_0.7fr_1.5fr_0.9fr] lg:items-center">
|
||||
<div className="grid gap-4 sm:grid-cols-[92px_1fr]">
|
||||
<a href={entry.artwork?.canonical_url || '#'} target="_blank" rel="noreferrer" className="overflow-hidden rounded-2xl border border-white/10 bg-[#08111d]">
|
||||
<img src={entry.artwork?.thumbnail?.url} alt={entry.artwork?.title || 'Artwork preview'} className="h-24 w-full object-cover" />
|
||||
</a>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-white">{entry.artwork?.title || 'Missing artwork'}</span>
|
||||
<span className="text-xs text-slate-400">#{entry.artwork?.id || entry.artwork_id}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-slate-400">Visibility: {entry.artwork?.visibility || '—'} • Published: {entry.artwork?.published_at ? 'Yes' : 'No'}</div>
|
||||
{entry.is_winner && entry.winner_reason ? <div className="mt-2 text-xs leading-6 text-amber-100">{entry.winner_reason}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{entry.artwork?.owner?.display_name || 'Unknown'}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{entry.artwork?.owner?.type === 'group' ? 'Group publisher' : `@${entry.artwork?.owner?.username || ''}`}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-semibold text-white">{entry.priority}</div>
|
||||
<div className="text-sm text-slate-200">{formatDateTime(entry.featured_at)}</div>
|
||||
<div className="text-sm text-slate-200">{formatDateTime(entry.expires_at)}</div>
|
||||
<div className="text-sm font-semibold text-white">{entry.medals?.score_30d || 0}</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(entry.status_badges || []).map((badge, index) => (
|
||||
<Badge key={`${entry.id}-${badge.label}-${index}`} label={badge.label} tone={badge.tone} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
<button type="button" onClick={() => editEntry(entry)} className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition hover:border-white/20 hover:bg-white/5">
|
||||
Edit
|
||||
</button>
|
||||
{capabilities.forceHeroEnabled ? (
|
||||
<button type="button" onClick={() => handleForceHero(entry)} disabled={busy === `force-${entry.id}`} className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition disabled:cursor-not-allowed disabled:opacity-60 ${entry.is_force_hero ? 'border-amber-300/25 text-amber-100 hover:border-amber-300/40 hover:bg-amber-400/10' : 'border-amber-300/15 text-amber-50 hover:border-amber-300/30 hover:bg-amber-400/5'}`}>
|
||||
{busy === `force-${entry.id}` ? 'Saving…' : entry.is_force_hero ? 'Disable Force Hero' : 'Force Hero'}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => handleToggle(entry)} disabled={busy === `toggle-${entry.id}`} className="rounded-full border border-sky-300/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-400/10 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{busy === `toggle-${entry.id}` ? 'Saving…' : entry.is_active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(entry)} disabled={busy === `delete-${entry.id}`} className="rounded-full border border-rose-300/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-rose-100 transition hover:border-rose-300/40 hover:bg-rose-400/10 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{busy === `delete-${entry.id}` ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+638
@@ -0,0 +1,638 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
import NovaCardCanvasPreview from '../../components/nova-cards/NovaCardCanvasPreview'
|
||||
|
||||
function requestJson(url, { method = 'GET', body } = {}) {
|
||||
return fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(payload?.message || 'Request failed')
|
||||
return payload
|
||||
})
|
||||
}
|
||||
|
||||
function renderOverrideHistoryItems(items, prefix) {
|
||||
return (items || []).slice(0, 3).map((entry, index) => (
|
||||
<div key={`${prefix}-${index}-${entry.updated_at || entry.source || entry.moderation_status || 'override'}`} className="rounded-2xl border border-white/10 bg-black/10 px-3 py-3">
|
||||
<div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100/75">
|
||||
<span>{entry.moderation_status || 'unknown status'}</span>
|
||||
{entry.disposition_label ? <span>{entry.disposition_label}</span> : null}
|
||||
{entry.actor_username ? <span>@{entry.actor_username}</span> : null}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.14em] text-sky-100/55">
|
||||
{entry.source ? <span>{String(entry.source).replaceAll('_', ' ')}</span> : null}
|
||||
{entry.updated_at ? <span>{new Date(entry.updated_at).toLocaleString()}</span> : null}
|
||||
</div>
|
||||
{entry.note ? <div className="mt-2 text-sm leading-6 text-sky-50">{entry.note}</div> : null}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
export default function NovaCardsAdminIndex() {
|
||||
const { props } = usePage()
|
||||
const [cards, setCards] = React.useState(props.cards?.data || [])
|
||||
const [featuredCreators, setFeaturedCreators] = React.useState(props.featuredCreators || [])
|
||||
const [categories, setCategories] = React.useState(props.categories || [])
|
||||
const [reportStatus, setReportStatus] = React.useState('open')
|
||||
const [reports, setReports] = React.useState([])
|
||||
const [reportsMeta, setReportsMeta] = React.useState({ total: 0 })
|
||||
const [reportCounts, setReportCounts] = React.useState(props.reportingQueue?.statuses || { open: 0, reviewing: 0, closed: 0 })
|
||||
const [reportNotes, setReportNotes] = React.useState({})
|
||||
const [reportBusy, setReportBusy] = React.useState({})
|
||||
const [reportsLoading, setReportsLoading] = React.useState(false)
|
||||
const [reportsError, setReportsError] = React.useState('')
|
||||
const [cardDispositions, setCardDispositions] = React.useState(() => Object.fromEntries((props.cards?.data || []).map((card) => [card.id, card.moderation_override?.disposition || ''])))
|
||||
const [reportDispositions, setReportDispositions] = React.useState({})
|
||||
const [newCategory, setNewCategory] = React.useState({ slug: '', name: '', description: '', active: true, order_num: categories.length })
|
||||
const endpoints = props.endpoints || {}
|
||||
const stats = props.stats || {}
|
||||
const reportingQueue = props.reportingQueue || {}
|
||||
const moderationDispositionOptions = props.moderationDispositionOptions || {}
|
||||
|
||||
function dispositionOptionsForStatus(status) {
|
||||
return moderationDispositionOptions?.[status] || []
|
||||
}
|
||||
|
||||
function preferredDisposition(status, currentValue) {
|
||||
const options = dispositionOptionsForStatus(status)
|
||||
if (currentValue && options.some((option) => option.value === currentValue)) {
|
||||
return currentValue
|
||||
}
|
||||
|
||||
return options[0]?.value || ''
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true
|
||||
|
||||
async function loadReports() {
|
||||
if (!endpoints.reportsQueue) {
|
||||
return
|
||||
}
|
||||
|
||||
setReportsLoading(true)
|
||||
setReportsError('')
|
||||
|
||||
try {
|
||||
const separator = String(endpoints.reportsQueue).includes('?') ? '&' : '?'
|
||||
const response = await requestJson(`${endpoints.reportsQueue}${separator}status=${reportStatus}`)
|
||||
if (!active) return
|
||||
setReports(response.data || [])
|
||||
setReportsMeta(response.meta || { total: 0 })
|
||||
setReportDispositions((current) => {
|
||||
const next = { ...current }
|
||||
;(response.data || []).forEach((report) => {
|
||||
const target = report?.target?.moderation_target
|
||||
if (target?.card_id && !(report.id in next)) {
|
||||
next[report.id] = preferredDisposition(target.moderation_status, target.moderation_override?.disposition)
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
setReportNotes((current) => {
|
||||
const next = { ...current }
|
||||
;(response.data || []).forEach((report) => {
|
||||
if (!(report.id in next)) {
|
||||
next[report.id] = report.moderator_note || ''
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
if (!active) return
|
||||
setReportsError(error.message)
|
||||
setReports([])
|
||||
} finally {
|
||||
if (active) {
|
||||
setReportsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadReports()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [endpoints.reportsQueue, reportStatus])
|
||||
|
||||
async function updateCard(cardId, patch) {
|
||||
const response = await requestJson(String(endpoints.updateCardPattern || '').replace('__CARD__', String(cardId)), {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
})
|
||||
|
||||
setCardDispositions((current) => ({
|
||||
...current,
|
||||
[cardId]: preferredDisposition(response.card?.moderation_status, response.card?.moderation_override?.disposition),
|
||||
}))
|
||||
setCards((current) => current.map((card) => (card.id === cardId ? response.card : card)))
|
||||
}
|
||||
|
||||
async function updateCreator(creatorId, patch) {
|
||||
const response = await requestJson(String(endpoints.updateCreatorPattern || '').replace('__CREATOR__', String(creatorId)), {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
})
|
||||
|
||||
setFeaturedCreators((current) => current.map((creator) => (creator.id === creatorId ? response.creator : creator)))
|
||||
}
|
||||
|
||||
function syncReportCounts(previousStatus, nextStatus) {
|
||||
if (!previousStatus || !nextStatus || previousStatus === nextStatus) {
|
||||
return
|
||||
}
|
||||
|
||||
setReportCounts((current) => ({
|
||||
...current,
|
||||
[previousStatus]: Math.max(0, Number(current?.[previousStatus] || 0) - 1),
|
||||
[nextStatus]: Number(current?.[nextStatus] || 0) + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
function mergeUpdatedReport(updatedReport, previousStatus) {
|
||||
setReportNotes((current) => ({ ...current, [updatedReport.id]: updatedReport.moderator_note || '' }))
|
||||
setReportDispositions((current) => ({
|
||||
...current,
|
||||
[updatedReport.id]: preferredDisposition(updatedReport?.target?.moderation_target?.moderation_status, updatedReport?.target?.moderation_target?.moderation_override?.disposition),
|
||||
}))
|
||||
|
||||
if (previousStatus && previousStatus !== updatedReport.status) {
|
||||
syncReportCounts(previousStatus, updatedReport.status)
|
||||
}
|
||||
|
||||
setReports((current) => {
|
||||
if (previousStatus && previousStatus !== updatedReport.status) {
|
||||
return current.filter((report) => report.id !== updatedReport.id)
|
||||
}
|
||||
|
||||
return current.map((report) => (report.id === updatedReport.id ? updatedReport : report))
|
||||
})
|
||||
}
|
||||
|
||||
async function saveCategory(category) {
|
||||
const isExisting = Boolean(category.id)
|
||||
const url = isExisting
|
||||
? String(endpoints.updateCategoryPattern || '').replace('__CATEGORY__', String(category.id))
|
||||
: endpoints.storeCategory
|
||||
const response = await requestJson(url, {
|
||||
method: isExisting ? 'PATCH' : 'POST',
|
||||
body: category,
|
||||
})
|
||||
|
||||
setCategories((current) => {
|
||||
if (isExisting) {
|
||||
return current.map((item) => (item.id === category.id ? { ...item, ...response.category } : item))
|
||||
}
|
||||
|
||||
return [...current, { ...response.category, cards_count: 0 }]
|
||||
})
|
||||
|
||||
if (!isExisting) {
|
||||
setNewCategory({ slug: '', name: '', description: '', active: true, order_num: categories.length + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
async function updateReport(reportId, patch) {
|
||||
const currentReport = reports.find((report) => report.id === reportId)
|
||||
if (!currentReport) {
|
||||
return
|
||||
}
|
||||
|
||||
setReportBusy((current) => ({ ...current, [reportId]: true }))
|
||||
|
||||
try {
|
||||
const response = await requestJson(String(endpoints.updateReportPattern || '').replace('__REPORT__', String(reportId)), {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
})
|
||||
|
||||
mergeUpdatedReport(response.report, currentReport.status)
|
||||
} finally {
|
||||
setReportBusy((current) => ({ ...current, [reportId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
async function moderateReportTarget(reportId, action) {
|
||||
const currentReport = reports.find((report) => report.id === reportId)
|
||||
if (!currentReport) {
|
||||
return
|
||||
}
|
||||
|
||||
setReportBusy((current) => ({ ...current, [reportId]: true }))
|
||||
|
||||
try {
|
||||
const response = await requestJson(String(endpoints.moderateReportTargetPattern || '').replace('__REPORT__', String(reportId)), {
|
||||
method: 'POST',
|
||||
body: { action, disposition: reportDispositions[reportId] || null },
|
||||
})
|
||||
|
||||
mergeUpdatedReport(response.report, currentReport.status)
|
||||
setCards((current) => current.map((card) => (card.id === response.report?.target?.moderation_target?.card_id
|
||||
? {
|
||||
...card,
|
||||
moderation_status: response.report.target.moderation_target.moderation_status,
|
||||
moderation_override: response.report.target.moderation_target.moderation_override,
|
||||
moderation_override_history: response.report.target.moderation_target.moderation_override_history,
|
||||
}
|
||||
: card)))
|
||||
} finally {
|
||||
setReportBusy((current) => ({ ...current, [reportId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Nova Cards Moderation" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Nova Cards control panel</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Review pending cards, feature standout work, and keep the starter category taxonomy healthy as Nova Cards launches.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={endpoints.templates || '/cp/cards/templates'} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<i className="fa-solid fa-swatchbook" />
|
||||
Manage templates
|
||||
</Link>
|
||||
<Link href={endpoints.assetPacks || '/cp/cards/asset-packs'} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<i className="fa-solid fa-shapes" />
|
||||
Asset packs
|
||||
</Link>
|
||||
<Link href={endpoints.challenges || '/cp/cards/challenges'} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<i className="fa-solid fa-trophy" />
|
||||
Challenges
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{[
|
||||
['Pending', stats.pending || 0, 'fa-clock'],
|
||||
['Flagged', stats.flagged || 0, 'fa-flag'],
|
||||
['Featured', stats.featured || 0, 'fa-star'],
|
||||
['Published', stats.published || 0, 'fa-earth-americas'],
|
||||
['Remixable', stats.remixable || 0, 'fa-code-branch'],
|
||||
['Challenges', stats.challenges || 0, 'fa-trophy'],
|
||||
].map(([label, value, icon]) => (
|
||||
<div key={label} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{label}</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100"><i className={`fa-solid ${icon}`} /></span>
|
||||
<span className="text-3xl font-semibold tracking-[-0.04em] text-white">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Reporting queue</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{reportingQueue.label || 'Nova Cards report queue'}</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{reportingQueue.description || 'Review reports targeting Nova Cards surfaces.'}</p>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-right">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/75">Pending reports</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-amber-50">{reportCounts.open || 0}</div>
|
||||
<div className="mt-1 text-xs text-amber-100/70">{reportingQueue.enabled ? 'Connected to moderation pipeline' : 'Reporting disabled'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{[
|
||||
['open', reportCounts.open || 0],
|
||||
['reviewing', reportCounts.reviewing || 0],
|
||||
['closed', reportCounts.closed || 0],
|
||||
].map(([status, count]) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => setReportStatus(status)}
|
||||
className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition ${reportStatus === status ? 'border-sky-300/30 bg-sky-400/12 text-sky-100' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.06]'}`}
|
||||
>
|
||||
{status} • {count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 rounded-[24px] border border-white/10 bg-[#08111f]/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">{reportStatus.charAt(0).toUpperCase() + reportStatus.slice(1)} reports</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">{reportsMeta.total || reports.length} total</div>
|
||||
</div>
|
||||
{reportsLoading ? <div className="mt-4 text-sm text-slate-400">Loading report queue…</div> : null}
|
||||
{reportsError ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{reportsError}</div> : null}
|
||||
{!reportsLoading && !reportsError && !reports.length ? <div className="mt-4 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">No reports in this state.</div> : null}
|
||||
<div className="mt-4 space-y-3">
|
||||
{reports.map((report) => (
|
||||
<div key={report.id} className="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">{String(report.target?.type || report.target_type).replaceAll('_', ' ')}</span>
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{report.status}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-semibold text-white">{report.target?.label || `Target #${report.target_id}`}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{report.target?.subtitle || 'No target details available.'}</div>
|
||||
<div className="mt-3 text-sm font-semibold text-slate-200">{report.reason}</div>
|
||||
{report.details ? <p className="mt-2 text-sm leading-6 text-slate-300">{report.details}</p> : null}
|
||||
<div className="mt-3 text-xs uppercase tracking-[0.16em] text-slate-500">Reported by {report.reporter?.username ? `@${report.reporter.username}` : 'unknown'}{report.created_at ? ` • ${new Date(report.created_at).toLocaleString()}` : ''}</div>
|
||||
{report.last_moderated_by?.username || report.last_moderated_at ? (
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">
|
||||
Last touched {report.last_moderated_by?.username ? `by @${report.last_moderated_by.username}` : 'by staff'}{report.last_moderated_at ? ` • ${new Date(report.last_moderated_at).toLocaleString()}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
{report.target?.moderation_target ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-300/15 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
|
||||
<div className="font-semibold uppercase tracking-[0.16em] text-amber-100/80">Card moderation target</div>
|
||||
<div className="mt-2">{report.target.moderation_target.title}</div>
|
||||
{report.target.moderation_target.context ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-amber-100/70">{report.target.moderation_target.context}</div> : null}
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs uppercase tracking-[0.14em] text-amber-100/80">
|
||||
<span>Status {report.target.moderation_target.status}</span>
|
||||
<span>Moderation {report.target.moderation_target.moderation_status}</span>
|
||||
</div>
|
||||
{report.target.moderation_target.moderation_reason_labels?.length ? (
|
||||
<div className="mt-3 rounded-2xl border border-amber-200/15 bg-black/10 px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/75">Heuristic flags</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{report.target.moderation_target.moderation_reason_labels.map((label) => (
|
||||
<span key={`${report.id}-${label}`} className="rounded-full border border-amber-200/20 bg-amber-50/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-50">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{report.target.moderation_target.moderation_source ? <div className="mt-2 text-[11px] uppercase tracking-[0.14em] text-amber-100/60">Source {String(report.target.moderation_target.moderation_source).replaceAll('_', ' ')}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{report.target.moderation_target.moderation_override ? (
|
||||
<div className="mt-3 rounded-2xl border border-sky-200/15 bg-sky-400/10 px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">Latest staff override</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs uppercase tracking-[0.14em] text-sky-100/80">
|
||||
<span>Status {report.target.moderation_target.moderation_override.moderation_status}</span>
|
||||
{report.target.moderation_target.moderation_override.disposition_label ? <span>{report.target.moderation_target.moderation_override.disposition_label}</span> : null}
|
||||
{report.target.moderation_target.moderation_override.actor_username ? <span>@{report.target.moderation_target.moderation_override.actor_username}</span> : null}
|
||||
{report.target.moderation_target.moderation_override.source ? <span>{String(report.target.moderation_target.moderation_override.source).replaceAll('_', ' ')}</span> : null}
|
||||
</div>
|
||||
{report.target.moderation_target.moderation_override.note ? <div className="mt-2 text-sm leading-6 text-sky-50">{report.target.moderation_target.moderation_override.note}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{report.target.moderation_target.moderation_override_history?.length > 1 ? (
|
||||
<div className="mt-3 rounded-2xl border border-sky-200/10 bg-sky-400/[0.08] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/75">Recent override history</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{renderOverrideHistoryItems(report.target.moderation_target.moderation_override_history, `report-${report.id}`)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 max-w-xs">
|
||||
<label className="text-sm text-amber-50">
|
||||
<span className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/75">Disposition</span>
|
||||
<select
|
||||
value={preferredDisposition(report.target.moderation_target.moderation_status, reportDispositions[report.id])}
|
||||
onChange={(event) => setReportDispositions((current) => ({ ...current, [report.id]: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-amber-200/20 bg-[#0d1726] px-4 py-3 text-white"
|
||||
>
|
||||
{dispositionOptionsForStatus(report.target.moderation_target.moderation_status).map((option) => <option key={`${report.id}-disp-${option.value}`} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(report.target.moderation_target.available_actions || []).map((actionItem) => (
|
||||
<button
|
||||
key={`${report.id}-${actionItem.action}`}
|
||||
type="button"
|
||||
onClick={() => moderateReportTarget(report.id, actionItem.action)}
|
||||
disabled={Boolean(reportBusy[report.id])}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-amber-200/20 bg-amber-50/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-50 transition hover:bg-amber-50/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{actionItem.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-[#08111f]/70 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Moderator note</div>
|
||||
<textarea
|
||||
value={reportNotes[report.id] ?? ''}
|
||||
onChange={(event) => setReportNotes((current) => ({ ...current, [report.id]: event.target.value }))}
|
||||
rows={3}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white"
|
||||
placeholder="Capture reviewer context, outcome, or escalation notes."
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateReport(report.id, { moderator_note: (reportNotes[report.id] || '').trim() || null })}
|
||||
disabled={Boolean(reportBusy[report.id])}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Save note
|
||||
</button>
|
||||
{['open', 'reviewing', 'closed'].filter((status) => status !== report.status).map((status) => (
|
||||
<button
|
||||
key={`${report.id}-${status}`}
|
||||
type="button"
|
||||
onClick={() => updateReport(report.id, { status, moderator_note: (reportNotes[report.id] || '').trim() || null })}
|
||||
disabled={Boolean(reportBusy[report.id])}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Mark {status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-[#08111f]/70 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Audit trail</div>
|
||||
{!report.history?.length ? <div className="mt-3 text-sm text-slate-400">No moderator actions recorded yet.</div> : null}
|
||||
<div className="mt-3 space-y-3">
|
||||
{(report.history || []).map((entry) => (
|
||||
<div key={entry.id} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.16em] text-slate-500">
|
||||
<span>{entry.summary || entry.action_type}</span>
|
||||
<span>{entry.actor?.username ? `@${entry.actor.username}` : 'system'}</span>
|
||||
<span>{entry.created_at ? new Date(entry.created_at).toLocaleString() : ''}</span>
|
||||
</div>
|
||||
{entry.note ? <div className="mt-2 text-sm leading-6 text-slate-300">{entry.note}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 lg:max-w-[260px] lg:justify-end">
|
||||
{report.target?.public_url ? <a href={report.target.public_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Open target</a> : null}
|
||||
{report.target?.moderation_url ? <a href={report.target.moderation_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Moderate</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-5">
|
||||
{cards.map((card) => (
|
||||
<div key={card.id} className="rounded-[28px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<NovaCardCanvasPreview card={card} />
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xl font-semibold tracking-[-0.03em] text-white">{card.title}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">@{card.creator?.username} • {card.category?.name || 'Uncategorized'}</div>
|
||||
</div>
|
||||
<a href={card.public_url} className="text-sm text-sky-300 transition hover:text-sky-200">Open public page</a>
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-3 text-sm leading-7 text-slate-300">{card.quote_text}</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Status</span>
|
||||
<select value={card.status} onChange={(event) => updateCard(card.id, { status: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{['draft', 'processing', 'published', 'hidden', 'rejected'].map((item) => <option key={`${card.id}-${item}`} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Moderation</span>
|
||||
<select value={card.moderation_status} onChange={(event) => updateCard(card.id, { moderation_status: event.target.value, disposition: preferredDisposition(event.target.value, cardDispositions[card.id]) })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{['pending', 'approved', 'flagged', 'rejected'].map((item) => <option key={`${card.id}-mod-${item}`} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Disposition</span>
|
||||
<select
|
||||
value={preferredDisposition(card.moderation_status, cardDispositions[card.id])}
|
||||
onChange={(event) => {
|
||||
const disposition = event.target.value
|
||||
setCardDispositions((current) => ({ ...current, [card.id]: disposition }))
|
||||
updateCard(card.id, { moderation_status: card.moderation_status, disposition })
|
||||
}}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"
|
||||
>
|
||||
{dispositionOptionsForStatus(card.moderation_status).map((option) => <option key={`${card.id}-disp-${option.value}`} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<span>Featured</span>
|
||||
<input type="checkbox" checked={Boolean(card.featured)} onChange={(event) => updateCard(card.id, { featured: event.target.checked })} className="h-4 w-4" />
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<span>Allow remix</span>
|
||||
<input type="checkbox" checked={Boolean(card.allow_remix)} onChange={(event) => updateCard(card.id, { allow_remix: event.target.checked })} className="h-4 w-4" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-4 text-xs text-slate-400">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.likes_count || 0} likes</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.saves_count || 0} saves</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.remixes_count || 0} remixes</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.challenge_entries_count || 0} challenge entries</div>
|
||||
</div>
|
||||
{card.moderation_reason_labels?.length ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-300/15 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Heuristic moderation flags</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{card.moderation_reason_labels.map((label) => (
|
||||
<span key={`${card.id}-${label}`} className="rounded-full border border-amber-200/20 bg-amber-50/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-50">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{card.moderation_source ? <div className="mt-2 text-[11px] uppercase tracking-[0.14em] text-amber-100/70">Source {String(card.moderation_source).replaceAll('_', ' ')}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{card.moderation_override ? (
|
||||
<div className="mt-4 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-4 py-3 text-sm text-sky-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">Latest staff override</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs uppercase tracking-[0.14em] text-sky-100/80">
|
||||
<span>Status {card.moderation_override.moderation_status}</span>
|
||||
{card.moderation_override.disposition_label ? <span>{card.moderation_override.disposition_label}</span> : null}
|
||||
{card.moderation_override.actor_username ? <span>@{card.moderation_override.actor_username}</span> : null}
|
||||
{card.moderation_override.source ? <span>{String(card.moderation_override.source).replaceAll('_', ' ')}</span> : null}
|
||||
</div>
|
||||
{card.moderation_override.note ? <div className="mt-2 text-sm leading-6 text-sky-50">{card.moderation_override.note}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{card.moderation_override_history?.length > 1 ? (
|
||||
<div className="mt-4 rounded-2xl border border-sky-300/10 bg-sky-400/[0.08] px-4 py-3 text-sm text-sky-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/75">Recent override history</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{renderOverrideHistoryItems(card.moderation_override_history, `card-${card.id}`)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Creator curation</div>
|
||||
{!featuredCreators.length ? <div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">No public Nova creators are available for curation yet.</div> : null}
|
||||
<div className="space-y-3">
|
||||
{featuredCreators.map((creator) => (
|
||||
<div key={creator.id} className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-white">{creator.display_name}</div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">@{creator.username}</div>
|
||||
</div>
|
||||
{creator.public_url ? <a href={creator.public_url} className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-300 transition hover:text-sky-200">Open profile</a> : null}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-slate-300">
|
||||
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.public_cards_count || 0} public cards</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.featured_cards_count || 0} featured</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.total_views_count || 0} views</div>
|
||||
</div>
|
||||
<label className="mt-3 flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<span>Feature on editorial page</span>
|
||||
<input type="checkbox" checked={Boolean(creator.nova_featured_creator)} onChange={(event) => updateCreator(creator.id, { nova_featured_creator: event.target.checked })} className="h-4 w-4" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Categories</div>
|
||||
<div className="space-y-3">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-white">{category.name}</div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{category.slug} • {category.cards_count} cards</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => saveCategory(category)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.08]">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Add category</div>
|
||||
<div className="space-y-3">
|
||||
<input value={newCategory.name} onChange={(event) => setNewCategory((current) => ({ ...current, name: event.target.value }))} placeholder="Name" className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<input value={newCategory.slug} onChange={(event) => setNewCategory((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={newCategory.description} onChange={(event) => setNewCategory((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<button type="button" onClick={() => saveCategory(newCategory)} className="w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">Create category</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
|
||||
function requestJson(url, { method = 'GET', body } = {}) {
|
||||
return fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(payload?.message || 'Request failed')
|
||||
return payload
|
||||
})
|
||||
}
|
||||
|
||||
export default function NovaCardsAssetPackAdmin() {
|
||||
const { props } = usePage()
|
||||
const [packs, setPacks] = React.useState(props.packs || [])
|
||||
const [selectedId, setSelectedId] = React.useState(null)
|
||||
const [form, setForm] = React.useState({ slug: '', name: '', description: '', type: 'asset', preview_image: '', manifest_json: {}, official: true, active: true, order_num: 0 })
|
||||
const endpoints = props.endpoints || {}
|
||||
|
||||
function loadPack(pack) {
|
||||
setSelectedId(pack.id)
|
||||
setForm({
|
||||
slug: pack.slug,
|
||||
name: pack.name,
|
||||
description: pack.description || '',
|
||||
type: pack.type || 'asset',
|
||||
preview_image: pack.preview_image || '',
|
||||
manifest_json: pack.manifest_json || {},
|
||||
official: Boolean(pack.official),
|
||||
active: Boolean(pack.active),
|
||||
order_num: pack.order_num || 0,
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setSelectedId(null)
|
||||
setForm({ slug: '', name: '', description: '', type: 'asset', preview_image: '', manifest_json: {}, official: true, active: true, order_num: packs.length })
|
||||
}
|
||||
|
||||
async function savePack() {
|
||||
const isExisting = Boolean(selectedId)
|
||||
const url = isExisting ? String(endpoints.updatePattern || '').replace('__PACK__', String(selectedId)) : endpoints.store
|
||||
const response = await requestJson(url, { method: isExisting ? 'PATCH' : 'POST', body: form })
|
||||
if (isExisting) {
|
||||
setPacks((current) => current.map((pack) => (pack.id === selectedId ? response.pack : pack)))
|
||||
} else {
|
||||
setPacks((current) => [...current, response.pack])
|
||||
setSelectedId(response.pack.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Nova Cards Asset Packs" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">V2 pack system</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Official asset and template packs</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Control the official packs exposed in the v2 editor and public pack directories.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetForm} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">New pack</button>
|
||||
<Link href={endpoints.cards || '/cp/cards'} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Back to cards</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Existing packs</div>
|
||||
<div className="space-y-3">
|
||||
{packs.map((pack) => (
|
||||
<button key={pack.id} type="button" onClick={() => loadPack(pack)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === pack.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-[-0.03em] text-white">{pack.name}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{pack.slug}</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200">{pack.type}</span>
|
||||
</div>
|
||||
{pack.description ? <div className="mt-2 text-sm text-slate-400">{pack.description}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Pack editor</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Pack name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Type</span>
|
||||
<select value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
<option value="asset">asset</option>
|
||||
<option value="template">template</option>
|
||||
</select>
|
||||
</label>
|
||||
<input value={form.preview_image} onChange={(event) => setForm((current) => ({ ...current, preview_image: event.target.value }))} placeholder="Preview image URL" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={JSON.stringify(form.manifest_json || {}, null, 2)} onChange={(event) => {
|
||||
try {
|
||||
setForm((current) => ({ ...current, manifest_json: JSON.parse(event.target.value || '{}') }))
|
||||
} catch {
|
||||
// ignore invalid json until user fixes it
|
||||
}
|
||||
}} rows={10} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" />
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} className="h-4 w-4" /> Active</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label>
|
||||
</div>
|
||||
<button type="button" onClick={savePack} className="mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">{selectedId ? 'Update pack' : 'Create pack'}</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
|
||||
function requestJson(url, { method = 'GET', body } = {}) {
|
||||
return fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(payload?.message || 'Request failed')
|
||||
return payload
|
||||
})
|
||||
}
|
||||
|
||||
export default function NovaCardsChallengeAdmin() {
|
||||
const { props } = usePage()
|
||||
const [challenges, setChallenges] = React.useState(props.challenges || [])
|
||||
const [selectedId, setSelectedId] = React.useState(null)
|
||||
const [form, setForm] = React.useState({ slug: '', title: '', description: '', prompt: '', rules_json: {}, status: 'draft', official: true, featured: false, winner_card_id: '', starts_at: '', ends_at: '' })
|
||||
const endpoints = props.endpoints || {}
|
||||
const cards = props.cards || []
|
||||
|
||||
function loadChallenge(challenge) {
|
||||
setSelectedId(challenge.id)
|
||||
setForm({
|
||||
slug: challenge.slug,
|
||||
title: challenge.title,
|
||||
description: challenge.description || '',
|
||||
prompt: challenge.prompt || '',
|
||||
rules_json: challenge.rules_json || {},
|
||||
status: challenge.status || 'draft',
|
||||
official: Boolean(challenge.official),
|
||||
featured: Boolean(challenge.featured),
|
||||
winner_card_id: challenge.winner_card_id || '',
|
||||
starts_at: challenge.starts_at || '',
|
||||
ends_at: challenge.ends_at || '',
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setSelectedId(null)
|
||||
setForm({ slug: '', title: '', description: '', prompt: '', rules_json: {}, status: 'draft', official: true, featured: false, winner_card_id: '', starts_at: '', ends_at: '' })
|
||||
}
|
||||
|
||||
async function saveChallenge() {
|
||||
const isExisting = Boolean(selectedId)
|
||||
const url = isExisting ? String(endpoints.updatePattern || '').replace('__CHALLENGE__', String(selectedId)) : endpoints.store
|
||||
const response = await requestJson(url, { method: isExisting ? 'PATCH' : 'POST', body: { ...form, winner_card_id: form.winner_card_id || null } })
|
||||
if (isExisting) {
|
||||
setChallenges((current) => current.map((challenge) => (challenge.id === selectedId ? response.challenge : challenge)))
|
||||
} else {
|
||||
setChallenges((current) => [...current, response.challenge])
|
||||
setSelectedId(response.challenge.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Nova Cards Challenges" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Challenge system</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Nova Cards challenge programming</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Program official challenge prompts, track featured runs, and connect winner cards.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetForm} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">New challenge</button>
|
||||
<Link href={endpoints.cards || '/cp/cards'} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Back to cards</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Existing challenges</div>
|
||||
<div className="space-y-3">
|
||||
{challenges.map((challenge) => (
|
||||
<button key={challenge.id} type="button" onClick={() => loadChallenge(challenge)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === challenge.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-[-0.03em] text-white">{challenge.title}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{challenge.slug}</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200">{challenge.status}</span>
|
||||
</div>
|
||||
{challenge.description ? <div className="mt-2 text-sm text-slate-400">{challenge.description}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Challenge editor</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input value={form.title} onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))} placeholder="Title" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
|
||||
<textarea value={form.prompt} onChange={(event) => setForm((current) => ({ ...current, prompt: event.target.value }))} placeholder="Prompt" rows={4} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Status</span>
|
||||
<select value={form.status} onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{['draft', 'active', 'completed', 'archived'].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Winner card</span>
|
||||
<select value={form.winner_card_id} onChange={(event) => setForm((current) => ({ ...current, winner_card_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
<option value="">No winner</option>
|
||||
{cards.map((card) => <option key={card.id} value={card.id}>{card.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Starts at</span>
|
||||
<input type="datetime-local" value={form.starts_at} onChange={(event) => setForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Ends at</span>
|
||||
<input type="datetime-local" value={form.ends_at} onChange={(event) => setForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
</label>
|
||||
<textarea value={JSON.stringify(form.rules_json || {}, null, 2)} onChange={(event) => {
|
||||
try {
|
||||
setForm((current) => ({ ...current, rules_json: JSON.parse(event.target.value || '{}') }))
|
||||
} catch {
|
||||
// ignore invalid json until fixed
|
||||
}
|
||||
}} rows={10} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" />
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} className="h-4 w-4" /> Featured</label>
|
||||
</div>
|
||||
<button type="button" onClick={saveChallenge} className="mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">{selectedId ? 'Update challenge' : 'Create challenge'}</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
|
||||
function requestJson(url, { method = 'GET', body } = {}) {
|
||||
return fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(payload?.message || 'Request failed')
|
||||
return payload
|
||||
})
|
||||
}
|
||||
|
||||
export default function NovaCardsCollectionAdmin() {
|
||||
const { props } = usePage()
|
||||
const [collections, setCollections] = React.useState(props.collections || [])
|
||||
const [selectedId, setSelectedId] = React.useState(props.collections?.[0]?.id || null)
|
||||
const [cardId, setCardId] = React.useState('')
|
||||
const [cardNote, setCardNote] = React.useState('')
|
||||
const endpoints = props.endpoints || {}
|
||||
const admins = props.admins || []
|
||||
const cards = props.cards || []
|
||||
|
||||
const selected = React.useMemo(() => collections.find((entry) => entry.id === selectedId) || null, [collections, selectedId])
|
||||
const [form, setForm] = React.useState(() => ({
|
||||
user_id: admins[0]?.id || '',
|
||||
slug: '',
|
||||
name: '',
|
||||
description: '',
|
||||
visibility: 'public',
|
||||
official: true,
|
||||
featured: false,
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selected) {
|
||||
setForm({ user_id: admins[0]?.id || '', slug: '', name: '', description: '', visibility: 'public', official: true, featured: false })
|
||||
return
|
||||
}
|
||||
|
||||
setForm({
|
||||
user_id: selected.owner?.id || admins[0]?.id || '',
|
||||
slug: selected.slug || '',
|
||||
name: selected.name || '',
|
||||
description: selected.description || '',
|
||||
visibility: selected.visibility || 'public',
|
||||
official: Boolean(selected.official),
|
||||
featured: Boolean(selected.featured),
|
||||
})
|
||||
}, [admins, selected])
|
||||
|
||||
async function saveCollection() {
|
||||
const isExisting = Boolean(selectedId)
|
||||
const url = isExisting ? String(endpoints.updatePattern || '').replace('__COLLECTION__', String(selectedId)) : endpoints.store
|
||||
const response = await requestJson(url, { method: isExisting ? 'PATCH' : 'POST', body: form })
|
||||
|
||||
if (isExisting) {
|
||||
setCollections((current) => current.map((entry) => (entry.id === selectedId ? response.collection : entry)))
|
||||
} else {
|
||||
setCollections((current) => [response.collection, ...current])
|
||||
setSelectedId(response.collection.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function attachCard() {
|
||||
if (!selectedId || !cardId) return
|
||||
|
||||
const response = await requestJson(String(endpoints.attachCardPattern || '').replace('__COLLECTION__', String(selectedId)), {
|
||||
method: 'POST',
|
||||
body: { card_id: Number(cardId), note: cardNote || null },
|
||||
})
|
||||
setCollections((current) => current.map((entry) => (entry.id === selectedId ? response.collection : entry)))
|
||||
setCardId('')
|
||||
setCardNote('')
|
||||
}
|
||||
|
||||
async function detachCard(collectionId, currentCardId) {
|
||||
const response = await requestJson(
|
||||
String(endpoints.detachCardPattern || '')
|
||||
.replace('__COLLECTION__', String(collectionId))
|
||||
.replace('__CARD__', String(currentCardId)),
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
setCollections((current) => current.map((entry) => (entry.id === collectionId ? response.collection : entry)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Nova Cards Collections" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Editorial layer</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Official and public card collections</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Create editorial collections, assign owners, and curate the public card sets that the v2 browse surface links to.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={() => setSelectedId(null)} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">New collection</button>
|
||||
<Link href={endpoints.cards || '/cp/cards'} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Back to cards</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Collections</div>
|
||||
<div className="space-y-3">
|
||||
{collections.map((collection) => (
|
||||
<button key={collection.id} type="button" onClick={() => setSelectedId(collection.id)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === collection.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-[-0.03em] text-white">{collection.name}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{collection.featured ? 'Featured • ' : ''}{collection.official ? 'Official' : '@' + (collection.owner?.username || 'creator')}</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200">{collection.cards_count} cards</span>
|
||||
</div>
|
||||
{collection.description ? <div className="mt-2 text-sm text-slate-400">{collection.description}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Collection editor</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Owner</span>
|
||||
<select value={form.user_id} onChange={(event) => setForm((current) => ({ ...current, user_id: Number(event.target.value) }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{admins.map((admin) => <option key={admin.id} value={admin.id}>{admin.name || admin.username}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Visibility</span>
|
||||
<select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
<option value="public">public</option>
|
||||
<option value="private">private</option>
|
||||
</select>
|
||||
</label>
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Collection name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={4} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official collection</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} className="h-4 w-4" /> Featured collection</label>
|
||||
</div>
|
||||
{selected?.public_url ? <a href={selected.public_url} className="text-sky-100 transition hover:text-white" target="_blank" rel="noreferrer">Open public page</a> : null}
|
||||
</div>
|
||||
<button type="button" onClick={saveCollection} className="mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">{selectedId ? 'Update collection' : 'Create collection'}</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Curate cards</div>
|
||||
{!selectedId ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/12 bg-white/[0.03] px-4 py-8 text-center text-sm text-slate-400">Create or select a collection first.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
<select value={cardId} onChange={(event) => setCardId(event.target.value)} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
<option value="">Select a card</option>
|
||||
{cards.map((card) => <option key={card.id} value={card.id}>{card.title}</option>)}
|
||||
</select>
|
||||
<input value={cardNote} onChange={(event) => setCardNote(event.target.value)} placeholder="Optional curator note" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<button type="button" onClick={attachCard} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">Add</button>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{(selected?.items || []).map((item) => (
|
||||
<div key={item.id} className="flex items-start justify-between gap-4 rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">{item.card?.title}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">#{item.sort_order} {item.card?.creator?.username ? `• @${item.card.creator.username}` : ''}</div>
|
||||
{item.note ? <div className="mt-2 text-sm text-slate-400">{item.note}</div> : null}
|
||||
</div>
|
||||
<button type="button" onClick={() => detachCard(selectedId, item.card.id)} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15">Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
|
||||
function requestJson(url, { method = 'GET', body } = {}) {
|
||||
return fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(payload?.message || 'Request failed')
|
||||
return payload
|
||||
})
|
||||
}
|
||||
|
||||
export default function NovaCardsTemplateAdmin() {
|
||||
const { props } = usePage()
|
||||
const [templates, setTemplates] = React.useState(props.templates || [])
|
||||
const [selectedId, setSelectedId] = React.useState(null)
|
||||
const [form, setForm] = React.useState({
|
||||
slug: '',
|
||||
name: '',
|
||||
description: '',
|
||||
supported_formats: ['square'],
|
||||
active: true,
|
||||
official: true,
|
||||
order_num: templates.length,
|
||||
config_json: {
|
||||
font_preset: 'modern-sans',
|
||||
gradient_preset: 'midnight-nova',
|
||||
text_align: 'center',
|
||||
layout: 'quote_heavy',
|
||||
text_color: '#ffffff',
|
||||
overlay_style: 'dark-soft',
|
||||
},
|
||||
})
|
||||
const endpoints = props.endpoints || {}
|
||||
const formats = props.editorOptions?.formats || []
|
||||
const fonts = props.editorOptions?.font_presets || []
|
||||
const gradients = props.editorOptions?.gradient_presets || []
|
||||
|
||||
function loadTemplate(template) {
|
||||
setSelectedId(template.id)
|
||||
setForm({
|
||||
slug: template.slug,
|
||||
name: template.name,
|
||||
description: template.description || '',
|
||||
preview_image: template.preview_image || null,
|
||||
supported_formats: template.supported_formats || [],
|
||||
active: Boolean(template.active),
|
||||
official: Boolean(template.official),
|
||||
order_num: template.order_num || 0,
|
||||
config_json: template.config_json || {},
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setSelectedId(null)
|
||||
setForm({
|
||||
slug: '',
|
||||
name: '',
|
||||
description: '',
|
||||
supported_formats: ['square'],
|
||||
active: true,
|
||||
official: true,
|
||||
order_num: templates.length,
|
||||
config_json: {
|
||||
font_preset: 'modern-sans',
|
||||
gradient_preset: 'midnight-nova',
|
||||
text_align: 'center',
|
||||
layout: 'quote_heavy',
|
||||
text_color: '#ffffff',
|
||||
overlay_style: 'dark-soft',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
const isExisting = Boolean(selectedId)
|
||||
const url = isExisting
|
||||
? String(endpoints.updatePattern || '').replace('__TEMPLATE__', String(selectedId))
|
||||
: endpoints.store
|
||||
const response = await requestJson(url, {
|
||||
method: isExisting ? 'PATCH' : 'POST',
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (isExisting) {
|
||||
setTemplates((current) => current.map((template) => (template.id === selectedId ? response.template : template)))
|
||||
} else {
|
||||
setTemplates((current) => [...current, response.template])
|
||||
setSelectedId(response.template.id)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFormat(key) {
|
||||
setForm((current) => {
|
||||
const exists = current.supported_formats.includes(key)
|
||||
return {
|
||||
...current,
|
||||
supported_formats: exists ? current.supported_formats.filter((item) => item !== key) : [...current.supported_formats, key],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Nova Cards Templates" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Template system</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Official Nova Cards templates</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Keep starter templates config-driven so the editor and render pipeline stay aligned as new card styles ship.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetForm} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">New template</button>
|
||||
<Link href={endpoints.cards || '/cp/cards'} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Back to cards</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Existing templates</div>
|
||||
<div className="space-y-3">
|
||||
{templates.map((template) => (
|
||||
<button key={template.id} type="button" onClick={() => loadTemplate(template)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === template.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-[-0.03em] text-white">{template.name}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{template.slug}</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200">{template.supported_formats?.join(', ')}</span>
|
||||
</div>
|
||||
{template.description ? <div className="mt-2 text-sm text-slate-400">{template.description}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Template editor</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Template name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Font preset</span>
|
||||
<select value={form.config_json?.font_preset || 'modern-sans'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, font_preset: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{fonts.map((font) => <option key={font.key} value={font.key}>{font.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Gradient preset</span>
|
||||
<select value={form.config_json?.gradient_preset || 'midnight-nova'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, gradient_preset: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{gradients.map((gradient) => <option key={gradient.key} value={gradient.key}>{gradient.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Layout preset</span>
|
||||
<select value={form.config_json?.layout || 'quote_heavy'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, layout: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{['quote_heavy', 'author_emphasis', 'centered', 'minimal'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Text alignment</span>
|
||||
<select value={form.config_json?.text_align || 'center'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_align: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{['left', 'center', 'right'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Overlay style</span>
|
||||
<select value={form.config_json?.overlay_style || 'dark-soft'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, overlay_style: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{['none', 'dark-soft', 'dark-strong', 'light-soft'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Text color</span>
|
||||
<input type="color" value={form.config_json?.text_color || '#ffffff'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_color: event.target.value } }))} className="h-12 w-full rounded-2xl border border-white/10 bg-[#0d1726] p-2" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Supported formats</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{formats.map((format) => (
|
||||
<label key={format.key} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-200">
|
||||
<input type="checkbox" checked={form.supported_formats.includes(format.key)} onChange={() => toggleFormat(format.key)} className="h-4 w-4" />
|
||||
{format.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} className="h-4 w-4" /> Active</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label>
|
||||
</div>
|
||||
<button type="button" onClick={saveTemplate} className="mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">{selectedId ? 'Update template' : 'Create template'}</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function buildFilterUrl(next, baseUrl) {
|
||||
if (typeof window === 'undefined' && !baseUrl) return '#'
|
||||
|
||||
const url = new URL(baseUrl || window.location.href, window.location.origin)
|
||||
Object.entries(next).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined || value === '' || value === 'all') {
|
||||
url.searchParams.delete(key)
|
||||
} else {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
})
|
||||
|
||||
return `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, '')
|
||||
}
|
||||
|
||||
function buildSearchActionUrl(baseUrl) {
|
||||
if (typeof window === 'undefined' && !baseUrl) return '#'
|
||||
|
||||
const url = new URL(baseUrl || window.location.href, window.location.origin)
|
||||
url.searchParams.delete('q')
|
||||
|
||||
return `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, '')
|
||||
}
|
||||
|
||||
function reorderCollectionIds(collections, collectionId, direction) {
|
||||
const currentIndex = collections.findIndex((item) => Number(item.id) === Number(collectionId))
|
||||
if (currentIndex === -1) return null
|
||||
|
||||
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
|
||||
if (targetIndex < 0 || targetIndex >= collections.length) return null
|
||||
|
||||
const nextCollections = [...collections]
|
||||
const [movedCollection] = nextCollections.splice(currentIndex, 1)
|
||||
nextCollections.splice(targetIndex, 0, movedCollection)
|
||||
|
||||
return nextCollections.map((item) => Number(item.id))
|
||||
}
|
||||
|
||||
function EmptyState({ browseUrl }) {
|
||||
return (
|
||||
<div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
|
||||
<i className="fa-solid fa-bookmark text-3xl" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-semibold text-white">No saved collections yet</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-300">
|
||||
Save collections to build a personal reference library for inspiration, campaigns, and creators you want to revisit.
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<a href={browseUrl} className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<i className="fa-solid fa-compass fa-fw" />
|
||||
Browse collections
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterLink({ href, active, children, count }) {
|
||||
return (
|
||||
<a href={href} className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-sm transition ${active ? 'border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.07]'}`}>
|
||||
<span>{children}</span>
|
||||
{typeof count === 'number' ? <span className={`text-xs ${active ? 'text-sky-100/80' : 'text-slate-400'}`}>{count}</span> : null}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return null
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
export default function SavedCollections() {
|
||||
const { props } = usePage()
|
||||
const seo = props.seo || {}
|
||||
const initialCollections = Array.isArray(props.collections) ? props.collections : []
|
||||
const recentlyRevisited = Array.isArray(props.recentlyRevisited) ? props.recentlyRevisited : []
|
||||
const recommendedCollections = Array.isArray(props.recommendedCollections) ? props.recommendedCollections : []
|
||||
const browseUrl = props.browseUrl || '/collections/featured'
|
||||
const libraryUrl = props.libraryUrl || '/me/saved/collections'
|
||||
const activeFilters = props.activeFilters || { q: '', filter: 'all', sort: 'saved_desc', list: null }
|
||||
const activeList = props.activeList || null
|
||||
const filterOptions = Array.isArray(props.filterOptions) ? props.filterOptions : []
|
||||
const sortOptions = Array.isArray(props.sortOptions) ? props.sortOptions : []
|
||||
const searchBaseUrl = activeList?.url || libraryUrl
|
||||
const [collections, setCollections] = React.useState(initialCollections)
|
||||
const [savedLists, setSavedLists] = React.useState(Array.isArray(props.savedLists) ? props.savedLists : [])
|
||||
const [newListTitle, setNewListTitle] = React.useState('')
|
||||
const [selectedLists, setSelectedLists] = React.useState({})
|
||||
const [notes, setNotes] = React.useState(() => Object.fromEntries(initialCollections.map((collection) => [collection.id, collection.saved_note || ''])))
|
||||
const [search, setSearch] = React.useState(activeFilters.q || '')
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [busy, setBusy] = React.useState('')
|
||||
React.useEffect(() => {
|
||||
setCollections(initialCollections)
|
||||
setNotes(Object.fromEntries(initialCollections.map((collection) => [collection.id, collection.saved_note || ''])))
|
||||
}, [initialCollections])
|
||||
|
||||
React.useEffect(() => {
|
||||
setSearch(activeFilters.q || '')
|
||||
}, [activeFilters.q])
|
||||
|
||||
React.useEffect(() => {
|
||||
setSavedLists(Array.isArray(props.savedLists) ? props.savedLists : [])
|
||||
}, [props.savedLists])
|
||||
|
||||
const listSchema = seo?.canonical ? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'Saved collections',
|
||||
description: seo?.description || 'Your saved collections on Skinbase Nova.',
|
||||
url: seo.canonical,
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
numberOfItems: collections.length,
|
||||
itemListElement: collections.slice(0, 18).map((collection, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: collection.url,
|
||||
name: collection.title,
|
||||
})),
|
||||
},
|
||||
} : null
|
||||
|
||||
async function handleCreateList(event) {
|
||||
event.preventDefault()
|
||||
if (!newListTitle.trim() || !props.endpoints?.createList) return
|
||||
|
||||
setBusy('create-list')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(props.endpoints.createList, {
|
||||
method: 'POST',
|
||||
body: { title: newListTitle.trim() },
|
||||
})
|
||||
|
||||
setSavedLists((current) => [...current, payload.list].sort((left, right) => String(left.title).localeCompare(String(right.title))))
|
||||
setNewListTitle('')
|
||||
setNotice('Saved list created.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to create list.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddToList(collectionId) {
|
||||
const listId = selectedLists[collectionId] || savedLists[0]?.id
|
||||
if (!listId || !props.endpoints?.addToListPattern) return
|
||||
|
||||
setBusy(`list-${collectionId}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(props.endpoints.addToListPattern.replace('__COLLECTION__', String(collectionId)), {
|
||||
method: 'POST',
|
||||
body: { saved_list_id: Number(listId) },
|
||||
})
|
||||
|
||||
setSavedLists((current) => current.map((list) => (
|
||||
Number(list.id) === Number(listId)
|
||||
? { ...list, items_count: Number(payload?.list?.items_count || list.items_count || 0) }
|
||||
: list
|
||||
)))
|
||||
setNotice(payload?.added ? 'Collection added to saved list.' : 'Collection already exists in that saved list.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to add collection to list.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnsave(collection) {
|
||||
if (!collection?.id || !props.endpoints?.unsavePattern) return
|
||||
|
||||
setBusy(`unsave-${collection.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
await requestJson(props.endpoints.unsavePattern.replace('__COLLECTION__', String(collection.id)), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
setCollections((current) => current.filter((item) => Number(item.id) !== Number(collection.id)))
|
||||
setSavedLists((current) => current.map((list) => {
|
||||
const isMember = Array.isArray(collection.saved_list_ids) && collection.saved_list_ids.includes(Number(list.id))
|
||||
|
||||
return isMember
|
||||
? { ...list, items_count: Math.max(0, Number(list.items_count || 0) - 1) }
|
||||
: list
|
||||
}))
|
||||
setNotice('Collection removed from your saved library.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to remove collection from your saved library.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveFromList(collection) {
|
||||
if (!activeList?.id || !collection?.id || !props.endpoints?.removeFromListPattern) return
|
||||
|
||||
setBusy(`remove-${collection.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(
|
||||
props.endpoints.removeFromListPattern
|
||||
.replace('__LIST__', String(activeList.id))
|
||||
.replace('__COLLECTION__', String(collection.id)),
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
|
||||
setCollections((current) => current.filter((item) => Number(item.id) !== Number(collection.id)))
|
||||
setSavedLists((current) => current.map((list) => (
|
||||
Number(list.id) === Number(payload?.list?.id)
|
||||
? { ...list, items_count: Number(payload?.list?.items_count || 0) }
|
||||
: list
|
||||
)))
|
||||
setNotice('Collection removed from this saved list.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to remove collection from this saved list.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReorderCollection(collectionId, direction) {
|
||||
if (!activeList?.id || !props.endpoints?.reorderItemsPattern) return
|
||||
|
||||
const reorderedCollectionIds = reorderCollectionIds(collections, collectionId, direction)
|
||||
if (!reorderedCollectionIds) return
|
||||
|
||||
setBusy(`reorder-${collectionId}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
await requestJson(
|
||||
props.endpoints.reorderItemsPattern.replace('__LIST__', String(activeList.id)),
|
||||
{
|
||||
method: 'POST',
|
||||
body: { collection_ids: reorderedCollectionIds },
|
||||
},
|
||||
)
|
||||
|
||||
setCollections((current) => {
|
||||
const order = new Map(reorderedCollectionIds.map((id, index) => [Number(id), index]))
|
||||
|
||||
return [...current].sort((left, right) => {
|
||||
const leftOrder = order.get(Number(left.id)) ?? Number.MAX_SAFE_INTEGER
|
||||
const rightOrder = order.get(Number(right.id)) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
return leftOrder - rightOrder
|
||||
})
|
||||
})
|
||||
setNotice('Saved list order updated.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to update saved list order.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveNote(collectionId) {
|
||||
if (!props.endpoints?.updateNotePattern) return
|
||||
|
||||
setBusy(`note-${collectionId}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(
|
||||
props.endpoints.updateNotePattern.replace('__COLLECTION__', String(collectionId)),
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: { note: notes[collectionId] || '' },
|
||||
},
|
||||
)
|
||||
|
||||
setCollections((current) => current.map((collection) => (
|
||||
Number(collection.id) === Number(collectionId)
|
||||
? { ...collection, saved_note: payload?.note?.note || null }
|
||||
: collection
|
||||
)))
|
||||
setNotice(payload?.note ? 'Saved note updated.' : 'Saved note removed.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to update saved note.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoHead seo={seo} title={seo?.title || 'Saved Collections — Skinbase Nova'} description={seo?.description || 'Your saved collections on Skinbase Nova.'} jsonLd={listSchema} />
|
||||
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
||||
<a href={browseUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
|
||||
<i className="fa-solid fa-arrow-left fa-fw text-[11px]" />
|
||||
Browse collections
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Library</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Saved collections</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
A personal shortlist of collections worth revisiting. Organize them into saved lists, pivot by editorial or campaign relevance, and keep a working shelf of what should influence your next publish.
|
||||
</p>
|
||||
{activeList ? <p className="mt-4 inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100"><i className="fa-solid fa-folder-open fa-fw" />Viewing list: {activeList.title}</p> : null}
|
||||
{activeFilters.q ? <p className="mt-4 inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100"><i className="fa-solid fa-magnifying-glass fa-fw" />Search: {activeFilters.q}</p> : null}
|
||||
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="space-y-5">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Search</p>
|
||||
<form method="GET" action={buildSearchActionUrl(searchBaseUrl)} className="mt-4 space-y-3">
|
||||
<input name="q" value={search} onChange={(event) => setSearch(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" placeholder="Search titles, notes context, or curator" maxLength={120} />
|
||||
{activeFilters.filter && activeFilters.filter !== 'all' ? <input type="hidden" name="filter" value={activeFilters.filter} /> : null}
|
||||
{activeFilters.sort && activeFilters.sort !== 'saved_desc' ? <input type="hidden" name="sort" value={activeFilters.sort} /> : null}
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" className="inline-flex flex-1 items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-magnifying-glass fa-fw" />Apply</button>
|
||||
{(activeFilters.q || search) ? <a href={buildFilterUrl({ q: null }, searchBaseUrl)} className="inline-flex items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]">Clear</a> : null}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Filters</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{filterOptions.map((option) => (
|
||||
<FilterLink
|
||||
key={option.key}
|
||||
href={buildFilterUrl({ q: activeFilters.q, filter: option.key, sort: activeFilters.sort }, searchBaseUrl)}
|
||||
active={activeFilters.filter === option.key}
|
||||
count={option.count}
|
||||
>
|
||||
{option.label}
|
||||
</FilterLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Sort</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{sortOptions.map((option) => (
|
||||
<FilterLink
|
||||
key={option.key}
|
||||
href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: option.key }, searchBaseUrl)}
|
||||
active={activeFilters.sort === option.key}
|
||||
>
|
||||
{option.label}
|
||||
</FilterLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Saved Lists</p>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{savedLists.length}</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<FilterLink href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: activeFilters.sort }, libraryUrl)} active={!activeFilters.list}>All saved collections</FilterLink>
|
||||
{savedLists.map((list) => (
|
||||
<FilterLink key={list.id} href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: activeFilters.sort }, list.url || libraryUrl)} active={Number(activeFilters.list) === Number(list.id)} count={list.items_count}>{list.title}</FilterLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateList} className="mt-5 space-y-3">
|
||||
<input value={newListTitle} onChange={(event) => setNewListTitle(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" placeholder="Create a saved list" maxLength={120} />
|
||||
<button type="submit" disabled={busy === 'create-list'} className="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'create-list' ? 'fa-circle-notch fa-spin' : 'fa-folder-plus'} fa-fw`} />Create list</button>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div className="space-y-8">
|
||||
{recentlyRevisited.length ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Recently Revisited</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Jump back into active references</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{recentlyRevisited.length}</span>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-3">
|
||||
{recentlyRevisited.map((collection) => (
|
||||
<CollectionCard key={`revisited-${collection.id}`} collection={collection} isOwner={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
{collections.length ? (
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2">
|
||||
{collections.map((collection, index) => (
|
||||
<div key={collection.id} className="space-y-3">
|
||||
<CollectionCard collection={collection} isOwner={false} />
|
||||
{(collection.saved_because || collection.last_viewed_at) ? (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
||||
{collection.saved_because ? <span className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-2 text-xs font-semibold text-amber-100"><i className="fa-solid fa-lightbulb fa-fw" />{collection.saved_because}</span> : null}
|
||||
{collection.last_viewed_at ? <span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-[#0d1726] px-3 py-2 text-xs font-semibold text-slate-200"><i className="fa-solid fa-clock-rotate-left fa-fw" />Last revisited {formatDateTime(collection.last_viewed_at)}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<button type="button" onClick={() => handleUnsave(collection)} disabled={busy === `unsave-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-2.5 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `unsave-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-bookmark-slash'} fa-fw`} />Remove from saved</button>
|
||||
{activeList ? <button type="button" onClick={() => handleRemoveFromList(collection)} disabled={busy === `remove-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:opacity-60"><i className={`fa-solid ${busy === `remove-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-folder-minus'} fa-fw`} />Remove from list</button> : null}
|
||||
{activeList && collections.length > 1 ? (
|
||||
<div className="ml-auto inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-[#0d1726] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReorderCollection(collection.id, 'up')}
|
||||
disabled={index === 0 || busy === `reorder-${collection.id}`}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-xl text-sm text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={`Move ${collection.title} up`}
|
||||
>
|
||||
<i className={`fa-solid ${busy === `reorder-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-arrow-up'} fa-fw`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReorderCollection(collection.id, 'down')}
|
||||
disabled={index === collections.length - 1 || busy === `reorder-${collection.id}`}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-xl text-sm text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={`Move ${collection.title} down`}
|
||||
>
|
||||
<i className={`fa-solid ${busy === `reorder-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-arrow-down'} fa-fw`} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{savedLists.length ? (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<select value={selectedLists[collection.id] || savedLists[0]?.id || ''} onChange={(event) => setSelectedLists((current) => ({ ...current, [collection.id]: event.target.value }))} className="min-w-[180px] rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-2.5 text-sm text-white outline-none">
|
||||
{savedLists.map((list) => <option key={list.id} value={list.id}>{list.title}</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={() => handleAddToList(collection.id)} disabled={busy === `list-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:opacity-60"><i className={`fa-solid ${busy === `list-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-folder-plus'} fa-fw`} />Add to list</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Private Note</p>
|
||||
<button type="button" onClick={() => handleSaveNote(collection.id)} disabled={busy === `note-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `note-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-note-sticky'} fa-fw`} />Save note</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={notes[collection.id] || ''}
|
||||
onChange={(event) => setNotes((current) => ({ ...current, [collection.id]: event.target.value }))}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
placeholder="Why did you save this collection? Add campaign context, inspiration notes, or follow-up ideas."
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
activeFilters.q || activeFilters.filter !== 'all' || activeFilters.sort !== 'saved_desc' || activeFilters.list
|
||||
? <div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center text-sm text-slate-300">No saved collections match the current search or filters.</div>
|
||||
: <EmptyState browseUrl={browseUrl} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{recommendedCollections.length ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Recommended Next</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Because of what you save</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{recommendedCollections.length}</span>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-3">
|
||||
{recommendedCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import ActivityFeed from '../../components/community/ActivityFeed'
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All Activity' },
|
||||
{ key: 'comments', label: 'Comments' },
|
||||
{ key: 'replies', label: 'Replies' },
|
||||
{ key: 'following', label: 'Following', authRequired: true },
|
||||
{ key: 'my', label: 'My Activity', authRequired: true },
|
||||
]
|
||||
|
||||
function FilterPills({ activeFilter, isAuthenticated, onChange }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{FILTER_TABS.map((tab) => {
|
||||
const disabled = tab.authRequired && !isAuthenticated
|
||||
const active = activeFilter === tab.key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onChange(tab.key)}
|
||||
className={[
|
||||
'rounded-full border px-4 py-2 text-sm font-medium transition-all',
|
||||
active
|
||||
? 'border-sky-400/30 bg-sky-500/14 text-sky-200 shadow-[0_0_0_1px_rgba(56,189,248,0.08)]'
|
||||
: 'border-white/[0.06] bg-white/[0.03] text-white/55 hover:border-white/15 hover:bg-white/[0.05] hover:text-white/85',
|
||||
disabled ? 'cursor-not-allowed opacity-35' : '',
|
||||
].join(' ')}
|
||||
title={disabled ? 'Log in to use this filter' : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function updateUrl(filter, userId) {
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
if (filter && filter !== 'all') url.searchParams.set('filter', filter)
|
||||
else url.searchParams.delete('filter')
|
||||
|
||||
if (userId) url.searchParams.set('user_id', String(userId))
|
||||
else url.searchParams.delete('user_id')
|
||||
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
function updateHeaderSummary(filter, userId) {
|
||||
const filterLabels = {
|
||||
all: 'All Activity',
|
||||
comments: 'Comments',
|
||||
replies: 'Replies',
|
||||
following: 'Following',
|
||||
my: 'My Activity',
|
||||
}
|
||||
|
||||
const filterNode = document.getElementById('community-activity-filter-summary')
|
||||
const scopeNode = document.getElementById('community-activity-scope-summary')
|
||||
|
||||
if (filterNode) {
|
||||
filterNode.innerHTML = `<i class="fa-solid fa-filter"></i> ${filterLabels[filter] || filterLabels.all}`
|
||||
}
|
||||
|
||||
if (scopeNode) {
|
||||
if (userId) {
|
||||
scopeNode.className = 'inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/65'
|
||||
scopeNode.innerHTML = `<i class="fa-solid fa-user"></i> User #${userId}`
|
||||
} else {
|
||||
scopeNode.className = 'hidden'
|
||||
scopeNode.innerHTML = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function CommunityActivityPage({
|
||||
initialActivities = [],
|
||||
initialMeta = {},
|
||||
initialFilter = 'all',
|
||||
initialUserId = null,
|
||||
isAuthenticated = false,
|
||||
}) {
|
||||
const [activeFilter, setActiveFilter] = useState(initialFilter)
|
||||
const [activities, setActivities] = useState(initialActivities)
|
||||
const [meta, setMeta] = useState(initialMeta)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const sentinelRef = useRef(null)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
const hasMore = Boolean(meta?.has_more)
|
||||
const nextPage = Number(meta?.current_page || 1) + 1
|
||||
|
||||
const fetchFeed = useCallback(async ({ filter, page, append }) => {
|
||||
const requestId = ++requestIdRef.current
|
||||
setError(null)
|
||||
if (append) setLoadingMore(true)
|
||||
else setLoading(true)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ filter, page: String(page) })
|
||||
if (initialUserId) params.set('user_id', String(initialUserId))
|
||||
|
||||
const response = await fetch(`/api/activity?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (requestId !== requestIdRef.current) return
|
||||
|
||||
if (response.status === 401) {
|
||||
setError('Please log in to view this activity filter.')
|
||||
if (!append) {
|
||||
setActivities([])
|
||||
setMeta({ current_page: 1, last_page: 1, has_more: false, total: 0 })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load community activity.')
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
setActivities((prev) => append ? [...prev, ...(payload.data || [])] : (payload.data || []))
|
||||
setMeta(payload.meta || {})
|
||||
} catch {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setError('Failed to load community activity. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}, [initialUserId])
|
||||
|
||||
const handleFilterChange = useCallback((nextFilter) => {
|
||||
if (nextFilter === activeFilter) return
|
||||
setActiveFilter(nextFilter)
|
||||
updateUrl(nextFilter, initialUserId)
|
||||
fetchFeed({ filter: nextFilter, page: 1, append: false })
|
||||
}, [activeFilter, fetchFeed, initialUserId])
|
||||
|
||||
useEffect(() => {
|
||||
updateHeaderSummary(activeFilter, initialUserId)
|
||||
}, [activeFilter, initialUserId])
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel || loading || loadingMore || !hasMore) return undefined
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
fetchFeed({ filter: activeFilter, page: nextPage, append: true })
|
||||
}
|
||||
}, { rootMargin: '220px 0px' })
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage])
|
||||
|
||||
const resultsLabel = useMemo(() => {
|
||||
const total = Number(meta?.total || activities.length || 0)
|
||||
if (!total) return 'No recent activity'
|
||||
return `${total.toLocaleString()} events`
|
||||
}, [activities.length, meta?.total])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 pt-8 pb-20 md:px-10">
|
||||
<div className="mb-6 flex flex-col gap-4 rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] p-5 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/35">Live community pulse</p>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/55">
|
||||
Comments, replies, reactions, and mentions from across Skinbase in one scrolling Nova feed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-white/45">{resultsLabel}</div>
|
||||
</div>
|
||||
|
||||
<FilterPills activeFilter={activeFilter} isAuthenticated={isAuthenticated} onChange={handleFilterChange} />
|
||||
</div>
|
||||
|
||||
<ActivityFeed
|
||||
activities={activities}
|
||||
isLoggedIn={isAuthenticated}
|
||||
loading={loading}
|
||||
loadingMore={loadingMore}
|
||||
error={error}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mountEl = document.getElementById('community-activity-root')
|
||||
|
||||
if (mountEl) {
|
||||
let props = {}
|
||||
try {
|
||||
const propsEl = document.getElementById('community-activity-props')
|
||||
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
|
||||
} catch {
|
||||
props = {}
|
||||
}
|
||||
|
||||
createRoot(mountEl).render(<CommunityActivityPage {...props} />)
|
||||
}
|
||||
|
||||
export default CommunityActivityPage
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import CommentsFeed from '../../components/comments/CommentsFeed'
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'following', label: 'Following', authRequired: true },
|
||||
{ key: 'mine', label: 'My Comments', authRequired: true },
|
||||
]
|
||||
|
||||
function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenticated = false }) {
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
const [comments, setComments] = useState(initialComments)
|
||||
const [meta, setMeta] = useState(initialMeta)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// Track if we've moved off the initial server-rendered data
|
||||
const initialized = useRef(false)
|
||||
|
||||
const fetchComments = useCallback(async (filter, page = 1) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const url = `/api/comments/latest?type=${encodeURIComponent(filter)}&page=${page}`
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
setError('Please log in to view this feed.')
|
||||
setComments([])
|
||||
setMeta({})
|
||||
return
|
||||
}
|
||||
|
||||
if (! res.ok) {
|
||||
setError('Failed to load comments. Please try again.')
|
||||
return
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
setComments(json.data ?? [])
|
||||
setMeta(json.meta ?? {})
|
||||
} catch {
|
||||
setError('Network error. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFilterChange = (key) => {
|
||||
if (key === activeFilter) return
|
||||
setActiveFilter(key)
|
||||
initialized.current = true
|
||||
fetchComments(key, 1)
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
initialized.current = true
|
||||
fetchComments(activeFilter, page)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
||||
{/* Page header */}
|
||||
<div className="mb-7">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
||||
<h1 className="text-3xl font-bold text-white leading-tight">Latest Comments</h1>
|
||||
<p className="mt-1 text-sm text-white/50">Most recent artwork comments from the community.</p>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs — pill style */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{FILTER_TABS.map((tab) => {
|
||||
const disabled = tab.authRequired && !isAuthenticated
|
||||
const active = activeFilter === tab.key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => !disabled && handleFilterChange(tab.key)}
|
||||
disabled={disabled}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
title={disabled ? 'Log in to use this filter' : undefined}
|
||||
className={[
|
||||
'px-4 py-1.5 rounded-full text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
|
||||
active
|
||||
? 'bg-sky-600/25 text-sky-300 ring-1 ring-sky-500/40'
|
||||
: 'text-white/50 hover:text-white/80 hover:bg-white/[0.06]',
|
||||
disabled && 'opacity-30 cursor-not-allowed',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Feed content */}
|
||||
<CommentsFeed
|
||||
comments={comments}
|
||||
meta={meta}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-mount when the Blade view provides #latest-comments-root
|
||||
const mountEl = document.getElementById('latest-comments-root')
|
||||
if (mountEl) {
|
||||
let props = {}
|
||||
try {
|
||||
const propsEl = document.getElementById('latest-comments-props')
|
||||
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
|
||||
} catch {
|
||||
props = {}
|
||||
}
|
||||
createRoot(mountEl).render(<LatestCommentsPage {...props} />)
|
||||
}
|
||||
|
||||
export default LatestCommentsPage
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'shares', label: 'Artwork Shares' },
|
||||
{ value: 'uploads', label: 'New Uploads' },
|
||||
{ value: 'text', label: 'Text Posts' },
|
||||
]
|
||||
|
||||
function EmptyFollowingState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-600">
|
||||
<i className="fa-solid fa-users text-3xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing here yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-sm leading-relaxed">
|
||||
Follow some creators to see their posts here. Discover amazing artwork on{' '}
|
||||
<a href="/discover/trending" className="text-sky-400 hover:underline">Trending</a>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FollowingFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1, f = filter) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/posts/following', {
|
||||
params: { page: p, filter: f },
|
||||
})
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed(1, filter)
|
||||
}, [filter])
|
||||
|
||||
const handleFilterChange = (f) => {
|
||||
if (f === filter) return
|
||||
setFilter(f)
|
||||
setPosts([])
|
||||
setLoaded(false)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleDeleted = useCallback((postId) => {
|
||||
setPosts((prev) => prev.filter((p) => p.id !== postId))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
{/* ── Page header ────────────────────────────────────────────────────── */}
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-users-rays mr-2 text-sky-400 opacity-80" />
|
||||
Following Feed
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Posts from creators you follow</p>
|
||||
</div>
|
||||
<a
|
||||
href="/discover/trending"
|
||||
className="text-xs text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
Discover creators →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{FILTER_OPTIONS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => handleFilterChange(f.value)}
|
||||
className={`px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all border ${
|
||||
filter === f.value
|
||||
? 'bg-sky-600/20 border-sky-500/40 text-sky-300'
|
||||
: 'bg-white/[0.03] border-white/[0.06] text-slate-400 hover:text-white hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Feed ────────────────────────────────────────────────────────────── */}
|
||||
<div className="max-w-2xl mx-auto px-4 pb-16 space-y-4">
|
||||
{/* Loading skeletons */}
|
||||
{!loaded && loading && (
|
||||
<>
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{loaded && !loading && posts.length === 0 && <EmptyFollowingState />}
|
||||
|
||||
{/* Posts */}
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Load more */}
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
export default function HashtagFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, tag } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [totalPosts, setTotalPosts] = useState(null)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/feed/hashtag/${encodeURIComponent(tag)}`, {
|
||||
params: { page: p },
|
||||
})
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setTotalPosts(data.meta.total ?? null)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [tag])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [tag])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-16">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="inline-flex items-center justify-center w-10 h-10 rounded-xl bg-sky-500/15 text-sky-400 text-lg font-bold">
|
||||
#
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">#{tag}</h1>
|
||||
{totalPosts !== null && (
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
{totalPosts.toLocaleString()} post{totalPosts !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<a
|
||||
href="/feed/trending"
|
||||
className="text-xs text-slate-500 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
← Trending
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div className="space-y-4">
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-hashtag text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">No posts yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-xs">
|
||||
No posts tagged <span className="text-sky-400">#{tag}</span> yet. Be the first!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
export default function SavedFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/posts/saved', { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
// When a post is unsaved, remove it from the list too
|
||||
const handleUnsaved = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-16">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-bookmark mr-2 text-amber-400 opacity-80" />
|
||||
Saved Posts
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Posts you've bookmarked</p>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div className="space-y-4">
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-bookmark text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing saved yet</h2>
|
||||
<p className="text-slate-500 text-sm max-w-xs leading-relaxed">
|
||||
Bookmark posts to read later. Look for the{' '}
|
||||
<i className="fa-regular fa-bookmark text-amber-400" /> icon on any post.
|
||||
</p>
|
||||
<a
|
||||
href="/feed/trending"
|
||||
className="mt-4 text-sm text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
Browse trending posts →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
onUnsaved={handleUnsaved}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
/* ── Trending hashtags sidebar ─────────────────────────────────────────────── */
|
||||
function TrendingHashtagsSidebar({ hashtags }) {
|
||||
if (!hashtags || hashtags.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
<i className="fa-solid fa-hashtag text-slate-500 fa-fw text-[13px]" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">
|
||||
Trending Tags
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{hashtags.map((h) => (
|
||||
<a
|
||||
key={h.tag}
|
||||
href={`/feed/search?q=%23${h.tag}`}
|
||||
className="flex items-center justify-between group px-2 py-1.5 rounded-lg transition-colors hover:bg-white/5 text-slate-400 hover:text-white"
|
||||
>
|
||||
<span className="text-sm font-medium">#{h.tag}</span>
|
||||
<span className="text-[11px] text-slate-600 group-hover:text-slate-500 tabular-nums">
|
||||
{h.post_count}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main page ─────────────────────────────────────────────────────────────── */
|
||||
export default function SearchFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, initialQuery, trendingHashtags } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [query, setQuery] = useState(initialQuery ?? '')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [meta, setMeta] = useState(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const debounceRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
/* ── Push query into URL without reload ──────────────────────────────────── */
|
||||
const pushUrl = useCallback((q) => {
|
||||
const url = q.trim()
|
||||
? `/feed/search?q=${encodeURIComponent(q.trim())}`
|
||||
: '/feed/search'
|
||||
window.history.replaceState({}, '', url)
|
||||
}, [])
|
||||
|
||||
/* ── Fetch results ───────────────────────────────────────────────────────── */
|
||||
const fetchResults = useCallback(async (q, p = 1) => {
|
||||
if (!q.trim() || q.trim().length < 2) {
|
||||
setResults([])
|
||||
setMeta(null)
|
||||
setSearched(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/feed/search', {
|
||||
params: { q: q.trim(), page: p },
|
||||
})
|
||||
setResults((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setMeta(data.meta)
|
||||
setPage(p)
|
||||
setSearched(true)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── Debounce typing ─────────────────────────────────────────────────────── */
|
||||
const handleChange = useCallback((e) => {
|
||||
const q = e.target.value
|
||||
setQuery(q)
|
||||
pushUrl(q)
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchResults(q, 1)
|
||||
}, 350)
|
||||
}, [fetchResults, pushUrl])
|
||||
|
||||
const handleSubmit = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
clearTimeout(debounceRef.current)
|
||||
fetchResults(query, 1)
|
||||
}, [fetchResults, query])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setMeta(null)
|
||||
setSearched(false)
|
||||
pushUrl('')
|
||||
inputRef.current?.focus()
|
||||
}, [pushUrl])
|
||||
|
||||
/* ── Run initial query if pre-filled from URL ────────────────────────────── */
|
||||
useEffect(() => {
|
||||
if (initialQuery?.trim().length >= 2) {
|
||||
fetchResults(initialQuery.trim(), 1)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => {
|
||||
setResults((prev) => prev.filter((p) => p.id !== id))
|
||||
}, [])
|
||||
|
||||
const hasMore = meta ? meta.current_page < meta.last_page : false
|
||||
const noResults = searched && !loading && results.length === 0
|
||||
const hasResults = results.length > 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-5xl mx-auto px-4 pt-8 pb-16">
|
||||
<div className="flex gap-8">
|
||||
|
||||
{/* ── Main ─────────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-5">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-magnifying-glass mr-2 text-slate-400/80" />
|
||||
Search Posts
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
Search by keywords, hashtags, or phrases
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search box */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
placeholder="Search posts…"
|
||||
className="w-full bg-white/[0.05] border border-white/[0.08] rounded-xl pl-10 pr-10 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-sky-500/40 focus:ring-1 focus:ring-sky-500/30 transition-colors"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-500 hover:text-white transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Skeletons while first load */}
|
||||
{loading && !hasResults && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{/* Idle / too short */}
|
||||
{!searched && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-magnifying-glass text-2xl" />
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Type at least 2 characters to search posts
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{noResults && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-face-rolling-eyes text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-1">No results</h2>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Nothing matched <span className="text-slate-300">“{query}”</span>.
|
||||
Try different keywords or a hashtag.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results meta */}
|
||||
{hasResults && meta && (
|
||||
<p className="text-[11px] text-slate-600 px-1">
|
||||
{meta.total.toLocaleString()} result{meta.total !== 1 ? 's' : ''} for{' '}
|
||||
<span className="text-slate-400">“{query}”</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Post cards */}
|
||||
{results.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Loading more indicator */}
|
||||
{loading && hasResults && (
|
||||
<div className="flex justify-center py-4">
|
||||
<i className="fa-solid fa-spinner fa-spin text-slate-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{!loading && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchResults(query, page + 1)}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar ──────────────────────────────────────────────────── */}
|
||||
<aside className="hidden lg:block w-64 shrink-0 space-y-4 pt-14">
|
||||
<TrendingHashtagsSidebar hashtags={trendingHashtags} />
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] px-4 py-4 text-center">
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Tip: search <span className="text-sky-400/80">#hashtag</span> to find
|
||||
posts by topic.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import PostCard from '../../Components/Feed/PostCard'
|
||||
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
|
||||
|
||||
function TrendingHashtagsSidebar({ hashtags, activeTag = null }) {
|
||||
if (!hashtags || hashtags.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
<i className="fa-solid fa-hashtag text-slate-500 fa-fw text-[13px]" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">Trending Tags</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-1.5">
|
||||
{hashtags.map((h) => (
|
||||
<a
|
||||
key={h.tag}
|
||||
href={`/tags/${h.tag}`}
|
||||
className={`flex items-center justify-between group px-2 py-1.5 rounded-lg transition-colors ${
|
||||
activeTag === h.tag
|
||||
? 'bg-sky-500/15 text-sky-400'
|
||||
: 'hover:bg-white/5 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">#{h.tag}</span>
|
||||
<span className="text-[11px] text-slate-600 group-hover:text-slate-500 tabular-nums">
|
||||
{h.post_count} posts
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TrendingFeed() {
|
||||
const { props } = usePage()
|
||||
const { auth, trendingHashtags } = props
|
||||
const authUser = auth?.user ?? null
|
||||
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const fetchFeed = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/feed/trending', { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchFeed(1) }, [])
|
||||
|
||||
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080f1e]">
|
||||
<div className="max-w-5xl mx-auto px-4 pt-8 pb-16">
|
||||
<div className="flex gap-8">
|
||||
{/* ── Main feed ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<i className="fa-solid fa-fire mr-2 text-orange-400 opacity-80" />
|
||||
Trending
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Most engaging posts right now</p>
|
||||
</div>
|
||||
|
||||
{!loaded && loading && (
|
||||
<>{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</>
|
||||
)}
|
||||
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
|
||||
<i className="fa-solid fa-fire text-2xl" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white/80 mb-2">Nothing trending yet</h2>
|
||||
<p className="text-slate-500 text-sm">Check back soon — posts are ranked by engagement.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLoggedIn={!!authUser}
|
||||
viewerUsername={authUser?.username ?? null}
|
||||
onDelete={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
: 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar ────────────────────────────────────────────────── */}
|
||||
<aside className="hidden lg:block w-64 shrink-0 space-y-4 pt-14">
|
||||
<TrendingHashtagsSidebar hashtags={trendingHashtags} />
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] px-4 py-4 text-center">
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Posts are ranked by likes, comments & engagement over the last 7 days.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import ThreadRow from '../../components/forum/ThreadRow'
|
||||
import Pagination from '../../components/forum/Pagination'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
export default function ForumCategory({ category, parentCategory = null, threads = [], pagination = {}, isAuthenticated = false }) {
|
||||
const name = category?.name ?? 'Category'
|
||||
const slug = category?.slug
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
...(parentCategory ? [{ label: parentCategory.name, href: `/forum/category/${parentCategory.slug}` }] : []),
|
||||
{ label: name },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Forum</p>
|
||||
<h1 className="text-3xl font-bold text-white leading-tight">{name}</h1>
|
||||
{category?.description && <p className="mt-2 text-sm text-white/50">{category.description}</p>}
|
||||
</div>
|
||||
{isAuthenticated && slug && (
|
||||
<a href={`/forum/${slug}/new`}>
|
||||
<Button variant="primary" size="sm"
|
||||
leftIcon={
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
New topic
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thread list */}
|
||||
<section className="overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-4 border-b border-white/[0.06] px-5 py-3">
|
||||
<span className="flex-1 text-xs font-semibold uppercase tracking-widest text-white/30">Topics</span>
|
||||
<span className="w-16 text-center text-xs font-semibold uppercase tracking-widest text-white/30">Replies</span>
|
||||
</div>
|
||||
|
||||
{threads.length === 0 ? (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<svg className="mx-auto mb-4 text-zinc-600" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<p className="text-sm text-zinc-500">No topics in this board yet.</p>
|
||||
{isAuthenticated && slug && (
|
||||
<a href={`/forum/${slug}/new`} className="mt-3 inline-block text-sm text-sky-300 hover:text-sky-200">
|
||||
Be the first to start a discussion →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{threads.map((thread, i) => (
|
||||
<ThreadRow key={thread.topic_id ?? thread.id ?? i} thread={thread} isFirst={i === 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination?.last_page > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination meta={pagination} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import Button from '../../components/ui/Button'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
import TurnstileField from '../../components/security/TurnstileField'
|
||||
import { populateBotFingerprint } from '../../lib/security/botFingerprint'
|
||||
|
||||
export default function ForumEditPost({ post, thread, csrfToken, errors = {}, captcha = {} }) {
|
||||
const [content, setContent] = useState(post?.content ?? '')
|
||||
const [captchaToken, setCaptchaToken] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: thread?.title ?? 'Topic', href: thread?.slug ? `/forum/topic/${thread.slug}` : '/forum' },
|
||||
{ label: 'Edit post' },
|
||||
]
|
||||
|
||||
const handleSubmit = useCallback((e) => {
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
// Let the form submit normally for PRG
|
||||
populateBotFingerprint(e.currentTarget).finally(() => {
|
||||
e.currentTarget.submit()
|
||||
})
|
||||
e.preventDefault()
|
||||
}, [submitting])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Edit</p>
|
||||
<h1 className="text-2xl font-bold text-white leading-tight">Edit post</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
method="POST"
|
||||
action={`/forum/post/${post?.id}`}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
|
||||
>
|
||||
<input type="hidden" name="_token" value={csrfToken} />
|
||||
<input type="hidden" name="_method" value="PUT" />
|
||||
<input type="text" name="homepage_url" defaultValue="" autoComplete="off" className="hidden" aria-hidden="true" tabIndex={-1} />
|
||||
<input type="hidden" name="_bot_fingerprint" value="" />
|
||||
<input type="hidden" name={captcha.inputName || 'cf-turnstile-response'} value={captchaToken} />
|
||||
|
||||
{errors.bot ? (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{Array.isArray(errors.bot) ? errors.bot[0] : errors.bot}
|
||||
</div>
|
||||
) : null}
|
||||
{errors.captcha ? (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
|
||||
{Array.isArray(errors.captcha) ? errors.captcha[0] : errors.captcha}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Rich text editor */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-white/85">
|
||||
Content
|
||||
</label>
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Edit your post…"
|
||||
error={errors.content}
|
||||
minHeight={14}
|
||||
autofocus={false}
|
||||
/>
|
||||
<input type="hidden" name="content" value={content} />
|
||||
</div>
|
||||
|
||||
{captcha.siteKey ? (
|
||||
<TurnstileField
|
||||
provider={captcha.provider}
|
||||
siteKey={captcha.siteKey}
|
||||
scriptUrl={captcha.scriptUrl}
|
||||
onToken={setCaptchaToken}
|
||||
className="rounded-lg border border-white/10 bg-black/20 p-3"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<a
|
||||
href={thread?.slug ? `/forum/topic/${thread.slug}` : '/forum'}
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
← Cancel
|
||||
</a>
|
||||
<Button type="submit" variant="primary" size="md" loading={submitting}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from 'react'
|
||||
import CategoryCard from '../../components/forum/CategoryCard'
|
||||
|
||||
export default function ForumIndex({ categories = [], trendingTopics = [], latestTopics = [] }) {
|
||||
const totalThreads = categories.reduce((sum, cat) => sum + (Number(cat?.thread_count) || 0), 0)
|
||||
const totalPosts = categories.reduce((sum, cat) => sum + (Number(cat?.post_count) || 0), 0)
|
||||
const sortedByActivity = [...categories].sort((a, b) => {
|
||||
const aTime = a?.last_activity_at ? new Date(a.last_activity_at).getTime() : 0
|
||||
const bTime = b?.last_activity_at ? new Date(b.last_activity_at).getTime() : 0
|
||||
return bTime - aTime
|
||||
})
|
||||
const latestActive = sortedByActivity[0] ?? null
|
||||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
<section className="relative overflow-hidden border-b border-white/10 bg-[radial-gradient(circle_at_15%_20%,rgba(34,211,238,0.24),transparent_40%),radial-gradient(circle_at_80%_0%,rgba(56,189,248,0.16),transparent_42%),linear-gradient(180deg,rgba(10,14,26,0.96),rgba(8,12,22,0.92))]">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-40 [background-image:linear-gradient(rgba(255,255,255,0.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.06)_1px,transparent_1px)] [background-size:40px_40px]" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-[1400px] px-4 py-10 sm:px-6 lg:px-10 lg:py-14">
|
||||
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
|
||||
<div>
|
||||
<p className="mb-2 inline-flex items-center gap-2 rounded-full border border-cyan-300/30 bg-cyan-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-100">
|
||||
Community Hub
|
||||
</p>
|
||||
<h1 className="text-4xl font-black leading-[0.95] tracking-[-0.02em] text-white sm:text-5xl lg:text-6xl">
|
||||
Skinbase Forum
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-200/80 sm:text-base">
|
||||
Ask questions, share progress, and join focused conversations across every part of Skinbase.
|
||||
This page is your launch point to active topics and community knowledge.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<a href="/forum" className="inline-flex items-center gap-2 rounded-xl bg-cyan-400 px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-cyan-300">
|
||||
Explore Categories
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
|
||||
<StatCard label="Sections" value={number(categories.length)} />
|
||||
<StatCard label="Topics" value={number(totalThreads)} />
|
||||
<StatCard label="Posts" value={number(totalPosts)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latestActive && (
|
||||
<div className="mt-7 rounded-2xl border border-white/15 bg-white/[0.04] p-4 backdrop-blur">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/50">Latest Activity</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<a
|
||||
href={`/forum/${latestActive.board_slug ?? latestActive.slug}`}
|
||||
className="text-base font-semibold text-cyan-200 transition hover:text-cyan-100"
|
||||
>
|
||||
{latestActive.name}
|
||||
</a>
|
||||
<span className="text-xs text-white/45">{formatLastActivity(latestActive.last_activity_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto w-full max-w-[1400px] px-4 pt-8 sm:px-6 lg:px-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-white/40">Browse</p>
|
||||
<h2 className="mt-1 text-2xl font-bold text-white sm:text-3xl">Forum Sections</h2>
|
||||
</div>
|
||||
<p className="text-xs text-white/50 sm:text-sm">Choose a section to view threads or start a discussion.</p>
|
||||
</div>
|
||||
|
||||
{/* Category grid */}
|
||||
{categories.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-nova-800/50 p-12 text-center">
|
||||
<svg className="mx-auto mb-4 text-zinc-600" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<p className="text-sm text-zinc-400">No forum categories available yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{categories.map((cat) => (
|
||||
<CategoryCard key={cat.id ?? cat.slug} category={cat} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mx-auto grid w-full max-w-[1400px] gap-5 px-4 pt-8 sm:px-6 lg:grid-cols-2 lg:px-10">
|
||||
<Panel title="Trending Topics" items={trendingTopics} emptyLabel="Trending topics will appear once boards become active." />
|
||||
<Panel title="Latest Topics" items={latestTopics} emptyLabel="Latest topics will appear here." />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Panel({ title, items, emptyLabel }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-nova-800/50 p-5 backdrop-blur">
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-white/45">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.map((item) => (
|
||||
<a key={item.slug} href={`/forum/topic/${item.slug}`} className="block rounded-xl border border-white/6 px-4 py-3 transition hover:border-cyan-400/20 hover:bg-white/[0.03]">
|
||||
<div className="text-sm font-semibold text-white">{item.title}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-3 text-xs text-white/45">
|
||||
{item.board && <span>{item.board}</span>}
|
||||
{item.author && <span>by {item.author}</span>}
|
||||
{typeof item.replies_count === 'number' && <span>{item.replies_count} replies</span>}
|
||||
{item.score !== undefined && <span>score {item.score}</span>}
|
||||
{item.last_post_at && <span>{formatLastActivity(item.last_post_at)}</span>}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/15 bg-white/[0.04] px-4 py-3 backdrop-blur">
|
||||
<p className="text-[11px] uppercase tracking-[0.14em] text-white/50">{label}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-white">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function number(n) {
|
||||
return (n ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function formatLastActivity(value) {
|
||||
if (!value) {
|
||||
return 'No recent activity'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'No recent activity'
|
||||
}
|
||||
|
||||
return `Updated ${date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}`
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import Button from '../../components/ui/Button'
|
||||
import TextInput from '../../components/ui/TextInput'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
import TurnstileField from '../../components/security/TurnstileField'
|
||||
import { populateBotFingerprint } from '../../lib/security/botFingerprint'
|
||||
|
||||
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {}, captcha = {} }) {
|
||||
const [title, setTitle] = useState(oldValues.title ?? '')
|
||||
const [content, setContent] = useState(oldValues.content ?? '')
|
||||
const [captchaToken, setCaptchaToken] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const slug = category?.slug
|
||||
const categoryName = category?.name ?? 'Category'
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: categoryName, href: slug ? `/forum/${slug}` : '/forum' },
|
||||
{ label: 'New topic' },
|
||||
]
|
||||
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
|
||||
// Standard form submission to keep server-side validation + redirect
|
||||
await populateBotFingerprint(e.currentTarget)
|
||||
e.target.submit()
|
||||
}, [submitting])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">New topic</p>
|
||||
<h1 className="text-2xl font-bold text-white leading-tight">
|
||||
Create topic in {categoryName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
method="POST"
|
||||
action={`/forum/${slug}/new`}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
|
||||
>
|
||||
<input type="hidden" name="_token" value={csrfToken} />
|
||||
<input type="text" name="homepage_url" defaultValue="" autoComplete="off" className="hidden" aria-hidden="true" tabIndex={-1} />
|
||||
<input type="hidden" name="_bot_fingerprint" value="" />
|
||||
<input type="hidden" name={captcha.inputName || 'cf-turnstile-response'} value={captchaToken} />
|
||||
|
||||
{errors.bot ? (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{Array.isArray(errors.bot) ? errors.bot[0] : errors.bot}
|
||||
</div>
|
||||
) : null}
|
||||
{errors.captcha ? (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
|
||||
{Array.isArray(errors.captcha) ? errors.captcha[0] : errors.captcha}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Title"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Thread title…"
|
||||
error={errors.title}
|
||||
/>
|
||||
|
||||
{/* Rich text editor */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-white/85">
|
||||
Content <span className="text-red-400 ml-1">*</span>
|
||||
</label>
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Write your post…"
|
||||
error={errors.content}
|
||||
minHeight={14}
|
||||
autofocus={false}
|
||||
/>
|
||||
<input type="hidden" name="content" value={content} />
|
||||
</div>
|
||||
|
||||
{captcha.siteKey ? (
|
||||
<TurnstileField
|
||||
provider={captcha.provider}
|
||||
siteKey={captcha.siteKey}
|
||||
scriptUrl={captcha.scriptUrl}
|
||||
onToken={setCaptchaToken}
|
||||
className="rounded-lg border border-white/10 bg-black/20 p-3"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<a href={`/forum/${slug}`} className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
← Cancel
|
||||
</a>
|
||||
<Button type="submit" variant="primary" size="md" loading={submitting}>
|
||||
Publish topic
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
|
||||
export default function ForumSection({ category, boards = [] }) {
|
||||
const name = category?.name ?? 'Forum Section'
|
||||
const description = category?.description
|
||||
const preview = category?.preview_image ?? '/images/forum/default.jpg'
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: name },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 pb-20 pt-10 sm:px-6 lg:px-8">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
<section className="mt-5 overflow-hidden rounded-3xl border border-white/10 bg-nova-800/55 shadow-xl backdrop-blur">
|
||||
<div className="relative h-56 overflow-hidden sm:h-64">
|
||||
<img src={preview} alt={`${name} preview`} className="h-full w-full object-cover object-center" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-6 sm:p-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-200/85">Forum Section</p>
|
||||
<h1 className="mt-2 text-3xl font-black text-white sm:text-4xl">{name}</h1>
|
||||
{description && <p className="mt-2 max-w-3xl text-sm text-white/70 sm:text-base">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 rounded-2xl border border-white/8 bg-nova-800/45 p-5 backdrop-blur sm:p-6">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-white/40">Subcategories</p>
|
||||
<h2 className="mt-1 text-2xl font-bold text-white">Browse boards</h2>
|
||||
</div>
|
||||
<p className="text-xs text-white/45 sm:text-sm">Select a board to open its thread list.</p>
|
||||
</div>
|
||||
|
||||
{boards.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-white/45">No boards are available in this section yet.</div>
|
||||
) : (
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||
{boards.map((board) => (
|
||||
<a key={board.id ?? board.slug} href={`/forum/${board.slug}`} className="rounded-2xl border border-white/8 bg-white/[0.02] p-5 transition hover:border-cyan-400/25 hover:bg-white/[0.04]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{board.title}</h3>
|
||||
{board.description && <p className="mt-2 text-sm text-white/55">{board.description}</p>}
|
||||
</div>
|
||||
<span className="rounded-full border border-cyan-300/20 bg-cyan-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-cyan-200">
|
||||
Open
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-xs text-white/50">
|
||||
<span>{board.topics_count ?? 0} topics</span>
|
||||
<span>{board.posts_count ?? 0} posts</span>
|
||||
{board.latest_topic?.title && <span>Latest: {board.latest_topic.title}</span>}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import PostCard from '../../components/forum/PostCard'
|
||||
import ReplyForm from '../../components/forum/ReplyForm'
|
||||
import Pagination from '../../components/forum/Pagination'
|
||||
|
||||
export default function ForumThread({
|
||||
thread,
|
||||
category,
|
||||
forumCategory,
|
||||
author,
|
||||
opPost,
|
||||
posts = [],
|
||||
pagination = {},
|
||||
replyCount = 0,
|
||||
sort = 'asc',
|
||||
quotedPost = null,
|
||||
replyPrefill = '',
|
||||
isAuthenticated = false,
|
||||
canModerate = false,
|
||||
csrfToken = '',
|
||||
status = null,
|
||||
captcha = {},
|
||||
}) {
|
||||
const [currentSort, setCurrentSort] = useState(sort)
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
...(forumCategory?.name ? [{ label: forumCategory.name }] : []),
|
||||
{ label: category?.name ?? 'Board', href: category?.slug ? `/forum/${category.slug}` : '/forum' },
|
||||
{ label: thread?.title ?? 'Thread' },
|
||||
]
|
||||
|
||||
const handleSortToggle = useCallback(() => {
|
||||
const newSort = currentSort === 'asc' ? 'desc' : 'asc'
|
||||
setCurrentSort(newSort)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('sort', newSort)
|
||||
window.location.href = url.toString()
|
||||
}, [currentSort])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto space-y-5">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Status flash */}
|
||||
{status && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thread header card */}
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-nova-800/50 p-5 backdrop-blur">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold text-white leading-snug">{thread?.title}</h1>
|
||||
{thread?.description ? (
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-zinc-300 sm:text-[15px]">
|
||||
{thread.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||
<span>By {author?.name ?? 'Unknown'}</span>
|
||||
<span className="text-zinc-700">•</span>
|
||||
{thread?.created_at && (
|
||||
<time dateTime={thread.created_at}>{formatDate(thread.created_at)}</time>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-sky-500/15 px-2.5 py-1 text-sky-300">
|
||||
{number(thread?.views ?? 0)} views
|
||||
</span>
|
||||
<span className="rounded-full bg-cyan-500/15 px-2.5 py-1 text-cyan-300">
|
||||
{number(replyCount)} replies
|
||||
</span>
|
||||
{thread?.is_pinned && (
|
||||
<span className="rounded-full bg-amber-500/15 px-2.5 py-1 text-amber-300">Pinned</span>
|
||||
)}
|
||||
{thread?.is_locked && (
|
||||
<span className="rounded-full bg-red-500/15 px-2.5 py-1 text-red-300">Locked</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Moderation tools */}
|
||||
{canModerate && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-white/[0.06] pt-3">
|
||||
{thread?.is_locked ? (
|
||||
<ModForm action={`/forum/topic/${thread.slug}/unlock`} csrf={csrfToken} label="Unlock" variant="danger" />
|
||||
) : (
|
||||
<ModForm action={`/forum/topic/${thread.slug}/lock`} csrf={csrfToken} label="Lock" variant="danger" />
|
||||
)}
|
||||
{thread?.is_pinned ? (
|
||||
<ModForm action={`/forum/topic/${thread.slug}/unpin`} csrf={csrfToken} label="Unpin" variant="warning" />
|
||||
) : (
|
||||
<ModForm action={`/forum/topic/${thread.slug}/pin`} csrf={csrfToken} label="Pin" variant="warning" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sort toggle + reply count */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-500">{number(replyCount)} {replyCount === 1 ? 'reply' : 'replies'}</p>
|
||||
<button
|
||||
onClick={handleSortToggle}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points={currentSort === 'asc' ? '18 15 12 21 6 15' : '18 9 12 3 6 9'} />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
{currentSort === 'asc' ? 'Oldest first' : 'Newest first'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OP Post */}
|
||||
{opPost && (
|
||||
<PostCard
|
||||
post={opPost}
|
||||
thread={thread}
|
||||
isOp
|
||||
isAuthenticated={isAuthenticated}
|
||||
canModerate={canModerate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reply list */}
|
||||
<section className="space-y-4" aria-label="Replies">
|
||||
{posts.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/40 px-5 py-8 text-center text-zinc-500 text-sm">
|
||||
No replies yet. Be the first to respond!
|
||||
</div>
|
||||
) : (
|
||||
posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
thread={thread}
|
||||
isAuthenticated={isAuthenticated}
|
||||
canModerate={canModerate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination?.last_page > 1 && (
|
||||
<div className="sticky bottom-3 z-10 rounded-xl border border-white/[0.06] bg-nova-800/80 p-2 backdrop-blur">
|
||||
<Pagination meta={pagination} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply form or locked / auth prompt */}
|
||||
{isAuthenticated ? (
|
||||
thread?.is_locked ? (
|
||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/5 px-5 py-4 text-sm text-red-300">
|
||||
This thread is locked. Replies are disabled.
|
||||
</div>
|
||||
) : (
|
||||
<ReplyForm
|
||||
topicKey={thread?.slug ?? thread?.id}
|
||||
prefill={replyPrefill}
|
||||
quotedAuthor={quotedPost?.user?.name}
|
||||
csrfToken={csrfToken}
|
||||
captcha={captcha}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/40 px-5 py-5 text-sm text-zinc-400">
|
||||
<a href="/login" className="text-sky-300 hover:text-sky-200 font-medium">Sign in</a> to post a reply.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModForm({ action, csrf, label, variant }) {
|
||||
const colors = variant === 'danger'
|
||||
? 'bg-red-500/15 text-red-300 hover:bg-red-500/25 border-red-500/20'
|
||||
: 'bg-amber-500/15 text-amber-300 hover:bg-amber-500/25 border-amber-500/20'
|
||||
|
||||
return (
|
||||
<form method="POST" action={action}>
|
||||
<input type="hidden" name="_token" value={csrf} />
|
||||
<button type="submit" className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${colors}`}>
|
||||
{label}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function number(n) {
|
||||
return (n ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
export default function GroupChallengeShow() {
|
||||
const { props } = usePage()
|
||||
const group = props.group || {}
|
||||
const challenge = props.challenge || {}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(234,179,8,0.15),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={`${challenge.title || group.name} - Skinbase`} description={challenge.summary || challenge.description || 'Group challenge'} />
|
||||
<div className="mx-auto max-w-6xl space-y-8">
|
||||
<section className="overflow-hidden rounded-[32px] border border-white/10 bg-white/[0.03]">
|
||||
{challenge.cover_url ? <img src={challenge.cover_url} alt={challenge.title} className="h-56 w-full object-cover" /> : <div className="h-40 bg-white/[0.03]" />}
|
||||
<div className="p-6">
|
||||
<a href={group.urls?.public} className="text-sm font-semibold text-amber-200">{group.name}</a>
|
||||
<h1 className="mt-4 text-4xl font-semibold text-white">{challenge.title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-300">{challenge.summary || challenge.description || 'Group challenge'}</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
<span>{challenge.status}</span>
|
||||
<span>{challenge.visibility}</span>
|
||||
<span>{String(challenge.participation_scope || '').replace('_', ' ')}</span>
|
||||
{challenge.start_at ? <span>Starts {new Date(challenge.start_at).toLocaleDateString()}</span> : null}
|
||||
{challenge.end_at ? <span>Ends {new Date(challenge.end_at).toLocaleDateString()}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Challenge brief</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{challenge.description || 'No extended challenge brief yet.'}</p>
|
||||
{challenge.rules_text ? (
|
||||
<div className="mt-6 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Rules</div>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{challenge.rules_text}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{challenge.submission_instructions ? (
|
||||
<div className="mt-6 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Submission instructions</div>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{challenge.submission_instructions}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Entries</h2>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{Array.isArray(challenge.artworks) && challenge.artworks.length > 0 ? challenge.artworks.map((artwork) => (
|
||||
<a key={artwork.id} href={artwork.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="aspect-[4/3] w-full object-cover" /> : null}
|
||||
<div className="p-4 text-white">{artwork.title}</div>
|
||||
</a>
|
||||
)) : <p className="text-sm text-slate-400">No entries linked yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
export default function GroupEventShow() {
|
||||
const { props } = usePage()
|
||||
const group = props.group || {}
|
||||
const event = props.event || {}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.15),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={`${event.title || group.name} - Skinbase`} description={event.summary || event.description || 'Group event'} />
|
||||
<div className="mx-auto max-w-5xl space-y-8">
|
||||
<section className="overflow-hidden rounded-[32px] border border-white/10 bg-white/[0.03]">
|
||||
{event.cover_url ? <img src={event.cover_url} alt={event.title} className="h-56 w-full object-cover" /> : <div className="h-40 bg-white/[0.03]" />}
|
||||
<div className="p-6">
|
||||
<a href={group.urls?.public} className="text-sm font-semibold text-emerald-200">{group.name}</a>
|
||||
<h1 className="mt-4 text-4xl font-semibold text-white">{event.title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-300">{event.summary || event.description || 'Group event'}</p>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Starts</div>
|
||||
<div className="mt-2 text-white">{event.start_at ? new Date(event.start_at).toLocaleString() : 'Not scheduled'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Details</div>
|
||||
<div className="mt-2 text-white">{event.event_type} • {event.visibility}</div>
|
||||
{event.location ? <div className="mt-2">{event.location}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{event.external_url ? <a href={event.external_url} className="mt-5 inline-flex rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Open external link</a> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">About this event</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{event.description || 'No extended event details yet.'}</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsFaqAccordion from '../../components/docs/DocsFaqAccordion'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import FaqSearchInput from '../../components/docs/FaqSearchInput'
|
||||
import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import { FAQ_CATEGORIES, RELATED_HELP_ITEMS } from './groupFaqContent'
|
||||
|
||||
function HeroStat({ label, value, note }) {
|
||||
return (
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FaqAnswer({ item, links }) {
|
||||
return (
|
||||
<>
|
||||
{Array.isArray(item.paragraphs) ? item.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
)) : null}
|
||||
{Array.isArray(item.bullets) && item.bullets.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{item.bullets.map((bullet) => (
|
||||
<li key={bullet} className="flex gap-3">
|
||||
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-sky-300" />
|
||||
<span>{bullet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{Array.isArray(item.example) && item.example.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{item.example.map((entry) => (
|
||||
<div key={entry.label} className="rounded-[20px] border border-white/10 bg-white/[0.03] p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{entry.label}</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{entry.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{Array.isArray(item.links) && item.links.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
{item.links.map((link) => (
|
||||
<a key={link.label} href={links[link.linkKey] || '#'} className="text-sm font-semibold text-sky-200 underline underline-offset-4">
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GroupFaqPage() {
|
||||
const { props } = usePage()
|
||||
const links = props.links || {}
|
||||
const [query, setQuery] = useState('')
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
|
||||
const visibleCategories = FAQ_CATEGORIES.map((category) => {
|
||||
const items = category.items.filter((item) => {
|
||||
if (!normalizedQuery) return true
|
||||
|
||||
const haystack = [
|
||||
item.question,
|
||||
...(item.paragraphs || []),
|
||||
...(item.bullets || []),
|
||||
...(item.example || []).flatMap((entry) => [entry.label, entry.value]),
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
return haystack.includes(normalizedQuery)
|
||||
})
|
||||
|
||||
return {
|
||||
...category,
|
||||
items,
|
||||
}
|
||||
}).filter((category) => category.items.length > 0)
|
||||
|
||||
const visibleQuestionCount = visibleCategories.reduce((total, category) => total + category.items.length, 0)
|
||||
const relatedHelpItems = RELATED_HELP_ITEMS.map((item) => ({
|
||||
...item,
|
||||
href: links[item.linkKey] || '#',
|
||||
}))
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_CATEGORIES.flatMap((category) => category.items).map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: (item.paragraphs || []).join(' '),
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.18),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.14),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1450px]">
|
||||
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.16),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Groups FAQ</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Find quick answers about Groups without digging through the full guide.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page answers the most common practical questions about Groups, roles, publishing, contributor credit, invites, workflows, and troubleshooting. Use it when you want fast answers first, then go deeper only if you need to.</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={links.full_documentation} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Read full Groups documentation</a>
|
||||
<a href={links.quickstart} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open Groups Quickstart</a>
|
||||
<a href={links.group_studio} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Open Group Studio</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<HeroStat label="Best for" value="Fast practical questions" note="Use the FAQ when you need answers quickly instead of reading the longer guide front to back." />
|
||||
<HeroStat label="Core idea" value="Shared identity, preserved credit" note="Groups publish together under one identity, but the people behind the work still matter and stay visible." />
|
||||
<HeroStat label="If you need more" value="Jump deeper anytime" note="This page links back to the quickstart, the full guide, Group Studio, and the creation flow." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 max-w-3xl">
|
||||
<FaqSearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onClear={() => setQuery('')}
|
||||
resultCount={visibleQuestionCount}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<DocsSidebarNav sections={visibleCategories.map((category) => ({ id: category.id, label: category.label }))} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<DocsCallout tone="note" title="How to use this page">
|
||||
Start with the category closest to your problem. If you only need the fastest route to first success, use the quickstart. If you need broader reference or advanced workflows, open the full Groups guide.
|
||||
</DocsCallout>
|
||||
|
||||
{visibleCategories.length === 0 ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6 shadow-[0_22px_70px_rgba(2,6,23,0.22)] md:p-7">
|
||||
<h2 className="text-2xl font-semibold text-white">No matching questions</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">Try a broader search term like roles, invite, publish, contributor, review, or Studio.</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{visibleCategories.map((category) => (
|
||||
<DocsSection
|
||||
key={category.id}
|
||||
id={category.id}
|
||||
eyebrow="FAQ category"
|
||||
title={category.title}
|
||||
summary={category.summary}
|
||||
>
|
||||
<DocsFaqAccordion items={category.items} renderAnswer={(item) => <FaqAnswer item={item} links={links} />} />
|
||||
</DocsSection>
|
||||
))}
|
||||
|
||||
<DocsSection
|
||||
id="related-help"
|
||||
eyebrow="Related help"
|
||||
title="Need the next step, not just the answer?"
|
||||
summary="Use these links when the FAQ has answered the question and you are ready to act, learn more, or get support."
|
||||
>
|
||||
<QuickstartNextSteps items={relatedHelpItems} />
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<a href={links.contact_support} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Contact</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">Contact support</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">Use this if your question is not answered here or if you need help with an account or workflow issue.</p>
|
||||
</a>
|
||||
<a href={links.report_issue} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Report</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">Report a problem</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">Use this if a route, role, contributor record, or Group workflow appears broken rather than just unclear.</p>
|
||||
</a>
|
||||
</div>
|
||||
</DocsSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
|
||||
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Support flow</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<a href={links.quickstart} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open Quickstart</a>
|
||||
<a href={links.full_documentation} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read full documentation</a>
|
||||
<a href={links.group_studio} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open Group Studio</a>
|
||||
<a href={links.create_group} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Create a Group</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Quick troubleshooting rule</div>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-50/85">If something feels wrong, check three things first: are you in the right Group context, do you have the right role, and is the content public or internal?</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsComparisonTable from '../../components/docs/DocsComparisonTable'
|
||||
import DocsFaqAccordion from '../../components/docs/DocsFaqAccordion'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import DocsStepList from '../../components/docs/DocsStepList'
|
||||
import {
|
||||
BENEFITS,
|
||||
BEST_PRACTICES,
|
||||
COMMON_MISTAKES,
|
||||
CREATE_STEPS,
|
||||
FAQ_ITEMS,
|
||||
FEATURE_CARDS,
|
||||
GOOD_FIT,
|
||||
NOT_YET,
|
||||
ROLE_TABLE,
|
||||
SECTION_ITEMS,
|
||||
STUDIO_AREAS,
|
||||
TROUBLESHOOTING_ITEMS,
|
||||
WORKFLOWS,
|
||||
} from './groupHelpContent'
|
||||
|
||||
function HeroMetric({ label, value, note }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TwoColumnChecklist({ title, eyebrow, items, tone = 'sky' }) {
|
||||
const toneClass = tone === 'emerald'
|
||||
? 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100'
|
||||
: 'border-sky-300/15 bg-sky-400/10 text-sky-100'
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{eyebrow}</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white">{title}</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="flex gap-3">
|
||||
<span className={`mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border ${toneClass}`}>
|
||||
<i className="fa-solid fa-check text-[10px]" />
|
||||
</span>
|
||||
<p className="text-sm leading-6 text-slate-300">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCard({ title, body, icon }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-sky-200">
|
||||
<i className={icon} />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold text-white">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{body}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BulletGrid({ items }) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-6 text-slate-300">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowCard({ workflow }) {
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-xl font-semibold text-white">{workflow.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{workflow.summary}</p>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{workflow.bullets.map((bullet) => (
|
||||
<li key={bullet} className="flex gap-3 text-sm leading-6 text-slate-300">
|
||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-sky-300" />
|
||||
<span>{bullet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function TroubleCard({ item }) {
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GroupHelpPage() {
|
||||
const { props } = usePage()
|
||||
const links = props.links || {}
|
||||
const heroJsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Groups Help & Guide',
|
||||
description: props.description,
|
||||
url: props.seo?.canonical,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Skinbase',
|
||||
},
|
||||
about: ['Groups', 'Collaborative publishing', 'Contributor credit', 'Group Studio', 'Releases'],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.18),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.14),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={heroJsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.16),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Groups documentation</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Build, manage, and publish through Groups without losing personal credit.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Groups on Skinbase Nova are shared creative identities for studios, collectives, release teams, and long-term collaborations. This guide explains when to use them, how to structure roles, how publishing works, and how to keep the public page clear, trustworthy, and easy to maintain.</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={links.create_group} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Create a Group</a>
|
||||
<a href={links.group_studio} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open Group Studio</a>
|
||||
<a href="#roles-and-permissions" className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Jump to roles and permissions</a>
|
||||
</div>
|
||||
{links.quickstart ? (
|
||||
<div className="mt-4">
|
||||
<a href={links.quickstart} className="text-sm font-semibold text-sky-200 underline underline-offset-4">
|
||||
Prefer the shorter onboarding version? Open the Groups Quickstart.
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{links.faq ? (
|
||||
<div className="mt-2">
|
||||
<a href={links.faq} className="text-sm font-semibold text-slate-300 underline underline-offset-4 hover:text-white">
|
||||
Need faster answers instead? Open the Groups FAQ.
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<HeroMetric label="Shared identity" value="One public home for team work" note="Use a Group when a studio, crew, or release team needs its own visible brand." />
|
||||
<HeroMetric label="Preserved credit" value="Authorship stays visible" note="Published by, uploaded by, primary author, and contributors can still reflect the real humans behind the work." />
|
||||
<HeroMetric label="Studio workflow" value="Roles, reviews, projects, releases" note="Groups work best when the team needs structure, not just a different display name." />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<DocsSidebarNav sections={SECTION_ITEMS} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<DocsSection
|
||||
id="what-are-groups"
|
||||
eyebrow="Foundations"
|
||||
title="What are Groups?"
|
||||
summary="A Group is a shared creative identity for collaboration and publishing. It is not a replacement for personal profiles. It is the public home for work that belongs to a team, studio, collective, or release-focused collaboration."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<InfoCard title="Personal profile" icon="fa-solid fa-user" body="Your personal profile is your own portfolio, reputation, and identity. It is where your individual voice, uploads, followers, and personal presence live." />
|
||||
<InfoCard title="Group" icon="fa-solid fa-people-group" body="A Group is the shared layer. It gives a team one public identity for publishing together, managing members, and presenting collaborative work without flattening individual credit." />
|
||||
</div>
|
||||
|
||||
<DocsCallout tone="note" title="The most important rule">
|
||||
A Group is a shared publishing identity, not a way to erase authorship. If real people made the work, their authorship and contribution history should still be represented clearly.
|
||||
</DocsCallout>
|
||||
|
||||
<div className="mt-6 rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-xl font-semibold text-white">Groups are a good fit for</h3>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{['Design studios', 'Pixel art crews', 'Wallpaper teams', 'Photography collectives', 'Event-based collaborations', 'Release teams'].map((label) => (
|
||||
<span key={label} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-slate-200">{label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="why-use-a-group"
|
||||
eyebrow="Decision guide"
|
||||
title="Why use a Group?"
|
||||
summary="Use a Group when the work is bigger than one person, or when a shared identity helps the team stay organized, trustworthy, and easy to understand publicly."
|
||||
>
|
||||
<BulletGrid items={BENEFITS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="when-to-create-a-group"
|
||||
eyebrow="Decision guide"
|
||||
title="When should you create a Group?"
|
||||
summary="Create a Group when it solves a real workflow or identity problem. If it is just adding overhead, you probably do not need it yet."
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<TwoColumnChecklist title="Create one when..." eyebrow="Good fit" items={GOOD_FIT} tone="emerald" />
|
||||
<TwoColumnChecklist title="Hold off when..." eyebrow="Not yet" items={NOT_YET} tone="sky" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="tip" title="A simple rule of thumb">
|
||||
If the team needs shared publishing, shared coordination, or a shared public identity more than it needs absolute simplicity, a Group is probably worth it.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="how-groups-work"
|
||||
eyebrow="Model"
|
||||
title="How Groups work"
|
||||
summary="Think of a Group as two connected surfaces: a public identity page and an internal Studio workspace. One is for visibility. The other is for coordination."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<InfoCard title="Group page" icon="fa-solid fa-earth-americas" body="The public face of the Group with branding, releases, posts, projects, challenges, events, members, and activity." />
|
||||
<InfoCard title="Group Studio" icon="fa-solid fa-sliders" body="The internal workspace for permissions, publishing, review flows, releases, assets, invites, and day-to-day operations." />
|
||||
<InfoCard title="Shared content" icon="fa-solid fa-layer-group" body="Groups can own artworks, collections, posts, projects, challenges, events, assets, and releases depending on the team workflow." />
|
||||
<InfoCard title="Public vs internal" icon="fa-solid fa-lock-open" body="Not everything is public. Some areas are internal, role-based, or review-gated. The public page should be curated. Studio should stay operational." />
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="roles-and-permissions"
|
||||
eyebrow="Team structure"
|
||||
title="Roles and permissions"
|
||||
summary="Keep roles understandable. Most Groups do best when only a small number of people can change settings or manage members, while everyone else gets exactly the access they need and nothing more."
|
||||
>
|
||||
<DocsCallout tone="practice" title="Start simpler than you think">
|
||||
Most new Groups should begin with one Owner, a very small Admin circle, Editors for day-to-day managers, and Contributors for creative participation. Complexity is easier to add later than remove.
|
||||
</DocsCallout>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsComparisonTable columns={ROLE_TABLE.columns} rows={ROLE_TABLE.rows} caption="Group role comparison" />
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="creating-a-group"
|
||||
eyebrow="Setup"
|
||||
title="Creating a Group"
|
||||
summary="A strong first setup prevents confusion later. Good names, clean branding, and clear role assignments make every other workflow easier."
|
||||
>
|
||||
<DocsStepList items={CREATE_STEPS} />
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="practice" title="Setup tips">
|
||||
Choose a name that will still make sense when the Group grows. Add a short description that says what the Group makes, not just what it likes.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="warning" title="Do not skip ownership decisions">
|
||||
Decide early who should be Owner and who truly needs Admin. Teams create a lot of avoidable friction when this stays vague.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="public-group-page"
|
||||
eyebrow="Public identity"
|
||||
title="Group profile and public page"
|
||||
summary="The public Group page is the identity page for the team. It should feel active, coherent, and curated instead of looking like a random collection of leftovers."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{['Cover and avatar', 'Description and About', 'Members and leadership', 'Artworks and collections', 'Posts and announcements', 'Projects, challenges, events, releases'].map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm font-medium text-slate-200">{item}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="tip" title="Keep the page feeling alive">
|
||||
Use a consistent visual identity, keep the About copy current, feature the best work, and pin only the update that gives new visitors the best context.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="group-studio"
|
||||
eyebrow="Operations"
|
||||
title="Group Studio"
|
||||
summary="Group Studio is where you switch from being an individual creator to operating inside a shared team context. That context matters every time you publish, review, or manage content."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<InfoCard title="Personal Studio" icon="fa-solid fa-user-gear" body="Use Personal Studio when you are managing your own portfolio, drafts, uploads, and audience as an individual creator." />
|
||||
<InfoCard title="Group Studio" icon="fa-solid fa-people-roof" body="Use Group Studio when the work belongs to the shared identity, or when roles, reviews, projects, releases, and member access need to be respected." />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<BulletGrid items={STUDIO_AREAS} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="warning" title="Check context before publishing">
|
||||
The easiest way to create confusing attribution is to publish from the wrong context. If the work belongs to the Group, confirm that Group Studio is active before you submit or publish.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="publishing-as-a-group"
|
||||
eyebrow="Publishing"
|
||||
title="Publishing as a Group"
|
||||
summary="Publishing as a Group means the shared identity is the public publish surface. It does not mean the Group replaces every human role in the record."
|
||||
>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-xl font-semibold text-white">How to read the publishing record</h3>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Published by</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">Warlock</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">The shared identity the work appears under publicly.</p>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Uploaded by</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">Gregor</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">The person who performed the upload or publishing action.</p>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Primary author</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">Gregor</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">The person who should be understood as the main author of the work.</p>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contributors</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">Denis, Paula</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">Additional people who made meaningful creative contributions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="note" title="Why this matters">
|
||||
Group publishing creates a shared public identity for the work, but personal authorship, accountability, and contribution history should still be easy to understand.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="warning" title="Do not use Group publishing to hide authorship">
|
||||
If the work is mainly one person\'s piece, make sure the primary author and contributors reflect that reality clearly.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="contributor-credit"
|
||||
eyebrow="Attribution"
|
||||
title="Contributor credit and authorship"
|
||||
summary="Correct attribution keeps the Group healthy. It builds trust inside the team, makes the public record clearer, and reduces avoidable disputes later."
|
||||
>
|
||||
<BulletGrid items={[
|
||||
'Always credit real contributors, even when the Group brand is stronger than any single member.',
|
||||
'Use role labels when they add clarity, such as packaging lead, curator, reviewer, or art director.',
|
||||
'Do not swap uploader and author just because one person clicked Publish.',
|
||||
'Discuss credits early for bigger releases so nobody is negotiating attribution after launch day.',
|
||||
]} />
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="warning" title="Incorrect credit causes real friction">
|
||||
Attribution problems are rarely just metadata problems. They affect trust, morale, and how future collaborators feel about the Group.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="member-management"
|
||||
eyebrow="Team health"
|
||||
title="Inviting members and managing the team"
|
||||
summary="Healthy Groups are clear about who has access, why they have it, and how that access changes as the team grows."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<InfoCard title="Invites and onboarding" icon="fa-solid fa-user-plus" body="Invite people with the smallest role that still lets them do the work. Explain expectations before they accept so there is no ambiguity about ownership or workflow." />
|
||||
<InfoCard title="Role reviews" icon="fa-solid fa-user-check" body="Review roles periodically. People change, projects end, and old permissions should not stay permanent by accident." />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="practice" title="Role assignment guidance">
|
||||
Keep Owner count very limited, give Admin only to trusted operators, use Editor for content managers, and keep Contributor focused on creation.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="note" title="Join requests and recruiting">
|
||||
If your Group supports join requests or recruiting, use them with a real onboarding process. Recruiting without follow-through makes the Group feel abandoned.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="review-workflow"
|
||||
eyebrow="Quality control"
|
||||
title="Review queue and approval workflow"
|
||||
summary="Review flows help larger or more structured Groups keep public quality high without forcing every trusted team to work the same way."
|
||||
>
|
||||
<DocsStepList
|
||||
items={[
|
||||
{ title: 'Contributor submits a draft', description: 'The work enters the Group pipeline without immediately going public.' },
|
||||
{ title: 'Reviewer checks the work', description: 'Editors, admins, or designated reviewers confirm quality, context, and credit.' },
|
||||
{ title: 'Approve, request changes, or reject', description: 'Feedback should be specific enough that the creator knows what to do next.' },
|
||||
{ title: 'Publish when ready', description: 'Once the draft is approved, the right person can publish it under the correct Group context.' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="tip" title="When direct publishing makes sense">
|
||||
Small, trusted teams often move faster with direct publishing. Use review only when it protects quality or reduces confusion.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="practice" title="When review-first helps">
|
||||
Larger teams, new contributors, and release-heavy groups usually benefit from a review queue because it catches context, permission, and attribution mistakes before they go public.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="group-features"
|
||||
eyebrow="Feature ecosystem"
|
||||
title="Posts, projects, challenges, events, assets, and releases"
|
||||
summary="These features are most useful when they connect. A healthy Group does not use them all at once. It chooses the smallest set that makes the public story and internal workflow clearer."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{FEATURE_CARDS.map((card, index) => (
|
||||
<InfoCard key={card.title} title={card.title} body={card.body} icon={['fa-solid fa-diagram-project', 'fa-solid fa-bullseye', 'fa-solid fa-calendar-day', 'fa-solid fa-box-open', 'fa-solid fa-rocket', 'fa-solid fa-bullhorn'][index]} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="note" title="A practical progression">
|
||||
Many teams start with artworks and posts, then add projects when collaboration gets busier, and use releases when the Group is ready for stronger public storytelling around major drops.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="tips-and-best-practices"
|
||||
eyebrow="Operating well"
|
||||
title="Tips and best practices"
|
||||
summary="Most support questions come from a small set of preventable mistakes. These habits keep Groups easier to manage and easier to trust."
|
||||
>
|
||||
<BulletGrid items={BEST_PRACTICES} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="common-mistakes"
|
||||
eyebrow="Avoid these"
|
||||
title="Common mistakes to avoid"
|
||||
summary="Groups become confusing when identity, permissions, and attribution drift out of sync."
|
||||
>
|
||||
<DocsCallout tone="warning" title="The fastest way to make a Group feel unreliable">
|
||||
Mix unclear roles with vague attribution and inconsistent publishing context. Users will stop trusting what they are looking at.
|
||||
</DocsCallout>
|
||||
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-2">
|
||||
{COMMON_MISTAKES.map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-6 text-slate-300">{item}</div>
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="suggested-workflows"
|
||||
eyebrow="Patterns"
|
||||
title="Suggested workflows"
|
||||
summary="You do not need one perfect workflow. You need the right amount of structure for the team you actually have."
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{WORKFLOWS.map((workflow) => <WorkflowCard key={workflow.title} workflow={workflow} />)}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="faq"
|
||||
eyebrow="FAQ"
|
||||
title="Frequently asked questions"
|
||||
summary="Short answers to the questions people most often ask before creating, joining, or managing a Group."
|
||||
>
|
||||
<DocsFaqAccordion items={FAQ_ITEMS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="troubleshooting"
|
||||
eyebrow="Troubleshooting"
|
||||
title="Common problems and how to think through them"
|
||||
summary="If something feels confusing, start with context, role, and visibility. Most Group issues live in one of those three buckets."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{TROUBLESHOOTING_ITEMS.map((item) => <TroubleCard key={item.title} item={item} />)}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="need-help"
|
||||
eyebrow="Support"
|
||||
title="Still need help?"
|
||||
summary="Use these next steps if you are ready to create a Group, need to check your current setup, or want to contact Skinbase support."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<a href={links.create_group} className="rounded-[28px] border border-sky-300/20 bg-sky-300/10 p-5 transition hover:border-sky-300/35 hover:bg-sky-300/15">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Create</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">Create your first Group</div>
|
||||
<p className="mt-3 text-sm leading-6 text-sky-50/80">Start with branding, visibility, and your first member invites.</p>
|
||||
</a>
|
||||
<a href={links.group_studio} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Manage</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">Open Group Studio</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">Check members, workflow, releases, recruitment, and review status.</p>
|
||||
</a>
|
||||
<a href={links.contact_support} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Contact</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">Contact support</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">Use the general support flow if you need help untangling an account or workflow issue.</p>
|
||||
</a>
|
||||
<a href={links.report_issue} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Report</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">Report a problem</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">Use this if a route, permission, credit record, or workflow appears broken.</p>
|
||||
</a>
|
||||
</div>
|
||||
</DocsSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
|
||||
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Quick actions</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<a href={links.groups_directory} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Browse public Groups</a>
|
||||
<a href={links.group_studio} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open Group Studio</a>
|
||||
{links.faq ? <a href={links.faq} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open Groups FAQ</a> : null}
|
||||
<a href="#publishing-as-a-group" className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Review publishing guidance</a>
|
||||
<a href="#contributor-credit" className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Check contributor credit rules</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Read this before launch day</div>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-50/85">Before the first public release or artwork, confirm the Group context, contributor credit, and review expectations. Those three checks prevent most avoidable confusion.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import GroupPromoCard from '../../components/groups/GroupPromoCard'
|
||||
import GroupTrendingSection from '../../components/groups/GroupTrendingSection'
|
||||
import GroupBrowseFilters from '../../components/groups/GroupBrowseFilters'
|
||||
import GroupDiscoveryCard from '../../components/groups/GroupDiscoveryCard'
|
||||
import GroupLeaderboardCard from '../../components/groups/GroupLeaderboardCard'
|
||||
|
||||
export default function GroupIndex() {
|
||||
const { props } = usePage()
|
||||
const groups = props.groups?.data || []
|
||||
const surfaces = Array.isArray(props.surfaces) ? props.surfaces : []
|
||||
const currentSurface = props.currentSurface || 'featured'
|
||||
const highlightSections = Array.isArray(props.highlightSections) ? props.highlightSections : []
|
||||
const leaderboardItems = Array.isArray(props.leaderboard?.items) ? props.leaderboard.items : []
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<SeoHead title="Groups - Skinbase" description={props.description} />
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Groups</p>
|
||||
<h1 className="mt-2 text-4xl font-semibold text-white">Collective publishing identities</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-6 text-slate-300">Discover collaborative studios, follow shared creative brands, and browse the artworks, releases, and collections published under each group identity.</p>
|
||||
<GroupBrowseFilters surfaces={surfaces} currentSurface={currentSurface} />
|
||||
</section>
|
||||
|
||||
<div className="mt-8">
|
||||
<GroupPromoCard
|
||||
group={props.spotlightGroup}
|
||||
eyebrow="Public groups"
|
||||
title="Find collaborative identities with real momentum"
|
||||
description="Groups now sit alongside creators and artworks across Nova, making shared publishing, team recruitment, and release-driven collaboration easier to discover."
|
||||
ctaLabel="Open spotlight"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{highlightSections.map((section) => (
|
||||
<GroupTrendingSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
items={section.items || []}
|
||||
href={`/groups?surface=${encodeURIComponent(section.key)}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
{leaderboardItems.length > 0 ? (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.02em] text-white">Monthly group leaderboard</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">A fast view of the collaborative teams moving the most attention and publishing energy right now.</p>
|
||||
</div>
|
||||
<a href="/leaderboard?type=groups&period=monthly" className="text-sm font-semibold text-sky-200 transition hover:text-white">View leaderboard</a>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{leaderboardItems.slice(0, 3).map((item) => <GroupLeaderboardCard key={item.entity?.id || item.rank} item={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.02em] text-white">Browse groups</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Filter the directory by discovery surface, then jump into each group’s public page for artworks, releases, projects, events, and activity.</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">{Number(props.groups?.meta?.total || 0).toLocaleString()} public groups</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map((group) => <GroupDiscoveryCard key={group.slug || group.id} group={group} />)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function csrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
export default function GroupPostShow() {
|
||||
const { props } = usePage()
|
||||
const group = props.group || {}
|
||||
const post = props.post || {}
|
||||
const recentPosts = Array.isArray(props.recentPosts) ? props.recentPosts : []
|
||||
|
||||
const submitReport = async () => {
|
||||
if (!props.reportEndpoint || !post.id) return
|
||||
|
||||
const reason = window.prompt('Reason for reporting this post?')
|
||||
if (!reason) return
|
||||
|
||||
await fetch(props.reportEndpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ target_type: 'group_post', target_id: post.id, reason }),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={`${post.title || group.name} - Skinbase`} description={post.excerpt || group.headline || group.bio || 'Group post'} />
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<article className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6 sm:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<a href={group.urls?.public} className="text-sm font-semibold text-sky-200">← Back to {group.name}</a>
|
||||
{props.reportEndpoint ? <button type="button" onClick={submitReport} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Report</button> : null}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
{post.type ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{post.type}</span> : null}
|
||||
{post.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-amber-100">Pinned</span> : null}
|
||||
</div>
|
||||
<h1 className="mt-5 text-4xl font-semibold text-white">{post.title}</h1>
|
||||
<div className="mt-3 text-sm text-slate-400">{post.author?.name || post.author?.username || group.name} • {post.published_at ? new Date(post.published_at).toLocaleString() : 'Recently'}</div>
|
||||
{post.excerpt ? <p className="mt-6 text-lg leading-8 text-slate-200">{post.excerpt}</p> : null}
|
||||
<div className="mt-8 whitespace-pre-wrap text-sm leading-7 text-slate-300">{post.content || ''}</div>
|
||||
</article>
|
||||
|
||||
{recentPosts.length > 0 ? (
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">More from {group.name}</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{recentPosts.filter((item) => item.id !== post.id).map((item) => (
|
||||
<a key={item.id} href={item.url} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{item.type}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{item.title}</div>
|
||||
<p className="mt-2 text-sm text-slate-400">{item.excerpt || 'Read the full post.'}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function ArtworkGrid({ artworks }) {
|
||||
if (!Array.isArray(artworks) || artworks.length === 0) {
|
||||
return <p className="mt-4 text-sm text-slate-400">No linked artworks yet.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{artworks.map((artwork) => (
|
||||
<a key={artwork.id} href={artwork.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="aspect-[4/3] w-full object-cover" /> : null}
|
||||
<div className="p-4">
|
||||
<h3 className="text-base font-semibold text-white">{artwork.title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">{artwork.author || 'Artwork'}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GroupProjectShow() {
|
||||
const { props } = usePage()
|
||||
const group = props.group || {}
|
||||
const project = props.project || {}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={`${project.title || group.name} - Skinbase`} description={project.summary || project.description || group.headline || 'Group project'} />
|
||||
<div className="mx-auto max-w-6xl space-y-8">
|
||||
<section className="overflow-hidden rounded-[32px] border border-white/10 bg-white/[0.03]">
|
||||
{project.cover_url ? <img src={project.cover_url} alt={project.title} className="h-56 w-full object-cover" /> : <div className="h-40 bg-white/[0.03]" />}
|
||||
<div className="p-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a href={group.urls?.public} className="text-sm font-semibold text-sky-200">{group.name}</a>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{project.status}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{project.visibility}</span>
|
||||
</div>
|
||||
<h1 className="mt-4 text-4xl font-semibold text-white">{project.title}</h1>
|
||||
{project.summary ? <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-300">{project.summary}</p> : null}
|
||||
<div className="mt-5 flex flex-wrap gap-4 text-xs text-slate-400">
|
||||
{project.start_date ? <span>Started {new Date(project.start_date).toLocaleDateString()}</span> : null}
|
||||
{project.target_date ? <span>Target {new Date(project.target_date).toLocaleDateString()}</span> : null}
|
||||
{project.released_at ? <span>Released {new Date(project.released_at).toLocaleDateString()}</span> : null}
|
||||
{project.lead?.name || project.lead?.username ? <span>Lead: {project.lead?.name || project.lead?.username}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Overview</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{project.description || 'No long-form description yet.'}</p>
|
||||
{Array.isArray(project.milestones) && project.milestones.length > 0 ? <div className="mt-6 space-y-3">{project.milestones.map((milestone) => <div key={milestone.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="flex items-center justify-between gap-3"><div className="font-semibold text-white">{milestone.title}</div><span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{milestone.status}</span></div>{milestone.summary ? <p className="mt-2 text-sm text-slate-400">{milestone.summary}</p> : null}{milestone.owner?.name || milestone.owner?.username ? <div className="mt-2 text-xs text-slate-500">Owner: {milestone.owner?.name || milestone.owner?.username}</div> : null}</div>)}</div> : null}
|
||||
<ArtworkGrid artworks={project.artworks} />
|
||||
</section>
|
||||
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Pipeline</h2>
|
||||
<div className="mt-4 text-sm leading-7 text-slate-300">This project currently has {project.counts?.milestones || 0} milestones and is linked to {project.release_count || project.counts?.releases || 0} releases.</div>
|
||||
</section>
|
||||
{Array.isArray(project.assets) && project.assets.length > 0 ? (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Assets</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{project.assets.map((asset) => (
|
||||
<a key={asset.id} href={asset.download_url} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<div className="font-semibold">{asset.title}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{asset.category} • {asset.visibility}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{Array.isArray(project.team) && project.team.length > 0 ? (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Team</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{project.team.map((member) => (
|
||||
<div key={member.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<div className="font-semibold">{member.name || member.username}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{member.role_label || (member.is_lead ? 'Lead' : 'Contributor')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{project.pinned_post ? (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Pinned update</h2>
|
||||
<a href={project.pinned_post.url} className="mt-4 inline-block text-sm font-semibold text-sky-200">{project.pinned_post.title}</a>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import DocsStepList from '../../components/docs/DocsStepList'
|
||||
import QuickstartChecklist from '../../components/docs/QuickstartChecklist'
|
||||
import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import {
|
||||
COMPARISON_CARDS,
|
||||
COMMON_MISTAKES,
|
||||
CREATE_STEPS,
|
||||
CREDIT_TERMS,
|
||||
FIRST_WEEK_BEST_PRACTICES,
|
||||
GOOD_FIT,
|
||||
NEXT_STEPS,
|
||||
NOT_NEEDED_YET,
|
||||
PUBLISH_STEPS,
|
||||
QUICK_CHECKLIST,
|
||||
ROLE_CARDS,
|
||||
SECTION_ITEMS,
|
||||
SETUP_TASKS,
|
||||
} from './groupQuickstartContent'
|
||||
|
||||
function HeroStat({ label, value, note }) {
|
||||
return (
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonCard({ card }) {
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-sky-200">
|
||||
<i className={card.icon} />
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-semibold text-white">{card.title}</h3>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{card.bullets.map((item) => (
|
||||
<li key={item} className="flex gap-3 text-sm leading-6 text-slate-300">
|
||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-sky-300" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function SimpleListCard({ title, eyebrow, items, tone = 'sky' }) {
|
||||
const toneClass = tone === 'emerald'
|
||||
? 'border-emerald-300/15 bg-emerald-400/10 text-emerald-50'
|
||||
: 'border-white/10 bg-black/20 text-white'
|
||||
|
||||
return (
|
||||
<div className={`rounded-[28px] border p-5 ${toneClass}`}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">{eyebrow}</p>
|
||||
<h3 className="mt-2 text-xl font-semibold">{title}</h3>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="flex gap-3 text-sm leading-6 opacity-95">
|
||||
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.06] text-[10px]">
|
||||
<i className="fa-solid fa-check" />
|
||||
</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleCard({ role }) {
|
||||
return (
|
||||
<article className="rounded-[26px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="inline-flex rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100">
|
||||
{role.role}
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{role.summary}</p>
|
||||
<p className="mt-3 text-sm font-medium text-slate-200">{role.note}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactGrid({ items }) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="rounded-[22px] border border-white/10 bg-black/20 p-4 text-sm leading-6 text-slate-300">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreditCard({ item }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{item.label}</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">{item.value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{item.note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GroupQuickstartPage() {
|
||||
const { props } = usePage()
|
||||
const links = props.links || {}
|
||||
const nextSteps = NEXT_STEPS.map((item) => ({
|
||||
...item,
|
||||
href: links[item.linkKey] || '#',
|
||||
}))
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: props.title,
|
||||
description: props.description,
|
||||
url: props.seo?.canonical,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Skinbase',
|
||||
},
|
||||
about: ['Groups', 'Quickstart', 'Collaborative publishing', 'Contributor credit'],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.18),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.14),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1380px]">
|
||||
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.74)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.18),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Groups quickstart</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Get started with Groups fast and publish together without losing individual credit.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This quickstart is the fast path from curiosity to first success. It shows what a Group is, when to use one, how to invite the right people, and how to publish your first Group artwork with contributor credit handled properly.</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={links.create_group} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Create a Group</a>
|
||||
<a href={links.group_studio} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open Group Studio</a>
|
||||
<a href={links.full_documentation} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Read full Groups documentation</a>
|
||||
</div>
|
||||
{links.faq ? (
|
||||
<div className="mt-4">
|
||||
<a href={links.faq} className="text-sm font-semibold text-sky-200 underline underline-offset-4">
|
||||
Need quick answers instead of the full guide? Open the Groups FAQ.
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<HeroStat label="What a Group is" value="A shared team identity" note="Use it when a studio, crew, or project needs one public home instead of scattered personal uploads." />
|
||||
<HeroStat label="What stays visible" value="Real contributor credit" note="Published by, uploaded by, primary author, and contributor roles can still reflect the real people behind the work." />
|
||||
<HeroStat label="First win" value="Create, invite, publish" note="The goal of this page is simple: get you to a clean first Group publish without confusion." />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<DocsSidebarNav sections={SECTION_ITEMS} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<DocsSection
|
||||
id="what-is-a-group"
|
||||
eyebrow="Start here"
|
||||
title="What is a Group?"
|
||||
summary="A Group is a shared creative identity for multiple people. It lets a team publish together under one name while still showing who uploaded, authored, and contributed to the work."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{COMPARISON_CARDS.map((card) => <ComparisonCard key={card.title} card={card} />)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="note" title="The key idea to keep in your head">
|
||||
Group and personal publishing can coexist. A Group gives the team a shared identity, but it should not erase the people behind the work.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="when-to-use"
|
||||
eyebrow="Decision"
|
||||
title="When should you use a Group?"
|
||||
summary="Use a Group when collaboration is real enough to need shared identity, shared workflow, or shared publishing. Skip it for now if it only adds overhead."
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<SimpleListCard title="Use a Group when..." eyebrow="Good fit" items={GOOD_FIT} tone="emerald" />
|
||||
<SimpleListCard title="You can wait when..." eyebrow="Not necessary yet" items={NOT_NEEDED_YET} />
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="create-first-group"
|
||||
eyebrow="Build the foundation"
|
||||
title="Create your first Group"
|
||||
summary="The fastest clean start is a simple start. Get the identity created first, then improve it as the Group becomes active."
|
||||
>
|
||||
<DocsStepList items={CREATE_STEPS} />
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="tip" title="Start simple">
|
||||
You do not need perfect branding or a complex team structure on day one. You need a clear name, a usable page, and the right first members.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="setup-properly"
|
||||
eyebrow="Right after creation"
|
||||
title="Set up your Group properly"
|
||||
summary="The first few setup moves decide whether the Group feels trustworthy and active or unfinished and confusing."
|
||||
>
|
||||
<CompactGrid items={SETUP_TASKS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="invite-and-roles"
|
||||
eyebrow="Team setup"
|
||||
title="Invite members and choose roles"
|
||||
summary="Keep the role model clear. Most teams should stay simple at first: very few Owners, very few Admins, Editors for trusted content operators, and Contributors for most collaborators."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{ROLE_CARDS.map((role) => <RoleCard key={role.role} role={role} />)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="practice" title="Recommended first move">
|
||||
Invite your first members, assign only the roles they need right now, and avoid advanced permission tuning until the team has real workflow pressure.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="note" title="Advanced overrides can wait">
|
||||
If you need permission overrides later, you can add them later. The quickstart path is deliberately simpler than the full feature set.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="publish-first-artwork"
|
||||
eyebrow="First success"
|
||||
title="Publish your first artwork as a Group"
|
||||
summary="This is where new teams get tripped up most often. The artwork should appear under the Group publicly, but the people behind it should still be represented correctly."
|
||||
>
|
||||
<DocsStepList items={PUBLISH_STEPS} />
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="warning" title="Always check publishing context before the final click">
|
||||
Confirm whether you are publishing as your personal profile or as the Group. That one check prevents a lot of cleanup later.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="contributor-credit"
|
||||
eyebrow="Credit"
|
||||
title="Understand contributor credit"
|
||||
summary="Groups are for shared identity, not for hiding who did the actual work. Before the first public publish, make sure the credit record reflects reality."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{CREDIT_TERMS.map((item) => <CreditCard key={item.label} item={item} />)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="practice" title="Best practice">
|
||||
Review contributor credit before every first release, first Group artwork, or first major collaborative drop. Do not leave attribution as an afterthought.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="first-week-best-practices"
|
||||
eyebrow="First week"
|
||||
title="First-week best practices"
|
||||
summary="The first week should make the Group feel intentional, active, and easy to understand."
|
||||
>
|
||||
<CompactGrid items={FIRST_WEEK_BEST_PRACTICES} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="common-mistakes"
|
||||
eyebrow="Avoid these"
|
||||
title="Common mistakes to avoid"
|
||||
summary="These are the fastest ways to make a new Group feel confusing or unreliable."
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{COMMON_MISTAKES.map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-amber-300/15 bg-amber-400/10 p-4 text-sm leading-6 text-amber-50/95">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<section id="quick-checklist" className="scroll-mt-24">
|
||||
<QuickstartChecklist
|
||||
title="Use this before your first Group publish"
|
||||
summary="This is the lightweight completion list you want to be able to say yes to before the Group starts publishing publicly."
|
||||
items={QUICK_CHECKLIST}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<DocsSection
|
||||
id="next-steps"
|
||||
eyebrow="Keep going"
|
||||
title="Next steps"
|
||||
summary="Once the first Group exists and the first publish is clear, move into the next surface that helps your team actually operate."
|
||||
>
|
||||
<QuickstartNextSteps items={nextSteps} />
|
||||
</DocsSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function ArtworkGrid({ artworks }) {
|
||||
if (!Array.isArray(artworks) || artworks.length === 0) {
|
||||
return <p className="mt-4 text-sm text-slate-400">No linked artworks yet.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{artworks.map((artwork) => (
|
||||
<a key={artwork.id} href={artwork.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 transition hover:border-white/20">
|
||||
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="aspect-[4/3] w-full object-cover" /> : null}
|
||||
<div className="p-4">
|
||||
<h3 className="text-base font-semibold text-white">{artwork.title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">{artwork.author || 'Artwork'}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GroupReleaseShow() {
|
||||
const { props } = usePage()
|
||||
const group = props.group || {}
|
||||
const release = props.release || {}
|
||||
const contributors = Array.isArray(release.contributors) ? release.contributors : []
|
||||
const milestones = Array.isArray(release.milestones) ? release.milestones : []
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={`${release.title || group.name} - Skinbase`} description={release.summary || release.description || group.headline || 'Group release'} />
|
||||
<div className="mx-auto max-w-6xl space-y-8">
|
||||
<section className="overflow-hidden rounded-[32px] border border-white/10 bg-white/[0.03]">
|
||||
{release.cover_url ? <img src={release.cover_url} alt={release.title} className="h-64 w-full object-cover" /> : <div className="h-44 bg-white/[0.03]" />}
|
||||
<div className="p-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a href={group.urls?.public} className="text-sm font-semibold text-sky-200">{group.name}</a>
|
||||
{release.status ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.status}</span> : null}
|
||||
{release.current_stage ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.current_stage}</span> : null}
|
||||
</div>
|
||||
<h1 className="mt-4 text-4xl font-semibold text-white">{release.title}</h1>
|
||||
{release.summary ? <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-300">{release.summary}</p> : null}
|
||||
<div className="mt-5 flex flex-wrap gap-4 text-xs text-slate-400">
|
||||
{release.released_at ? <span>Released {new Date(release.released_at).toLocaleDateString()}</span> : null}
|
||||
{release.planned_release_at ? <span>Planned {new Date(release.planned_release_at).toLocaleDateString()}</span> : null}
|
||||
{release.lead?.name || release.lead?.username ? <span>Lead: {release.lead?.name || release.lead?.username}</span> : null}
|
||||
<span>{release.counts?.artworks || 0} artworks</span>
|
||||
<span>{release.counts?.contributors || 0} contributors</span>
|
||||
<span>{release.counts?.milestones || 0} milestones</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Overview</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{release.description || 'No long-form release description yet.'}</p>
|
||||
{release.release_notes ? <div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Release notes</div><div className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-300">{release.release_notes}</div></div> : null}
|
||||
<ArtworkGrid artworks={release.artworks} />
|
||||
</section>
|
||||
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Links</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{release.linked_project?.url ? <a href={release.linked_project.url} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><div className="font-semibold">{release.linked_project.title}</div><div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">Linked project</div></a> : null}
|
||||
{release.linked_collection?.url ? <a href={release.linked_collection.url} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><div className="font-semibold">{release.linked_collection.title}</div><div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">Linked collection</div></a> : null}
|
||||
{release.featured_artwork ? <div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><div className="font-semibold">{release.featured_artwork.title}</div><div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">Featured artwork</div></div> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Contributors</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{contributors.length > 0 ? contributors.map((contributor) => (
|
||||
<div key={contributor.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
{contributor.avatar_url ? <img src={contributor.avatar_url} alt={contributor.name || contributor.username} className="h-11 w-11 rounded-2xl object-cover" /> : <div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-white">{contributor.name || contributor.username}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{contributor.role_label || 'Contributor'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="text-sm text-slate-400">No contributor credits yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Milestones</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{milestones.length > 0 ? milestones.map((milestone) => (
|
||||
<div key={milestone.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{milestone.title}</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{milestone.status}</span>
|
||||
</div>
|
||||
{milestone.summary ? <p className="mt-2 text-sm text-slate-400">{milestone.summary}</p> : null}
|
||||
<div className="mt-2 text-xs text-slate-500">{milestone.owner?.name || milestone.owner?.username || 'No owner'}{milestone.due_date ? ` • due ${milestone.due_date}` : ''}</div>
|
||||
</div>
|
||||
)) : <p className="text-sm text-slate-400">No milestones defined yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
import React, { useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import useWebShare from '../../hooks/useWebShare'
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function formatCompactNumber(value) {
|
||||
return Number(value ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function formatDateLabel(value) {
|
||||
if (!value) return null
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
function websiteLabel(url) {
|
||||
if (!url) return null
|
||||
|
||||
try {
|
||||
const parsed = new URL(url.startsWith('http') ? url : `https://${url}`)
|
||||
return parsed.hostname
|
||||
} catch {
|
||||
return String(url).replace(/^https?:\/\//, '')
|
||||
}
|
||||
}
|
||||
|
||||
const SECTION_TABS = [
|
||||
{ id: 'overview', label: 'Overview', icon: 'fa-compass' },
|
||||
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
|
||||
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
|
||||
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
|
||||
{ id: 'projects', label: 'Projects', icon: 'fa-diagram-project' },
|
||||
{ id: 'releases', label: 'Releases', icon: 'fa-rocket' },
|
||||
{ id: 'challenges', label: 'Challenges', icon: 'fa-trophy' },
|
||||
{ id: 'events', label: 'Events', icon: 'fa-calendar-days' },
|
||||
{ id: 'activity', label: 'Activity', icon: 'fa-bolt' },
|
||||
{ id: 'members', label: 'Members', icon: 'fa-users' },
|
||||
{ id: 'about', label: 'About', icon: 'fa-id-card' },
|
||||
]
|
||||
|
||||
function sectionHref(baseUrl, tab) {
|
||||
return tab === 'overview' ? baseUrl : `${baseUrl}/${tab}`
|
||||
}
|
||||
|
||||
function GroupTabs({ baseUrl, activeSection }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-30 border-b border-white/10 bg-[#08111f]/80 backdrop-blur-2xl">
|
||||
<nav className="overflow-x-auto scrollbar-hide" aria-label="Group sections">
|
||||
<div className="mx-auto flex w-max min-w-full gap-2 px-3 py-3 justify-center xl:items-stretch">
|
||||
{SECTION_TABS.map((tab) => {
|
||||
const isActive = activeSection === tab.id
|
||||
|
||||
return (
|
||||
<a
|
||||
key={tab.id}
|
||||
href={sectionHref(baseUrl, tab.id)}
|
||||
className={`group relative flex items-center gap-2.5 rounded-2xl border px-3.5 py-3 text-sm font-medium whitespace-nowrap outline-none transition-all duration-150 ${isActive
|
||||
? 'border-sky-300/25 bg-gradient-to-br from-sky-400/18 via-white/[0.06] to-cyan-400/10 text-white shadow-[0_16px_32px_rgba(14,165,233,0.12)]'
|
||||
: 'border-white/8 bg-white/[0.03] text-slate-400 hover:border-white/15 hover:bg-white/[0.05] hover:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl border text-sm ${isActive ? 'border-sky-300/20 bg-sky-400/10 text-sky-200' : 'border-white/10 bg-white/[0.04] text-slate-500 group-hover:text-slate-300'}`}>
|
||||
<i className={`fa-solid ${tab.icon} fa-fw`} />
|
||||
</span>
|
||||
{tab.label}
|
||||
{isActive ? <span className="absolute inset-x-4 bottom-0 h-0.5 rounded-full bg-sky-300 shadow-[0_0_10px_rgba(125,211,252,0.8)]" aria-hidden="true" /> : null}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupHero({
|
||||
group,
|
||||
recruitment,
|
||||
trustSignals,
|
||||
following,
|
||||
followersCount,
|
||||
currentJoinRequest,
|
||||
shareLabel,
|
||||
onToggleFollow,
|
||||
onJoinRequest,
|
||||
onWithdrawJoinRequest,
|
||||
onShare,
|
||||
onReport,
|
||||
reportEndpoint,
|
||||
}) {
|
||||
const activeSignals = Array.isArray(trustSignals) ? trustSignals.slice(0, 3) : []
|
||||
const joinDate = formatDateLabel(group.founded_at || group.created_at)
|
||||
const heroStats = [
|
||||
{ label: 'Followers', value: formatCompactNumber(followersCount) },
|
||||
{ label: 'Members', value: formatCompactNumber(group.counts?.members) },
|
||||
{ label: 'Artworks', value: formatCompactNumber(group.counts?.artworks) },
|
||||
{ label: 'Collections', value: formatCompactNumber(group.counts?.collections) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto max-w-7xl px-4 pt-4 md:pt-6">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-10 top-8 -z-10 h-44 rounded-full blur-3xl"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, rgba(56,189,248,0.18), rgba(16,185,129,0.14), rgba(59,130,246,0.12))',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative overflow-hidden rounded-[32px] border border-white/10 bg-[#09111f]/80 shadow-[0_24px_80px_rgba(2,6,23,0.55)]">
|
||||
<div
|
||||
className="w-full h-[208px] md:h-[248px] xl:h-[288px]"
|
||||
style={{
|
||||
background: group.banner_url
|
||||
? `url('${group.banner_url}') center center / cover no-repeat`
|
||||
: 'linear-gradient(140deg, #07101d 0%, #0b1726 42%, #07111e 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div className="absolute left-4 top-4 z-20 flex flex-wrap items-center gap-2 md:left-6 md:top-6">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-200 backdrop-blur-md">
|
||||
<span className="h-2 w-2 rounded-full bg-sky-400 shadow-[0_0_12px_rgba(56,189,248,0.9)]" />
|
||||
Group profile
|
||||
</span>
|
||||
{group.is_verified ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100 backdrop-blur-md">
|
||||
<i className="fa-solid fa-badge-check text-[10px]" />
|
||||
Verified
|
||||
</span>
|
||||
) : null}
|
||||
{recruitment?.is_recruiting ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-300/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100 backdrop-blur-md">
|
||||
<i className="fa-solid fa-user-plus text-[10px]" />
|
||||
Recruiting
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: group.banner_url
|
||||
? 'linear-gradient(180deg, rgba(2,6,23,0.16) 0%, rgba(2,6,23,0.28) 38%, rgba(2,6,23,0.9) 100%)'
|
||||
: 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(16,185,129,.14) 0%, transparent 54%)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
|
||||
</div>
|
||||
|
||||
<div className="relative px-4 pb-6 md:px-7 md:pb-7">
|
||||
<div className="relative -mt-16 flex flex-col gap-5 md:-mt-20 md:flex-row md:items-start md:gap-6">
|
||||
<div className="mx-auto z-10 shrink-0 md:mx-0">
|
||||
<div className="flex h-[112px] w-[112px] items-center justify-center overflow-hidden rounded-[28px] border border-white/15 bg-[#0b1320] shadow-[0_0_0_8px_rgba(9,17,31,0.92),0_22px_44px_rgba(2,6,23,0.5)] md:h-[132px] md:w-[132px]">
|
||||
{group.avatar_url ? (
|
||||
<img src={group.avatar_url} alt={group.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<i className="fa-solid fa-people-group text-4xl text-slate-300" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 text-center md:text-left">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_430px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
||||
<i className="fa-solid fa-stars text-[10px] text-sky-300" />
|
||||
Publishing collective
|
||||
</span>
|
||||
{group.owner?.username || group.owner?.name ? (
|
||||
<a href={group.owner?.profile_url || '#'} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300 transition hover:bg-white/[0.08] hover:text-white">
|
||||
<i className="fa-solid fa-user-gear text-[10px] text-slate-400" />
|
||||
Led by {group.owner?.username || group.owner?.name}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h1 className="mt-3 text-[30px] font-semibold leading-tight tracking-[-0.03em] text-white md:text-[42px]">
|
||||
{group.name}
|
||||
</h1>
|
||||
<p className="mt-1 font-mono text-sm text-slate-400 md:text-[15px]">@{group.slug}</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
||||
{group.visibility ? <span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">{group.visibility}</span> : null}
|
||||
{group.status ? <span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">{group.status}</span> : null}
|
||||
{group.type ? <span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">{group.type}</span> : null}
|
||||
{joinDate ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
|
||||
<i className="fa-solid fa-calendar-days fa-fw text-slate-500" />
|
||||
Since {joinDate}
|
||||
</span>
|
||||
) : null}
|
||||
{group.website_url ? (
|
||||
<a
|
||||
href={group.website_url.startsWith('http') ? group.website_url : `https://${group.website_url}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1.5 text-xs text-sky-200 transition-colors hover:border-sky-300/35 hover:bg-sky-400/15"
|
||||
>
|
||||
<i className="fa-solid fa-link fa-fw" />
|
||||
{websiteLabel(group.website_url)}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{group.headline ? <p className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-slate-300/90 md:mx-0 md:text-[15px]">{group.headline}</p> : null}
|
||||
{group.bio ? <p className="mx-auto mt-3 max-w-3xl text-sm leading-relaxed text-slate-400 md:mx-0 line-clamp-3">{group.bio}</p> : null}
|
||||
|
||||
{activeSignals.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
||||
{activeSignals.map((signal) => (
|
||||
<span key={signal.key} className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-medium text-slate-300">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-cyan-300" />
|
||||
{signal.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 xl:pt-1">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 xl:flex-nowrap xl:justify-end">
|
||||
{group.urls?.studio ? (
|
||||
<a
|
||||
href={group.urls.studio}
|
||||
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-2xl bg-gradient-to-r from-sky-500 to-cyan-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-[0_18px_36px_rgba(14,165,233,0.28)] transition-transform hover:-translate-y-0.5"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
|
||||
Open Studio
|
||||
</a>
|
||||
) : null}
|
||||
{group.urls?.follow ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFollow}
|
||||
className={`inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-2xl border px-4 py-2.5 text-sm font-medium transition-all ${following ? 'border-emerald-400/40 bg-emerald-500/12 text-emerald-300 hover:bg-emerald-500/18' : 'border-sky-400/40 bg-sky-500/12 text-sky-200 hover:bg-sky-500/20'}`}
|
||||
>
|
||||
<i className={`fa-solid ${following ? 'fa-circle-check' : 'fa-user-plus'} fa-fw`} />
|
||||
{following ? 'Following' : 'Follow group'}
|
||||
</button>
|
||||
) : null}
|
||||
{group.permissions?.can_request_join ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onJoinRequest}
|
||||
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-2xl border border-emerald-300/25 bg-emerald-300/10 px-4 py-2.5 text-sm font-medium text-emerald-100 transition hover:bg-emerald-300/15"
|
||||
>
|
||||
<i className="fa-solid fa-door-open fa-fw" />
|
||||
Request to join
|
||||
</button>
|
||||
) : null}
|
||||
{currentJoinRequest?.status === 'pending' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onWithdrawJoinRequest}
|
||||
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-xmark fa-fw" />
|
||||
Withdraw request
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShare}
|
||||
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||||
{shareLabel}
|
||||
</button>
|
||||
{reportEndpoint ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReport}
|
||||
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-flag fa-fw" />
|
||||
Report
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(9,17,31,0.92))] p-3 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{heroStats.map((fact) => (
|
||||
<div key={fact.label} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2.5">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{fact.label}</div>
|
||||
<div className="mt-1 text-sm font-semibold tracking-tight text-white md:text-[15px]">{fact.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
{group.owner?.username || group.owner?.name ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-medium text-slate-300">
|
||||
<i className="fa-solid fa-crown text-[10px] text-amber-300" />
|
||||
Owner {group.owner?.username || group.owner?.name}
|
||||
</span>
|
||||
) : null}
|
||||
{recruitment?.headline ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-medium text-slate-300">
|
||||
<i className="fa-solid fa-bullhorn text-[10px] text-sky-300" />
|
||||
{recruitment.headline}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtworkGrid({ artworks, emptyLabel = 'No artworks yet.' }) {
|
||||
if (!Array.isArray(artworks) || artworks.length === 0) {
|
||||
return <p className="mt-5 text-sm text-slate-400">{emptyLabel}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{artworks.map((artwork) => (
|
||||
<a key={artwork.id} href={artwork.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 transition hover:border-white/20">
|
||||
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="aspect-[4/3] w-full object-cover" /> : null}
|
||||
<div className="p-4">
|
||||
<h3 className="text-base font-semibold text-white">{artwork.title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">{artwork.author}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionGrid({ collections, emptyLabel = 'No collections yet.' }) {
|
||||
if (!Array.isArray(collections) || collections.length === 0) {
|
||||
return <p className="mt-5 text-sm text-slate-400">{emptyLabel}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||
{collections.map((collection) => (
|
||||
<a key={collection.id} href={collection.url} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white">{collection.title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-300">{collection.summary || collection.description_excerpt || 'Collection'}</p>
|
||||
</div>
|
||||
{collection.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">Featured</span> : null}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactCardGrid({ items, emptyLabel, badgeKey = 'status' }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <p className="mt-5 text-sm text-slate-400">{emptyLabel}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<a key={item.id} href={item.url} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-white">{item.title}</h3>
|
||||
{item[badgeKey] ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item[badgeKey]}</span> : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{item.summary || 'Open for more details.'}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseGrid({ releases, emptyLabel = 'No public releases yet.' }) {
|
||||
if (!Array.isArray(releases) || releases.length === 0) {
|
||||
return <p className="mt-5 text-sm text-slate-400">{emptyLabel}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{releases.map((release) => (
|
||||
<a key={release.id} href={release.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 transition hover:border-white/20">
|
||||
{release.cover_url ? <img src={release.cover_url} alt={release.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-white/[0.03] text-slate-500"><i className="fa-solid fa-rocket text-2xl" /></div>}
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.status}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.current_stage}</span>
|
||||
</div>
|
||||
<h3 className="mt-3 text-base font-semibold text-white">{release.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{release.summary || 'Release overview and linked artworks.'}</p>
|
||||
<div className="mt-3 text-xs text-slate-500">{release.counts?.artworks || 0} artworks • {release.counts?.contributors || 0} contributors • {release.counts?.milestones || 0} milestones</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssetGrid({ assets, emptyLabel = 'No public resources yet.' }) {
|
||||
if (!Array.isArray(assets) || assets.length === 0) {
|
||||
return <p className="mt-5 text-sm text-slate-400">{emptyLabel}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{assets.map((asset) => (
|
||||
<a key={asset.id} href={asset.download_url} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{asset.category}</div>
|
||||
<h3 className="mt-2 text-base font-semibold text-white">{asset.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{asset.description || 'Download this shared group asset.'}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityFeed({ items, emptyLabel = 'No public activity yet.' }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <p className="mt-5 text-sm text-slate-400">{emptyLabel}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-white">{item.headline}</h3>
|
||||
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Pinned</span> : null}
|
||||
</div>
|
||||
{item.summary ? <p className="mt-2 text-sm leading-6 text-slate-300">{item.summary}</p> : null}
|
||||
<div className="mt-2 text-xs text-slate-500">{item.actor?.name || item.actor?.username || 'System'} • {item.occurred_at ? new Date(item.occurred_at).toLocaleString() : 'Recently'}</div>
|
||||
{item.subject?.url ? <a href={item.subject.url} className="mt-3 inline-flex text-sm font-semibold text-sky-200">Open</a> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LeadershipPreview({ leadership }) {
|
||||
if (!Array.isArray(leadership) || leadership.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Leadership</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Owner and admins</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{leadership.map((member) => (
|
||||
<a key={member.id} href={member.profile_url || '#'} className="flex items-center gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 transition hover:border-white/20">
|
||||
{member.avatar_url ? <img src={member.avatar_url} alt={member.name || member.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-white">{member.name || member.username}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{member.role_label || member.role}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FocusCard({ eyebrow, item, badgeKey = 'status', ctaLabel }) {
|
||||
if (!item?.title) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">{eyebrow}</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h2 className="text-2xl font-semibold text-white">{item.title}</h2>
|
||||
{item[badgeKey] ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item[badgeKey]}</span> : null}
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{item.summary || 'Open for more details.'}</p>
|
||||
<a href={item.url} className="mt-4 inline-flex rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">{ctaLabel}</a>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TrustSignalPanel({ signals }) {
|
||||
if (!Array.isArray(signals) || signals.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toneClasses = {
|
||||
sky: 'border-sky-300/20 bg-sky-300/10 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-300/10 text-amber-100',
|
||||
violet: 'border-violet-300/20 bg-violet-300/10 text-violet-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Trust signals</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">How this group shows up</h2>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{signals.map((signal) => <span key={signal.key} className={`rounded-full border px-3 py-2 text-sm font-semibold ${toneClasses[signal.tone] || 'border-white/10 bg-white/[0.04] text-white'}`}>{signal.label}</span>)}
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{signals.map((signal) => <div key={`${signal.key}-reason`} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="font-semibold text-white">{signal.label}</div><p className="mt-2 text-sm leading-6 text-slate-400">{signal.reason}</p></div>)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BadgeShowcase({ badges }) {
|
||||
if (!Array.isArray(badges) || badges.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Badges</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Earned group signals</h2>
|
||||
<div className="mt-5 grid gap-3">
|
||||
{badges.map((badge) => (
|
||||
<div key={badge.key} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{badge.label}</div>
|
||||
{badge.awarded_at ? <div className="text-xs text-slate-500">{new Date(badge.awarded_at).toLocaleDateString()}</div> : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{badge.reason}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ContributorHighlights({ contributors }) {
|
||||
if (!Array.isArray(contributors) || contributors.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Contributors</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Trusted collaborators</h2>
|
||||
<div className="mt-5 space-y-3">
|
||||
{contributors.map((entry) => (
|
||||
<a key={entry.user?.id} href={entry.user?.profile_url || '#'} className="flex gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 transition hover:border-white/20">
|
||||
{entry.user?.avatar_url ? <img src={entry.user.avatar_url} alt={entry.user?.name || entry.user?.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="truncate font-semibold text-white">{entry.user?.name || entry.user?.username}</div>
|
||||
{entry.trusted_indicator ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Trusted</span> : null}
|
||||
</div>
|
||||
{entry.summary ? <p className="mt-1 text-sm text-slate-400">{entry.summary}</p> : null}
|
||||
<div className="mt-2 text-xs text-slate-500">{entry.counts?.releases || 0} releases • {entry.counts?.credited_artworks || 0} artworks • {entry.counts?.projects || 0} projects</div>
|
||||
{Array.isArray(entry.badges) && entry.badges.length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{entry.badges.slice(0, 3).map((badge) => <span key={`${entry.user?.id}-${badge.key}`} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{badge.label}</span>)}</div> : null}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
export default function GroupShow() {
|
||||
const { props } = usePage()
|
||||
const group = props.group || {}
|
||||
const section = props.section || 'overview'
|
||||
const featuredArtworks = Array.isArray(props.featuredArtworks) ? props.featuredArtworks : []
|
||||
const artworks = Array.isArray(props.artworks) ? props.artworks : []
|
||||
const featuredCollections = Array.isArray(props.featuredCollections) ? props.featuredCollections : []
|
||||
const collections = Array.isArray(props.collections) ? props.collections : []
|
||||
const posts = Array.isArray(props.posts) ? props.posts : []
|
||||
const projects = Array.isArray(props.projects) ? props.projects : []
|
||||
const releases = Array.isArray(props.releases) ? props.releases : []
|
||||
const challenges = Array.isArray(props.challenges) ? props.challenges : []
|
||||
const events = Array.isArray(props.events) ? props.events : []
|
||||
const assets = Array.isArray(props.assets) ? props.assets : []
|
||||
const activity = Array.isArray(props.activity) ? props.activity : []
|
||||
const recruitment = props.recruitment || null
|
||||
const currentJoinRequest = group.current_join_request || null
|
||||
const leadership = Array.isArray(props.leadership) ? props.leadership : []
|
||||
const members = Array.isArray(props.members) ? props.members : []
|
||||
const topContributors = Array.isArray(props.topContributors) ? props.topContributors : []
|
||||
const trustSignals = Array.isArray(props.trustSignals) ? props.trustSignals : []
|
||||
const badgeShowcase = Array.isArray(props.badgeShowcase) ? props.badgeShowcase : []
|
||||
const [following, setFollowing] = useState(Boolean(group.viewer?.is_following))
|
||||
const [followersCount, setFollowersCount] = useState(Number(group.counts?.followers || 0))
|
||||
const [shareLabel, setShareLabel] = useState('Share')
|
||||
const [artworkQuery, setArtworkQuery] = useState('')
|
||||
const [artworkSort, setArtworkSort] = useState('latest')
|
||||
const contentShellClassName = section === 'artworks'
|
||||
? 'mx-auto max-w-7xl px-4 md:px-6'
|
||||
: section === 'overview' || section === 'posts'
|
||||
? 'mx-auto max-w-7xl px-4 md:px-6'
|
||||
: 'mx-auto max-w-6xl px-4'
|
||||
|
||||
const filteredArtworks = artworks
|
||||
.filter((artwork) => {
|
||||
const q = normalizeText(artworkQuery)
|
||||
if (!q) return true
|
||||
|
||||
return normalizeText(artwork.title).includes(q) || normalizeText(artwork.author).includes(q)
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (artworkSort === 'oldest') {
|
||||
return new Date(left.published_at || 0).getTime() - new Date(right.published_at || 0).getTime()
|
||||
}
|
||||
|
||||
if (artworkSort === 'title') {
|
||||
return String(left.title || '').localeCompare(String(right.title || ''))
|
||||
}
|
||||
|
||||
return new Date(right.published_at || 0).getTime() - new Date(left.published_at || 0).getTime()
|
||||
})
|
||||
|
||||
const groupedMembers = {
|
||||
owner: members.filter((member) => member.role === 'owner'),
|
||||
admins: members.filter((member) => member.role === 'admin'),
|
||||
editors: members.filter((member) => member.role === 'editor'),
|
||||
contributors: members.filter((member) => member.role !== 'owner' && member.role !== 'admin' && member.role !== 'editor'),
|
||||
}
|
||||
|
||||
const { share } = useWebShare({
|
||||
onFallback: async ({ url }) => {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setShareLabel('Link copied')
|
||||
window.setTimeout(() => setShareLabel('Share'), 2000)
|
||||
return
|
||||
}
|
||||
|
||||
window.prompt('Copy this link', url)
|
||||
},
|
||||
})
|
||||
|
||||
const submitReport = async () => {
|
||||
if (!props.reportEndpoint) return
|
||||
const reason = window.prompt('Reason for reporting this group?')
|
||||
if (!reason) return
|
||||
await fetch(props.reportEndpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ target_type: 'group', target_id: group.id, reason }),
|
||||
})
|
||||
}
|
||||
|
||||
const toggleFollow = async () => {
|
||||
const response = await fetch(following ? group.urls?.unfollow : group.urls?.follow, {
|
||||
method: following ? 'DELETE' : 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
})
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (response.ok) {
|
||||
setFollowing(Boolean(payload?.following))
|
||||
setFollowersCount(Number(payload?.followers_count || 0))
|
||||
}
|
||||
}
|
||||
|
||||
const handleShare = async () => {
|
||||
const url = group.urls?.public || (typeof window !== 'undefined' ? window.location.href : '')
|
||||
|
||||
await share({
|
||||
title: `${group.name} on Skinbase`,
|
||||
text: group.headline || group.bio || 'Check out this Skinbase group.',
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
||||
const submitJoinRequest = async () => {
|
||||
const message = window.prompt('Why do you want to join this group?') || ''
|
||||
const desiredRole = window.prompt('Desired role: contributor, editor, or admin', 'contributor') || 'contributor'
|
||||
router.post(group.urls?.join_request_store, { message, desired_role: desiredRole })
|
||||
}
|
||||
|
||||
const withdrawJoinRequest = async () => {
|
||||
if (!currentJoinRequest?.id || !group.urls?.join_request_withdraw_pattern) return
|
||||
router.delete(group.urls.join_request_withdraw_pattern.replace('__JOIN_REQUEST__', String(currentJoinRequest.id)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<SeoHead title={`${group.name} - Skinbase`} description={group.headline || group.bio || 'Skinbase group'} />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"
|
||||
style={{
|
||||
background: 'radial-gradient(circle at top left, rgba(56,189,248,0.18), transparent 32%), radial-gradient(circle at 82% 10%, rgba(16,185,129,0.16), transparent 28%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #0a1220 100%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 -z-10 opacity-[0.06]"
|
||||
style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }}
|
||||
/>
|
||||
|
||||
<GroupHero
|
||||
group={group}
|
||||
recruitment={recruitment}
|
||||
trustSignals={trustSignals}
|
||||
following={following}
|
||||
followersCount={followersCount}
|
||||
currentJoinRequest={currentJoinRequest}
|
||||
shareLabel={shareLabel}
|
||||
onToggleFollow={toggleFollow}
|
||||
onJoinRequest={submitJoinRequest}
|
||||
onWithdrawJoinRequest={withdrawJoinRequest}
|
||||
onShare={handleShare}
|
||||
onReport={submitReport}
|
||||
reportEndpoint={props.reportEndpoint}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<GroupTabs baseUrl={group.urls?.public || '/groups'} activeSection={section} />
|
||||
</div>
|
||||
|
||||
<div className={`${contentShellClassName} pt-6`}>
|
||||
|
||||
{section === 'overview' ? (
|
||||
<div className="mt-8 grid gap-8">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Highlights</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Featured artworks</h2>
|
||||
</div>
|
||||
<a href={`${group.urls?.public}/artworks`} className="text-sm font-semibold text-sky-200">Browse all</a>
|
||||
</div>
|
||||
<ArtworkGrid artworks={featuredArtworks} emptyLabel="No featured artworks yet." />
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Latest work</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Latest artworks</h2>
|
||||
</div>
|
||||
<a href={`${group.urls?.public}/artworks`} className="text-sm font-semibold text-sky-200">View archive</a>
|
||||
</div>
|
||||
<ArtworkGrid artworks={artworks.slice(0, 6)} emptyLabel="No published artworks yet." />
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Pipeline</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Recent releases</h2>
|
||||
</div>
|
||||
<a href={`${group.urls?.public}/releases`} className="text-sm font-semibold text-sky-200">View releases</a>
|
||||
</div>
|
||||
<ReleaseGrid releases={releases.slice(0, 3)} emptyLabel="No public releases yet." />
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Curated</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Featured collections</h2>
|
||||
</div>
|
||||
<a href={`${group.urls?.public}/collections`} className="text-sm font-semibold text-sky-200">View collections</a>
|
||||
</div>
|
||||
<CollectionGrid collections={featuredCollections.length > 0 ? featuredCollections : collections.slice(0, 2)} emptyLabel="No featured collections yet." />
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8">
|
||||
{group.pinned_post ? (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Pinned post</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">{group.pinned_post.title}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{group.pinned_post.excerpt || 'Read the latest pinned update from this group.'}</p>
|
||||
<a href={group.pinned_post.url} className="mt-4 inline-flex rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Read post</a>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<FocusCard eyebrow="Releases" item={group.featured_release} badgeKey="current_stage" ctaLabel="Open release" />
|
||||
<FocusCard eyebrow="Projects" item={group.featured_project} ctaLabel="Open project" />
|
||||
<FocusCard eyebrow="Challenges" item={group.active_challenge} ctaLabel="View challenge" />
|
||||
<FocusCard eyebrow="Events" item={group.upcoming_event} badgeKey="event_type" ctaLabel="View event" />
|
||||
<LeadershipPreview leadership={leadership} />
|
||||
<TrustSignalPanel signals={trustSignals} />
|
||||
<BadgeShowcase badges={badgeShowcase} />
|
||||
<ContributorHighlights contributors={topContributors.slice(0, 4)} />
|
||||
|
||||
{recruitment?.is_recruiting ? (
|
||||
<section className="rounded-[30px] border border-emerald-300/20 bg-emerald-400/10 p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-100/80">Recruiting</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">{recruitment.headline || `${group.name} is looking for collaborators`}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-emerald-50/90">{recruitment.description || 'This group is currently open to new contributors.'}</p>
|
||||
{Array.isArray(recruitment.roles) && recruitment.roles.length > 0 ? <div className="mt-4 flex flex-wrap gap-2">{recruitment.roles.map((role) => <span key={role} className="rounded-full border border-white/10 bg-white/[0.08] px-3 py-1.5 text-xs font-semibold text-white">{role}</span>)}</div> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Resources</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Shared downloads</h2>
|
||||
<AssetGrid assets={assets.slice(0, 3)} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Public feed</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Recent activity</h2>
|
||||
<ActivityFeed items={activity.slice(0, 4)} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">About</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">About {group.name}</h2>
|
||||
<p className="mt-5 text-sm leading-7 text-slate-300">{group.bio || 'No long-form description yet.'}</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-xs text-slate-400">
|
||||
{group.founded_at ? <span>Founded {new Date(group.founded_at).toLocaleDateString()}</span> : null}
|
||||
{group.type ? <span>{group.type}</span> : null}
|
||||
{group.website_url ? <a href={group.website_url} className="text-sky-200 underline underline-offset-4">Website</a> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{section === 'artworks' ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white">Artworks</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">Filter the group archive by title or contributor credit label, then change the sort order.</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
|
||||
<input value={artworkQuery} onChange={(event) => setArtworkQuery(event.target.value)} placeholder="Filter artworks" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Sort</span>
|
||||
<select value={artworkSort} onChange={(event) => setArtworkSort(event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="latest">Latest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
<option value="title">Title A-Z</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<ArtworkGrid artworks={filteredArtworks} emptyLabel="No published artworks match the current filter." />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{section === 'collections' ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Collections</h2>
|
||||
<CollectionGrid collections={collections} emptyLabel="No collections yet." />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{section === 'posts' ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Posts</h2>
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||
{posts.length > 0 ? posts.map((post) => (
|
||||
<a key={post.id} href={post.url} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{post.type}</div>
|
||||
{post.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">Pinned</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-2 text-lg font-semibold text-white">{post.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{post.excerpt || 'Open the post to read more.'}</p>
|
||||
</a>
|
||||
)) : <p className="text-sm text-slate-400">No posts published yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{section === 'projects' ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Projects</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">Structured releases, collaboration hubs, and production pages published by this group.</p>
|
||||
<CompactCardGrid items={projects} emptyLabel="No public projects yet." />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{section === 'releases' ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Releases</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">Published drops, milestone pipelines, and linked showcases from this group.</p>
|
||||
<ReleaseGrid releases={releases} emptyLabel="No public releases yet." />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{section === 'challenges' ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Challenges</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">Current and past prompts, internal sprints, and public-facing challenge runs.</p>
|
||||
<CompactCardGrid items={challenges} emptyLabel="No public challenges yet." />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{section === 'events' ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Events</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">Launches, milestones, streams, and other moments on the group timeline.</p>
|
||||
<CompactCardGrid items={events} emptyLabel="No public events yet." badgeKey="event_type" />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{section === 'activity' ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Activity</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">Public milestones from posts, releases, events, member changes, and challenge highlights.</p>
|
||||
<ActivityFeed items={activity} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{section === 'members' ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Members</h2>
|
||||
<div className="mt-6 grid gap-8">
|
||||
{[
|
||||
['Owner', groupedMembers.owner],
|
||||
['Admins', groupedMembers.admins],
|
||||
['Editors', groupedMembers.editors],
|
||||
['Contributors', groupedMembers.contributors],
|
||||
].map(([label, bucket]) => (
|
||||
bucket.length > 0 ? (
|
||||
<section key={label}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{label}</h3>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{bucket.length}</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{bucket.map((member) => (
|
||||
<a key={member.id} href={member.user?.profile_url || '#'} className="flex items-center gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 transition hover:border-white/20">
|
||||
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-white">{member.user?.name || member.user?.username}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{member.role_label || member.role}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{section === 'about' ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">About</h2>
|
||||
<div className="mt-5 space-y-4 text-sm leading-7 text-slate-300">
|
||||
<p>{group.bio || 'No long-form description yet.'}</p>
|
||||
{group.website_url ? <p><a href={group.website_url} className="text-sky-200 underline underline-offset-4">{group.website_url}</a></p> : null}
|
||||
{Array.isArray(group.links) && group.links.length > 0 ? <div className="flex flex-wrap gap-3">{group.links.map((link) => <a key={`${link.label}-${link.url}`} href={link.url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">{link.label}</a>)}</div> : null}
|
||||
{group.founded_at ? <p>Founded: {new Date(group.founded_at).toLocaleDateString()}</p> : null}
|
||||
{group.type ? <p>Type: {group.type}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import GroupShow from '../GroupShow'
|
||||
|
||||
let pageMock = { props: {} }
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
usePage: () => pageMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../../components/seo/SeoHead', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks/useWebShare', () => ({
|
||||
default: () => ({ share: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('GroupShow public page', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the public group hero, tabs, and grouped members', () => {
|
||||
pageMock = {
|
||||
props: {
|
||||
section: 'members',
|
||||
group: {
|
||||
id: 1,
|
||||
name: 'Warp Collective',
|
||||
headline: 'Retro visual lab',
|
||||
visibility: 'public',
|
||||
status: 'active',
|
||||
counts: { artworks: 4, collections: 2, members: 4, followers: 10 },
|
||||
urls: { public: '/groups/warp-collective', follow: '/groups/warp-collective/follow', unfollow: '/groups/warp-collective/follow' },
|
||||
viewer: { is_following: false },
|
||||
},
|
||||
featuredArtworks: [],
|
||||
artworks: [],
|
||||
featuredCollections: [],
|
||||
collections: [],
|
||||
leadership: [],
|
||||
members: [
|
||||
{ id: 1, role: 'owner', role_label: 'owner', user: { name: 'Owner', username: 'owner', profile_url: '/@owner', avatar_url: null } },
|
||||
{ id: 2, role: 'admin', role_label: 'admin', user: { name: 'Admin', username: 'admin', profile_url: '/@admin', avatar_url: null } },
|
||||
{ id: 3, role: 'editor', role_label: 'editor', user: { name: 'Editor', username: 'editor', profile_url: '/@editor', avatar_url: null } },
|
||||
{ id: 4, role: 'contributor', role_label: 'contributor', user: { name: 'Contributor', username: 'contributor', profile_url: '/@contributor', avatar_url: null } },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<GroupShow />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: /warp collective/i })).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'overview' })).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'artworks' })).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'collections' })).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'members' })).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'about' })).not.toBeNull()
|
||||
expect(screen.getByRole('heading', { name: 'Owner' })).not.toBeNull()
|
||||
expect(screen.getByRole('heading', { name: 'Admins' })).not.toBeNull()
|
||||
expect(screen.getByRole('heading', { name: 'Editors' })).not.toBeNull()
|
||||
expect(screen.getByRole('heading', { name: 'Contributors' })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,484 @@
|
||||
export const FAQ_CATEGORIES = [
|
||||
{
|
||||
id: 'basics',
|
||||
label: 'Basics',
|
||||
title: 'Basics',
|
||||
summary: 'Start here if you need the fastest explanation of what Groups are and when they make sense.',
|
||||
items: [
|
||||
{
|
||||
question: 'What is a Group?',
|
||||
paragraphs: [
|
||||
'A Group is a shared creative identity for teams, collectives, projects, and recurring collaboration. It gives multiple creators one public home for work, updates, and shared activity.',
|
||||
'It is meant for collaboration, not as a replacement for your personal profile.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between a personal profile and a Group?',
|
||||
paragraphs: [
|
||||
'A personal profile is your individual identity, portfolio, and reputation. A Group is the team or shared identity layer.',
|
||||
'Both can exist side by side. You can keep publishing personally while also publishing collaborative work under a Group.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Should I create a Group?',
|
||||
paragraphs: [
|
||||
'Create one if you collaborate regularly, want a shared public brand, or need shared roles and publishing workflows.',
|
||||
'If you only publish solo work and do not need a team identity yet, you can stay on your personal profile for now.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can I still publish personally if I also use a Group?',
|
||||
paragraphs: [
|
||||
'Yes. Many creators use both. Personal publishing is for individual work, while Group publishing is for collaborative work or a shared brand.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can a Group exist with only one member?',
|
||||
paragraphs: [
|
||||
'Yes, if the product allows it, but it is most useful when there is a real shared identity or collaboration reason behind it.',
|
||||
'If it is only being used to rename personal work, a personal profile may still be the simpler choice.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'roles-and-permissions',
|
||||
label: 'Roles & Permissions',
|
||||
title: 'Roles & permissions',
|
||||
summary: 'These answers explain who can do what and why role differences exist inside a Group.',
|
||||
items: [
|
||||
{
|
||||
question: 'What does the Owner role do?',
|
||||
paragraphs: [
|
||||
'Owner is the highest-trust role. Owners control sensitive settings, membership structure, and the overall direction of the Group.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What does the Admin role do?',
|
||||
paragraphs: [
|
||||
'Admins usually help manage day-to-day Group operations, member access, and important content workflows.',
|
||||
'This role should stay limited to people the Group deeply trusts.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What does the Editor role do?',
|
||||
paragraphs: [
|
||||
'Editors are usually the best fit for people who help manage content, publishing, reviews, releases, or coordination without needing full Group control.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What does the Contributor role do?',
|
||||
paragraphs: [
|
||||
'Contributors participate in the creative side of the Group without needing broad access to settings or member management.',
|
||||
'For many teams, this is the right starting role for most collaborators.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Who can invite members?',
|
||||
paragraphs: [
|
||||
'Usually Owners and Admins, depending on the Group setup. If you do not see invite controls, your role probably does not include member management.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Who can change member roles?',
|
||||
paragraphs: [
|
||||
'Usually Owners and sometimes Admins. This depends on the Group’s trust model and any role restrictions already in place.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Who can publish as the Group?',
|
||||
paragraphs: [
|
||||
'That depends on the Group role and workflow. Owners and Admins often can. Editors often can. Contributors may submit drafts without publishing directly if the team uses approvals.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Why can’t I do something another member can do?',
|
||||
paragraphs: [
|
||||
'Roles are not identical. One person may have a higher role or a permission override that gives access you do not have.',
|
||||
'If you are unsure, ask an Owner or Admin what your role is meant to cover.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Should I give lots of people Admin access?',
|
||||
paragraphs: [
|
||||
'Usually no. Keep high-level roles limited. It is easier to add trust later than clean up a Group where too many people can change everything.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can permissions be customized?',
|
||||
paragraphs: [
|
||||
'In some cases, yes. Some Groups may use permission overrides on top of the main role system.',
|
||||
'If your team is new, it is usually better to keep the role model simple first and only customize later when there is a clear need.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'publishing-and-credit',
|
||||
label: 'Publishing & Credit',
|
||||
title: 'Publishing & contributor credit',
|
||||
summary: 'This section covers the biggest source of user confusion: how shared identity and individual attribution work together.',
|
||||
items: [
|
||||
{
|
||||
question: 'What does “publish as Group” mean?',
|
||||
paragraphs: [
|
||||
'It means the work appears publicly under the Group identity rather than under a personal profile.',
|
||||
'That does not erase individual authorship or responsibility for the work.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Will my name still appear if I publish under a Group?',
|
||||
paragraphs: [
|
||||
'Yes. Group publishing is designed to preserve individual credit and accountability, not hide it.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between Published by, Uploaded by, Primary author, and Contributors?',
|
||||
paragraphs: [
|
||||
'Published by is the public identity the work appears under. Uploaded by is the person who handled the upload or final publish step. Primary author is the main author of the work. Contributors are additional people who made meaningful creative contributions.',
|
||||
],
|
||||
example: [
|
||||
{ label: 'Published by', value: 'Warlock' },
|
||||
{ label: 'Uploaded by', value: 'Gregor' },
|
||||
{ label: 'Primary author', value: 'Gregor' },
|
||||
{ label: 'Contributors', value: 'Denis, Paula' },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Who should be listed as Primary author?',
|
||||
paragraphs: [
|
||||
'The primary author should be the person who should clearly be understood as the main author of the work.',
|
||||
'Do not choose this field based only on who clicked Publish.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Should all contributors be credited?',
|
||||
paragraphs: [
|
||||
'Yes, if they made meaningful creative contributions. Clear credit keeps the Group trustworthy and helps avoid internal confusion later.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can a Group publish an artwork while still showing who made it?',
|
||||
paragraphs: [
|
||||
'Yes. That is one of the main points of Group publishing: shared identity on the public surface, clear attribution for the humans behind the work.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can I publish both personal and Group artworks?',
|
||||
paragraphs: [
|
||||
'Yes. Many creators do both. The important thing is choosing the correct context before the final publish step.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Why does the platform keep individual credit visible?',
|
||||
paragraphs: [
|
||||
'Because collaboration should not erase accountability or authorship. The Group represents the shared identity, but people still deserve clear credit for the work they did.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What should we do if contributor credit is wrong?',
|
||||
paragraphs: [
|
||||
'Fix it quickly. Review who uploaded the work, who authored it, and who contributed before making changes publicly.',
|
||||
'If there is disagreement inside the team, resolve that first so the public record reflects a clear shared decision.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can contributor credit be changed later?',
|
||||
paragraphs: [
|
||||
'In many cases, yes, depending on your Group permissions and workflow. The best habit is to get it right before publishing so you do not have to correct it afterward.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'members-and-invites',
|
||||
label: 'Members & Invites',
|
||||
title: 'Members, invites, and join requests',
|
||||
summary: 'Use these answers when you need to manage who gets access, what role they should have, and what happens when the team changes.',
|
||||
items: [
|
||||
{
|
||||
question: 'How do I invite someone to a Group?',
|
||||
paragraphs: [
|
||||
'Open Group Studio, go to the member or invitation controls, choose the right role, and send the invite once you know what access that person actually needs.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can I remove a member later?',
|
||||
paragraphs: [
|
||||
'Yes, if your role allows member management. Owners and authorized admins can usually update access, revoke invites, or remove active members.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What happens if a member leaves the Group?',
|
||||
paragraphs: [
|
||||
'Their active access can be removed, but that does not usually erase the history of work they already contributed to.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can a former member still appear on older artworks they contributed to?',
|
||||
paragraphs: [
|
||||
'Yes. Older work may still show their contribution because that is part of the record of who helped make it.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can people request to join a Group?',
|
||||
paragraphs: [
|
||||
'If the Group allows join requests or recruiting, yes. Otherwise access usually depends on direct invites from the team.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What is recruitment mode?',
|
||||
paragraphs: [
|
||||
'Recruitment mode is the public-facing signal that a Group is looking for new collaborators. It helps teams describe what roles or skills they want and how people should reach out.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How do I choose the right role for a new member?',
|
||||
paragraphs: [
|
||||
'Start from what they actually need to do right now. If you are unsure, start lower and promote later instead of giving broad access too early.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can I change someone’s role later?',
|
||||
paragraphs: [
|
||||
'Yes, if your role allows it. Many teams adjust roles over time as trust, responsibility, or activity changes.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Why can’t I manage members?',
|
||||
paragraphs: [
|
||||
'Your role probably does not include member management. That level of access is usually kept to Owners and selected Admins.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Why can’t I see invite controls?',
|
||||
paragraphs: [
|
||||
'Invite controls are normally hidden if your role does not include them or if you are not operating inside the correct Group context.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'review-workflow',
|
||||
label: 'Workflow & Review',
|
||||
title: 'Review workflow and approvals',
|
||||
summary: 'These answers explain why some Groups use review queues and how that affects contributors and trusted publishers.',
|
||||
items: [
|
||||
{
|
||||
question: 'Why is my artwork in review?',
|
||||
paragraphs: [
|
||||
'Your Group may use a review-first workflow so contributors can submit work without publishing directly. That helps the team catch quality, context, or credit issues before something goes public.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Who can approve Group submissions?',
|
||||
paragraphs: [
|
||||
'Usually the people whose roles include review access, such as Owners, Admins, or selected Editors.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What does “needs changes” mean?',
|
||||
paragraphs: [
|
||||
'It means the submission is not ready yet but may become ready after updates. It is a request to revise, not an automatic rejection.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can contributors submit drafts without publishing directly?',
|
||||
paragraphs: [
|
||||
'Yes. That is a common Group workflow. Contributors hand work off for review while a trusted reviewer or publisher handles the final public step.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Why would a Group use a review queue?',
|
||||
paragraphs: [
|
||||
'Review queues help larger or more structured teams keep public quality high, coordinate releases, and catch mistakes before launch.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Do all Groups need approval workflow?',
|
||||
paragraphs: [
|
||||
'No. Small, trusted teams may prefer direct publishing. Review is useful when it solves a real quality or coordination problem.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can trusted members publish directly?',
|
||||
paragraphs: [
|
||||
'Yes, if their role allows it. Many teams reserve direct publishing for trusted operators and use review for everyone else.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What should I do if my submission was rejected?',
|
||||
paragraphs: [
|
||||
'Check the feedback first, then ask for clarification if needed. Treat rejection as workflow feedback, not as punishment.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'features-and-content-types',
|
||||
label: 'Features & Content Types',
|
||||
title: 'Group features and content types',
|
||||
summary: 'This section explains how the wider Group ecosystem fits together so users know what to start with and what to add later.',
|
||||
items: [
|
||||
{
|
||||
question: 'Can a Group create posts or announcements?',
|
||||
paragraphs: [
|
||||
'Yes. Posts are useful for release notes, updates, announcements, recruitment, or milestone communication.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What are Group projects used for?',
|
||||
paragraphs: [
|
||||
'Projects are for structured collaboration. They give the team a shared place to organize work, milestones, linked content, and progress.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What are Group challenges used for?',
|
||||
paragraphs: [
|
||||
'Challenges help run themed prompts, community events, or internal creative pushes that keep the Group active and focused.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What are Group events used for?',
|
||||
paragraphs: [
|
||||
'Events are for launches, streams, showcases, meetups, release windows, or any time-based public moment the Group wants to anchor clearly.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What is the shared asset library for?',
|
||||
paragraphs: [
|
||||
'The asset library stores shared resources, references, files, and internal materials so they do not get lost in scattered chat or personal storage.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What are releases?',
|
||||
paragraphs: [
|
||||
'Releases package a major publication moment with a title, summary, contributors, milestones, notes, and linked work in one public surface.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Do all Groups need to use projects, challenges, events, or releases?',
|
||||
paragraphs: [
|
||||
'No. Start with the smallest set of tools that makes your workflow clearer. Not every Group needs every feature from day one.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What should a simple Group use first?',
|
||||
paragraphs: [
|
||||
'Most simple Groups should begin with a clear profile, member roles, artworks, and occasional posts. Add more structure only when it solves a real problem.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What should a more advanced Group use later?',
|
||||
paragraphs: [
|
||||
'As the Group grows, projects, releases, review queues, recruitment, challenges, events, and shared assets become more useful.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'troubleshooting',
|
||||
label: 'Troubleshooting',
|
||||
title: 'Troubleshooting',
|
||||
summary: 'Use these answers when something feels wrong, missing, or inconsistent. Most Group issues come down to context, role, or visibility.',
|
||||
items: [
|
||||
{
|
||||
question: 'I can’t publish as the Group. Why?',
|
||||
paragraphs: [
|
||||
'The usual reasons are the wrong context, insufficient permissions, inactive membership, or a Group state or policy restriction. Start by confirming you are inside Group Studio and that your role allows publishing.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'I don’t see Group Studio.',
|
||||
paragraphs: [
|
||||
'You may not be in the Group, may still have a pending invite, or may not be signed in. Accept the invitation first if one is waiting.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'I was invited, but I still can’t access what I expected.',
|
||||
paragraphs: [
|
||||
'Check whether the invitation was fully accepted and whether the content you expect is internal, role-limited, or review-limited.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'My role does not let me do what I need.',
|
||||
paragraphs: [
|
||||
'Your Group may be intentionally limiting that action to a higher-trust role. Ask an Owner or Admin whether your current role matches the work you are actually doing.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'I published under the wrong context. What should I do?',
|
||||
paragraphs: [
|
||||
'Review the affected content immediately. Confirm whether it should live under the personal profile or the Group, then correct it before more linked content builds around the mistake.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Contributor credit is incorrect. What should I do?',
|
||||
paragraphs: [
|
||||
'Check the publish record and confirm who was published under, who uploaded the work, who authored it, and who contributed. Fix the incorrect part instead of replacing everything blindly.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'I can’t manage members.',
|
||||
paragraphs: [
|
||||
'Member management is usually restricted to Owners and selected Admins. If you do not see those controls, your role probably does not include them.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'I can’t see internal Group assets or projects.',
|
||||
paragraphs: [
|
||||
'Those areas may be internal-only, visibility-limited, or restricted by role. Confirm that you are an active member and that your role is supposed to see that content.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'I don’t understand why I can’t approve submissions.',
|
||||
paragraphs: [
|
||||
'Approval access is usually reserved for trusted operators. If your role is Contributor or a limited Editor role, approvals may be intentionally hidden from you.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Our Group page looks empty. What should we do first?',
|
||||
paragraphs: [
|
||||
'Start with the basics: complete the profile, upload branding, publish one strong piece, and add one meaningful update. A small amount of clear activity is better than a big empty shell.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'We are not sure which role to assign someone. What should we do?',
|
||||
paragraphs: [
|
||||
'Base the role on what they need to do this month, not on what title sounds impressive. If you are unsure, start lower and adjust later.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const RELATED_HELP_ITEMS = [
|
||||
{
|
||||
eyebrow: 'Deep dive',
|
||||
title: 'Read the full Groups guide',
|
||||
body: 'Use the full documentation for broader reference, advanced workflows, FAQ overlap, and deeper best practices.',
|
||||
linkKey: 'full_documentation',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Start here',
|
||||
title: 'Open the Groups Quickstart',
|
||||
body: 'Use the shorter onboarding path if you want the fastest route to create a Group and publish correctly.',
|
||||
linkKey: 'quickstart',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Operate',
|
||||
title: 'Open Group Studio',
|
||||
body: 'Jump into Studio if your next step is inviting members, reviewing content, or working inside the Group context.',
|
||||
linkKey: 'group_studio',
|
||||
tone: 'sky',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Create',
|
||||
title: 'Create a Group',
|
||||
body: 'If the FAQ answered the basics and you are ready to move, start the creation flow directly.',
|
||||
linkKey: 'create_group',
|
||||
tone: 'white',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,300 @@
|
||||
export const SECTION_ITEMS = [
|
||||
{ id: 'introduction', label: 'Introduction' },
|
||||
{ id: 'what-are-groups', label: 'What are Groups?' },
|
||||
{ id: 'why-use-a-group', label: 'Why use a Group?' },
|
||||
{ id: 'when-to-create-a-group', label: 'When should you create one?' },
|
||||
{ id: 'how-groups-work', label: 'How Groups work' },
|
||||
{ id: 'roles-and-permissions', label: 'Roles and permissions' },
|
||||
{ id: 'creating-a-group', label: 'Creating a Group' },
|
||||
{ id: 'public-group-page', label: 'Public Group page' },
|
||||
{ id: 'group-studio', label: 'Group Studio' },
|
||||
{ id: 'publishing-as-a-group', label: 'Publishing as a Group' },
|
||||
{ id: 'contributor-credit', label: 'Contributor credit' },
|
||||
{ id: 'member-management', label: 'Invites and team management' },
|
||||
{ id: 'review-workflow', label: 'Review workflow' },
|
||||
{ id: 'group-features', label: 'Projects, posts, events, releases' },
|
||||
{ id: 'tips-and-best-practices', label: 'Tips and best practices' },
|
||||
{ id: 'common-mistakes', label: 'Common mistakes' },
|
||||
{ id: 'suggested-workflows', label: 'Suggested workflows' },
|
||||
{ id: 'faq', label: 'FAQ' },
|
||||
{ id: 'troubleshooting', label: 'Troubleshooting' },
|
||||
{ id: 'need-help', label: 'Need help?' },
|
||||
]
|
||||
|
||||
export const BENEFITS = [
|
||||
'Publish under a shared name without hiding the people behind the work.',
|
||||
'Separate team identity from personal portfolios when a project needs its own public home.',
|
||||
'Manage roles, approvals, and release workflows in one place.',
|
||||
'Run projects, posts, challenges, events, assets, and releases under the same umbrella.',
|
||||
'Recruit collaborators through a Group page instead of repeating the same pitch in DMs.',
|
||||
'Keep contributor credit visible even when the final publish surface is the Group.',
|
||||
]
|
||||
|
||||
export const GOOD_FIT = [
|
||||
'You collaborate with two or more people regularly.',
|
||||
'You want a shared public brand for a studio, crew, or collective.',
|
||||
'You publish themed drops, packs, showcases, or release-driven work.',
|
||||
'You need shared collections, posts, assets, or release notes.',
|
||||
'You want role-based access instead of everyone sharing one account.',
|
||||
]
|
||||
|
||||
export const NOT_YET = [
|
||||
'You only publish solo work and do not need a separate identity.',
|
||||
'You are still testing ideas and not ready to manage members.',
|
||||
'You want a Group only to rename personal work without collaboration value.',
|
||||
'You do not want shared workflow, shared publishing context, or shared moderation responsibility.',
|
||||
]
|
||||
|
||||
export const ROLE_TABLE = {
|
||||
columns: [
|
||||
{ key: 'role', label: 'Role' },
|
||||
{ key: 'settings', label: 'Manage settings' },
|
||||
{ key: 'members', label: 'Invite and change roles' },
|
||||
{ key: 'publishing', label: 'Publish content' },
|
||||
{ key: 'review', label: 'Review submissions' },
|
||||
{ key: 'workflow', label: 'Manage posts, projects, events, releases' },
|
||||
{ key: 'assets', label: 'Manage assets and shared resources' },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: 'owner',
|
||||
role: 'Owner',
|
||||
settings: 'Full control over branding, settings, membership policy, and archive actions.',
|
||||
members: 'Can invite, remove, promote, transfer ownership, and approve the overall structure.',
|
||||
publishing: 'Can publish directly and define the team workflow.',
|
||||
review: 'Can always review, approve, request changes, or reject.',
|
||||
workflow: 'Full access to posts, projects, challenges, events, releases, and reputation.',
|
||||
assets: 'Full access.',
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
role: 'Admin',
|
||||
settings: 'Can usually manage day-to-day settings and operations.',
|
||||
members: 'Can invite and manage most members, but should not be handed out casually.',
|
||||
publishing: 'Usually yes.',
|
||||
review: 'Usually yes.',
|
||||
workflow: 'Usually full operational access across content areas.',
|
||||
assets: 'Usually full access.',
|
||||
},
|
||||
{
|
||||
id: 'editor',
|
||||
role: 'Editor',
|
||||
settings: 'Usually limited or no access to sensitive settings.',
|
||||
members: 'Usually cannot change member roles unless explicitly allowed.',
|
||||
publishing: 'Often yes, depending on your workflow.',
|
||||
review: 'Often yes when the team uses review queues.',
|
||||
workflow: 'Good fit for content managers, release coordinators, and project leads.',
|
||||
assets: 'Often yes.',
|
||||
},
|
||||
{
|
||||
id: 'contributor',
|
||||
role: 'Contributor',
|
||||
settings: 'No sensitive settings access.',
|
||||
members: 'Cannot manage team structure.',
|
||||
publishing: 'Usually submits drafts instead of publishing directly.',
|
||||
review: 'Usually no.',
|
||||
workflow: 'Best for creative collaborators who need to contribute without running the Group.',
|
||||
assets: 'Limited to what the Group makes available.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const CREATE_STEPS = [
|
||||
{ title: 'Open Groups in Studio', description: 'Go to Group Studio from your main Studio area, then choose Create Group.' },
|
||||
{ title: 'Choose a clear name and slug', description: 'Pick a name people can remember and a slug that still makes sense a year from now.' },
|
||||
{ title: 'Add branding', description: 'Upload a recognizable avatar or logo and a cover image that gives the Group a clear visual identity.' },
|
||||
{ title: 'Write a short headline and description', description: 'Tell visitors what the Group makes, who it is for, and what kind of collaboration to expect.' },
|
||||
{ title: 'Set visibility', description: 'Choose whether the Group should be public, unlisted, or private based on how ready you are.' },
|
||||
{ title: 'Create the Group', description: 'Finish setup, then review the public page so the branding and copy feel intentional.' },
|
||||
{ title: 'Invite your first members', description: 'Bring in owners, admins, editors, or contributors based on the work each person will actually do.' },
|
||||
{ title: 'Decide on your workflow', description: 'Choose whether the team should use direct publishing, review-first publishing, projects, releases, or lightweight milestones.' },
|
||||
{ title: 'Publish with care', description: 'Before the first public post or artwork, confirm that contributor credit and publishing context are correct.' },
|
||||
]
|
||||
|
||||
export const STUDIO_AREAS = [
|
||||
'Dashboard for a quick read on releases, review items, events, and activity.',
|
||||
'Artworks for group-owned publishing and shared presentation.',
|
||||
'Review Queue for approval workflows, changes requested, and moderation hygiene.',
|
||||
'Posts for announcements, release notes, recruitment, and milestones.',
|
||||
'Projects for structured collaboration and shared progress.',
|
||||
'Challenges and Events for community activity and time-based launches.',
|
||||
'Assets for internal shared files and reusable resources.',
|
||||
'Collections and Releases for public packaging, curation, and major launch moments.',
|
||||
'Members, Recruitment, Invitations, and Reputation for team health and trust signals.',
|
||||
]
|
||||
|
||||
export const FEATURE_CARDS = [
|
||||
{
|
||||
title: 'Projects',
|
||||
body: 'Use projects when a collaboration needs a shared home, milestones, attachments, and team ownership before it becomes a public release.',
|
||||
},
|
||||
{
|
||||
title: 'Challenges',
|
||||
body: 'Challenges work well for themed prompts, community events, and structured contribution windows that keep the Group active.',
|
||||
},
|
||||
{
|
||||
title: 'Events',
|
||||
body: 'Events are for launches, showcases, meetups, timed drops, or public moments that need a calendar-style anchor.',
|
||||
},
|
||||
{
|
||||
title: 'Assets',
|
||||
body: 'The asset library keeps shared files, references, and working materials organized instead of buried in chat history.',
|
||||
},
|
||||
{
|
||||
title: 'Releases',
|
||||
body: 'Releases package a major publication moment with summary, contributors, milestones, notes, and linked work in one polished surface.',
|
||||
},
|
||||
{
|
||||
title: 'Posts',
|
||||
body: 'Posts keep the Group human. Use them for updates, recruitment, changelogs, launch notes, and curated public communication.',
|
||||
},
|
||||
]
|
||||
|
||||
export const BEST_PRACTICES = [
|
||||
'Keep the Group identity focused. Visitors should understand who you are in seconds.',
|
||||
'Define Owner, Admin, Editor, and Contributor responsibilities early.',
|
||||
'Use the simplest permissions setup that supports the team today.',
|
||||
'Check publishing context before every public action.',
|
||||
'Credit real people accurately, even when the Group is the publish surface.',
|
||||
'Use projects and releases for bigger work instead of burying everything in posts.',
|
||||
'Keep assets tidy so new members are not onboarding into chaos.',
|
||||
'Pin only the most important public update, not every update.',
|
||||
'Review inactive memberships and old roles periodically.',
|
||||
'Use recruitment only when the Group can actually onboard people well.',
|
||||
'Write release notes that explain what changed and why it matters.',
|
||||
'Treat the Group page like a living portfolio, not a one-time setup screen.',
|
||||
]
|
||||
|
||||
export const COMMON_MISTAKES = [
|
||||
'Giving too many people admin power before the team knows how it wants to work.',
|
||||
'Publishing under the wrong context because no one checked whether Personal Studio or Group Studio was active.',
|
||||
'Forgetting contributor credit or using vague labels that do not explain the work.',
|
||||
'Creating a Group with no clear purpose, rhythm, or public identity.',
|
||||
'Letting the public page go stale after the initial setup.',
|
||||
'Using the Group identity to hide who actually made the work.',
|
||||
'Inviting members without setting expectations around ownership, publishing, and approval flow.',
|
||||
'Treating posts as noise instead of meaningful updates.',
|
||||
'Keeping an asset library that nobody can search or trust.',
|
||||
'Adding projects, challenges, events, and releases before the team has a simple baseline workflow.',
|
||||
]
|
||||
|
||||
export const WORKFLOWS = [
|
||||
{
|
||||
title: 'Workflow A: Small trusted team',
|
||||
summary: 'Owner plus one editor and one or two contributors. Simple permissions, direct publishing when the team already trusts the process.',
|
||||
bullets: ['Use lightweight posts for updates.', 'Keep contributor labels accurate.', 'Only add milestones when a release needs coordination.'],
|
||||
},
|
||||
{
|
||||
title: 'Workflow B: Growing art collective',
|
||||
summary: 'Owner, admins, editors, and contributors with a review queue and recruitment enabled.',
|
||||
bullets: ['Use projects for medium-term work.', 'Review submissions before public publishing.', 'Keep the member list and public page curated.'],
|
||||
},
|
||||
{
|
||||
title: 'Workflow C: Release-driven group',
|
||||
summary: 'A team built around themed drops, packs, or showcase moments.',
|
||||
bullets: ['Use releases as the main public storytelling surface.', 'Attach artworks, release notes, and milestones.', 'Pair launches with a post and pinned update.'],
|
||||
},
|
||||
{
|
||||
title: 'Workflow D: Community challenge group',
|
||||
summary: 'A Group centered on challenges, events, and recurring prompts.',
|
||||
bullets: ['Use join requests or recruiting to control growth.', 'Publish clear challenge briefs and event dates.', 'Keep moderation and review communication constructive.'],
|
||||
},
|
||||
]
|
||||
|
||||
export const FAQ_ITEMS = [
|
||||
{
|
||||
question: 'What is the difference between a personal profile and a Group?',
|
||||
answer: 'A personal profile is your individual identity. A Group is a shared identity for collaboration and publishing. The Group can publish the work publicly, but contributor credit should still show the people behind it.',
|
||||
},
|
||||
{
|
||||
question: 'Can I publish both personally and as a Group?',
|
||||
answer: 'Yes. Many creators keep their personal portfolio active while also publishing collaborative work under a Group. The key is to choose the right context before you publish.',
|
||||
},
|
||||
{
|
||||
question: 'Will my name still appear if I publish under a Group?',
|
||||
answer: 'Yes. Group publishing is meant to preserve authorship, not erase it. Uploaded by, primary author, and contributor credit should still reflect the humans involved.',
|
||||
},
|
||||
{
|
||||
question: 'Who can publish as a Group?',
|
||||
answer: 'That depends on role and workflow. Owners and admins usually can. Editors often can. Contributors may submit drafts without publishing directly if the Group uses review-first workflows.',
|
||||
},
|
||||
{
|
||||
question: 'Can contributors submit drafts without publishing directly?',
|
||||
answer: 'Yes. That is one of the most useful Group workflows. Contributors can hand work off for review while editors or admins handle final approval and public publishing.',
|
||||
},
|
||||
{
|
||||
question: 'How do I invite members?',
|
||||
answer: 'Open Group Studio, go to members or invitations, choose the right role, and send an invite only after you know what that person needs access to.',
|
||||
},
|
||||
{
|
||||
question: 'Can I remove a member later?',
|
||||
answer: 'Yes. Owners and people with the right member-management permissions can update roles, revoke invitations, or remove active members when needed.',
|
||||
},
|
||||
{
|
||||
question: 'What happens to old artworks if someone leaves the Group?',
|
||||
answer: 'Existing publishing history and contributor credit should remain as part of the public record unless the content itself changes or is removed through normal moderation or management flows.',
|
||||
},
|
||||
{
|
||||
question: 'Should every team create a Group?',
|
||||
answer: 'No. Create a Group when you need shared identity, shared workflows, or recurring collaboration. If you only publish solo work, a personal profile may be enough.',
|
||||
},
|
||||
{
|
||||
question: 'How should we use roles?',
|
||||
answer: 'Start simple. Keep Owner count small, reserve Admin for deeply trusted operators, use Editor for day-to-day management, and keep Contributor focused on creative participation.',
|
||||
},
|
||||
{
|
||||
question: 'Can we recruit new members through the Group page?',
|
||||
answer: 'Yes, if recruitment is enabled. Use it when the Group is actually ready to onboard people, not just to look active.',
|
||||
},
|
||||
{
|
||||
question: 'What are releases, projects, and challenges used for?',
|
||||
answer: 'Projects help teams organize work. Releases package major publication moments. Challenges create themed participation and energy. Together they make the Group feel structured and alive.',
|
||||
},
|
||||
{
|
||||
question: 'Can a Group have posts and announcements?',
|
||||
answer: 'Yes. Posts are useful for release notes, milestone updates, recruitment, public announcements, and pinned context on the Group page.',
|
||||
},
|
||||
{
|
||||
question: 'How should we organize our assets?',
|
||||
answer: 'Keep them categorized, named clearly, and cleaned up over time. Shared assets should help the team work faster, not become a second mystery archive.',
|
||||
},
|
||||
{
|
||||
question: 'What should we do if contributor credit is wrong?',
|
||||
answer: 'Fix it quickly. Attribution errors create confusion, trust issues, and sometimes conflict. Confirm who uploaded the work, who authored it, and who contributed before changing anything publicly.',
|
||||
},
|
||||
]
|
||||
|
||||
export const TROUBLESHOOTING_ITEMS = [
|
||||
{
|
||||
title: 'I cannot publish as the Group',
|
||||
body: 'Check that you are in Group Studio, not Personal Studio. Then confirm your role allows publishing and that the Group is active and not archived or suspended.',
|
||||
},
|
||||
{
|
||||
title: 'I do not see Group Studio',
|
||||
body: 'You may not be signed in, may not belong to the Group, or may only have a pending invitation. Accept the invite first, then reload Studio.',
|
||||
},
|
||||
{
|
||||
title: 'My role does not let me do what I expected',
|
||||
body: 'Ask the Owner or Admin which permissions are meant for your role. In many teams, Editors manage content while Contributors only submit drafts.',
|
||||
},
|
||||
{
|
||||
title: 'Contributor credit is wrong',
|
||||
body: 'Review the publish record carefully: published by, uploaded by, primary author, and contributors each mean different things. Correct the one that is inaccurate rather than replacing all of them.',
|
||||
},
|
||||
{
|
||||
title: 'I was invited but cannot access content',
|
||||
body: 'Some areas may still be internal, role-limited, or pending approval. First confirm that the invitation was accepted and the membership is active.',
|
||||
},
|
||||
{
|
||||
title: 'I published under the wrong context',
|
||||
body: 'Stop and review the affected content immediately. Confirm whether it should live under the personal profile or the Group, then correct the publish context before more linked items are built around it.',
|
||||
},
|
||||
{
|
||||
title: 'I do not understand why my draft is in review',
|
||||
body: 'Your Group may use a review-first workflow so contributors can submit work without publishing directly. Check the review queue feedback or ask the assigned reviewer what needs to change.',
|
||||
},
|
||||
{
|
||||
title: 'I do not know which role to assign someone',
|
||||
body: 'Ask what they need to do this month, not what title sounds impressive. If you are unsure, start lower and promote later.',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,160 @@
|
||||
export const SECTION_ITEMS = [
|
||||
{ id: 'introduction', label: 'Welcome' },
|
||||
{ id: 'what-is-a-group', label: 'What is a Group?' },
|
||||
{ id: 'when-to-use', label: 'When to use a Group' },
|
||||
{ id: 'create-first-group', label: 'Create your first Group' },
|
||||
{ id: 'setup-properly', label: 'Set it up properly' },
|
||||
{ id: 'invite-and-roles', label: 'Invite members and roles' },
|
||||
{ id: 'publish-first-artwork', label: 'Publish your first artwork' },
|
||||
{ id: 'contributor-credit', label: 'Contributor credit' },
|
||||
{ id: 'first-week-best-practices', label: 'First-week best practices' },
|
||||
{ id: 'common-mistakes', label: 'Common mistakes' },
|
||||
{ id: 'quick-checklist', label: 'Quick checklist' },
|
||||
{ id: 'next-steps', label: 'Next steps' },
|
||||
]
|
||||
|
||||
export const COMPARISON_CARDS = [
|
||||
{
|
||||
title: 'Personal profile',
|
||||
icon: 'fa-solid fa-user',
|
||||
bullets: ['Individual identity', 'Solo publishing', 'Personal portfolio and reputation'],
|
||||
},
|
||||
{
|
||||
title: 'Group',
|
||||
icon: 'fa-solid fa-people-group',
|
||||
bullets: ['Team identity', 'Collaborative publishing', 'Shared brand, shared activity, shared workflow'],
|
||||
},
|
||||
]
|
||||
|
||||
export const GOOD_FIT = [
|
||||
'You work with other creators regularly.',
|
||||
'You want a shared public brand for a studio, team, or collective.',
|
||||
'You want one home for member roles, publishing, and shared activity.',
|
||||
'You release projects, themed drops, or collaborative packs together.',
|
||||
]
|
||||
|
||||
export const NOT_NEEDED_YET = [
|
||||
'You only publish solo work right now.',
|
||||
'You do not need shared identity or member management yet.',
|
||||
'You are not collaborating enough to justify shared workflow overhead.',
|
||||
]
|
||||
|
||||
export const CREATE_STEPS = [
|
||||
{ title: 'Open Groups or Creator Studio', description: 'Start from the Groups area in Studio and choose Create Group.' },
|
||||
{ title: 'Choose your name and slug', description: 'Pick something clear, memorable, and easy for other creators to recognize.' },
|
||||
{ title: 'Add your visuals', description: 'Upload a logo or avatar and a cover image so the Group feels real immediately.' },
|
||||
{ title: 'Write a short description', description: 'Explain what the Group makes and who it is for in one strong paragraph.' },
|
||||
{ title: 'Choose visibility', description: 'Decide whether the Group should be public, unlisted, or private while you set it up.' },
|
||||
{ title: 'Create the Group', description: 'Finish creation, then review the public page before you invite the rest of the team.' },
|
||||
]
|
||||
|
||||
export const SETUP_TASKS = [
|
||||
'Upload a clean avatar or logo.',
|
||||
'Add a cover image that matches the Group identity.',
|
||||
'Write a short description instead of leaving the page blank.',
|
||||
'Decide who should be Owner and who really needs Admin access.',
|
||||
'Choose public or private visibility intentionally.',
|
||||
'Make the page feel alive before you ask people to join it.',
|
||||
]
|
||||
|
||||
export const ROLE_CARDS = [
|
||||
{
|
||||
role: 'Owner',
|
||||
summary: 'Full control over branding, membership, settings, and the overall workflow.',
|
||||
note: 'Keep this count very small.',
|
||||
},
|
||||
{
|
||||
role: 'Admin',
|
||||
summary: 'Helps run the Group day to day, manage members, and keep operations moving.',
|
||||
note: 'Only give this to deeply trusted people.',
|
||||
},
|
||||
{
|
||||
role: 'Editor',
|
||||
summary: 'A strong fit for content managers, release coordinators, and people who help publish work.',
|
||||
note: 'Usually the best default for trusted operators.',
|
||||
},
|
||||
{
|
||||
role: 'Contributor',
|
||||
summary: 'Contributes work without needing full control over the Group structure.',
|
||||
note: 'Best starting role for most collaborators.',
|
||||
},
|
||||
]
|
||||
|
||||
export const PUBLISH_STEPS = [
|
||||
{ title: 'Open Group Studio', description: 'Make sure you are working inside the Group, not your personal publishing context.' },
|
||||
{ title: 'Start the upload or open the draft', description: 'Prepare the artwork that should appear under the Group identity publicly.' },
|
||||
{ title: 'Confirm Group context before publish', description: 'Double-check that you are publishing as the Group, not as your personal profile.' },
|
||||
{ title: 'Review credit before final publish', description: 'Check primary author and contributor fields before the artwork goes public.' },
|
||||
]
|
||||
|
||||
export const CREDIT_TERMS = [
|
||||
{ label: 'Published by', value: 'Warlock', note: 'The shared identity the artwork appears under publicly.' },
|
||||
{ label: 'Uploaded by', value: 'Gregor', note: 'The person who performed the upload or final publish action.' },
|
||||
{ label: 'Primary author', value: 'Gregor', note: 'The main author of the work.' },
|
||||
{ label: 'Contributors', value: 'Denis, Paula', note: 'Additional people who made meaningful creative contributions.' },
|
||||
]
|
||||
|
||||
export const FIRST_WEEK_BEST_PRACTICES = [
|
||||
'Publish one strong piece before publishing a lot of weak or unfinished work.',
|
||||
'Fill out the Group profile early so the public page does not feel abandoned.',
|
||||
'Agree internally on how contributor credit should be assigned before launch day.',
|
||||
'Keep roles simple until the team actually needs more complexity.',
|
||||
'Use posts and updates for meaningful announcements, not noise.',
|
||||
'Feature the best work so the Group makes a strong first impression.',
|
||||
]
|
||||
|
||||
export const COMMON_MISTAKES = [
|
||||
'Giving too many people admin access too early.',
|
||||
'Publishing under the wrong context because no one checked whether the Group was selected.',
|
||||
'Forgetting contributor credit or leaving it vague.',
|
||||
'Creating a Group with no real purpose or activity plan.',
|
||||
'Leaving the profile blank and expecting the page to feel trustworthy.',
|
||||
'Overcomplicating permissions on day one.',
|
||||
'Letting inactive members keep strong permissions forever.',
|
||||
'Using the Group identity without clear authorship inside the team.',
|
||||
]
|
||||
|
||||
export const QUICK_CHECKLIST = [
|
||||
'Group created',
|
||||
'Name and slug chosen',
|
||||
'Avatar or logo uploaded',
|
||||
'Cover added',
|
||||
'Description written',
|
||||
'First members invited',
|
||||
'Roles assigned',
|
||||
'Group context selected in Studio',
|
||||
'First artwork prepared',
|
||||
'Contributor credit reviewed',
|
||||
'First Group publish completed',
|
||||
]
|
||||
|
||||
export const NEXT_STEPS = [
|
||||
{
|
||||
eyebrow: 'Create',
|
||||
title: 'Create a Group',
|
||||
body: 'Start the shared identity now if you are ready to move from solo work to team publishing.',
|
||||
linkKey: 'create_group',
|
||||
tone: 'sky',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Manage',
|
||||
title: 'Open Group Studio',
|
||||
body: 'Go straight into Studio if your Group already exists and you want to invite members or publish.',
|
||||
linkKey: 'group_studio',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Learn more',
|
||||
title: 'Read the full Groups guide',
|
||||
body: 'Open the deeper documentation for releases, challenges, review workflows, troubleshooting, and advanced usage.',
|
||||
linkKey: 'full_documentation',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Explore',
|
||||
title: 'Browse public Groups',
|
||||
body: 'See how other teams present themselves, structure their identity, and publish together.',
|
||||
linkKey: 'groups_directory',
|
||||
tone: 'white',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,225 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsFaqAccordion from '../../components/docs/DocsFaqAccordion'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import DocsStepList from '../../components/docs/DocsStepList'
|
||||
import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import {
|
||||
COVERAGE_ITEMS,
|
||||
EMAIL_PASSWORD_STEPS,
|
||||
FAQ_ITEMS,
|
||||
HERO_METRICS,
|
||||
MAINTENANCE_HABITS,
|
||||
PROFILE_PREFERENCE_ITEMS,
|
||||
RELATED_HELP_ITEMS,
|
||||
SECTION_ITEMS,
|
||||
SETTINGS_AREA_ITEMS,
|
||||
} from './accountHelpContent'
|
||||
|
||||
function HeroMetric({ label, value, note }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InsightCard({ item }) {
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function BulletGrid({ items, tone = 'sky' }) {
|
||||
const dotColor = tone === 'amber' ? 'bg-amber-300' : tone === 'emerald' ? 'bg-emerald-300' : 'bg-sky-300'
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex gap-3 text-sm leading-6 text-slate-300">
|
||||
<span className={`mt-2 h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AccountHelpPage() {
|
||||
const { props } = usePage()
|
||||
const links = props.links || {}
|
||||
const signedIn = Boolean(props.auth?.signed_in)
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Account Settings Help',
|
||||
description: props.description,
|
||||
url: props.seo?.canonical,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Skinbase',
|
||||
},
|
||||
about: ['Account settings', 'Profile settings', 'Password changes', 'Email changes', 'Preferences'],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const relatedHelpItems = RELATED_HELP_ITEMS.map((item) => ({
|
||||
...item,
|
||||
href: links[item.linkKey],
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_22%),radial-gradient(circle_at_bottom_right,_rgba(34,197,94,0.12),_transparent_20%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(74,222,128,0.14),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-emerald-200/80">Account settings help</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Account settings should feel like steady maintenance, not a maze you only visit when something is already on fire.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Use this page when access already works and you need clearer guidance for profile settings, account details, email and password care, notifications, and the ongoing habits that keep the account easier to manage.</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={signedIn ? links.profile_settings : links.login} className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100 transition hover:border-emerald-300/40 hover:bg-emerald-300/18">{signedIn ? 'Open account settings' : 'Open login'}</a>
|
||||
<a href={links.help_auth} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Read auth help</a>
|
||||
<a href={links.help_troubleshooting} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Open troubleshooting</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
{HERO_METRICS.map((metric) => (
|
||||
<HeroMetric key={metric.label} label={metric.label} value={metric.value} note={metric.note} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<DocsSidebarNav sections={SECTION_ITEMS} ariaLabel="Account help sections" selectLabel="Jump to account help section" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<DocsSection
|
||||
id="what-account-help-covers"
|
||||
eyebrow="Scope"
|
||||
title="What account help covers"
|
||||
summary="Account help sits between pure access recovery and deeper module-specific workflow guides. It is for the practical middle ground where the account works, but the settings still need attention."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{COVERAGE_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="settings-areas"
|
||||
eyebrow="Areas"
|
||||
title="The settings areas that matter most"
|
||||
summary="Most account questions fall into a few repeat categories. Naming the category first makes the right next step much easier to see."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{SETTINGS_AREA_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="note" title="A useful way to think about settings">
|
||||
Settings are not only administrative. They control recovery, trust, profile clarity, and how manageable the creator experience feels over time.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="email-and-password"
|
||||
eyebrow="Sensitive changes"
|
||||
title="Email and password changes"
|
||||
summary="Treat identity and security changes carefully. These are not hard tasks, but they are the least forgiving when rushed."
|
||||
>
|
||||
<DocsStepList items={EMAIL_PASSWORD_STEPS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="profile-and-preferences"
|
||||
eyebrow="Daily use"
|
||||
title="Profile and preference maintenance"
|
||||
summary="The account feels healthier when profile details, notification choices, and other core preferences stay aligned with how you actually use the platform."
|
||||
>
|
||||
<BulletGrid items={PROFILE_PREFERENCE_ITEMS} tone="sky" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="maintenance-habits"
|
||||
eyebrow="Good habits"
|
||||
title="Maintenance habits that prevent bigger problems"
|
||||
summary="The easiest account problems to fix are the ones that never turn into a crisis because the basics stayed current."
|
||||
>
|
||||
<BulletGrid items={MAINTENANCE_HABITS} tone="emerald" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="faq"
|
||||
eyebrow="FAQ"
|
||||
title="Account settings FAQ"
|
||||
summary="These answers cover the common point where access is fine, but settings and maintenance still feel unclear."
|
||||
>
|
||||
<DocsFaqAccordion items={FAQ_ITEMS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="related-help"
|
||||
eyebrow="Next steps"
|
||||
title="Related help"
|
||||
summary="Use these guides when the account question turns back into access recovery, identity presentation, faster troubleshooting, or broader creator workflow help."
|
||||
>
|
||||
<QuickstartNextSteps items={relatedHelpItems} />
|
||||
</DocsSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
|
||||
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/80">Quick route map</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<a href={signedIn ? links.profile_settings : links.login} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">{signedIn ? 'Open account settings' : 'Open login'}</a>
|
||||
<a href={links.help_auth} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read auth help</a>
|
||||
<a href={links.help_profile} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read Profile help</a>
|
||||
<a href={links.help_troubleshooting} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open troubleshooting</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-emerald-300/20 bg-emerald-400/10 p-4 text-emerald-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100/80">Fast reminder</div>
|
||||
<p className="mt-2 text-sm leading-6 text-emerald-50/85">The healthiest account is the one with a current email, a manageable password, and settings reviewed before they become emergency work.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsFaqAccordion from '../../components/docs/DocsFaqAccordion'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import DocsStepList from '../../components/docs/DocsStepList'
|
||||
import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import {
|
||||
ACCESS_BASICS_ITEMS,
|
||||
COMMON_MISTAKES,
|
||||
FAQ_ITEMS,
|
||||
HERO_METRICS,
|
||||
LOGIN_STEPS,
|
||||
RECOVERY_STEPS,
|
||||
RELATED_HELP_ITEMS,
|
||||
SAFETY_ITEMS,
|
||||
SECTION_ITEMS,
|
||||
SIGNUP_STEPS,
|
||||
TROUBLESHOOTING_ITEMS,
|
||||
} from './authHelpContent'
|
||||
|
||||
function HeroMetric({ label, value, note }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BulletGrid({ items, tone = 'sky' }) {
|
||||
const dotColor = tone === 'amber' ? 'bg-amber-300' : tone === 'emerald' ? 'bg-emerald-300' : 'bg-sky-300'
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex gap-3 text-sm leading-6 text-slate-300">
|
||||
<span className={`mt-2 h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InsightCard({ item }) {
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function TroubleCard({ item, links }) {
|
||||
return (
|
||||
<a href={links[item.linkKey]} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-sky-200">{item.linkLabel}</span>
|
||||
<span className="text-slate-500">→</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuthHelpPage() {
|
||||
const { props } = usePage()
|
||||
const links = props.links || {}
|
||||
const signedIn = Boolean(props.auth?.signed_in)
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Signup and Login Help',
|
||||
description: props.description,
|
||||
url: props.seo?.canonical,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Skinbase',
|
||||
},
|
||||
about: ['Signup', 'Login', 'Password recovery', 'Verification', 'Account access'],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const relatedHelpItems = RELATED_HELP_ITEMS.map((item) => ({
|
||||
...item,
|
||||
href: links[item.linkKey],
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_23%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.14),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.16),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Signup and login help</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Account access should feel clear, fixable, and much less stressful than it often does.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page explains how signup, login, password recovery, and account verification basics work on Skinbase Nova so you can get into your account, recover it when needed, and separate true access problems from workflow or permission confusion.</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={signedIn ? links.open_studio : links.login} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">{signedIn ? 'Open Studio' : 'Open login'}</a>
|
||||
<a href={links.register} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Create account</a>
|
||||
<a href={links.password_request} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Reset password</a>
|
||||
<a href={links.help_account} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Account settings help</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
{HERO_METRICS.map((metric) => (
|
||||
<HeroMetric key={metric.label} label={metric.label} value={metric.value} note={metric.note} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<DocsSidebarNav sections={SECTION_ITEMS} ariaLabel="Signup and login help sections" selectLabel="Jump to auth help section" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<DocsSection
|
||||
id="creating-an-account"
|
||||
eyebrow="Signup"
|
||||
title="Creating an account"
|
||||
summary="Account creation should feel straightforward: start from signup, use the right email, finish the flow carefully, and complete any verification the account still needs afterward."
|
||||
>
|
||||
<DocsStepList items={SIGNUP_STEPS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="logging-in"
|
||||
eyebrow="Login"
|
||||
title="Logging in"
|
||||
summary="Login is the return path into your Skinbase identity. When it works, it should bring you back into the account so you can continue with profile, Studio, and publishing work."
|
||||
>
|
||||
<DocsStepList items={LOGIN_STEPS} />
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="note" title="Where login leads afterward">
|
||||
The important point is not only getting signed in. It is getting back to the authenticated parts of Skinbase that depend on account access, such as Studio and creator settings.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="password-reset-recovery"
|
||||
eyebrow="Recovery"
|
||||
title="Password reset / recovery"
|
||||
summary="If the password is missing or no longer reliable, recovery is the safer and faster route. This should feel like a reset process, not a panic moment."
|
||||
>
|
||||
<DocsStepList items={RECOVERY_STEPS} />
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="tip" title="Check the right inbox first">
|
||||
Reset confusion often comes from watching the wrong email account or ignoring spam and promotions folders.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="warning" title="Do not keep guessing forever">
|
||||
If the password is unclear, move into recovery instead of burning time on repeated failed guesses that only make the situation feel worse.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="access-and-verification"
|
||||
eyebrow="Access basics"
|
||||
title="Account access and verification basics"
|
||||
summary="Being logged in, being verified, and having permission inside a specific workflow are related but not identical. Keeping those ideas separate reduces a lot of confusion."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{ACCESS_BASICS_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="safety-and-protection"
|
||||
eyebrow="Safety"
|
||||
title="Safety and account protection"
|
||||
summary="Security guidance works best when it is simple enough to follow in real life. These habits protect access without turning basic account care into a technical lecture."
|
||||
>
|
||||
<BulletGrid items={SAFETY_ITEMS} tone="emerald" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="common-mistakes"
|
||||
eyebrow="Avoid this"
|
||||
title="Common mistakes"
|
||||
summary="Most auth confusion comes from small mismatches: the wrong email, the wrong inbox, the wrong assumption about what login should solve, or a permissions issue being mistaken for an account-access failure."
|
||||
>
|
||||
<BulletGrid items={COMMON_MISTAKES} tone="amber" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="faq"
|
||||
eyebrow="FAQ"
|
||||
title="Signup and login FAQ"
|
||||
summary="These are the fastest answers for the questions people most often ask when access is blocked, incomplete, or simply confusing."
|
||||
>
|
||||
<DocsFaqAccordion items={FAQ_ITEMS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="troubleshooting"
|
||||
eyebrow="Troubleshooting"
|
||||
title="Troubleshooting"
|
||||
summary="Use these shortcuts when account access is failing, recovery feels unclear, or the problem may actually live in permissions and workflow rather than login itself."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{TROUBLESHOOTING_ITEMS.map((item) => (
|
||||
<TroubleCard key={item.title} item={item} links={links} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="related-help"
|
||||
eyebrow="Next steps"
|
||||
title="Related help"
|
||||
summary="Use these links when account access is clear and the next question belongs to profile setup, creator workflows, Group permissions, or broader troubleshooting."
|
||||
>
|
||||
<QuickstartNextSteps items={relatedHelpItems} />
|
||||
</DocsSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
|
||||
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Quick route map</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<a href={links.login} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open login</a>
|
||||
<a href={links.register} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Create account</a>
|
||||
<a href={links.password_request} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Reset password</a>
|
||||
<a href={links.help_account} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read account settings help</a>
|
||||
<a href={links.help_troubleshooting} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open troubleshooting hub</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Fast reminder</div>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-50/85">If access breaks, check four things first: the email, the password, the inbox, and whether the problem is really permissions rather than login.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsComparisonTable from '../../components/docs/DocsComparisonTable'
|
||||
import DocsFaqAccordion from '../../components/docs/DocsFaqAccordion'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import DocsStepList from '../../components/docs/DocsStepList'
|
||||
import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import {
|
||||
BEST_PRACTICES,
|
||||
COMMON_MISTAKES,
|
||||
COMPARISON_COLUMNS,
|
||||
COMPARISON_ROWS,
|
||||
CREATION_STEPS,
|
||||
FAQ_ITEMS,
|
||||
FORMAT_SIGNAL_ITEMS,
|
||||
HERO_METRICS,
|
||||
OWNERSHIP_BULLETS,
|
||||
OWNERSHIP_ITEMS,
|
||||
RELATED_HELP_ITEMS,
|
||||
SECTION_ITEMS,
|
||||
TROUBLESHOOTING_ITEMS,
|
||||
WHAT_CARDS_ARE_ITEMS,
|
||||
WORKFLOW_EXAMPLES,
|
||||
} from './cardsHelpContent'
|
||||
|
||||
function HeroMetric({ label, value, note }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BulletGrid({ items, tone = 'sky' }) {
|
||||
const dotColor = tone === 'amber' ? 'bg-amber-300' : tone === 'emerald' ? 'bg-emerald-300' : 'bg-sky-300'
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex gap-3 text-sm leading-6 text-slate-300">
|
||||
<span className={`mt-2 h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InsightCard({ item }) {
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function TroubleCard({ item, links }) {
|
||||
return (
|
||||
<a href={links[item.linkKey]} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-sky-200">{item.linkLabel}</span>
|
||||
<span className="text-slate-500">→</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CardsHelpPage() {
|
||||
const { props } = usePage()
|
||||
const links = props.links || {}
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Cards Help',
|
||||
description: props.description,
|
||||
url: props.seo?.canonical,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Skinbase',
|
||||
},
|
||||
about: ['Cards', 'Studio', 'Publishing', 'Visual communication', 'Group workflows'],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const relatedHelpItems = RELATED_HELP_ITEMS.map((item) => ({
|
||||
...item,
|
||||
href: links[item.linkKey],
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_23%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.14),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.16),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Cards help</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Cards are for ideas that need design, presentation, and message to work together.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page explains what Cards are on Skinbase Nova, how they differ from artworks, posts, and collections, how to create and publish them, and how to use them well in both personal and Group workflows.</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={links.create_card} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Create a Card</a>
|
||||
<a href={links.studio_cards} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open Cards workspace</a>
|
||||
<a href={links.cards_index} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Browse public Cards</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
{HERO_METRICS.map((metric) => (
|
||||
<HeroMetric key={metric.label} label={metric.label} value={metric.value} note={metric.note} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<DocsSidebarNav sections={SECTION_ITEMS} ariaLabel="Cards help sections" selectLabel="Jump to Cards help section" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<DocsSection
|
||||
id="what-cards-are"
|
||||
eyebrow="Foundations"
|
||||
title="What Cards are"
|
||||
summary="Cards are a creative format for visual communication. They are made for ideas that need design, layout, and message to land together in one polished public-facing unit."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{WHAT_CARDS_ARE_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<BulletGrid items={FORMAT_SIGNAL_ITEMS} tone="emerald" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="note" title="The simplest way to think about Cards">
|
||||
Cards are not “smaller artworks” and they are not just decorated posts. They are a format for concise, designed visual communication.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="cards-vs-other-formats"
|
||||
eyebrow="Format choice"
|
||||
title="Cards vs artworks vs posts vs collections"
|
||||
summary="Choose the format based on what the audience needs to experience. The right choice makes the content feel natural. The wrong choice creates friction immediately."
|
||||
>
|
||||
<DocsComparisonTable columns={COMPARISON_COLUMNS} rows={COMPARISON_ROWS} caption="Comparison between Cards, artworks, posts, and collections" />
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="tip" title="Choose Cards when presentation is part of the point">
|
||||
If the message needs typography, composition, or a designed editorial feel to work properly, Cards are usually the right format.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="warning" title="Do not force every idea into Cards">
|
||||
If the content is really an artwork, keep it as an artwork. If it is really an update, keep it as a post. Better format choices create clearer public experiences.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="how-to-create"
|
||||
eyebrow="Workflow"
|
||||
title="How to create a Card"
|
||||
summary="The creation flow should feel deliberate: enter Studio, open the Cards workflow, shape the idea clearly, preview the result, and publish only when the final presentation feels intentional."
|
||||
>
|
||||
<DocsStepList items={CREATION_STEPS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="publishing-and-ownership"
|
||||
eyebrow="Ownership"
|
||||
title="Publishing and ownership"
|
||||
summary="Cards can be personal or Group-owned depending on the context. Before you publish, confirm whose identity the Card represents and whether any shared authorship should be made clear."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{OWNERSHIP_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<BulletGrid items={OWNERSHIP_BULLETS} tone="sky" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="practice" title="Publishing context is part of quality">
|
||||
A polished Card published under the wrong identity is still a bad result. Treat ownership checks as part of the final review, not as cleanup after the fact.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="personal-and-group-workflows"
|
||||
eyebrow="Use cases"
|
||||
title="Using Cards in personal and Group workflows"
|
||||
summary="Cards become easier to understand once they are attached to real use cases. They are useful when you want a compact, designed surface for communication, mood, or presentation."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{WORKFLOW_EXAMPLES.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="best-practices"
|
||||
eyebrow="Quality habits"
|
||||
title="Best practices"
|
||||
summary="Strong Cards feel focused, readable, and intentional. They communicate one idea clearly instead of fighting for attention with too many competing elements."
|
||||
>
|
||||
<BulletGrid items={BEST_PRACTICES} tone="emerald" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="common-mistakes"
|
||||
eyebrow="Avoid this"
|
||||
title="Common mistakes"
|
||||
summary="Most Card problems come from using the wrong format, overloading the design, or ignoring publishing context until after the public result already exists."
|
||||
>
|
||||
<BulletGrid items={COMMON_MISTAKES} tone="amber" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="faq"
|
||||
eyebrow="FAQ"
|
||||
title="Cards FAQ"
|
||||
summary="These answers cover the core questions people ask when they are deciding whether Cards fit the idea they want to publish."
|
||||
>
|
||||
<DocsFaqAccordion items={FAQ_ITEMS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="troubleshooting"
|
||||
eyebrow="Troubleshooting"
|
||||
title="Troubleshooting"
|
||||
summary="Use these shortcuts when the Cards workflow feels unclear, the format choice feels wrong, or the result is not behaving the way you expected."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{TROUBLESHOOTING_ITEMS.map((item) => (
|
||||
<TroubleCard key={item.title} item={item} links={links} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="related-help"
|
||||
eyebrow="Next steps"
|
||||
title="Related help"
|
||||
summary="Use these links when the Cards format is clear and you need the next layer of help around Studio, Group workflows, uploads, or creator identity."
|
||||
>
|
||||
<QuickstartNextSteps items={relatedHelpItems} />
|
||||
</DocsSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
|
||||
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Quick route map</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<a href={links.create_card} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Create a Card</a>
|
||||
<a href={links.studio_cards} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open Cards workspace</a>
|
||||
<a href={links.cards_index} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Browse public Cards</a>
|
||||
<a href={links.studio_help} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read Studio help</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Fast reminder</div>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-50/85">If the content feels unclear, ask one question first: is this a Card, an artwork, a post, or a collection? The answer usually fixes the workflow too.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import React, { useDeferredValue, useEffect, useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import DocsStepList from '../../components/docs/DocsStepList'
|
||||
import HelpGuideCard from '../../components/help/HelpGuideCard'
|
||||
import HelpSearchBar from '../../components/help/HelpSearchBar'
|
||||
import HelpSupportCta from '../../components/help/HelpSupportCta'
|
||||
import HelpTopicCard from '../../components/help/HelpTopicCard'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import {
|
||||
FEATURED_GUIDES,
|
||||
GETTING_STARTED_LINKS,
|
||||
GETTING_STARTED_STEPS,
|
||||
HELP_CATEGORIES,
|
||||
HIGHLIGHTED_GUIDES,
|
||||
POPULAR_HELP_TOPICS,
|
||||
SEARCH_SUGGESTIONS,
|
||||
SUPPORT_ITEMS,
|
||||
TROUBLESHOOTING_ITEMS,
|
||||
} from './helpCenterContent'
|
||||
|
||||
function matchesQuery(item, query) {
|
||||
if (!query) return true
|
||||
|
||||
const haystack = [
|
||||
item.title,
|
||||
item.description,
|
||||
item.status,
|
||||
item.eyebrow,
|
||||
item.plannedPath,
|
||||
...(item.tags || []),
|
||||
...(item.highlights || []),
|
||||
...(item.linkItems || []).map((linkItem) => linkItem.label),
|
||||
].filter(Boolean).join(' ').toLowerCase()
|
||||
|
||||
return haystack.includes(query)
|
||||
}
|
||||
|
||||
function MiniLink({ item, href }) {
|
||||
return (
|
||||
<a href={href} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
{item.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function PopularTopicCard({ item, href }) {
|
||||
return (
|
||||
<a href={href} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<h3 className="text-base font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{item.description}</p>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HelpCenterPage() {
|
||||
const page = usePage()
|
||||
const { props, url } = page
|
||||
const links = props.links || {}
|
||||
const signedIn = Boolean(props.auth?.signed_in)
|
||||
const urlQuery = new URLSearchParams((url.split('?')[1] || '')).get('q') || ''
|
||||
const [query, setQuery] = useState(urlQuery)
|
||||
const normalizedQuery = useDeferredValue(query.trim().toLowerCase())
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(urlQuery)
|
||||
}, [urlQuery])
|
||||
|
||||
const highlightedGuides = HIGHLIGHTED_GUIDES.filter((item) => matchesQuery(item, normalizedQuery))
|
||||
const featuredGuides = FEATURED_GUIDES.filter((item) => matchesQuery(item, normalizedQuery))
|
||||
const categories = HELP_CATEGORIES.map((category) => ({
|
||||
...category,
|
||||
topics: category.topics.filter((topic) => matchesQuery(topic, normalizedQuery)),
|
||||
})).filter((category) => category.topics.length > 0)
|
||||
const troubleshootingItems = TROUBLESHOOTING_ITEMS.filter((item) => matchesQuery(item, normalizedQuery))
|
||||
const popularTopics = POPULAR_HELP_TOPICS.filter((item) => matchesQuery(item, normalizedQuery))
|
||||
|
||||
const totalMatches = highlightedGuides.length
|
||||
+ featuredGuides.length
|
||||
+ troubleshootingItems.length
|
||||
+ popularTopics.length
|
||||
+ categories.reduce((sum, category) => sum + category.topics.length, 0)
|
||||
|
||||
const resultSummary = normalizedQuery
|
||||
? `Showing ${totalMatches} matching help items for “${query.trim()}”.`
|
||||
: 'Search across live guides, planned help topics, popular questions, and troubleshooting shortcuts.'
|
||||
|
||||
const supportItems = SUPPORT_ITEMS.map((item) => ({
|
||||
...item,
|
||||
href: links[item.linkKey],
|
||||
}))
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Help Center',
|
||||
description: props.description,
|
||||
url: props.seo?.canonical,
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
itemListElement: FEATURED_GUIDES.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.title,
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_22%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.16),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1480px]">
|
||||
<section id="introduction" className="rounded-[38px] border border-white/10 bg-[linear-gradient(140deg,rgba(15,23,42,0.94),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.18),transparent_26%)] p-6 shadow-[0_32px_100px_rgba(2,6,23,0.34)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase Nova Help Center</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Find the right guide, quickstart, FAQ, or fix without digging through scattered help.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This is the central help hub for Skinbase Nova. Use it to get started, find module-specific guidance, open the live Groups documentation set, and move quickly toward the next useful answer.</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href="#featured-guides" className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Browse help topics</a>
|
||||
<a href={links.groups_documentation} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open Groups help</a>
|
||||
<a href={links.studio_help} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Read Studio help</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 max-w-3xl">
|
||||
<HelpSearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onSelectSuggestion={setQuery}
|
||||
onClear={() => setQuery('')}
|
||||
suggestions={SEARCH_SUGGESTIONS}
|
||||
resultSummary={resultSummary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[30px] border border-white/10 bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Start here</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<a href={links.studio_help} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Studio help</a>
|
||||
<a href={links.upload_help} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Upload help</a>
|
||||
<a href={links.help_cards} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Cards help</a>
|
||||
<a href={links.help_profile} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Profile help</a>
|
||||
<a href={links.help_auth} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Signup / Login help</a>
|
||||
<a href={links.help_troubleshooting} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Troubleshooting help</a>
|
||||
<a href={links.groups_quickstart} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Groups Quickstart</a>
|
||||
<a href={signedIn ? links.open_studio : links.login} className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">{signedIn ? 'Open Studio workspace' : 'Sign in to start'}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<DocsSidebarNav
|
||||
sections={[
|
||||
{ id: 'highlighted-guides', label: 'Highlighted guides' },
|
||||
{ id: 'featured-guides', label: 'Featured guides' },
|
||||
{ id: 'help-categories', label: 'Help categories' },
|
||||
{ id: 'getting-started', label: 'Getting started' },
|
||||
{ id: 'troubleshooting', label: 'Troubleshooting' },
|
||||
{ id: 'popular-topics', label: 'Popular topics' },
|
||||
{ id: 'support-direction', label: 'Support direction' },
|
||||
]}
|
||||
ariaLabel="Help center sections"
|
||||
selectLabel="Jump to help center section"
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{normalizedQuery ? (
|
||||
<DocsCallout tone="note" title="Filtered help view">
|
||||
Search is filtering the live guides, planned topics, popular questions, and troubleshooting shortcuts below. Clear the search anytime to return to the full Help Center view.
|
||||
</DocsCallout>
|
||||
) : (
|
||||
<DocsCallout tone="note" title="How to use the Help Center">
|
||||
Start with the highlighted live guides if you need complete written help now. Use the category sections to see the long-term help architecture and the next high-priority modules that will expand after Groups.
|
||||
</DocsCallout>
|
||||
)}
|
||||
|
||||
<DocsSection
|
||||
id="highlighted-guides"
|
||||
eyebrow="Live now"
|
||||
title="Highlighted guides"
|
||||
summary="These are the strongest live help surfaces in the current Help Center. They show the quality bar and structural pattern the wider system will follow."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{highlightedGuides.map((item) => (
|
||||
<HelpGuideCard key={item.title} item={item} links={links} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="featured-guides"
|
||||
eyebrow="Featured"
|
||||
title="Start with the highest-priority help topics"
|
||||
summary="These are the first modules users most often need help with. Groups is already live, while the rest are surfaced here with real product entry points and clean future help paths."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{featuredGuides.map((item) => (
|
||||
<HelpGuideCard key={item.title} item={item} links={links} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<section id="help-categories" className="space-y-6 scroll-mt-24">
|
||||
{categories.map((category) => (
|
||||
<DocsSection
|
||||
key={category.id}
|
||||
id={category.id}
|
||||
eyebrow="Help category"
|
||||
title={category.title}
|
||||
summary={category.summary}
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{category.topics.map((topic) => (
|
||||
<HelpTopicCard key={topic.title} item={topic} links={links} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<DocsSection
|
||||
id="getting-started"
|
||||
eyebrow="Onboarding"
|
||||
title="Getting started with Skinbase"
|
||||
summary="This path is designed for first-time creators who want a practical order of operations instead of a giant wall of documentation."
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<DocsStepList items={GETTING_STARTED_STEPS} />
|
||||
|
||||
<div className="space-y-3 rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Quick links</p>
|
||||
<div className="grid gap-3">
|
||||
{GETTING_STARTED_LINKS.map((item) => (
|
||||
<MiniLink key={item.label} item={item} href={links[item.linkKey]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="troubleshooting"
|
||||
eyebrow="Fixes first"
|
||||
title="Troubleshooting shortcuts"
|
||||
summary="These are the fast paths for users who are blocked and need a direct fix, not a longer article."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{troubleshootingItems.map((item) => (
|
||||
<a key={item.title} href={links[item.linkKey]} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-sky-200">{item.linkLabel}</span>
|
||||
<span className="text-slate-500">→</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="popular-topics"
|
||||
eyebrow="Popular"
|
||||
title="Popular help topics"
|
||||
summary="These common journeys make the Help Center feel immediately useful even before every module-specific help page is fully written."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{popularTopics.map((item) => (
|
||||
<PopularTopicCard key={item.title} item={item} href={links[item.linkKey]} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="support-direction"
|
||||
eyebrow="Next steps"
|
||||
title="Need more help than the hub can give?"
|
||||
summary="Use these routes when you need a person, need to report a product issue, or want the most complete live documentation surface right now."
|
||||
>
|
||||
<HelpSupportCta items={supportItems} />
|
||||
</DocsSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
|
||||
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Help architecture</p>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
||||
<li>Use <span className="font-semibold text-white">/help</span> as the main hub.</li>
|
||||
<li>Use <span className="font-semibold text-white">/help/topic</span> for overview pages.</li>
|
||||
<li>Use <span className="font-semibold text-white">/help/topic/subpage</span> for quickstarts, FAQs, and troubleshooting.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Current coverage</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">Groups is the first complete multi-page topic family, and Studio, Upload, Cards, Profile, Signup / Login, Account Settings, and Troubleshooting are now live topic guides. The rest of the Help Center still follows the same predictable expansion path.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsComparisonTable from '../../components/docs/DocsComparisonTable'
|
||||
import DocsFaqAccordion from '../../components/docs/DocsFaqAccordion'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import {
|
||||
BEST_PRACTICES,
|
||||
COMMON_MISTAKES,
|
||||
FAQ_ITEMS,
|
||||
HERO_METRICS,
|
||||
PROFILE_COMPARISON_COLUMNS,
|
||||
PROFILE_COMPARISON_ROWS,
|
||||
PROFILE_CONTENT_ITEMS,
|
||||
PROFILE_IMPROVEMENT_TIPS,
|
||||
RELATED_HELP_ITEMS,
|
||||
SECTION_ITEMS,
|
||||
SETUP_BASICS_ITEMS,
|
||||
TROUBLESHOOTING_ITEMS,
|
||||
WHAT_PROFILE_IS_ITEMS,
|
||||
WHAT_TO_PUT_ITEMS,
|
||||
} from './profileHelpContent'
|
||||
|
||||
function HeroMetric({ label, value, note }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BulletGrid({ items, tone = 'sky' }) {
|
||||
const dotColor = tone === 'amber' ? 'bg-amber-300' : tone === 'emerald' ? 'bg-emerald-300' : 'bg-sky-300'
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex gap-3 text-sm leading-6 text-slate-300">
|
||||
<span className={`mt-2 h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InsightCard({ item }) {
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function TroubleCard({ item, links }) {
|
||||
return (
|
||||
<a href={links[item.linkKey]} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-sky-200">{item.linkLabel}</span>
|
||||
<span className="text-slate-500">→</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfileHelpPage() {
|
||||
const { props } = usePage()
|
||||
const links = props.links || {}
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Profile Help',
|
||||
description: props.description,
|
||||
url: props.seo?.canonical,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Skinbase',
|
||||
},
|
||||
about: ['Profile', 'Creator identity', 'Groups', 'Studio', 'Publishing'],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const relatedHelpItems = RELATED_HELP_ITEMS.map((item) => ({
|
||||
...item,
|
||||
href: links[item.linkKey],
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_23%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.14),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.16),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Profile help</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Your profile is the personal identity people remember when they discover your work.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page explains what a profile is on Skinbase Nova, how it differs from a Group, how to set it up well, and how to build a stronger public creator presence without turning the page into noise.</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={links.profile_settings} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Open profile settings</a>
|
||||
<a href={links.groups_help} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Read Groups help</a>
|
||||
<a href={links.studio_help} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Read Studio help</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
{HERO_METRICS.map((metric) => (
|
||||
<HeroMetric key={metric.label} label={metric.label} value={metric.value} note={metric.note} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<DocsSidebarNav sections={SECTION_ITEMS} ariaLabel="Profile help sections" selectLabel="Jump to Profile help section" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<DocsSection
|
||||
id="what-profile-is"
|
||||
eyebrow="Foundations"
|
||||
title="What a profile is"
|
||||
summary="Your profile is your personal public presence on Skinbase. It is where people build a first impression of who you are, what you create, and how your identity connects to the work they see."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{WHAT_PROFILE_IS_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="note" title="The simplest way to think about your profile">
|
||||
Your profile is not just a settings page. It is the public identity layer that helps people recognize you as a creator.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="profile-vs-group"
|
||||
eyebrow="Identity"
|
||||
title="Profile vs Group"
|
||||
summary="This is the most important distinction for many creators. Your profile represents you personally. A Group represents a shared identity. Both can exist at the same time without competing with each other."
|
||||
>
|
||||
<DocsComparisonTable columns={PROFILE_COMPARISON_COLUMNS} rows={PROFILE_COMPARISON_ROWS} caption="Comparison between Profile and Group" />
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="tip" title="You do not lose your personal identity inside Groups">
|
||||
Group publishing adds a shared layer, but your own profile still matters because it helps people understand your individual presence and contribution history.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="warning" title="Do not let profile and Group identity blur together">
|
||||
When the difference is unclear, people have a harder time understanding who is speaking, who owns the work, and what belongs to the shared team versus the individual creator.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="profile-setup-basics"
|
||||
eyebrow="Setup"
|
||||
title="Profile setup basics"
|
||||
summary="The best profiles are not overbuilt. They are recognizable, readable, and consistent enough that people can understand the creator quickly."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{SETUP_BASICS_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<BulletGrid items={PROFILE_IMPROVEMENT_TIPS} tone="emerald" />
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="what-to-put-on-your-profile"
|
||||
eyebrow="Presentation"
|
||||
title="What to put on your profile"
|
||||
summary="Think of your profile as a curated introduction rather than a dumping ground. The strongest pages make your identity and best work easier to notice quickly."
|
||||
>
|
||||
<BulletGrid items={WHAT_TO_PUT_ITEMS} tone="sky" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="profile-content-and-activity"
|
||||
eyebrow="Visibility"
|
||||
title="Profile content and activity"
|
||||
summary="Profiles are not only bios and avatars. They can also help people understand your personal work, your public contributions, and how active you are as a creator."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{PROFILE_CONTENT_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="practice" title="Profiles become stronger through activity, not decoration alone">
|
||||
Better profile visuals help, but the strongest identity pages are backed by real work, visible contributions, and consistent public presence.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="best-practices"
|
||||
eyebrow="Good habits"
|
||||
title="Best practices"
|
||||
summary="A strong profile does not need to be complicated. It needs to feel real, intentional, and easy for other people to understand."
|
||||
>
|
||||
<BulletGrid items={BEST_PRACTICES} tone="emerald" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="common-mistakes"
|
||||
eyebrow="Avoid this"
|
||||
title="Common mistakes"
|
||||
summary="Most profile problems come from neglect, inconsistency, or mixing personal identity with other public surfaces until the page stops feeling coherent."
|
||||
>
|
||||
<BulletGrid items={COMMON_MISTAKES} tone="amber" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="faq"
|
||||
eyebrow="FAQ"
|
||||
title="Profile FAQ"
|
||||
summary="These answers cover the most common questions people ask when they are trying to build a stronger public identity on Skinbase."
|
||||
>
|
||||
<DocsFaqAccordion items={FAQ_ITEMS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="troubleshooting"
|
||||
eyebrow="Troubleshooting"
|
||||
title="Troubleshooting"
|
||||
summary="Use these shortcuts when your profile feels unclear, incomplete, or disconnected from the way you actually want to present yourself."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{TROUBLESHOOTING_ITEMS.map((item) => (
|
||||
<TroubleCard key={item.title} item={item} links={links} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="related-help"
|
||||
eyebrow="Next steps"
|
||||
title="Related help"
|
||||
summary="Use these links when your profile setup question leads into shared identity, publishing workflows, or access issues elsewhere in the product."
|
||||
>
|
||||
<QuickstartNextSteps items={relatedHelpItems} />
|
||||
</DocsSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
|
||||
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Quick route map</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<a href={links.profile_settings} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open profile settings</a>
|
||||
<a href={links.groups_help} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read Groups help</a>
|
||||
<a href={links.studio_help} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read Studio help</a>
|
||||
<a href={links.upload_help} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read Upload help</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Fast reminder</div>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-50/85">A better profile usually starts with three things: a recognizable avatar, a clearer bio, and a stronger sense of what you want people to remember about you.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsComparisonTable from '../../components/docs/DocsComparisonTable'
|
||||
import DocsFaqAccordion from '../../components/docs/DocsFaqAccordion'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import DocsStepList from '../../components/docs/DocsStepList'
|
||||
import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import {
|
||||
ADVANCED_MODULES,
|
||||
ARTWORK_GUIDANCE,
|
||||
BEST_PRACTICES,
|
||||
CARD_COLLECTION_GUIDANCE,
|
||||
COMMON_MISTAKES,
|
||||
DRAFT_STEPS,
|
||||
FAQ_ITEMS,
|
||||
HERO_METRICS,
|
||||
RELATED_HELP_ITEMS,
|
||||
SECTION_ITEMS,
|
||||
STUDIO_AREAS,
|
||||
STUDIO_COMPARISON_COLUMNS,
|
||||
STUDIO_COMPARISON_ROWS,
|
||||
TROUBLESHOOTING_ITEMS,
|
||||
} from './studioHelpContent'
|
||||
|
||||
function HeroMetric({ label, value, note }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AreaCard({ item, links }) {
|
||||
const actions = [
|
||||
{ label: 'Open Studio', href: links.open_studio },
|
||||
{ label: 'View content dashboard', href: links.studio_content },
|
||||
{ label: 'Open artworks', href: links.studio_artworks },
|
||||
{ label: 'Open drafts', href: links.studio_drafts },
|
||||
{ label: 'Open cards', href: links.studio_cards },
|
||||
{ label: 'Open collections', href: links.studio_collections },
|
||||
{ label: 'Open Group Studio', href: links.group_studio },
|
||||
{ label: 'Read Groups help', href: links.groups_help },
|
||||
{ label: 'Open settings', href: links.studio_settings },
|
||||
{ label: 'Read Profile help', href: links.help_profile },
|
||||
{ label: 'Help Center', href: links.help_home },
|
||||
{ label: 'Report issue', href: links.report_issue },
|
||||
]
|
||||
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.links.map((label) => {
|
||||
const action = actions.find((candidate) => candidate.label === label)
|
||||
if (!action?.href) return null
|
||||
|
||||
return (
|
||||
<a key={label} href={action.href} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.07]">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function BulletGrid({ items, tone = 'sky' }) {
|
||||
const dotColor = tone === 'amber' ? 'bg-amber-300' : tone === 'emerald' ? 'bg-emerald-300' : 'bg-sky-300'
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex gap-3 text-sm leading-6 text-slate-300">
|
||||
<span className={`mt-2 h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TroubleCard({ item, links }) {
|
||||
return (
|
||||
<a href={links[item.linkKey]} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-sky-200">{item.linkLabel}</span>
|
||||
<span className="text-slate-500">→</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioHelpPage() {
|
||||
const { props } = usePage()
|
||||
const links = props.links || {}
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Studio Help',
|
||||
description: props.description,
|
||||
url: props.seo?.canonical,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Skinbase',
|
||||
},
|
||||
about: ['Studio', 'Drafts', 'Publishing', 'Creator workflow', 'Group Studio'],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const relatedHelpItems = RELATED_HELP_ITEMS.map((item) => ({
|
||||
...item,
|
||||
href: links[item.linkKey],
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_23%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.14),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.16),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Studio help</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Studio is the creative control center of Skinbase Nova.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Use Studio to manage drafts, uploads, publishing, artworks, cards, collections, and collaborative work before and after it goes public. This page explains how Studio fits into the platform, how personal and Group contexts differ, and how to use the workspace without creating avoidable confusion.</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={links.open_studio} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Open Studio</a>
|
||||
<a href={links.upload_help} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Read Upload help</a>
|
||||
<a href={links.groups_help} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Read Groups help</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
{HERO_METRICS.map((metric) => (
|
||||
<HeroMetric key={metric.label} label={metric.label} value={metric.value} note={metric.note} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<DocsSidebarNav sections={SECTION_ITEMS} ariaLabel="Studio help sections" selectLabel="Jump to Studio help section" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<DocsSection
|
||||
id="what-is-studio"
|
||||
eyebrow="Foundations"
|
||||
title="What Studio is"
|
||||
summary="Studio is the private management workspace for creators. Public pages show published work. Studio is where you prepare, organize, edit, review, and manage that work before and after it reaches the public side of Skinbase."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">Studio is private</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">Studio is not your public profile. It is the working area where creator actions happen, drafts live, and management choices are made.</p>
|
||||
</div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">Public pages are the result</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">The public side of Skinbase is what people see after you publish. Studio is where you shape that result intentionally.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="note" title="The simplest mental model">
|
||||
Think of Studio as your control room. It is where unfinished work is prepared, published work is managed, and context-sensitive actions live.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="personal-vs-group"
|
||||
eyebrow="Context"
|
||||
title="Personal Studio vs Group Studio"
|
||||
summary="This is one of the most important parts of Studio. The active context changes ownership, available actions, and who is allowed to do what."
|
||||
>
|
||||
<DocsComparisonTable
|
||||
columns={STUDIO_COMPARISON_COLUMNS}
|
||||
rows={STUDIO_COMPARISON_ROWS}
|
||||
caption="Comparison between Personal Studio and Group Studio"
|
||||
/>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="warning" title="Check context before publishing">
|
||||
If you do not confirm whether you are in personal or Group Studio, it becomes much easier to publish under the wrong identity or lose track of where a draft belongs.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="tip" title="Why actions can disappear">
|
||||
In Group Studio, actions may change based on role, permissions, approvals, or workflow stage. Missing actions are often a context or permission issue, not a bug.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="main-studio-areas"
|
||||
eyebrow="Workspace map"
|
||||
title="Main Studio areas"
|
||||
summary="Studio already includes a broad set of creator surfaces. You do not need to memorize every route, but it helps to understand the main areas and what each one is for."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{STUDIO_AREAS.map((item) => (
|
||||
<AreaCard key={item.title} item={item} links={links} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="drafts-and-publishing"
|
||||
eyebrow="Workflow"
|
||||
title="Drafts and publishing"
|
||||
summary="Drafts are unfinished workspace items. Publishing is the moment work becomes public. Treat those as different stages with different responsibilities."
|
||||
>
|
||||
<DocsStepList items={DRAFT_STEPS} />
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="practice" title="Before you publish">
|
||||
Review metadata, preview quality, contributor credit, and context every time. Publishing is fastest when those checks become a habit.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="managing-artworks"
|
||||
eyebrow="Content"
|
||||
title="Managing artworks"
|
||||
summary="Studio is where artwork workflows happen: upload, draft review, metadata cleanup, preview checks, updates, and final publishing decisions."
|
||||
>
|
||||
<BulletGrid items={ARTWORK_GUIDANCE} tone="emerald" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="cards-and-collections"
|
||||
eyebrow="Creative tools"
|
||||
title="Managing cards and collections"
|
||||
summary="Cards and collections are part of the creative management side of Studio. They are not only public-facing features; they also live inside the workspace where you build and organize them."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{CARD_COLLECTION_GUIDANCE.map((item) => (
|
||||
<div key={item.title} className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="advanced-modules"
|
||||
eyebrow="Advanced workflows"
|
||||
title="Projects, releases, and other advanced modules"
|
||||
summary="As workflows become more collaborative or structured, Studio extends beyond simple drafts and publishes into richer operating surfaces."
|
||||
>
|
||||
<BulletGrid items={ADVANCED_MODULES} tone="amber" />
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="note" title="Use advanced modules when they solve a real need">
|
||||
Projects, releases, challenges, events, assets, and review queues are powerful, but they work best when the team actually needs more structure rather than more complexity.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="best-practices"
|
||||
eyebrow="Habits"
|
||||
title="Best practices"
|
||||
summary="Good Studio habits reduce confusion, keep work organized, and make publishing smoother for both solo creators and teams."
|
||||
>
|
||||
<BulletGrid items={BEST_PRACTICES} tone="sky" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="common-mistakes"
|
||||
eyebrow="Avoid this"
|
||||
title="Common mistakes"
|
||||
summary="Most Studio confusion does not come from the existence of many tools. It comes from using the right tool in the wrong context or skipping basic review steps."
|
||||
>
|
||||
<BulletGrid items={COMMON_MISTAKES} tone="amber" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="faq"
|
||||
eyebrow="FAQ"
|
||||
title="Studio FAQ"
|
||||
summary="These fast answers cover the questions that come up most often when people are new to Studio or switching into collaborative workflows."
|
||||
>
|
||||
<DocsFaqAccordion items={FAQ_ITEMS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="troubleshooting"
|
||||
eyebrow="Troubleshooting"
|
||||
title="Troubleshooting"
|
||||
summary="Use these shortcuts when Studio feels confusing, empty, or inconsistent. Most issues come down to context, filters, or permissions."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{TROUBLESHOOTING_ITEMS.map((item) => (
|
||||
<TroubleCard key={item.title} item={item} links={links} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="related-help"
|
||||
eyebrow="Next steps"
|
||||
title="Related help"
|
||||
summary="Use these links when Studio has answered the workflow question and you need to go deeper into the right part of the help system or product surface."
|
||||
>
|
||||
<QuickstartNextSteps items={relatedHelpItems} />
|
||||
</DocsSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
|
||||
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Quick route map</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<a href={links.open_studio} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open Studio</a>
|
||||
<a href={links.studio_drafts} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open drafts</a>
|
||||
<a href={links.group_studio} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open Group Studio</a>
|
||||
<a href={links.groups_help} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read Groups help</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Fast reminder</div>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-50/85">If something feels missing, check context first. Personal Studio and Group Studio are connected, but they are not identical workspaces.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsFaqAccordion from '../../components/docs/DocsFaqAccordion'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import {
|
||||
ACCOUNT_ACCESS_ITEMS,
|
||||
FAQ_ITEMS,
|
||||
FIRST_CHECKS,
|
||||
HERO_METRICS,
|
||||
PROFILE_SETTINGS_ITEMS,
|
||||
PUBLISHING_CONTEXT_ITEMS,
|
||||
RELATED_HELP_ITEMS,
|
||||
REPORTING_ITEMS,
|
||||
SECTION_ITEMS,
|
||||
} from './troubleshootingHelpContent'
|
||||
|
||||
function HeroMetric({ label, value, note }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InsightCard({ item }) {
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function BulletGrid({ items, tone = 'sky' }) {
|
||||
const dotColor = tone === 'amber' ? 'bg-amber-300' : tone === 'emerald' ? 'bg-emerald-300' : 'bg-sky-300'
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex gap-3 text-sm leading-6 text-slate-300">
|
||||
<span className={`mt-2 h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TroubleshootingHelpPage() {
|
||||
const { props } = usePage()
|
||||
const links = props.links || {}
|
||||
const signedIn = Boolean(props.auth?.signed_in)
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Troubleshooting Help',
|
||||
description: props.description,
|
||||
url: props.seo?.canonical,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Skinbase',
|
||||
},
|
||||
about: ['Troubleshooting', 'Account access', 'Permissions', 'Publishing blockers', 'Support'],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const relatedHelpItems = RELATED_HELP_ITEMS.map((item) => ({
|
||||
...item,
|
||||
href: links[item.linkKey],
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(244,114,182,0.12),_transparent_20%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.16),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(244,114,182,0.12),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-rose-200/80">Troubleshooting help</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">When something feels broken, the fastest fix usually starts with diagnosing the right kind of problem.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Use this page when you need shorter, support-oriented guidance for account entry issues, context confusion, publishing blockers, incomplete setup, permissions problems, or the point where a clean bug report becomes the right move.</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={signedIn ? links.open_studio : links.login} className="rounded-full border border-rose-300/25 bg-rose-300/12 px-5 py-3 text-sm font-semibold text-rose-100 transition hover:border-rose-300/40 hover:bg-rose-300/18">{signedIn ? 'Open Studio' : 'Open login'}</a>
|
||||
<a href={links.help_auth} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Read auth help</a>
|
||||
<a href={links.report_issue} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Report a problem</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
{HERO_METRICS.map((metric) => (
|
||||
<HeroMetric key={metric.label} label={metric.label} value={metric.value} note={metric.note} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<DocsSidebarNav sections={SECTION_ITEMS} ariaLabel="Troubleshooting help sections" selectLabel="Jump to troubleshooting section" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<DocsSection
|
||||
id="first-checks"
|
||||
eyebrow="Start here"
|
||||
title="First checks before you spiral"
|
||||
summary="Troubleshooting works better when you slow the situation down, label the failure clearly, and rule out the most common context mistakes first."
|
||||
>
|
||||
<BulletGrid items={FIRST_CHECKS} tone="sky" />
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="note" title="A useful troubleshooting split">
|
||||
Ask whether the issue is about access, permissions, settings, or a truly broken route. Those categories lead to very different fixes.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="warning" title="Do not skip straight to bug reports">
|
||||
Many urgent-feeling problems resolve much faster once you confirm the right account, inbox, route, or Group context.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="account-access"
|
||||
eyebrow="Access"
|
||||
title="Account access and recovery issues"
|
||||
summary="If you cannot enter the account consistently, or can only reach part of the platform, start here before diagnosing deeper creator workflow surfaces."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{ACCOUNT_ACCESS_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="publishing-and-context"
|
||||
eyebrow="Workflow"
|
||||
title="Publishing and context problems"
|
||||
summary="When uploads, Studio, or publishing actions feel blocked, the root cause is often context or permissions rather than a total platform failure."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{PUBLISHING_CONTEXT_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="profile-and-settings"
|
||||
eyebrow="Settings"
|
||||
title="Profile and account-settings confusion"
|
||||
summary="Some problems feel technical when they are really profile maintenance, settings drift, or identity confusion that belongs in a guide instead of a support ticket."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{PROFILE_SETTINGS_ITEMS.map((item) => (
|
||||
<InsightCard key={item.title} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="when-to-report"
|
||||
eyebrow="Escalation"
|
||||
title="When to contact support or report a bug"
|
||||
summary="Escalation works best when the problem is already described clearly enough that another person can follow it without guessing."
|
||||
>
|
||||
<BulletGrid items={REPORTING_ITEMS} tone="amber" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="faq"
|
||||
eyebrow="FAQ"
|
||||
title="Troubleshooting FAQ"
|
||||
summary="These are the fast answers for the moment when a problem exists, but the category of the problem still feels uncertain."
|
||||
>
|
||||
<DocsFaqAccordion items={FAQ_ITEMS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="related-help"
|
||||
eyebrow="Next steps"
|
||||
title="Related help"
|
||||
summary="Use these routes when the diagnosis points toward access recovery, account settings, publishing workflows, or role-based collaboration guidance."
|
||||
>
|
||||
<QuickstartNextSteps items={relatedHelpItems} />
|
||||
</DocsSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
|
||||
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-200/80">Quick route map</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<a href={links.help_auth} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read auth help</a>
|
||||
<a href={links.help_account} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read account settings help</a>
|
||||
<a href={links.upload_help} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read Upload help</a>
|
||||
<a href={links.groups_faq} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open Groups FAQ</a>
|
||||
<a href={links.report_issue} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Report a problem</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-rose-300/20 bg-rose-400/10 p-4 text-rose-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-rose-100/80">Fast reminder</div>
|
||||
<p className="mt-2 text-sm leading-6 text-rose-50/85">A clear problem statement beats frantic guessing. Name the route, the context, and what changed before you decide the product is broken.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import DocsCallout from '../../components/docs/DocsCallout'
|
||||
import DocsComparisonTable from '../../components/docs/DocsComparisonTable'
|
||||
import DocsFaqAccordion from '../../components/docs/DocsFaqAccordion'
|
||||
import DocsSection from '../../components/docs/DocsSection'
|
||||
import DocsSidebarNav from '../../components/docs/DocsSidebarNav'
|
||||
import DocsStepList from '../../components/docs/DocsStepList'
|
||||
import QuickstartNextSteps from '../../components/docs/QuickstartNextSteps'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import {
|
||||
BEST_PRACTICES,
|
||||
COMMON_MISTAKES,
|
||||
CREDIT_BULLETS,
|
||||
CREDIT_EXAMPLE,
|
||||
DRAFT_FLOW_ITEMS,
|
||||
FAQ_ITEMS,
|
||||
FILE_METADATA_ITEMS,
|
||||
HERO_METRICS,
|
||||
PREP_ITEMS,
|
||||
PUBLISH_FLOW_ITEMS,
|
||||
RELATED_HELP_ITEMS,
|
||||
SECTION_ITEMS,
|
||||
TROUBLESHOOTING_ITEMS,
|
||||
UPLOAD_COMPARISON_COLUMNS,
|
||||
UPLOAD_COMPARISON_ROWS,
|
||||
WORKFLOW_STEPS,
|
||||
} from './uploadHelpContent'
|
||||
|
||||
function HeroMetric({ label, value, note }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BulletGrid({ items, tone = 'sky' }) {
|
||||
const dotColor = tone === 'amber' ? 'bg-amber-300' : tone === 'emerald' ? 'bg-emerald-300' : 'bg-sky-300'
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex gap-3 text-sm leading-6 text-slate-300">
|
||||
<span className={`mt-2 h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TroubleCard({ item, links }) {
|
||||
return (
|
||||
<a href={links[item.linkKey]} className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.body}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-sky-200">{item.linkLabel}</span>
|
||||
<span className="text-slate-500">→</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UploadHelpPage() {
|
||||
const { props } = usePage()
|
||||
const links = props.links || {}
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Upload Help',
|
||||
description: props.description,
|
||||
url: props.seo?.canonical,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Skinbase',
|
||||
},
|
||||
about: ['Upload workflow', 'Drafts', 'Publishing', 'Contributor credit', 'Group uploads'],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const relatedHelpItems = RELATED_HELP_ITEMS.map((item) => ({
|
||||
...item,
|
||||
href: links[item.linkKey],
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_23%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.14),_transparent_22%),linear-gradient(180deg,_#020617_0%,_#030712_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={props.seo || {}} title={props.title} description={props.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<section id="introduction" className="rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.16),transparent_28%)] p-6 shadow-[0_30px_100px_rgba(2,6,23,0.35)] md:p-8 lg:p-10">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Upload help</p>
|
||||
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Uploading on Skinbase is a guided workflow, not just a raw file submission.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page explains how uploads move from file submission to draft, review, and final publish. It is designed to help you upload confidently, prepare the right details in advance, avoid common context mistakes, and understand what to do when something feels stuck.</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={links.upload} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Start an upload</a>
|
||||
<a href={links.studio_help} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Read Studio help</a>
|
||||
<a href={links.groups_help} className="rounded-full border border-white/10 bg-black/20 px-5 py-3 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">Read Groups help</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
{HERO_METRICS.map((metric) => (
|
||||
<HeroMetric key={metric.label} label={metric.label} value={metric.value} note={metric.note} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<DocsSidebarNav sections={SECTION_ITEMS} ariaLabel="Upload help sections" selectLabel="Jump to Upload help section" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<DocsSection
|
||||
id="how-uploading-works"
|
||||
eyebrow="Workflow"
|
||||
title="How uploading works"
|
||||
summary="Uploading is designed to feel understandable and safe. The workflow gives you space to review and finish the public version before it goes live."
|
||||
>
|
||||
<DocsStepList items={WORKFLOW_STEPS} />
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="note" title="Why draft-first upload exists">
|
||||
Drafts reduce rushed submissions. They give you a chance to review context, metadata, previews, and contributor credit before the upload becomes public.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="prepare-before-upload"
|
||||
eyebrow="Preparation"
|
||||
title="What to prepare before upload"
|
||||
summary="The better prepared you are before upload starts, the less likely you are to end up with an unfinished draft, weak presentation, or incorrect publishing context."
|
||||
>
|
||||
<BulletGrid items={PREP_ITEMS} tone="emerald" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="personal-vs-group"
|
||||
eyebrow="Context"
|
||||
title="Personal upload vs Group upload"
|
||||
summary="The most important upload decision is not just the file. It is whether the work should publish under your personal identity or under a Group."
|
||||
>
|
||||
<DocsComparisonTable
|
||||
columns={UPLOAD_COMPARISON_COLUMNS}
|
||||
rows={UPLOAD_COMPARISON_ROWS}
|
||||
caption="Comparison between Personal upload and Group upload"
|
||||
/>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<DocsCallout tone="warning" title="Always verify context before final publish">
|
||||
The wrong context can turn a correct upload into the wrong public record. Check whether the work belongs to you or to a Group before the final step.
|
||||
</DocsCallout>
|
||||
<DocsCallout tone="tip" title="When in doubt, open Groups help">
|
||||
If the upload involves shared ownership, contributor credit, or review queues, the Groups guide is the next best place to clarify how the upload should behave.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="draft-flow"
|
||||
eyebrow="Drafts"
|
||||
title="Draft flow"
|
||||
summary="Uploads usually begin as drafts so you can finish the details deliberately instead of publishing a half-finished item by accident."
|
||||
>
|
||||
<BulletGrid items={DRAFT_FLOW_ITEMS} tone="sky" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="publish-flow"
|
||||
eyebrow="Publish"
|
||||
title="Publish flow"
|
||||
summary="Publish is the final decision point. By the time you reach it, the file, context, metadata, and contributor details should already feel solid."
|
||||
>
|
||||
<BulletGrid items={PUBLISH_FLOW_ITEMS} tone="amber" />
|
||||
|
||||
<div className="mt-6">
|
||||
<DocsCallout tone="practice" title="Treat final publish like a checklist moment">
|
||||
If something still feels unclear, stop and review it before publishing. A short pause now is cheaper than cleaning up a public mistake later.
|
||||
</DocsCallout>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="file-preview-metadata"
|
||||
eyebrow="Presentation"
|
||||
title="File, preview, and metadata basics"
|
||||
summary="Strong uploads are not only about file quality. They also depend on how clearly the work is presented and how understandable it feels to other people."
|
||||
>
|
||||
<BulletGrid items={FILE_METADATA_ITEMS} tone="emerald" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="contributor-credit"
|
||||
eyebrow="Credit"
|
||||
title="Contributor credit during upload"
|
||||
summary="Upload identity, published identity, and authorship are related, but they are not always the same thing. That matters most in collaborative and Group uploads."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div>
|
||||
<BulletGrid items={CREDIT_BULLETS} tone="sky" />
|
||||
</div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Simple example</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{CREDIT_EXAMPLE.map((entry) => (
|
||||
<div key={entry.label} className="rounded-[20px] border border-white/10 bg-white/[0.03] p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{entry.label}</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{entry.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="best-practices"
|
||||
eyebrow="Best practices"
|
||||
title="Best practices"
|
||||
summary="The best upload habits are simple: prepare before you start, review before you publish, and keep the workspace clean enough that you can trust what you are looking at."
|
||||
>
|
||||
<BulletGrid items={BEST_PRACTICES} tone="emerald" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="common-mistakes"
|
||||
eyebrow="Avoid this"
|
||||
title="Common mistakes"
|
||||
summary="Most upload problems are not technical failures. They come from skipping review steps, using the wrong context, or leaving too many things unfinished at once."
|
||||
>
|
||||
<BulletGrid items={COMMON_MISTAKES} tone="amber" />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="faq"
|
||||
eyebrow="FAQ"
|
||||
title="Upload FAQ"
|
||||
summary="These answers cover the most common questions people ask when an upload becomes a draft, stalls before publish, or behaves differently inside a Group."
|
||||
>
|
||||
<DocsFaqAccordion items={FAQ_ITEMS} />
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="troubleshooting"
|
||||
eyebrow="Troubleshooting"
|
||||
title="Troubleshooting"
|
||||
summary="Use these shortcuts when the upload workflow feels stalled, confusing, or inconsistent."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{TROUBLESHOOTING_ITEMS.map((item) => (
|
||||
<TroubleCard key={item.title} item={item} links={links} />
|
||||
))}
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection
|
||||
id="related-help"
|
||||
eyebrow="Next steps"
|
||||
title="Related help"
|
||||
summary="Use these links when the upload workflow is clear and you need the next layer of guidance around Studio, Groups, profile context, or adjacent creative tools."
|
||||
>
|
||||
<QuickstartNextSteps items={relatedHelpItems} />
|
||||
</DocsSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24 xl:self-start">
|
||||
<div className="space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Quick route map</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<a href={links.upload} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Start upload</a>
|
||||
<a href={links.studio_drafts} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Open drafts</a>
|
||||
<a href={links.studio_help} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read Studio help</a>
|
||||
<a href={links.groups_help} className="block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]">Read Groups help</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Fast reminder</div>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-50/85">If an upload feels wrong, check three things first: context, draft state, and contributor credit.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
export const SECTION_ITEMS = [
|
||||
{ id: 'what-account-help-covers', label: 'What this covers' },
|
||||
{ id: 'settings-areas', label: 'Settings areas' },
|
||||
{ id: 'email-and-password', label: 'Email and password' },
|
||||
{ id: 'profile-and-preferences', label: 'Profile and preferences' },
|
||||
{ id: 'maintenance-habits', label: 'Maintenance habits' },
|
||||
{ id: 'faq', label: 'FAQ' },
|
||||
{ id: 'related-help', label: 'Related help' },
|
||||
]
|
||||
|
||||
export const HERO_METRICS = [
|
||||
{
|
||||
label: 'Best use case',
|
||||
value: 'Access works, settings need attention',
|
||||
note: 'This guide starts after login succeeds and you need help managing the account instead of regaining it.',
|
||||
},
|
||||
{
|
||||
label: 'Main surface',
|
||||
value: 'Dashboard profile settings',
|
||||
note: 'Most account, profile, notification, and password changes flow through the main settings area tied to your creator identity.',
|
||||
},
|
||||
{
|
||||
label: 'Common confusion',
|
||||
value: 'Settings issue vs access issue',
|
||||
note: 'A surprising number of “account problems” are really login, verification, or troubleshooting problems wearing a different label.',
|
||||
},
|
||||
]
|
||||
|
||||
export const COVERAGE_ITEMS = [
|
||||
{
|
||||
title: 'Account help is broader than login help',
|
||||
body: 'Use this page when you are already in the account, or know how to get back into it, and need guidance for settings, identity details, preferences, or maintenance.',
|
||||
},
|
||||
{
|
||||
title: 'Settings shape both behavior and presentation',
|
||||
body: 'Account choices affect how your public identity reads, how notifications reach you, and how safely you can recover or maintain access over time.',
|
||||
},
|
||||
{
|
||||
title: 'Not every question belongs in one settings panel',
|
||||
body: 'Some issues live in profile setup, some in security and email care, and some in creator workflow pages such as Studio. Separating those questions keeps fixes faster.',
|
||||
},
|
||||
]
|
||||
|
||||
export const SETTINGS_AREA_ITEMS = [
|
||||
{
|
||||
title: 'Profile and public identity',
|
||||
body: 'This is where presentation work matters most: avatar, bio, creator-facing details, and the way other people understand you when they land on the profile.',
|
||||
},
|
||||
{
|
||||
title: 'Account basics and email changes',
|
||||
body: 'Your account email needs to stay current because recovery, verification, and important notices depend on it reaching the right inbox.',
|
||||
},
|
||||
{
|
||||
title: 'Security and password care',
|
||||
body: 'Password updates and basic security hygiene belong to account maintenance, not crisis mode. They are easier to handle before something breaks.',
|
||||
},
|
||||
{
|
||||
title: 'Notifications and personal preferences',
|
||||
body: 'Notification settings decide how noisy or quiet the platform feels. Personal details and preference changes can make the account feel more stable day to day.',
|
||||
},
|
||||
]
|
||||
|
||||
export const EMAIL_PASSWORD_STEPS = [
|
||||
{
|
||||
title: 'Open account settings intentionally',
|
||||
description: 'Start from the authenticated settings surface instead of hunting for one-off forms across the product. That keeps related account changes in one place.',
|
||||
},
|
||||
{
|
||||
title: 'Confirm you are editing the correct account',
|
||||
description: 'This matters most if you manage more than one identity, recently switched devices, or are returning after a recovery flow.',
|
||||
},
|
||||
{
|
||||
title: 'Change the minimum necessary fields carefully',
|
||||
description: 'When updating email or password, reduce avoidable mistakes by changing one sensitive area at a time and confirming the result before moving on.',
|
||||
},
|
||||
{
|
||||
title: 'Watch for follow-up verification or confirmation',
|
||||
description: 'Email-related changes often require a follow-up message or verification step. Do not assume the change finished until that path is complete.',
|
||||
},
|
||||
{
|
||||
title: 'Return to troubleshooting only if something blocks the change',
|
||||
description: 'If an update fails, diagnose that failure directly instead of treating every blocked settings action as a generic login issue.',
|
||||
},
|
||||
]
|
||||
|
||||
export const PROFILE_PREFERENCE_ITEMS = [
|
||||
'Keep your avatar, display details, and short bio current enough that the profile still feels like you.',
|
||||
'Use profile settings for public identity questions and the auth guide for account-entry questions so you do not mix two different problem types.',
|
||||
'Trim notification noise deliberately instead of turning everything on and then missing what actually matters.',
|
||||
'Treat personal details, messaging preferences, and creator-facing settings as maintenance work rather than last-minute cleanup.',
|
||||
]
|
||||
|
||||
export const MAINTENANCE_HABITS = [
|
||||
'Keep the account email reachable so resets, verification, and important notices do not disappear into an abandoned inbox.',
|
||||
'Update passwords proactively when something feels off instead of waiting for a failure moment.',
|
||||
'Review settings after major workflow changes such as new devices, new collaboration patterns, or a shift in what you publish publicly.',
|
||||
'Use troubleshooting only when the settings surface feels blocked or broken, not for normal questions the guide can answer directly.',
|
||||
]
|
||||
|
||||
export const FAQ_ITEMS = [
|
||||
{
|
||||
question: 'What is the difference between auth help and account help?',
|
||||
answer: 'Auth help is about getting into the account. Account help is about what you manage after access works, such as settings, identity details, notifications, and password care.',
|
||||
},
|
||||
{
|
||||
question: 'Where should I change my profile details?',
|
||||
answer: 'Use the main profile settings surface for public identity details such as avatar, bio, and related creator-facing information.',
|
||||
},
|
||||
{
|
||||
question: 'Where should I change my password?',
|
||||
answer: 'Use the authenticated account settings surface for password changes. If you cannot reach that surface because access failed, switch to the auth help and recovery path instead.',
|
||||
},
|
||||
{
|
||||
question: 'What if changing email or password does not work?',
|
||||
answer: 'Move into the troubleshooting page if the settings flow appears blocked, broken, or inconsistent instead of assuming the whole account is unusable.',
|
||||
},
|
||||
{
|
||||
question: 'Does account help replace profile help?',
|
||||
answer: 'No. Profile help goes deeper on public identity and presentation. Account help stays broader and covers the settings side of maintaining the account over time.',
|
||||
},
|
||||
]
|
||||
|
||||
export const RELATED_HELP_ITEMS = [
|
||||
{
|
||||
eyebrow: 'Access',
|
||||
title: 'Signup and login help',
|
||||
body: 'Use the auth guide when the problem is getting into the account at all rather than maintaining it after access succeeds.',
|
||||
linkKey: 'help_auth',
|
||||
tone: 'sky',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Identity',
|
||||
title: 'Profile help',
|
||||
body: 'Use the profile guide when the question is more about public identity, creator presentation, and what visitors actually see.',
|
||||
linkKey: 'help_profile',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Fixes fast',
|
||||
title: 'Troubleshooting help',
|
||||
body: 'Use the troubleshooting page when settings actions feel blocked, inconsistent, or clearly broken and you need diagnosis first.',
|
||||
linkKey: 'help_troubleshooting',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Workflow',
|
||||
title: 'Studio help',
|
||||
body: 'Use the Studio guide when the issue stops being about the account itself and starts living in drafts, publishing, or creator workflows.',
|
||||
linkKey: 'studio_help',
|
||||
tone: 'white',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,230 @@
|
||||
export const SECTION_ITEMS = [
|
||||
{ id: 'creating-an-account', label: 'Creating an account' },
|
||||
{ id: 'logging-in', label: 'Logging in' },
|
||||
{ id: 'password-reset-recovery', label: 'Password reset / recovery' },
|
||||
{ id: 'access-and-verification', label: 'Access and verification basics' },
|
||||
{ id: 'safety-and-protection', label: 'Safety and protection' },
|
||||
{ id: 'common-mistakes', label: 'Common mistakes' },
|
||||
{ id: 'faq', label: 'FAQ' },
|
||||
{ id: 'troubleshooting', label: 'Troubleshooting' },
|
||||
{ id: 'related-help', label: 'Related help' },
|
||||
]
|
||||
|
||||
export const HERO_METRICS = [
|
||||
{
|
||||
label: 'What this unlocks',
|
||||
value: 'Identity and workspace access',
|
||||
note: 'Signup and login are how you reach your profile, enter Studio, and manage the rest of your creator workflow on Skinbase Nova.',
|
||||
},
|
||||
{
|
||||
label: 'Most common blocker',
|
||||
value: 'Recovery and verification confusion',
|
||||
note: 'Many access problems are solved by the right recovery step, the right email, or a quick check of what the account is still waiting for.',
|
||||
},
|
||||
{
|
||||
label: 'Golden rule',
|
||||
value: 'Slow down and check the basics',
|
||||
note: 'The fastest fix usually comes from verifying the email, password, inbox, or permissions question before assuming the whole account is broken.',
|
||||
},
|
||||
]
|
||||
|
||||
export const SIGNUP_STEPS = [
|
||||
{
|
||||
title: 'Open signup',
|
||||
description: 'Start from the account creation flow rather than trying to enter the platform through a sign-in screen that expects an existing account.',
|
||||
},
|
||||
{
|
||||
title: 'Enter the required details carefully',
|
||||
description: 'Use the email address you actually want tied to your Skinbase identity and choose account details you can remember and manage later.',
|
||||
},
|
||||
{
|
||||
title: 'Create the account',
|
||||
description: 'Finish the signup step, then watch for any confirmation or verification message the account may still need before it is fully usable.',
|
||||
},
|
||||
{
|
||||
title: 'Verify if required',
|
||||
description: 'Some flows may ask you to confirm access through email or verification before certain parts of the platform open up completely.',
|
||||
},
|
||||
{
|
||||
title: 'Continue into profile setup or the platform',
|
||||
description: 'Once access is working, the next useful step is usually profile setup, Studio access, or your first publishing workflow.',
|
||||
},
|
||||
]
|
||||
|
||||
export const LOGIN_STEPS = [
|
||||
{
|
||||
title: 'Open login',
|
||||
description: 'Go to the sign-in flow when you already have an account and want to return to your Skinbase identity and creator tools.',
|
||||
},
|
||||
{
|
||||
title: 'Enter your credentials carefully',
|
||||
description: 'Use the same email and password combination tied to the account you actually want to access. Small mismatches cause a surprising number of login problems.',
|
||||
},
|
||||
{
|
||||
title: 'Enter the platform',
|
||||
description: 'A successful login takes you back into your account so you can continue to Studio, profile settings, and your other authenticated creator surfaces.',
|
||||
},
|
||||
{
|
||||
title: 'Use remembered sessions carefully',
|
||||
description: 'If the device is personal, remembered sessions can save time. If it is shared, log out when you are done instead of leaving account access open.',
|
||||
},
|
||||
]
|
||||
|
||||
export const RECOVERY_STEPS = [
|
||||
{
|
||||
title: 'Open password recovery',
|
||||
description: 'If you forgot the password, start with the reset flow instead of guessing repeatedly until you get locked into more confusion.',
|
||||
},
|
||||
{
|
||||
title: 'Request the reset message',
|
||||
description: 'Enter the email you believe is tied to the account and let the recovery flow send the reset instructions.',
|
||||
},
|
||||
{
|
||||
title: 'Check the right inbox',
|
||||
description: 'Look in spam, promotions, or other folders if the message does not show up immediately. Wrong inboxes and old emails are common causes of panic.',
|
||||
},
|
||||
{
|
||||
title: 'Finish the reset with a new password',
|
||||
description: 'Choose a password you can manage safely and use the new credentials when returning to the login flow.',
|
||||
},
|
||||
]
|
||||
|
||||
export const ACCESS_BASICS_ITEMS = [
|
||||
{
|
||||
title: 'Verification can affect access',
|
||||
body: 'Some accounts may still need email confirmation or another verification step before every feature behaves the way a fully active account would expect.',
|
||||
},
|
||||
{
|
||||
title: 'Access problems are not always login problems',
|
||||
body: 'You can be logged in and still hit limits caused by verification state, incomplete setup, or permissions inside a Group or shared workflow.',
|
||||
},
|
||||
{
|
||||
title: 'Permissions and account access are different things',
|
||||
body: 'Entering the account is not the same as having permission inside every collaboration surface. Group roles and approvals can still control what you see or can do.',
|
||||
},
|
||||
]
|
||||
|
||||
export const SAFETY_ITEMS = [
|
||||
'Use a strong password that you are not reusing everywhere else.',
|
||||
'Do not share your credentials, even with collaborators or friends who work with you on Groups.',
|
||||
'Keep the account email current so recovery messages can actually reach you.',
|
||||
'Log out on shared devices instead of trusting remembered sessions in public or borrowed spaces.',
|
||||
'Review account and security-related settings when something about access changes or starts to feel off.',
|
||||
]
|
||||
|
||||
export const COMMON_MISTAKES = [
|
||||
'Signing up with the wrong email and then waiting for messages in a different inbox.',
|
||||
'Forgetting which credentials or login method belong to the account you are trying to access.',
|
||||
'Waiting for a reset or verification email while checking the wrong folder or wrong email account.',
|
||||
'Trying an outdated password repeatedly instead of moving directly into recovery.',
|
||||
'Assuming a Group or Profile workflow issue is a login problem when the account is actually signed in correctly.',
|
||||
'Confusing account access with Group permissions and expecting login alone to unlock restricted team actions.',
|
||||
]
|
||||
|
||||
export const FAQ_ITEMS = [
|
||||
{
|
||||
question: 'How do I create an account?',
|
||||
answer: 'Open signup, enter the required details carefully, finish the account creation step, and then complete any verification or follow-up setup the account still needs.',
|
||||
},
|
||||
{
|
||||
question: 'How do I log in?',
|
||||
answer: 'Open the login page, enter the email and password tied to your account, and continue into your authenticated creator workspace once access succeeds.',
|
||||
},
|
||||
{
|
||||
question: 'What should I do if I forgot my password?',
|
||||
answer: 'Use the password recovery flow instead of guessing repeatedly. It is the fastest route back into the account when the password is no longer clear.',
|
||||
},
|
||||
{
|
||||
question: 'Why didn’t I receive a verification or reset email?',
|
||||
answer: 'Check the email address you used, then check spam or other inbox folders. A lot of recovery confusion comes from using the wrong email or watching the wrong inbox.',
|
||||
},
|
||||
{
|
||||
question: 'Why can’t I access certain features after login?',
|
||||
answer: 'You may be signed in correctly but still dealing with verification state, incomplete setup, or permissions that belong to a Group or shared workflow rather than simple account access.',
|
||||
},
|
||||
{
|
||||
question: 'Is login the same as having permission inside a Group?',
|
||||
answer: 'No. Login proves account access. Group permissions are separate and can still limit what you are allowed to do inside collaborative spaces.',
|
||||
},
|
||||
{
|
||||
question: 'Can I change account information later?',
|
||||
answer: 'Yes. Once you are back in the account, profile and account settings can be updated through the normal authenticated settings surfaces.',
|
||||
},
|
||||
]
|
||||
|
||||
export const TROUBLESHOOTING_ITEMS = [
|
||||
{
|
||||
title: 'I can’t log in',
|
||||
body: 'Start with the login page, then slow down and re-check the email, password, and whether you are trying to enter the correct account.',
|
||||
linkKey: 'login',
|
||||
linkLabel: 'Open login',
|
||||
},
|
||||
{
|
||||
title: 'I forgot my password',
|
||||
body: 'Use recovery instead of repeated guessing. The password reset flow is the fastest path back when the credentials no longer feel reliable.',
|
||||
linkKey: 'password_request',
|
||||
linkLabel: 'Reset password',
|
||||
},
|
||||
{
|
||||
title: 'I didn’t receive the email',
|
||||
body: 'Check spam, promotions, and the exact email account you used during signup or recovery. Many “missing email” issues turn out to be inbox mix-ups.',
|
||||
linkKey: 'password_request',
|
||||
linkLabel: 'Retry recovery',
|
||||
},
|
||||
{
|
||||
title: 'I signed up but can’t access something',
|
||||
body: 'The missing step may be verification or post-signup setup rather than broken login. If access still feels partial, revisit the recovery and support paths carefully.',
|
||||
linkKey: 'help_troubleshooting',
|
||||
linkLabel: 'Open troubleshooting hub',
|
||||
},
|
||||
{
|
||||
title: 'I’m logged in but still missing permissions',
|
||||
body: 'That usually points to Group roles or workflow permissions rather than a sign-in failure. Check the Groups guide if the issue lives inside a shared workspace.',
|
||||
linkKey: 'groups_help',
|
||||
linkLabel: 'Read Groups help',
|
||||
},
|
||||
{
|
||||
title: 'I think I used the wrong email',
|
||||
body: 'Return to the recovery flow and try the email you most likely used at signup. If that still fails, contact support with a clear explanation instead of guessing endlessly.',
|
||||
linkKey: 'contact_support',
|
||||
linkLabel: 'Contact support',
|
||||
},
|
||||
]
|
||||
|
||||
export const RELATED_HELP_ITEMS = [
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Account settings help',
|
||||
body: 'Use the account guide when access already works and the real question is about settings, email changes, password care, or ongoing account maintenance.',
|
||||
linkKey: 'help_account',
|
||||
tone: 'sky',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Profile help',
|
||||
body: 'Use the Profile guide once account access is working and you need help turning that access into a stronger public identity.',
|
||||
linkKey: 'help_profile',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Studio help',
|
||||
body: 'Use the Studio guide when the real question starts after login and moves into drafts, publishing, and the main creator workspace.',
|
||||
linkKey: 'studio_help',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Groups help',
|
||||
body: 'Use the Groups guide if you are signed in correctly but the real blocker is collaboration roles, permissions, or Group workflow behavior.',
|
||||
linkKey: 'groups_help',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Support path',
|
||||
title: 'Troubleshooting hub',
|
||||
body: 'Use the dedicated troubleshooting page when the access problem is still unclear and you need faster diagnosis before opening a deeper module guide.',
|
||||
linkKey: 'help_troubleshooting',
|
||||
tone: 'white',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
export const SECTION_ITEMS = [
|
||||
{ id: 'what-cards-are', label: 'What Cards are' },
|
||||
{ id: 'cards-vs-other-formats', label: 'Cards vs other formats' },
|
||||
{ id: 'how-to-create', label: 'How to create a Card' },
|
||||
{ id: 'publishing-and-ownership', label: 'Publishing and ownership' },
|
||||
{ id: 'personal-and-group-workflows', label: 'Personal and Group workflows' },
|
||||
{ id: 'best-practices', label: 'Best practices' },
|
||||
{ id: 'common-mistakes', label: 'Common mistakes' },
|
||||
{ id: 'faq', label: 'FAQ' },
|
||||
{ id: 'troubleshooting', label: 'Troubleshooting' },
|
||||
{ id: 'related-help', label: 'Related help' },
|
||||
]
|
||||
|
||||
export const HERO_METRICS = [
|
||||
{
|
||||
label: 'Format role',
|
||||
value: 'Visual communication format',
|
||||
note: 'Cards are best when you want a designed message, presentation, or editorial visual rather than a straightforward artwork upload.',
|
||||
},
|
||||
{
|
||||
label: 'Best for',
|
||||
value: 'Short, styled ideas',
|
||||
note: 'Use Cards for quote designs, promo visuals, themed statements, highlight pieces, and compact presentation content.',
|
||||
},
|
||||
{
|
||||
label: 'Golden rule',
|
||||
value: 'Match the message to the format',
|
||||
note: 'Choose Cards when design and presentation are part of the message, not just decoration added afterward.',
|
||||
},
|
||||
]
|
||||
|
||||
export const WHAT_CARDS_ARE_ITEMS = [
|
||||
{
|
||||
title: 'Cards are designed content units',
|
||||
body: 'A Card is a creative format built for presentation, visual storytelling, and styled communication. It is meant to carry an idea clearly, not just display a raw asset.',
|
||||
},
|
||||
{
|
||||
title: 'Cards are flexible, not vague',
|
||||
body: 'They can be visual statements, promo pieces, quote graphics, short editorial concepts, or support content around a wider project or collection.',
|
||||
},
|
||||
{
|
||||
title: 'Cards are not a replacement for everything',
|
||||
body: 'Some ideas should stay artworks, some should be posts, and some belong inside collections. Cards work best when presentation and message need to live together in one polished format.',
|
||||
},
|
||||
]
|
||||
|
||||
export const FORMAT_SIGNAL_ITEMS = [
|
||||
'Use Cards when layout, typography, or composition are part of the meaning.',
|
||||
'Use Cards when you want a clean public-facing visual without turning it into a full artwork upload.',
|
||||
'Use Cards when a post feels too plain but a collection feels too large for the idea you want to publish.',
|
||||
]
|
||||
|
||||
export const COMPARISON_COLUMNS = [
|
||||
{ key: 'topic', label: 'Topic' },
|
||||
{ key: 'cards', label: 'Cards' },
|
||||
{ key: 'artworks', label: 'Artworks' },
|
||||
{ key: 'posts', label: 'Posts' },
|
||||
{ key: 'collections', label: 'Collections' },
|
||||
]
|
||||
|
||||
export const COMPARISON_ROWS = [
|
||||
{
|
||||
id: 'primary-job',
|
||||
topic: 'Primary job',
|
||||
cards: 'Designed visual communication, editorial presentation, or compact storytelling.',
|
||||
artworks: 'A main creative work or finished visual creation published as its own piece.',
|
||||
posts: 'Updates, announcements, status sharing, or direct communication.',
|
||||
collections: 'Grouping related items into a bigger curated set or presentation.',
|
||||
},
|
||||
{
|
||||
id: 'best-when',
|
||||
topic: 'Best when',
|
||||
cards: 'The look, message, and layout all matter together.',
|
||||
artworks: 'The work itself is the central thing being shown.',
|
||||
posts: 'You need clarity and speed more than designed presentation.',
|
||||
collections: 'You want to organize multiple related works into one structured view.',
|
||||
},
|
||||
{
|
||||
id: 'audience-expectation',
|
||||
topic: 'Audience expectation',
|
||||
cards: 'A polished visual statement or concise editorial piece.',
|
||||
artworks: 'A primary artwork worthy of direct viewing and appreciation.',
|
||||
posts: 'A message, update, or announcement to read quickly.',
|
||||
collections: 'A curated journey through more than one item.',
|
||||
},
|
||||
{
|
||||
id: 'scale',
|
||||
topic: 'Typical scale',
|
||||
cards: 'One focused idea, concept, or promo moment.',
|
||||
artworks: 'One major visual work.',
|
||||
posts: 'One update or communication moment.',
|
||||
collections: 'Multiple related works or references gathered together.',
|
||||
},
|
||||
{
|
||||
id: 'common-misuse',
|
||||
topic: 'Common misuse',
|
||||
cards: 'Turning every message into a design exercise even when a post would be clearer.',
|
||||
artworks: 'Uploading presentation graphics that are not really artworks.',
|
||||
posts: 'Using posts when the message needs stronger visual presentation.',
|
||||
collections: 'Making a collection when one good Card or one good artwork would communicate faster.',
|
||||
},
|
||||
]
|
||||
|
||||
export const CREATION_STEPS = [
|
||||
{
|
||||
title: 'Open Studio',
|
||||
description: 'Start in Studio so you are working inside the creator workspace rather than trying to manage Cards from public pages.',
|
||||
},
|
||||
{
|
||||
title: 'Choose the Cards area',
|
||||
description: 'Move into the Cards workflow where you can create, edit, preview, and manage Card-specific content deliberately.',
|
||||
},
|
||||
{
|
||||
title: 'Create a new Card',
|
||||
description: 'Begin a new Card when you know the message, idea, or visual concept you want the format to carry.',
|
||||
},
|
||||
{
|
||||
title: 'Add title, content, and design choices',
|
||||
description: 'Fill in the content structure clearly. The best Cards feel intentional in both wording and presentation.',
|
||||
},
|
||||
{
|
||||
title: 'Preview the result',
|
||||
description: 'Check readability, balance, visual hierarchy, and whether the Card still communicates well outside the editor context.',
|
||||
},
|
||||
{
|
||||
title: 'Publish when the Card feels clear',
|
||||
description: 'Publish only when the message, design, and ownership context all feel correct for the public result you want.',
|
||||
},
|
||||
]
|
||||
|
||||
export const OWNERSHIP_ITEMS = [
|
||||
{
|
||||
title: 'Personal Cards',
|
||||
body: 'Personal Cards are best for profile highlights, visual notes, branded self-presentation, concept pieces, and compact editorial content under your own creator identity.',
|
||||
},
|
||||
{
|
||||
title: 'Group Cards',
|
||||
body: 'Group Cards are best for shared promos, event graphics, announcements, release support visuals, and presentation content that belongs to the Group rather than one member alone.',
|
||||
},
|
||||
]
|
||||
|
||||
export const OWNERSHIP_BULLETS = [
|
||||
'Check the active context before publishing so the Card goes live under the right identity.',
|
||||
'If a Card represents a shared campaign, promo, or announcement, Group ownership is often the better fit.',
|
||||
'Make authorship and contribution clear whenever more than one person shaped the final Card.',
|
||||
'Treat publishing context as part of quality control, not as a detail to fix later.',
|
||||
]
|
||||
|
||||
export const WORKFLOW_EXAMPLES = [
|
||||
{
|
||||
title: 'Personal profile highlight Card',
|
||||
body: 'Use a Card to introduce a creator direction, showcase a visual theme, or present a compact statement that sits well beside your published work.',
|
||||
},
|
||||
{
|
||||
title: 'Group promo Card',
|
||||
body: 'Use a Group Card for launches, campaigns, member spotlights, collaborations, or audience-facing promo moments that need a shared identity.',
|
||||
},
|
||||
{
|
||||
title: 'Themed editorial Card',
|
||||
body: 'Use a Card when you want one designed visual to communicate a mood, concept, or mini editorial idea without building a larger collection first.',
|
||||
},
|
||||
{
|
||||
title: 'Announcement Card',
|
||||
body: 'If the message should feel polished and visual, a Card can carry an announcement more effectively than a plain post.',
|
||||
},
|
||||
{
|
||||
title: 'Quote or concept Card',
|
||||
body: 'Cards are a strong fit for text-led ideas where typography, color, and layout are part of the creative statement.',
|
||||
},
|
||||
{
|
||||
title: 'Collection support Card',
|
||||
body: 'Use a Card to frame, promote, or introduce a collection without turning the collection itself into a wall of explanation.',
|
||||
},
|
||||
]
|
||||
|
||||
export const BEST_PRACTICES = [
|
||||
'Keep one Card focused on one clear message, theme, or visual purpose.',
|
||||
'Prioritize readability before decoration so the Card still works on smaller screens and quick scrolls.',
|
||||
'Use Cards when presentation adds value, not as a default replacement for artworks or posts.',
|
||||
'Keep branding, typography, and visual tone consistent when Cards support a wider project or Group identity.',
|
||||
'Publish fewer stronger Cards instead of flooding the feed with low-value variations.',
|
||||
'Preview the Card as a viewer would see it, not only as the creator sees it while editing.',
|
||||
]
|
||||
|
||||
export const COMMON_MISTAKES = [
|
||||
'Using a Card when the content should really be a finished artwork.',
|
||||
'Using a Card when a plain post would communicate the message faster and more clearly.',
|
||||
'Adding too much text, too many visual ideas, or too many competing styles into one Card.',
|
||||
'Publishing under the wrong personal-or-Group context and creating avoidable ownership confusion.',
|
||||
'Treating Card design as decoration instead of making it support the message itself.',
|
||||
'Letting typography, spacing, or hierarchy become inconsistent enough that the Card feels cluttered.',
|
||||
]
|
||||
|
||||
export const FAQ_ITEMS = [
|
||||
{
|
||||
question: 'What are Cards used for?',
|
||||
answer: 'Cards are used for visual communication, styled presentation, compact editorial ideas, quote graphics, promos, announcements, and other creative content where layout and message belong together.',
|
||||
},
|
||||
{
|
||||
question: 'How are Cards different from artworks?',
|
||||
answer: 'Artworks are primary creative works presented on their own. Cards are presentation-oriented content units that combine message, design, and visual framing more like a polished communication format.',
|
||||
},
|
||||
{
|
||||
question: 'Can Groups create Cards?',
|
||||
answer: 'Yes. Groups can use Cards for shared promo pieces, announcements, release support visuals, and other communication that belongs under a Group identity.',
|
||||
},
|
||||
{
|
||||
question: 'Should I use a Card or a post?',
|
||||
answer: 'Use a post when a straightforward update is enough. Use a Card when design and presentation are part of what makes the message land properly.',
|
||||
},
|
||||
{
|
||||
question: 'Can I manage Cards in Studio?',
|
||||
answer: 'Yes. Studio is the main workspace for creating, editing, previewing, and managing Cards before and after publishing.',
|
||||
},
|
||||
{
|
||||
question: 'Are Cards public?',
|
||||
answer: 'Cards become public when you publish them. Until then, the creation and management workflow belongs in Studio rather than on public profile or browse pages.',
|
||||
},
|
||||
{
|
||||
question: 'How should I design a good Card?',
|
||||
answer: 'Start with one clear idea, keep the visual hierarchy readable, avoid clutter, and make sure typography, spacing, and composition all support the message instead of competing with it.',
|
||||
},
|
||||
]
|
||||
|
||||
export const TROUBLESHOOTING_ITEMS = [
|
||||
{
|
||||
title: 'I can’t find Cards in Studio',
|
||||
body: 'Start by reopening the Cards workspace directly. If the workflow still feels missing, check whether you are in the right account state or creator context first.',
|
||||
linkKey: 'studio_cards',
|
||||
linkLabel: 'Open Cards workspace',
|
||||
},
|
||||
{
|
||||
title: 'I don’t know which content type to use',
|
||||
body: 'When the choice between Card, artwork, post, and collection still feels blurry, the wider Studio guide helps place each format inside the overall creator workflow.',
|
||||
linkKey: 'studio_help',
|
||||
linkLabel: 'Read Studio help',
|
||||
},
|
||||
{
|
||||
title: 'My Card looks cluttered',
|
||||
body: 'If the Card feels overloaded, simplify the message, reduce competing styles, and preview the result again before you publish or keep editing.',
|
||||
linkKey: 'create_card',
|
||||
linkLabel: 'Return to card creation',
|
||||
},
|
||||
{
|
||||
title: 'I published under the wrong context',
|
||||
body: 'Ownership confusion often comes from publishing under a personal context when the Card belongs to a Group, or the other way around. Use the Groups guide to correct the workflow deliberately.',
|
||||
linkKey: 'groups_help',
|
||||
linkLabel: 'Read Groups help',
|
||||
},
|
||||
{
|
||||
title: 'I can’t edit my Card',
|
||||
body: 'Go back through Studio rather than public pages. Cards are managed inside the workspace, so the edit path usually starts from Studio or the Cards area.',
|
||||
linkKey: 'open_studio',
|
||||
linkLabel: 'Open Studio',
|
||||
},
|
||||
{
|
||||
title: 'I expected Cards to behave like artworks',
|
||||
body: 'Cards are a different format with different goals. If the problem is really about publishing files, metadata, or the artwork draft flow, the upload guide is the better next step.',
|
||||
linkKey: 'upload_help',
|
||||
linkLabel: 'Read Upload help',
|
||||
},
|
||||
]
|
||||
|
||||
export const RELATED_HELP_ITEMS = [
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Studio help',
|
||||
body: 'Use the Studio guide when you want the wider workspace context around Cards, drafts, content management, and publishing decisions.',
|
||||
linkKey: 'studio_help',
|
||||
tone: 'sky',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Groups help',
|
||||
body: 'Use the Groups guide when Cards are part of a shared identity, campaign, review flow, or contributor-credit conversation.',
|
||||
linkKey: 'groups_help',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Upload help',
|
||||
body: 'Use the Upload guide if the real question is about artwork file publishing, drafts, metadata, or personal versus Group upload context.',
|
||||
linkKey: 'upload_help',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Profile help',
|
||||
body: 'Use the Profile guide if your Cards question is really about public identity, creator presentation, or how your personal presence should read to visitors.',
|
||||
linkKey: 'help_profile',
|
||||
tone: 'white',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,663 @@
|
||||
export const SEARCH_SUGGESTIONS = [
|
||||
'upload image',
|
||||
'upload draft',
|
||||
'studio drafts',
|
||||
'group roles',
|
||||
'login issue',
|
||||
'auth help',
|
||||
'account settings',
|
||||
'change email',
|
||||
'troubleshooting',
|
||||
'forgot password',
|
||||
'create card',
|
||||
'cards help',
|
||||
'profile help',
|
||||
'profile bio',
|
||||
'publish artwork',
|
||||
'edit profile',
|
||||
]
|
||||
|
||||
export const HIGHLIGHTED_GUIDES = [
|
||||
{
|
||||
eyebrow: 'Live now',
|
||||
title: 'Upload help',
|
||||
description: 'A workflow-first guide to draft creation, metadata, previews, contributor credit, and final publish checks.',
|
||||
status: 'Guide',
|
||||
tone: 'amber',
|
||||
primaryLinkKey: 'upload_help',
|
||||
primaryLabel: 'Read Upload help',
|
||||
secondaryLinkKey: 'upload',
|
||||
secondaryLabel: 'Start upload',
|
||||
tags: ['upload', 'drafts', 'publish'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live now',
|
||||
title: 'Studio help',
|
||||
description: 'A creator-friendly guide to Personal Studio, Group Studio, drafts, publishing, and the main workspace areas.',
|
||||
status: 'Guide',
|
||||
tone: 'sky',
|
||||
primaryLinkKey: 'studio_help',
|
||||
primaryLabel: 'Read Studio help',
|
||||
secondaryLinkKey: 'open_studio',
|
||||
secondaryLabel: 'Open Studio',
|
||||
tags: ['studio', 'drafts', 'publishing'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live now',
|
||||
title: 'Cards help',
|
||||
description: 'A creator-friendly guide to what Cards are, when to use them, how to create them, and how they fit into personal and Group workflows.',
|
||||
status: 'Guide',
|
||||
tone: 'white',
|
||||
primaryLinkKey: 'help_cards',
|
||||
primaryLabel: 'Read Cards help',
|
||||
secondaryLinkKey: 'cards_create',
|
||||
secondaryLabel: 'Create a card',
|
||||
tags: ['cards', 'design', 'publishing'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live now',
|
||||
title: 'Profile help',
|
||||
description: 'A creator-friendly guide to personal identity, profile setup, profile-versus-Group clarity, and stronger public presentation on Skinbase Nova.',
|
||||
status: 'Guide',
|
||||
tone: 'white',
|
||||
primaryLinkKey: 'help_profile',
|
||||
primaryLabel: 'Read Profile help',
|
||||
secondaryLinkKey: 'profile_settings',
|
||||
secondaryLabel: 'Open profile settings',
|
||||
tags: ['profile', 'identity', 'onboarding'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live now',
|
||||
title: 'Signup and login help',
|
||||
description: 'A reassuring guide to account creation, sign-in, recovery, verification basics, and the fastest next steps for common access problems.',
|
||||
status: 'Guide',
|
||||
tone: 'amber',
|
||||
primaryLinkKey: 'help_auth',
|
||||
primaryLabel: 'Read auth help',
|
||||
secondaryLinkKey: 'password_request',
|
||||
secondaryLabel: 'Reset password',
|
||||
tags: ['login', 'signup', 'recovery'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live now',
|
||||
title: 'Groups documentation',
|
||||
description: 'The full Groups guide covering roles, publishing, contributor credit, workflows, and best practices.',
|
||||
status: 'Guide',
|
||||
tone: 'sky',
|
||||
primaryLinkKey: 'groups_documentation',
|
||||
primaryLabel: 'Read full guide',
|
||||
secondaryLinkKey: 'group_studio',
|
||||
secondaryLabel: 'Open Group Studio',
|
||||
tags: ['groups', 'publishing', 'roles'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Fast start',
|
||||
title: 'Groups quickstart',
|
||||
description: 'The shortest route to creating a Group, inviting members, and publishing correctly under a shared identity.',
|
||||
status: 'Quickstart',
|
||||
tone: 'amber',
|
||||
primaryLinkKey: 'groups_quickstart',
|
||||
primaryLabel: 'Open quickstart',
|
||||
secondaryLinkKey: 'create_group',
|
||||
secondaryLabel: 'Create a Group',
|
||||
tags: ['groups', 'quickstart', 'onboarding'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Answers fast',
|
||||
title: 'Groups FAQ',
|
||||
description: 'Quick answers about permissions, contributor credit, invites, approvals, and common troubleshooting.',
|
||||
status: 'FAQ',
|
||||
tone: 'white',
|
||||
primaryLinkKey: 'groups_faq',
|
||||
primaryLabel: 'Open FAQ',
|
||||
secondaryLinkKey: 'groups_directory',
|
||||
secondaryLabel: 'Browse Groups',
|
||||
tags: ['groups', 'faq', 'troubleshooting'],
|
||||
},
|
||||
]
|
||||
|
||||
export const FEATURED_GUIDES = [
|
||||
{
|
||||
eyebrow: 'Priority topic',
|
||||
title: 'Groups',
|
||||
description: 'Create shared identities, invite members, and publish together while preserving contributor credit and accountability.',
|
||||
status: 'Live now',
|
||||
tone: 'sky',
|
||||
plannedPath: '/help/groups',
|
||||
primaryLinkKey: 'groups_documentation',
|
||||
primaryLabel: 'Read Groups help',
|
||||
secondaryLinkKey: 'groups_quickstart',
|
||||
secondaryLabel: 'Open quickstart',
|
||||
highlights: ['Full guide, quickstart, and FAQ already live', 'Best for collaborative publishing and group workflows'],
|
||||
tags: ['groups', 'contributors', 'roles'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Priority topic',
|
||||
title: 'Studio',
|
||||
description: 'Learn the workspace for drafts, scheduling, content management, publishing, analytics, and creator operations.',
|
||||
status: 'Live now',
|
||||
tone: 'white',
|
||||
plannedPath: '/help/studio',
|
||||
primaryLinkKey: 'studio_help',
|
||||
primaryLabel: 'Read Studio help',
|
||||
secondaryLinkKey: 'open_studio',
|
||||
secondaryLabel: 'Open Studio',
|
||||
highlights: ['Now live as the first non-Groups module help page', 'Explains drafts, publishing, and context switching clearly'],
|
||||
tags: ['studio', 'drafts', 'publishing'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Priority topic',
|
||||
title: 'Upload',
|
||||
description: 'Get help with preparing images, starting uploads, handling drafts, and publishing without losing context or metadata.',
|
||||
status: 'Live now',
|
||||
tone: 'white',
|
||||
plannedPath: '/help/upload',
|
||||
primaryLinkKey: 'upload_help',
|
||||
primaryLabel: 'Read Upload help',
|
||||
secondaryLinkKey: 'studio_artworks',
|
||||
secondaryLabel: 'Open Studio artworks',
|
||||
highlights: ['Now live as the dedicated upload workflow guide', 'Explains draft flow, publish flow, and context clearly'],
|
||||
tags: ['upload', 'artworks', 'drafts'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Priority topic',
|
||||
title: 'Cards',
|
||||
description: 'Understand what Cards are, how they differ from artworks, posts, and collections, and how to create and publish them well.',
|
||||
status: 'Live now',
|
||||
tone: 'white',
|
||||
plannedPath: '/help/cards',
|
||||
primaryLinkKey: 'help_cards',
|
||||
primaryLabel: 'Read Cards help',
|
||||
secondaryLinkKey: 'cards_index',
|
||||
secondaryLabel: 'Browse cards',
|
||||
highlights: ['Now live as the dedicated Cards format guide', 'Explains format choice, ownership, and creator-friendly best practices'],
|
||||
tags: ['cards', 'design', 'editorial'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Priority topic',
|
||||
title: 'Profile',
|
||||
description: 'Set up your public identity, understand profile-versus-Group clarity, and improve the way your creator presence reads to other people.',
|
||||
status: 'Live now',
|
||||
tone: 'white',
|
||||
plannedPath: '/help/profile',
|
||||
primaryLinkKey: 'help_profile',
|
||||
primaryLabel: 'Read Profile help',
|
||||
secondaryLinkKey: 'profile_settings',
|
||||
secondaryLabel: 'Open profile settings',
|
||||
highlights: ['Now live as the dedicated profile identity guide', 'Explains setup, presentation, and profile-versus-Group clarity'],
|
||||
tags: ['profile', 'identity', 'settings'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Priority topic',
|
||||
title: 'Signup / Login',
|
||||
description: 'Learn how access, registration, password recovery, and account entry work so creators can get in and stay productive.',
|
||||
status: 'Live now',
|
||||
tone: 'amber',
|
||||
plannedPath: '/help/auth',
|
||||
primaryLinkKey: 'help_auth',
|
||||
primaryLabel: 'Read auth help',
|
||||
secondaryLinkKey: 'password_request',
|
||||
secondaryLabel: 'Recover account access',
|
||||
highlights: ['Now live as the dedicated signup and login guide', 'Explains recovery, verification basics, and permission-vs-access confusion clearly'],
|
||||
tags: ['login', 'signup', 'access'],
|
||||
},
|
||||
]
|
||||
|
||||
export const HELP_CATEGORIES = [
|
||||
{
|
||||
id: 'core-platform',
|
||||
label: 'Core platform',
|
||||
title: 'Core platform',
|
||||
summary: 'Account setup, identity, and platform basics for new and returning creators.',
|
||||
topics: [
|
||||
{
|
||||
eyebrow: 'Get started',
|
||||
title: 'Signup / Login',
|
||||
description: 'Account access, registration, password recovery, and getting back into Skinbase quickly.',
|
||||
status: 'Live',
|
||||
plannedPath: '/help/auth',
|
||||
primaryLinkKey: 'help_auth',
|
||||
primaryLabel: 'Read auth help',
|
||||
secondaryLinkKey: 'register',
|
||||
secondaryLabel: 'Create account',
|
||||
tags: ['login', 'signup', 'access'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Identity',
|
||||
title: 'Profile',
|
||||
description: 'Learn how profiles work, how to present yourself better, and how to keep your personal identity distinct from Group identity.',
|
||||
status: 'Live',
|
||||
plannedPath: '/help/profile',
|
||||
primaryLinkKey: 'help_profile',
|
||||
primaryLabel: 'Read Profile help',
|
||||
secondaryLinkKey: 'profile_settings',
|
||||
secondaryLabel: 'Open settings',
|
||||
tags: ['profile', 'identity', 'settings'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Account',
|
||||
title: 'Account settings',
|
||||
description: 'Use account and dashboard settings to control profile details, security flows, and creator preferences.',
|
||||
status: 'Live',
|
||||
plannedPath: '/help/account',
|
||||
primaryLinkKey: 'help_account',
|
||||
primaryLabel: 'Read account help',
|
||||
secondaryLinkKey: 'profile_settings',
|
||||
secondaryLabel: 'Open account settings',
|
||||
tags: ['account', 'settings', 'dashboard'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Safety',
|
||||
title: 'Privacy & safety',
|
||||
description: 'Future help coverage for privacy decisions, safety guidance, and account support concerns.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/privacy',
|
||||
primaryLinkKey: 'contact_support',
|
||||
primaryLabel: 'Contact support',
|
||||
tags: ['privacy', 'safety', 'support'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'creation-and-publishing',
|
||||
label: 'Creation & publishing',
|
||||
title: 'Creation and publishing',
|
||||
summary: 'The main surfaces creators use to make, edit, organize, and publish work on Skinbase Nova.',
|
||||
topics: [
|
||||
{
|
||||
eyebrow: 'Workspace',
|
||||
title: 'Studio',
|
||||
description: 'Content workflows, drafts, scheduling, analytics, and the main creator workspace.',
|
||||
status: 'Live',
|
||||
plannedPath: '/help/studio',
|
||||
primaryLinkKey: 'studio_help',
|
||||
primaryLabel: 'Read Studio help',
|
||||
secondaryLinkKey: 'open_studio',
|
||||
secondaryLabel: 'Open Studio',
|
||||
tags: ['studio', 'drafts', 'content'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Publish',
|
||||
title: 'Upload',
|
||||
description: 'Start uploads, manage drafts, and publish artwork safely and correctly.',
|
||||
status: 'Live',
|
||||
plannedPath: '/help/upload',
|
||||
primaryLinkKey: 'upload_help',
|
||||
primaryLabel: 'Read Upload help',
|
||||
secondaryLinkKey: 'upload',
|
||||
secondaryLabel: 'Open upload',
|
||||
tags: ['upload', 'artwork', 'publish'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Portfolio',
|
||||
title: 'Artworks',
|
||||
description: 'Future help for artwork editing, portfolio organization, and publishing workflows.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/artworks',
|
||||
primaryLinkKey: 'studio_artworks',
|
||||
primaryLabel: 'Open artworks',
|
||||
tags: ['artworks', 'portfolio', 'editing'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Visual compositions',
|
||||
title: 'Cards',
|
||||
description: 'Learn what Cards are, when to use them, and how card creation, editing, and publishing fit into the platform.',
|
||||
status: 'Live',
|
||||
plannedPath: '/help/cards',
|
||||
primaryLinkKey: 'help_cards',
|
||||
primaryLabel: 'Read Cards help',
|
||||
secondaryLinkKey: 'cards_index',
|
||||
secondaryLabel: 'Browse cards',
|
||||
tags: ['cards', 'design', 'workflow'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Organization',
|
||||
title: 'Collections',
|
||||
description: 'Planned help for organizing work, saved collections, and multi-item presentation flows.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/collections',
|
||||
primaryLinkKey: 'studio_home',
|
||||
primaryLabel: 'Open Studio',
|
||||
tags: ['collections', 'organization', 'curation'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'collaboration',
|
||||
label: 'Collaboration',
|
||||
title: 'Collaboration',
|
||||
summary: 'Shared publishing, group operations, and the advanced teamwork modules that grow out of the Groups system.',
|
||||
topics: [
|
||||
{
|
||||
eyebrow: 'Live ecosystem',
|
||||
title: 'Groups',
|
||||
description: 'Shared identity, member roles, contributor credit, review workflows, and Group publishing.',
|
||||
status: 'Live',
|
||||
plannedPath: '/help/groups',
|
||||
primaryLinkKey: 'groups_documentation',
|
||||
primaryLabel: 'Read Groups guide',
|
||||
secondaryLinkKey: 'group_studio',
|
||||
secondaryLabel: 'Open Group Studio',
|
||||
linkItems: [
|
||||
{ label: 'Quickstart', linkKey: 'groups_quickstart' },
|
||||
{ label: 'FAQ', linkKey: 'groups_faq' },
|
||||
{ label: 'Create Group', linkKey: 'create_group' },
|
||||
],
|
||||
tags: ['groups', 'members', 'contributors'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Planned next',
|
||||
title: 'Projects',
|
||||
description: 'Future help for structured collaboration, milestones, linked work, and team coordination.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/projects',
|
||||
primaryLinkKey: 'groups_documentation',
|
||||
primaryLabel: 'Start with Groups docs',
|
||||
tags: ['projects', 'planning', 'collaboration'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Planned next',
|
||||
title: 'Releases',
|
||||
description: 'Future guidance for packaging major publication moments with notes, contributors, and linked work.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/releases',
|
||||
primaryLinkKey: 'groups_documentation',
|
||||
primaryLabel: 'Start with Groups docs',
|
||||
tags: ['releases', 'launches', 'publishing'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Community format',
|
||||
title: 'Challenges',
|
||||
description: 'Planned help for challenge-based collaboration, themed prompts, and participation flows.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/challenges',
|
||||
primaryLinkKey: 'groups_faq',
|
||||
primaryLabel: 'See Group feature FAQ',
|
||||
tags: ['challenges', 'prompts', 'events'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Events',
|
||||
title: 'Events',
|
||||
description: 'Planned help for launch moments, showcases, time-based activities, and team promotion flows.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/events',
|
||||
primaryLinkKey: 'groups_faq',
|
||||
primaryLabel: 'See Group feature FAQ',
|
||||
tags: ['events', 'showcase', 'launch'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Resources',
|
||||
title: 'Assets',
|
||||
description: 'Future help for shared resource libraries, asset organization, and collaboration handoff materials.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/assets',
|
||||
primaryLinkKey: 'groups_faq',
|
||||
primaryLabel: 'See Group feature FAQ',
|
||||
tags: ['assets', 'resources', 'library'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'discovery-and-interaction',
|
||||
label: 'Discovery & interaction',
|
||||
title: 'Discovery and interaction',
|
||||
summary: 'Finding work, understanding notifications, and following what matters across the platform.',
|
||||
topics: [
|
||||
{
|
||||
eyebrow: 'Search flows',
|
||||
title: 'Search',
|
||||
description: 'Future help for discovery, filtering, and finding creators, artworks, cards, or groups quickly.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/search',
|
||||
primaryLinkKey: 'studio_home',
|
||||
primaryLabel: 'Open Studio',
|
||||
tags: ['search', 'discovery', 'filters'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Signals',
|
||||
title: 'Notifications',
|
||||
description: 'Planned help for notification flows, activity awareness, and follow-up actions.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/notifications',
|
||||
primaryLinkKey: 'studio_home',
|
||||
primaryLabel: 'Open Studio',
|
||||
tags: ['notifications', 'updates', 'activity'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Relationships',
|
||||
title: 'Following & activity',
|
||||
description: 'Future help for understanding follows, feed behavior, activity surfaces, and creator engagement.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/following',
|
||||
primaryLinkKey: 'groups_directory',
|
||||
primaryLabel: 'Browse creators and groups',
|
||||
tags: ['following', 'activity', 'feed'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Conversation',
|
||||
title: 'Comments & engagement',
|
||||
description: 'Planned help for community interaction, comment behavior, and response expectations across content.',
|
||||
status: 'Planned',
|
||||
plannedPath: '/help/engagement',
|
||||
primaryLinkKey: 'cards_index',
|
||||
primaryLabel: 'Browse public content',
|
||||
tags: ['comments', 'engagement', 'community'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'support-and-troubleshooting',
|
||||
label: 'Support & troubleshooting',
|
||||
title: 'Support and troubleshooting',
|
||||
summary: 'Fast fixes, account recovery paths, and help surfaces built for users who need answers right now.',
|
||||
topics: [
|
||||
{
|
||||
eyebrow: 'Fast fixes',
|
||||
title: 'Troubleshooting',
|
||||
description: 'A live support-oriented guide for diagnosing access, publishing, permissions, setup, and route-level problems faster.',
|
||||
status: 'Live',
|
||||
plannedPath: '/help/troubleshooting',
|
||||
primaryLinkKey: 'help_troubleshooting',
|
||||
primaryLabel: 'Open troubleshooting hub',
|
||||
secondaryLinkKey: 'report_issue',
|
||||
secondaryLabel: 'Report a problem',
|
||||
tags: ['troubleshooting', 'support', 'issues'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Self-service',
|
||||
title: 'FAQs & quickstarts',
|
||||
description: 'A scalable pattern for future FAQs, quickstarts, and module-specific fast-answer pages.',
|
||||
status: 'Live pattern',
|
||||
plannedPath: '/help/{topic}/faq',
|
||||
primaryLinkKey: 'groups_faq',
|
||||
primaryLabel: 'See the pattern in Groups FAQ',
|
||||
secondaryLinkKey: 'groups_quickstart',
|
||||
secondaryLabel: 'See quickstart pattern',
|
||||
tags: ['faq', 'quickstart', 'help system'],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Human support',
|
||||
title: 'Contact & issue reporting',
|
||||
description: 'Reach the right support path when the answer is not in self-service content or when something is broken.',
|
||||
status: 'Available now',
|
||||
primaryLinkKey: 'contact_support',
|
||||
primaryLabel: 'Contact support',
|
||||
secondaryLinkKey: 'report_issue',
|
||||
secondaryLabel: 'Report issue',
|
||||
tags: ['contact', 'bug report', 'support'],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const GETTING_STARTED_STEPS = [
|
||||
{
|
||||
title: 'Create or access your account',
|
||||
description: 'Start with account access so you can move into Studio, profile setup, and publishing without blockers.',
|
||||
},
|
||||
{
|
||||
title: 'Set up your profile',
|
||||
description: 'Complete the basics of your public identity so your work has a stronger creator context from day one.',
|
||||
},
|
||||
{
|
||||
title: 'Open Studio',
|
||||
description: 'Use Studio as the main operating surface for drafts, content management, and creator workflows.',
|
||||
},
|
||||
{
|
||||
title: 'Upload your first artwork',
|
||||
description: 'Move from onboarding into actual publishing so you can learn the platform by doing real work.',
|
||||
},
|
||||
{
|
||||
title: 'Create your first card',
|
||||
description: 'Explore the visual composition side of Nova once your core publishing flow feels comfortable.',
|
||||
},
|
||||
{
|
||||
title: 'Use Groups when collaboration starts',
|
||||
description: 'Once work becomes shared, open the Groups quickstart and switch into the collaboration workflow deliberately.',
|
||||
},
|
||||
]
|
||||
|
||||
export const GETTING_STARTED_LINKS = [
|
||||
{ label: 'Signup / Login help', linkKey: 'help_auth' },
|
||||
{ label: 'Set up your profile', linkKey: 'help_profile' },
|
||||
{ label: 'Using Studio', linkKey: 'studio_home' },
|
||||
{ label: 'Upload your first artwork', linkKey: 'upload' },
|
||||
{ label: 'Create your first card', linkKey: 'cards_create' },
|
||||
{ label: 'Understanding Groups', linkKey: 'groups_quickstart' },
|
||||
]
|
||||
|
||||
export const TROUBLESHOOTING_ITEMS = [
|
||||
{
|
||||
title: 'I can’t log in',
|
||||
description: 'Start with access recovery, then move to support if the issue is bigger than a password reset.',
|
||||
linkKey: 'password_request',
|
||||
linkLabel: 'Recover account access',
|
||||
tags: ['login', 'password', 'access'],
|
||||
},
|
||||
{
|
||||
title: 'Upload is not working',
|
||||
description: 'Jump back into the upload flow or Studio to confirm you are in the right context before reporting a bug.',
|
||||
linkKey: 'upload',
|
||||
linkLabel: 'Open upload',
|
||||
tags: ['upload', 'publishing', 'artwork'],
|
||||
},
|
||||
{
|
||||
title: 'I can’t publish',
|
||||
description: 'Check whether the issue is personal publishing, Group permissions, or the wrong active context.',
|
||||
linkKey: 'groups_faq',
|
||||
linkLabel: 'Read Group publishing FAQ',
|
||||
tags: ['publish', 'permissions', 'context'],
|
||||
},
|
||||
{
|
||||
title: 'I don’t see Studio',
|
||||
description: 'Use sign-in and onboarding checks first, then re-open Studio from the main creator workspace.',
|
||||
linkKey: 'studio_home',
|
||||
linkLabel: 'Open Studio',
|
||||
tags: ['studio', 'onboarding', 'access'],
|
||||
},
|
||||
{
|
||||
title: 'My Group role doesn’t allow something',
|
||||
description: 'The Groups FAQ explains how roles, approvals, invites, and permissions differ between members.',
|
||||
linkKey: 'groups_faq',
|
||||
linkLabel: 'Open Groups FAQ',
|
||||
tags: ['group roles', 'permissions', 'members'],
|
||||
},
|
||||
{
|
||||
title: 'My profile is incomplete',
|
||||
description: 'Go to profile settings and finish the basics that shape how other creators understand your identity.',
|
||||
linkKey: 'profile_settings',
|
||||
linkLabel: 'Open profile settings',
|
||||
tags: ['profile', 'settings', 'identity'],
|
||||
},
|
||||
{
|
||||
title: 'I published under the wrong context',
|
||||
description: 'Use the Groups documentation and FAQ to correct personal-versus-group publishing mistakes deliberately.',
|
||||
linkKey: 'groups_documentation',
|
||||
linkLabel: 'Read publishing guidance',
|
||||
tags: ['publishing', 'context', 'groups'],
|
||||
},
|
||||
]
|
||||
|
||||
export const POPULAR_HELP_TOPICS = [
|
||||
{
|
||||
title: 'How account settings work',
|
||||
description: 'Read the account guide for settings, email and password care, notifications, and ongoing maintenance after login succeeds.',
|
||||
linkKey: 'help_account',
|
||||
tags: ['account', 'settings', 'preferences'],
|
||||
},
|
||||
{
|
||||
title: 'How Group publishing works',
|
||||
description: 'Understand shared identity, contributor credit, and why individual attribution stays visible.',
|
||||
linkKey: 'groups_documentation',
|
||||
tags: ['groups', 'publishing', 'credit'],
|
||||
},
|
||||
{
|
||||
title: 'How contributor credit works',
|
||||
description: 'Use the Groups FAQ to understand Published by, Uploaded by, Primary author, and Contributors.',
|
||||
linkKey: 'groups_faq',
|
||||
tags: ['contributors', 'credit', 'faq'],
|
||||
},
|
||||
{
|
||||
title: 'How Studio works',
|
||||
description: 'Read the Studio guide for drafts, context switching, publishing, and the main creator workspace surfaces.',
|
||||
linkKey: 'studio_help',
|
||||
tags: ['studio', 'content', 'workflow'],
|
||||
},
|
||||
{
|
||||
title: 'How to upload artwork',
|
||||
description: 'Read the upload guide for draft flow, metadata review, previews, context checks, and final publishing.',
|
||||
linkKey: 'upload_help',
|
||||
tags: ['upload', 'artworks', 'publish'],
|
||||
},
|
||||
{
|
||||
title: 'How to create cards',
|
||||
description: 'Read the Cards guide when you need help choosing the format, creating a Card, and publishing it cleanly.',
|
||||
linkKey: 'help_cards',
|
||||
tags: ['cards', 'guide', 'design'],
|
||||
},
|
||||
{
|
||||
title: 'How profiles work',
|
||||
description: 'Read the Profile guide to understand setup, identity clarity, presentation, and how personal presence fits beside Group activity.',
|
||||
linkKey: 'help_profile',
|
||||
tags: ['profile', 'identity', 'guide'],
|
||||
},
|
||||
{
|
||||
title: 'How signup and login work',
|
||||
description: 'Read the auth guide for account creation, sign-in, recovery, verification basics, and common access problems.',
|
||||
linkKey: 'help_auth',
|
||||
tags: ['login', 'signup', 'recovery'],
|
||||
},
|
||||
{
|
||||
title: 'How to troubleshoot faster',
|
||||
description: 'Use the troubleshooting guide when the problem is urgent and you need faster diagnosis before jumping into a long module guide.',
|
||||
linkKey: 'help_troubleshooting',
|
||||
tags: ['troubleshooting', 'support', 'issues'],
|
||||
},
|
||||
{
|
||||
title: 'How to create a Group',
|
||||
description: 'Use the quickstart if you are ready to switch from solo publishing into collaboration.',
|
||||
linkKey: 'create_group',
|
||||
tags: ['groups', 'quickstart', 'collaboration'],
|
||||
},
|
||||
]
|
||||
|
||||
export const SUPPORT_ITEMS = [
|
||||
{
|
||||
eyebrow: 'Fast fixes',
|
||||
title: 'Open troubleshooting help',
|
||||
description: 'Use this when the problem feels urgent and you want shorter diagnosis-first guidance before filing a report.',
|
||||
linkKey: 'help_troubleshooting',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Human help',
|
||||
title: 'Contact support',
|
||||
description: 'Use this when the right answer is not in the help hub or when you need account-level guidance.',
|
||||
linkKey: 'contact_support',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Problem reports',
|
||||
title: 'Report a bug',
|
||||
description: 'Use this when a route, workflow, permission, or publishing surface appears broken rather than unclear.',
|
||||
linkKey: 'report_issue',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,256 @@
|
||||
export const SECTION_ITEMS = [
|
||||
{ id: 'what-profile-is', label: 'What a profile is' },
|
||||
{ id: 'profile-vs-group', label: 'Profile vs Group' },
|
||||
{ id: 'profile-setup-basics', label: 'Profile setup basics' },
|
||||
{ id: 'what-to-put-on-your-profile', label: 'What to put on your profile' },
|
||||
{ id: 'profile-content-and-activity', label: 'Profile content and activity' },
|
||||
{ id: 'best-practices', label: 'Best practices' },
|
||||
{ id: 'common-mistakes', label: 'Common mistakes' },
|
||||
{ id: 'faq', label: 'FAQ' },
|
||||
{ id: 'troubleshooting', label: 'Troubleshooting' },
|
||||
{ id: 'related-help', label: 'Related help' },
|
||||
]
|
||||
|
||||
export const HERO_METRICS = [
|
||||
{
|
||||
label: 'Core role',
|
||||
value: 'Personal public identity',
|
||||
note: 'Your profile is the main place where other people understand who you are, what you make, and how you show up on Skinbase.',
|
||||
},
|
||||
{
|
||||
label: 'What it shapes',
|
||||
value: 'Trust and recognition',
|
||||
note: 'A strong profile makes your work easier to recognize, your contributions easier to understand, and your creative identity easier to remember.',
|
||||
},
|
||||
{
|
||||
label: 'Golden rule',
|
||||
value: 'Keep it clear and current',
|
||||
note: 'Profiles work best when they feel real, complete, and consistent with the kind of creator presence you want to build.',
|
||||
},
|
||||
]
|
||||
|
||||
export const WHAT_PROFILE_IS_ITEMS = [
|
||||
{
|
||||
title: 'Your profile is your personal identity',
|
||||
body: 'A Skinbase profile represents you as an individual creator. It is the public-facing space where people connect your name, visual identity, and work together.',
|
||||
},
|
||||
{
|
||||
title: 'Profiles are discoverability surfaces',
|
||||
body: 'People use profiles to understand what you create, what kind of style or focus you have, and whether they want to follow your work more closely.',
|
||||
},
|
||||
{
|
||||
title: 'Profiles can reflect more than one kind of contribution',
|
||||
body: 'Your profile is not only about solo publishing. It can also help people understand your contributions, collaborations, and public activity across the platform.',
|
||||
},
|
||||
]
|
||||
|
||||
export const PROFILE_COMPARISON_COLUMNS = [
|
||||
{ key: 'topic', label: 'Topic' },
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
{ key: 'group', label: 'Group' },
|
||||
]
|
||||
|
||||
export const PROFILE_COMPARISON_ROWS = [
|
||||
{
|
||||
id: 'identity',
|
||||
topic: 'Identity',
|
||||
profile: 'One person or individual creator identity.',
|
||||
group: 'A shared identity for a team, collective, or collaborative project.',
|
||||
},
|
||||
{
|
||||
id: 'purpose',
|
||||
topic: 'Main purpose',
|
||||
profile: 'Show who you are, what you make, and how you present yourself publicly.',
|
||||
group: 'Represent shared publishing, shared operations, and collaborative creative activity.',
|
||||
},
|
||||
{
|
||||
id: 'ownership',
|
||||
topic: 'Who owns the space',
|
||||
profile: 'You manage your own profile and personal identity choices.',
|
||||
group: 'Multiple members may participate, depending on role and permissions.',
|
||||
},
|
||||
{
|
||||
id: 'publishing',
|
||||
topic: 'Publishing context',
|
||||
profile: 'Personal work publishes under your own creator identity.',
|
||||
group: 'Shared work publishes under the Group identity while still preserving individual credit where relevant.',
|
||||
},
|
||||
{
|
||||
id: 'coexistence',
|
||||
topic: 'How they coexist',
|
||||
profile: 'Your profile remains your personal home base even when you collaborate elsewhere.',
|
||||
group: 'A Group does not replace your profile. It adds a shared layer on top of your personal identity.',
|
||||
},
|
||||
]
|
||||
|
||||
export const SETUP_BASICS_ITEMS = [
|
||||
{
|
||||
title: 'Avatar and recognizable identity',
|
||||
body: 'Choose a profile image that people can recognize easily. A strong avatar gives your work a clearer anchor across comments, follows, and content surfaces.',
|
||||
},
|
||||
{
|
||||
title: 'Username and display identity',
|
||||
body: 'Keep your identity naming clear and consistent so people do not have to guess whether the profile belongs to you, a project, or a Group.',
|
||||
},
|
||||
{
|
||||
title: 'Bio and about text',
|
||||
body: 'A short, memorable bio is usually better than a vague paragraph. Tell people what you create, what you care about, or what makes your perspective distinctive.',
|
||||
},
|
||||
{
|
||||
title: 'Cover image and visual direction',
|
||||
body: 'If your profile uses broader visual presentation elements, keep them aligned with the tone of your avatar, work, and overall identity.',
|
||||
},
|
||||
{
|
||||
title: 'Useful links only',
|
||||
body: 'If you add socials or external links, keep them relevant. Profiles feel stronger when the links support your creative identity instead of distracting from it.',
|
||||
},
|
||||
{
|
||||
title: 'Visual consistency matters',
|
||||
body: 'Your profile should feel like one person or one creator perspective, not a collection of unrelated identity choices thrown together over time.',
|
||||
},
|
||||
]
|
||||
|
||||
export const PROFILE_IMPROVEMENT_TIPS = [
|
||||
'Use a recognizable avatar before you start publishing heavily.',
|
||||
'Write a bio that says what you create or what kind of creative identity you want people to remember.',
|
||||
'Keep your naming, visuals, and profile tone aligned across the page.',
|
||||
'Treat profile setup as part of your creative presentation, not as a settings chore you can ignore forever.',
|
||||
]
|
||||
|
||||
export const WHAT_TO_PUT_ITEMS = [
|
||||
'A strong avatar that people can recognize quickly.',
|
||||
'A concise bio that gives your profile personality and direction.',
|
||||
'A clear sense of your creative focus, style, or themes.',
|
||||
'Useful links only, especially if they support your work or identity directly.',
|
||||
'Your strongest published work and the contributions you want people to notice first.',
|
||||
'Branding or visual consistency that helps the profile feel intentional rather than random.',
|
||||
]
|
||||
|
||||
export const PROFILE_CONTENT_ITEMS = [
|
||||
{
|
||||
title: 'Personal artworks',
|
||||
body: 'Your profile can help people understand your personal published work and the direction of your creator identity over time.',
|
||||
},
|
||||
{
|
||||
title: 'Contributions to Group work',
|
||||
body: 'Even when work is published by a Group, your profile still matters because it helps people understand your personal role, authorship, and creative history.',
|
||||
},
|
||||
{
|
||||
title: 'Cards, collections, and presentation surfaces',
|
||||
body: 'As the platform grows, profiles can reflect more than one type of creative output. What matters most is whether the page still tells a coherent story about you.',
|
||||
},
|
||||
{
|
||||
title: 'Activity and community visibility',
|
||||
body: 'Profiles are not only static pages. They can also reflect how active you are, what you engage with, and how consistently you participate in the platform.',
|
||||
},
|
||||
]
|
||||
|
||||
export const BEST_PRACTICES = [
|
||||
'Complete your profile early so your identity feels stronger from the beginning.',
|
||||
'Keep your bio clear, real, and easy to remember.',
|
||||
'Use an avatar people can recognize without effort.',
|
||||
'Keep the profile active by publishing, contributing, and updating it when your direction changes.',
|
||||
'Make your best work and strongest contributions easier to notice than low-value filler.',
|
||||
'Separate personal identity from Group identity intentionally so viewers do not get confused about what belongs to whom.',
|
||||
'Keep public information current instead of letting old links, old bios, or old visuals drift indefinitely.',
|
||||
]
|
||||
|
||||
export const COMMON_MISTAKES = [
|
||||
'Leaving the profile incomplete and expecting the work alone to explain who you are.',
|
||||
'Confusing your personal profile with a Group identity and making the page feel unclear.',
|
||||
'Using a weak, empty, or generic bio that gives people nothing to remember.',
|
||||
'Letting the avatar, naming, and visual presentation feel inconsistent with each other.',
|
||||
'Making it hard to notice your best work because the page feels cluttered or unfocused.',
|
||||
'Keeping low-value or outdated public information visible long after it stops helping your creator identity.',
|
||||
]
|
||||
|
||||
export const FAQ_ITEMS = [
|
||||
{
|
||||
question: 'What is my profile for?',
|
||||
answer: 'Your profile is your personal identity and public presence on Skinbase. It helps people understand who you are, what you create, and how your work fits together.',
|
||||
},
|
||||
{
|
||||
question: 'How is a profile different from a Group?',
|
||||
answer: 'A profile represents one individual creator. A Group represents a shared team or collaborative identity. They can coexist without replacing each other.',
|
||||
},
|
||||
{
|
||||
question: 'Can I still have a personal identity if I publish in Groups?',
|
||||
answer: 'Yes. Group publishing does not erase your personal identity. Your profile still matters because it shows your individual presence and can help people understand your contributions.',
|
||||
},
|
||||
{
|
||||
question: 'What should I add to my profile first?',
|
||||
answer: 'Start with a recognizable avatar, a clear identity name, and a short bio that explains what you create or what kind of creative presence you want to build.',
|
||||
},
|
||||
{
|
||||
question: 'Can my contributions to Group work still appear on my profile?',
|
||||
answer: 'They can still reflect on you as a creator even when the work belongs to a Group. That is one reason your personal profile remains important in collaborative publishing.',
|
||||
},
|
||||
{
|
||||
question: 'How do I make my profile look better?',
|
||||
answer: 'Keep it simple, consistent, and real. Use a recognizable avatar, write a better bio, improve visual consistency, and make sure the strongest work is easier to notice than filler content.',
|
||||
},
|
||||
]
|
||||
|
||||
export const TROUBLESHOOTING_ITEMS = [
|
||||
{
|
||||
title: 'I don’t know what to put on my profile',
|
||||
body: 'Start with the basics first: avatar, bio, identity focus, and the work you most want people to notice. A profile does not need to say everything at once.',
|
||||
linkKey: 'profile_settings',
|
||||
linkLabel: 'Open profile settings',
|
||||
},
|
||||
{
|
||||
title: 'My profile feels empty',
|
||||
body: 'An empty profile is often a publishing or activity problem rather than a design problem. Use Studio and Upload help if the real issue is that your public work is still too thin.',
|
||||
linkKey: 'upload_help',
|
||||
linkLabel: 'Read Upload help',
|
||||
},
|
||||
{
|
||||
title: 'I want my Group work to still reflect on me',
|
||||
body: 'That is exactly why profile identity still matters alongside Groups. Use the Groups guide to understand shared publishing, contributor credit, and identity separation more clearly.',
|
||||
linkKey: 'groups_help',
|
||||
linkLabel: 'Read Groups help',
|
||||
},
|
||||
{
|
||||
title: 'I changed something and it doesn’t look right',
|
||||
body: 'Review your avatar, naming, bio, and overall visual consistency together rather than changing one field at a time without checking the full profile impression.',
|
||||
linkKey: 'profile_settings',
|
||||
linkLabel: 'Return to profile settings',
|
||||
},
|
||||
{
|
||||
title: 'I don’t understand profile vs Group publishing',
|
||||
body: 'If you are not sure whether the public identity should be yours or a Group’s, start with the Groups guide. Publishing context is usually the missing piece.',
|
||||
linkKey: 'groups_help',
|
||||
linkLabel: 'Open Groups guide',
|
||||
},
|
||||
]
|
||||
|
||||
export const RELATED_HELP_ITEMS = [
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Groups help',
|
||||
body: 'Use the Groups guide when the real question is how your personal profile should coexist with a shared identity and collaborative publishing.',
|
||||
linkKey: 'groups_help',
|
||||
tone: 'sky',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Studio help',
|
||||
body: 'Use the Studio guide when you need the wider creator-workspace context around drafts, publishing, and profile-facing creator operations.',
|
||||
linkKey: 'studio_help',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Upload help',
|
||||
body: 'Use the Upload guide if the profile feels thin because the real issue is getting more of your work published and presented well.',
|
||||
linkKey: 'upload_help',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Signup and login help',
|
||||
body: 'Use the auth guide if the blocker is sign-in, registration, or access recovery before you can even update the profile properly.',
|
||||
linkKey: 'help_auth',
|
||||
tone: 'white',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,279 @@
|
||||
export const SECTION_ITEMS = [
|
||||
{ id: 'what-is-studio', label: 'What Studio is' },
|
||||
{ id: 'personal-vs-group', label: 'Personal vs Group Studio' },
|
||||
{ id: 'main-studio-areas', label: 'Main Studio areas' },
|
||||
{ id: 'drafts-and-publishing', label: 'Drafts and publishing' },
|
||||
{ id: 'managing-artworks', label: 'Managing artworks' },
|
||||
{ id: 'cards-and-collections', label: 'Cards and collections' },
|
||||
{ id: 'advanced-modules', label: 'Advanced modules' },
|
||||
{ id: 'best-practices', label: 'Best practices' },
|
||||
{ id: 'common-mistakes', label: 'Common mistakes' },
|
||||
{ id: 'faq', label: 'FAQ' },
|
||||
{ id: 'troubleshooting', label: 'Troubleshooting' },
|
||||
{ id: 'related-help', label: 'Related help' },
|
||||
]
|
||||
|
||||
export const STUDIO_COMPARISON_COLUMNS = [
|
||||
{ key: 'topic', label: 'Topic' },
|
||||
{ key: 'personal', label: 'Personal Studio' },
|
||||
{ key: 'group', label: 'Group Studio' },
|
||||
]
|
||||
|
||||
export const STUDIO_COMPARISON_ROWS = [
|
||||
{
|
||||
id: 'ownership',
|
||||
topic: 'Who the work belongs to',
|
||||
personal: 'Your own drafts, uploads, cards, collections, and creator activity.',
|
||||
group: 'Work owned, published, or coordinated under a Group identity.',
|
||||
},
|
||||
{
|
||||
id: 'permissions',
|
||||
topic: 'Why actions differ',
|
||||
personal: 'You usually control the full flow for your own content.',
|
||||
group: 'Available actions depend on Group role, trust level, and review workflow.',
|
||||
},
|
||||
{
|
||||
id: 'publishing',
|
||||
topic: 'Publishing context',
|
||||
personal: 'Publishes under your personal creator identity.',
|
||||
group: 'Publishes under the Group identity while preserving individual credit.',
|
||||
},
|
||||
{
|
||||
id: 'drafts',
|
||||
topic: 'Where drafts live',
|
||||
personal: 'In your personal Studio draft and content views.',
|
||||
group: 'Inside the Group context, often with shared review or approval behavior.',
|
||||
},
|
||||
{
|
||||
id: 'coordination',
|
||||
topic: 'Coordination style',
|
||||
personal: 'Best for solo publishing and direct control.',
|
||||
group: 'Best for shared publishing, collaboration, reviews, member management, and structured releases.',
|
||||
},
|
||||
]
|
||||
|
||||
export const HERO_METRICS = [
|
||||
{
|
||||
label: 'Core purpose',
|
||||
value: 'Private creative workspace',
|
||||
note: 'Studio is where you prepare, organize, review, and manage work before and after it goes public.',
|
||||
},
|
||||
{
|
||||
label: 'Common confusion',
|
||||
value: 'Context changes behavior',
|
||||
note: 'Personal Studio and Group Studio can expose different actions because ownership and permissions are different.',
|
||||
},
|
||||
{
|
||||
label: 'Golden rule',
|
||||
value: 'Check context before publish',
|
||||
note: 'Publishing from the wrong context is one of the easiest ways to create avoidable confusion.',
|
||||
},
|
||||
]
|
||||
|
||||
export const STUDIO_AREAS = [
|
||||
{
|
||||
title: 'Dashboard and content views',
|
||||
body: 'Use the main Studio dashboard, content view, and analytics surfaces to see what is active, what is scheduled, and what still needs attention.',
|
||||
links: ['Open Studio', 'View content dashboard'],
|
||||
},
|
||||
{
|
||||
title: 'Artworks and drafts',
|
||||
body: 'Artworks, drafts, scheduled items, calendar views, and archived work all live inside the management side of Studio rather than on public profile pages.',
|
||||
links: ['Open artworks', 'Open drafts'],
|
||||
},
|
||||
{
|
||||
title: 'Cards and collections',
|
||||
body: 'Cards and collections are managed as creative tools inside Studio, where you can build, organize, and refine them before people see the result publicly.',
|
||||
links: ['Open cards', 'Open collections'],
|
||||
},
|
||||
{
|
||||
title: 'Groups and collaboration',
|
||||
body: 'When collaboration is involved, Group Studio adds shared publishing, member management, review flows, projects, releases, challenges, events, assets, and related operations.',
|
||||
links: ['Open Group Studio', 'Read Groups help'],
|
||||
},
|
||||
{
|
||||
title: 'Settings and preferences',
|
||||
body: 'Studio also includes settings, preferences, profile-facing tools, activity, and creator operations that do not belong on the public side of Skinbase.',
|
||||
links: ['Open settings', 'Read Profile help'],
|
||||
},
|
||||
{
|
||||
title: 'Future-ready workflow surface',
|
||||
body: 'The current Studio already covers many creator operations, and the help page is written to stay useful as more modules grow into the workspace over time.',
|
||||
links: ['Help Center', 'Report issue'],
|
||||
},
|
||||
]
|
||||
|
||||
export const DRAFT_STEPS = [
|
||||
{
|
||||
title: 'Start work in the right context',
|
||||
description: 'Before you upload, edit, or publish, confirm whether the work belongs to your personal Studio or to a Group context.',
|
||||
},
|
||||
{
|
||||
title: 'Treat drafts as unfinished workspace items',
|
||||
description: 'Drafts are where unfinished work lives while you are still checking metadata, previews, contributor credit, timing, or overall quality.',
|
||||
},
|
||||
{
|
||||
title: 'Review metadata before publishing',
|
||||
description: 'Titles, descriptions, tags, categories, previews, context, and contributor information should be reviewed before the final publish step.',
|
||||
},
|
||||
{
|
||||
title: 'Use review when collaboration needs it',
|
||||
description: 'Group workflows may put work into review before publish so trusted members can check context, quality, and credit.',
|
||||
},
|
||||
{
|
||||
title: 'Publish only when the public version is ready',
|
||||
description: 'Do not treat publish as a draft save button. Publish when the work is accurate, presentable, and in the right place.',
|
||||
},
|
||||
]
|
||||
|
||||
export const ARTWORK_GUIDANCE = [
|
||||
'Create or upload the work into the correct Studio context first.',
|
||||
'Review title, description, and public-facing metadata before publish.',
|
||||
'Check tags, categories, and preview quality so the public version lands clearly.',
|
||||
'Make sure contributor credit reflects who authored, uploaded, and contributed to the work.',
|
||||
'Update published work intentionally instead of letting metadata drift over time.',
|
||||
]
|
||||
|
||||
export const CARD_COLLECTION_GUIDANCE = [
|
||||
{
|
||||
title: 'Cards',
|
||||
body: 'Use Studio for card creation, remixing, editing, previews, and analytics. Cards are part of your creative workflow, not just a public gallery surface.',
|
||||
},
|
||||
{
|
||||
title: 'Collections',
|
||||
body: 'Use Studio to organize groups of work, shape presentation, and manage curated content as a creative management task rather than an afterthought.',
|
||||
},
|
||||
]
|
||||
|
||||
export const ADVANCED_MODULES = [
|
||||
'Projects help teams organize structured collaboration, milestones, and linked work.',
|
||||
'Releases package a larger publication moment into a clearer shared launch surface.',
|
||||
'Challenges, events, and assets extend Studio into themed collaboration, timed publishing, and shared resources.',
|
||||
'Group review queues, invitations, and join requests add operational structure when collaboration grows beyond simple direct publishing.',
|
||||
]
|
||||
|
||||
export const BEST_PRACTICES = [
|
||||
'Review drafts regularly so Studio stays usable instead of turning into a backlog graveyard.',
|
||||
'Keep personal and Group work clearly separated so ownership stays obvious.',
|
||||
'Publish only after metadata, previews, and contributor credit are truly ready.',
|
||||
'Use advanced modules only when they solve a real workflow problem.',
|
||||
'Treat Studio like a workspace for preparation and management, not as a public profile page.',
|
||||
'Keep contributor records accurate so teams avoid confusion later.',
|
||||
]
|
||||
|
||||
export const COMMON_MISTAKES = [
|
||||
'Publishing under the wrong context because the active Studio scope was not checked first.',
|
||||
'Leaving metadata half-finished and hoping to clean it up after the work is public.',
|
||||
'Forgetting to verify contributor credit before a collaborative publish.',
|
||||
'Treating Studio like a public page instead of a private working area.',
|
||||
'Giving too many Group members too much access when a smaller permission set would be safer.',
|
||||
'Ignoring drafts until lists become cluttered and hard to maintain.',
|
||||
]
|
||||
|
||||
export const FAQ_ITEMS = [
|
||||
{
|
||||
question: 'What is Studio?',
|
||||
answer: 'Studio is the private creator workspace on Skinbase Nova. It is where you manage drafts, uploads, publishing, cards, collections, settings, and other operational parts of your creative work.',
|
||||
},
|
||||
{
|
||||
question: 'Why do Personal Studio and Group Studio look different?',
|
||||
answer: 'Because the context changes ownership and permissions. Personal Studio manages your own work. Group Studio manages work under a shared identity, so some actions depend on your Group role and workflow.',
|
||||
},
|
||||
{
|
||||
question: 'Why can’t I publish from this area?',
|
||||
answer: 'You may be in the wrong context, in a non-publishing step, or using a role that does not include direct publishing. Check the active scope first, then check whether review or approval is part of the workflow.',
|
||||
},
|
||||
{
|
||||
question: 'Where are my drafts?',
|
||||
answer: 'Drafts live inside Studio, not on public pages. Look in the draft or artwork management views for the current context you are working in.',
|
||||
},
|
||||
{
|
||||
question: 'Can I manage both personal and Group content in Studio?',
|
||||
answer: 'Yes, but they are not the same context. You should switch deliberately and confirm which identity owns the work before editing or publishing.',
|
||||
},
|
||||
{
|
||||
question: 'Why don’t I see some modules?',
|
||||
answer: 'Some modules only appear in certain contexts, are tied to collaboration features, or depend on your Group role and permissions.',
|
||||
},
|
||||
{
|
||||
question: 'Is Studio public?',
|
||||
answer: 'No. Studio is the private management layer. Public pages are what other people see after content has been published.',
|
||||
},
|
||||
]
|
||||
|
||||
export const TROUBLESHOOTING_ITEMS = [
|
||||
{
|
||||
title: 'I can’t find my draft',
|
||||
body: 'Check whether the draft belongs to your personal Studio or to a Group. Draft confusion often comes from opening the right workspace in the wrong context.',
|
||||
linkKey: 'studio_drafts',
|
||||
linkLabel: 'Open drafts',
|
||||
},
|
||||
{
|
||||
title: 'I can’t publish',
|
||||
body: 'Confirm the active context, then check whether your role, workflow, or review state allows direct publishing from that surface.',
|
||||
linkKey: 'groups_faq',
|
||||
linkLabel: 'Read the Groups FAQ',
|
||||
},
|
||||
{
|
||||
title: 'I don’t see Group Studio',
|
||||
body: 'You may not be in a Group yet, may not have accepted an invitation, or may not have the expected access in the current account state.',
|
||||
linkKey: 'group_studio',
|
||||
linkLabel: 'Open Group Studio',
|
||||
},
|
||||
{
|
||||
title: 'I don’t understand why an action is missing',
|
||||
body: 'Missing actions usually come from context, permissions, or workflow stage. The action may exist elsewhere, or it may be intentionally limited in this scope.',
|
||||
linkKey: 'groups_help',
|
||||
linkLabel: 'Read Groups help',
|
||||
},
|
||||
{
|
||||
title: 'I changed context and now I can’t edit something',
|
||||
body: 'The work may belong to the other context. Switch back and confirm whether the item is personal, Group-owned, or limited by role.',
|
||||
linkKey: 'open_studio',
|
||||
linkLabel: 'Open Studio',
|
||||
},
|
||||
{
|
||||
title: 'My Studio looks empty',
|
||||
body: 'Start by checking the active context, current filters, and whether you are looking at drafts, artworks, scheduled items, or another view entirely.',
|
||||
linkKey: 'studio_content',
|
||||
linkLabel: 'Open content dashboard',
|
||||
},
|
||||
]
|
||||
|
||||
export const RELATED_HELP_ITEMS = [
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Groups help',
|
||||
body: 'Use the full Groups guide for roles, permissions, contributor credit, review queues, and shared publishing.',
|
||||
linkKey: 'groups_help',
|
||||
tone: 'sky',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Upload help',
|
||||
body: 'Use the Upload guide when the real question is about draft flow, metadata, previews, contributor credit, or final publish steps.',
|
||||
linkKey: 'upload_help',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Cards help',
|
||||
body: 'Use the Cards guide when you need help choosing the format, creating a Card, or understanding where Cards fit compared with other content types.',
|
||||
linkKey: 'help_cards',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Profile help',
|
||||
body: 'Use the Profile guide when the real question is how to build a stronger personal identity, cleaner presentation, and better profile-versus-Group clarity.',
|
||||
linkKey: 'help_profile',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Signup and login help',
|
||||
body: 'Use the auth guide when Studio access is blocked before you even get started or when the real problem is recovery, verification, or account entry.',
|
||||
linkKey: 'help_auth',
|
||||
tone: 'white',
|
||||
},
|
||||
]
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
export const SECTION_ITEMS = [
|
||||
{ id: 'first-checks', label: 'First checks' },
|
||||
{ id: 'account-access', label: 'Account access' },
|
||||
{ id: 'publishing-and-context', label: 'Publishing and context' },
|
||||
{ id: 'profile-and-settings', label: 'Profile and settings' },
|
||||
{ id: 'when-to-report', label: 'When to report it' },
|
||||
{ id: 'faq', label: 'FAQ' },
|
||||
{ id: 'related-help', label: 'Related help' },
|
||||
]
|
||||
|
||||
export const HERO_METRICS = [
|
||||
{
|
||||
label: 'Best first move',
|
||||
value: 'Name the failure clearly',
|
||||
note: 'Broken, blocked, and unclear are not the same thing. The faster you label the problem, the faster the right fix shows up.',
|
||||
},
|
||||
{
|
||||
label: 'Most common false alarm',
|
||||
value: 'Permission or context confusion',
|
||||
note: 'A lot of apparent product failures are actually caused by the wrong active context, incomplete setup, or missing collaboration permissions.',
|
||||
},
|
||||
{
|
||||
label: 'Escalate when',
|
||||
value: 'A route is repeatably broken',
|
||||
note: 'If the same steps fail consistently and the issue is not explained by access, context, or settings, it is time to report the problem clearly.',
|
||||
},
|
||||
]
|
||||
|
||||
export const FIRST_CHECKS = [
|
||||
'Ask whether the problem is broken, blocked, or just unclear. Those three paths have different fixes.',
|
||||
'Re-open the exact route you intended to use instead of guessing from memory or following an outdated tab.',
|
||||
'Check whether you are signed in, in the right account, and in the right personal or Group context before assuming the product failed.',
|
||||
'Reduce the problem to one sentence. If you cannot describe the failure clearly, start with the auth or account guides first.',
|
||||
]
|
||||
|
||||
export const ACCOUNT_ACCESS_ITEMS = [
|
||||
{
|
||||
title: 'Login, reset, and verification come first',
|
||||
body: 'If access itself is failing, do not over-diagnose a Studio, profile, or publishing bug yet. Start with the auth guide and recovery paths first.',
|
||||
},
|
||||
{
|
||||
title: 'Wrong email and wrong inbox cause a lot of panic',
|
||||
body: 'Many access problems feel severe until you realize the account email, reset inbox, or verification message path was simply not the one you thought it was.',
|
||||
},
|
||||
{
|
||||
title: 'Partial access is still a useful clue',
|
||||
body: 'If some features work and others do not, the issue may be permissions, setup state, or workflow context rather than a total account failure.',
|
||||
},
|
||||
]
|
||||
|
||||
export const PUBLISHING_CONTEXT_ITEMS = [
|
||||
{
|
||||
title: 'Personal vs Group context changes what you can do',
|
||||
body: 'Publishing problems often come from being in the wrong context or expecting personal access to behave like Group access.',
|
||||
},
|
||||
{
|
||||
title: 'Missing permission is not always a bug',
|
||||
body: 'If a Group role or shared workflow blocks an action, the fix usually lives in permissions guidance rather than product failure reporting.',
|
||||
},
|
||||
{
|
||||
title: 'Studio confusion often starts upstream',
|
||||
body: 'When Studio feels wrong, check whether the real issue is authentication, onboarding state, or the route you expected to open.',
|
||||
},
|
||||
]
|
||||
|
||||
export const PROFILE_SETTINGS_ITEMS = [
|
||||
{
|
||||
title: 'Incomplete profile is usually a settings task',
|
||||
body: 'When the profile looks thin, inconsistent, or outdated, the answer usually lives in profile or account settings rather than troubleshooting a broken surface.',
|
||||
},
|
||||
{
|
||||
title: 'Identity confusion can feel like a feature issue',
|
||||
body: 'If the page feels wrong because personal and Group identity are blurred together, use profile and Groups help before filing a bug.',
|
||||
},
|
||||
{
|
||||
title: 'Notification or preference issues belong in settings',
|
||||
body: 'If the platform feels too noisy, too quiet, or out of sync with what you expect, the account settings guide is usually the better first stop.',
|
||||
},
|
||||
]
|
||||
|
||||
export const REPORTING_ITEMS = [
|
||||
'Report a bug when you can reproduce the same failure on the same route with the same steps.',
|
||||
'Contact support when ownership, account identity, or a sensitive account question needs a human response instead of a pure product diagnosis.',
|
||||
'Include the route, what you expected, what actually happened, and whether the issue is personal-only or also affects Group workflows.',
|
||||
'Mention what you already checked so support does not have to restart from the most obvious first steps.',
|
||||
]
|
||||
|
||||
export const FAQ_ITEMS = [
|
||||
{
|
||||
question: 'How do I know whether something is broken or I am just in the wrong context?',
|
||||
answer: 'Check whether the same route works after confirming login state, account identity, and personal-versus-Group context. If the failure disappears when context is corrected, it was not a product bug.',
|
||||
},
|
||||
{
|
||||
question: 'When should I use auth help instead of troubleshooting help?',
|
||||
answer: 'Use auth help when the core problem is account entry, recovery, or verification. Use troubleshooting help when the failure is broader or less clearly labeled.',
|
||||
},
|
||||
{
|
||||
question: 'When should I use account help instead of troubleshooting help?',
|
||||
answer: 'Use account help when access already works and the question is really about settings, profile maintenance, passwords, email care, or preferences.',
|
||||
},
|
||||
{
|
||||
question: 'What should I include in a bug report?',
|
||||
answer: 'Include the route, the exact steps, what you expected, what happened instead, and whether the issue repeats consistently. Clear reproduction details save the most time.',
|
||||
},
|
||||
]
|
||||
|
||||
export const RELATED_HELP_ITEMS = [
|
||||
{
|
||||
eyebrow: 'Access',
|
||||
title: 'Signup and login help',
|
||||
body: 'Use the auth guide when the real failure starts with account entry, reset messages, or verification confusion.',
|
||||
linkKey: 'help_auth',
|
||||
tone: 'sky',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Settings',
|
||||
title: 'Account settings help',
|
||||
body: 'Use the account guide when access already works and the real fix lives in profile settings, notifications, email care, or password maintenance.',
|
||||
linkKey: 'help_account',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Publishing',
|
||||
title: 'Upload help',
|
||||
body: 'Use the upload guide when the “broken” feeling is really about drafts, metadata, publishing flow, or file-specific workflow confusion.',
|
||||
linkKey: 'upload_help',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Permissions',
|
||||
title: 'Groups FAQ',
|
||||
body: 'Use Groups guidance when the blocker is role-based access, contributor permissions, invites, or collaboration behavior.',
|
||||
linkKey: 'groups_faq',
|
||||
tone: 'white',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,266 @@
|
||||
export const SECTION_ITEMS = [
|
||||
{ id: 'how-uploading-works', label: 'How uploading works' },
|
||||
{ id: 'prepare-before-upload', label: 'What to prepare' },
|
||||
{ id: 'personal-vs-group', label: 'Personal vs Group upload' },
|
||||
{ id: 'draft-flow', label: 'Draft flow' },
|
||||
{ id: 'publish-flow', label: 'Publish flow' },
|
||||
{ id: 'file-preview-metadata', label: 'File, preview, metadata' },
|
||||
{ id: 'contributor-credit', label: 'Contributor credit' },
|
||||
{ id: 'best-practices', label: 'Best practices' },
|
||||
{ id: 'common-mistakes', label: 'Common mistakes' },
|
||||
{ id: 'faq', label: 'FAQ' },
|
||||
{ id: 'troubleshooting', label: 'Troubleshooting' },
|
||||
{ id: 'related-help', label: 'Related help' },
|
||||
]
|
||||
|
||||
export const HERO_METRICS = [
|
||||
{
|
||||
label: 'Core idea',
|
||||
value: 'Guided workflow, not raw submission',
|
||||
note: 'Uploading is more than sending a file. It includes draft setup, metadata, previews, context, and publishing checks.',
|
||||
},
|
||||
{
|
||||
label: 'Most common mistake',
|
||||
value: 'Wrong context at publish time',
|
||||
note: 'Personal and Group uploads can look similar, but the published identity and review behavior can be very different.',
|
||||
},
|
||||
{
|
||||
label: 'Safest habit',
|
||||
value: 'Review before final publish',
|
||||
note: 'Drafts exist to help you finish details before the public version goes live.',
|
||||
},
|
||||
]
|
||||
|
||||
export const WORKFLOW_STEPS = [
|
||||
{
|
||||
title: 'Start the upload',
|
||||
description: 'Begin with the file you want to publish and confirm whether the upload belongs to your personal identity or to a Group context.',
|
||||
},
|
||||
{
|
||||
title: 'The file is received',
|
||||
description: 'Skinbase accepts the file and starts turning the upload into a manageable workspace item instead of sending it public immediately.',
|
||||
},
|
||||
{
|
||||
title: 'A draft is created',
|
||||
description: 'Uploads usually start as drafts so you can review details, context, credits, and presentation before publishing.',
|
||||
},
|
||||
{
|
||||
title: 'Processing and previews happen',
|
||||
description: 'Previews or processing steps may run so the upload is easier to review and present clearly.',
|
||||
},
|
||||
{
|
||||
title: 'Metadata is completed',
|
||||
description: 'Titles, descriptions, tags, categories, and other public-facing details are finalized while the upload is still safe to edit.',
|
||||
},
|
||||
{
|
||||
title: 'Context and contributors are checked',
|
||||
description: 'Before publishing, verify whether the work belongs to you or a Group and make sure contributor credit reflects the real people behind the upload.',
|
||||
},
|
||||
{
|
||||
title: 'Publish or submit for review',
|
||||
description: 'Once the upload is ready, it is either published or routed into review depending on the workflow and permissions involved.',
|
||||
},
|
||||
]
|
||||
|
||||
export const PREP_ITEMS = [
|
||||
'Final file you actually want people to see, not a rough placeholder.',
|
||||
'Clear title and description so the upload is understandable without extra cleanup later.',
|
||||
'Tags and categories if they apply to the content type you are publishing.',
|
||||
'Contributor information for collaborative work, especially if a Group is involved.',
|
||||
'The correct publish context: personal or Group.',
|
||||
'A good preview mindset so the public version feels intentional and discoverable.',
|
||||
]
|
||||
|
||||
export const UPLOAD_COMPARISON_COLUMNS = [
|
||||
{ key: 'topic', label: 'Topic' },
|
||||
{ key: 'personal', label: 'Personal upload' },
|
||||
{ key: 'group', label: 'Group upload' },
|
||||
]
|
||||
|
||||
export const UPLOAD_COMPARISON_ROWS = [
|
||||
{
|
||||
id: 'published-as',
|
||||
topic: 'Published identity',
|
||||
personal: 'The work publishes under your personal creator identity.',
|
||||
group: 'The work publishes under the Group identity.',
|
||||
},
|
||||
{
|
||||
id: 'credit',
|
||||
topic: 'Human credit',
|
||||
personal: 'Your own authorship and upload role are usually straightforward.',
|
||||
group: 'Contributor credit still matters. Group identity does not replace human authorship.',
|
||||
},
|
||||
{
|
||||
id: 'permissions',
|
||||
topic: 'Why behavior can differ',
|
||||
personal: 'You usually control the full flow yourself.',
|
||||
group: 'Roles, review queues, and approvals may affect whether you can publish directly.',
|
||||
},
|
||||
{
|
||||
id: 'drafts',
|
||||
topic: 'Draft handling',
|
||||
personal: 'Drafts stay in your personal workspace until you finish them.',
|
||||
group: 'Drafts may be part of a team review flow before they are publicly published.',
|
||||
},
|
||||
]
|
||||
|
||||
export const DRAFT_FLOW_ITEMS = [
|
||||
'Uploads usually begin as drafts so you can finish details without rushing a public release.',
|
||||
'Drafts are where metadata, context, previews, and contributor setup are reviewed.',
|
||||
'Drafts may still be processing while you are working on the rest of the upload.',
|
||||
'Incomplete drafts can be left temporarily, but they are best finished quickly so the workspace stays clean.',
|
||||
'In Group workflows, drafts may be submitted for review instead of publishing directly.',
|
||||
]
|
||||
|
||||
export const PUBLISH_FLOW_ITEMS = [
|
||||
'Publishing should happen after file review, metadata review, and context confirmation.',
|
||||
'You should verify titles, descriptions, previews, and contributor information before the final step.',
|
||||
'Some Groups may route the upload into review instead of publishing immediately.',
|
||||
'Publishing under the wrong context is one of the most common avoidable mistakes.',
|
||||
]
|
||||
|
||||
export const FILE_METADATA_ITEMS = [
|
||||
'Previews matter because people often decide whether to open or trust a piece based on its first impression.',
|
||||
'Metadata matters because clear titles and descriptions help the work feel intentional and improve discoverability.',
|
||||
'Final review matters because small mistakes feel much bigger after the upload is public.',
|
||||
'Taking a minute to review the presentation is usually faster than correcting avoidable problems later.',
|
||||
]
|
||||
|
||||
export const CREDIT_EXAMPLE = [
|
||||
{ label: 'Published by', value: 'Nightshift Collective' },
|
||||
{ label: 'Uploaded by', value: 'Gregor' },
|
||||
{ label: 'Primary author', value: 'Gregor' },
|
||||
{ label: 'Contributors', value: 'Paula, Denis' },
|
||||
]
|
||||
|
||||
export const CREDIT_BULLETS = [
|
||||
'Group uploads still preserve human credit.',
|
||||
'Primary author should reflect the main author of the work, not just the person who clicked upload.',
|
||||
'Uploaded by and published identity are not always the same thing.',
|
||||
'Contributor lists should be intentional, accurate, and checked before publish.',
|
||||
]
|
||||
|
||||
export const BEST_PRACTICES = [
|
||||
'Prepare metadata before you start uploading whenever possible.',
|
||||
'Use strong files and previews so the public result feels finished.',
|
||||
'Check the publishing context before the final publish step.',
|
||||
'Do not rush final publish just because the file is already in the system.',
|
||||
'Give proper contributor credit for collaborative work.',
|
||||
'Keep drafts organized and return to incomplete uploads quickly.',
|
||||
]
|
||||
|
||||
export const COMMON_MISTAKES = [
|
||||
'Uploading under the wrong context and only noticing after publish.',
|
||||
'Forgetting contributor credit during a collaborative upload.',
|
||||
'Leaving metadata empty because the file itself looked finished.',
|
||||
'Abandoning drafts until the workspace becomes cluttered.',
|
||||
'Trying to publish before everything has been reviewed clearly.',
|
||||
'Misunderstanding review queue behavior in Group workflows.',
|
||||
]
|
||||
|
||||
export const FAQ_ITEMS = [
|
||||
{
|
||||
question: 'How does upload work on Skinbase?',
|
||||
answer: 'Uploads move through a guided workflow. The file is received, a draft is created, previews or processing may happen, metadata is completed, context and contributor credit are reviewed, and then the work is published or submitted for review.',
|
||||
},
|
||||
{
|
||||
question: 'Why is my upload a draft first?',
|
||||
answer: 'Draft-first flow gives you a safe place to finish titles, descriptions, previews, context, and credit before the public version goes live.',
|
||||
},
|
||||
{
|
||||
question: 'Can I upload for a Group?',
|
||||
answer: 'Yes, if your Group role and workflow allow it. Just make sure the active context is the Group and that contributor credit is set correctly before final publish.',
|
||||
},
|
||||
{
|
||||
question: 'Why can’t I publish immediately?',
|
||||
answer: 'You may still need to finish metadata, wait for processing, confirm context, or pass through a review flow if the upload belongs to a Group workflow.',
|
||||
},
|
||||
{
|
||||
question: 'What should I do before publishing?',
|
||||
answer: 'Check file quality, previews, metadata, tags, context, and contributor credit. Publishing should be the last review step, not the first one.',
|
||||
},
|
||||
{
|
||||
question: 'What happens if my upload is incomplete?',
|
||||
answer: 'It can stay as a draft until you return and finish it, but it is best to complete incomplete drafts quickly so the workspace stays manageable.',
|
||||
},
|
||||
{
|
||||
question: 'Can I come back later?',
|
||||
answer: 'Yes. Drafts exist so you can return later, but do not let unfinished uploads pile up without clear intent.',
|
||||
},
|
||||
]
|
||||
|
||||
export const TROUBLESHOOTING_ITEMS = [
|
||||
{
|
||||
title: 'My upload is stuck',
|
||||
body: 'Give processing a moment, then reopen the upload through Studio or the upload flow. If it still feels stuck, escalate instead of repeatedly retrying blindly.',
|
||||
linkKey: 'upload',
|
||||
linkLabel: 'Open upload flow',
|
||||
},
|
||||
{
|
||||
title: 'Preview is missing',
|
||||
body: 'Preview issues are often a sign that the upload is still processing or that you need to re-open the draft and review the current state before publishing.',
|
||||
linkKey: 'studio_drafts',
|
||||
linkLabel: 'Open drafts',
|
||||
},
|
||||
{
|
||||
title: 'I can’t publish',
|
||||
body: 'Check whether the issue is unfinished metadata, wrong context, or a Group review workflow that prevents direct publishing.',
|
||||
linkKey: 'groups_faq',
|
||||
linkLabel: 'Read Groups FAQ',
|
||||
},
|
||||
{
|
||||
title: 'I uploaded under the wrong context',
|
||||
body: 'Review the draft or published item immediately, then correct the personal-versus-Group context before more workflow steps build on top of the mistake.',
|
||||
linkKey: 'studio_help',
|
||||
linkLabel: 'Read Studio help',
|
||||
},
|
||||
{
|
||||
title: 'My Group submission went into review',
|
||||
body: 'That usually means the Group workflow expects approval before public publishing. This is often intentional, not a failure.',
|
||||
linkKey: 'groups_help',
|
||||
linkLabel: 'Read Groups help',
|
||||
},
|
||||
{
|
||||
title: 'I can’t find my draft',
|
||||
body: 'Draft confusion usually comes from checking the wrong context. Confirm whether the upload belongs to your personal workspace or a Group.',
|
||||
linkKey: 'studio_drafts',
|
||||
linkLabel: 'Open drafts',
|
||||
},
|
||||
{
|
||||
title: 'Upload failed',
|
||||
body: 'If the upload repeatedly fails, stop retrying blindly and use support or bug reporting with a clear description of what happened.',
|
||||
linkKey: 'report_issue',
|
||||
linkLabel: 'Report issue',
|
||||
},
|
||||
]
|
||||
|
||||
export const RELATED_HELP_ITEMS = [
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Studio help',
|
||||
body: 'Use the Studio guide to understand the wider workspace where drafts, content management, and publishing decisions live.',
|
||||
linkKey: 'studio_help',
|
||||
tone: 'sky',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Groups help',
|
||||
body: 'Use the Groups guide if the upload belongs to a shared identity, needs contributor credit, or goes through Group review.',
|
||||
linkKey: 'groups_help',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Profile help',
|
||||
body: 'Use the Profile guide if upload confusion is really about creator identity, presentation, or how your public presence should look after publishing.',
|
||||
linkKey: 'help_profile',
|
||||
tone: 'white',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Live help',
|
||||
title: 'Cards help',
|
||||
body: 'Use the Cards guide when the question is really about presentation content, Card creation, or choosing Cards instead of artworks or posts.',
|
||||
linkKey: 'help_cards',
|
||||
tone: 'white',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
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>
|
||||
<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"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden bg-neutral-900">
|
||||
<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 duration-300 ease-out group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||
/>
|
||||
<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 opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover: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-5 h-5 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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Because You Like {tag}: fresh or trending artworks for the user's top tag.
|
||||
* Only rendered when by_categories data is available and a top tag is known.
|
||||
*/
|
||||
export default function HomeBecauseYouLike({ items, preferences }) {
|
||||
const topTag = preferences?.top_tags?.[0]
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0 || !topTag) 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">
|
||||
✨ Because You Like{' '}
|
||||
<span className="text-accent">#{topTag}</span>
|
||||
</h2>
|
||||
<a
|
||||
href={`/browse?tags=${encodeURIComponent(topTag)}`}
|
||||
className="text-sm text-nova-300 hover:text-white transition"
|
||||
>
|
||||
See all →
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
|
||||
<ArtCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Upload CTA banner — shown at the bottom of both guest and logged-in homepages.
|
||||
*/
|
||||
export default function HomeCTA({ isLoggedIn }) {
|
||||
const uploadHref = isLoggedIn ? '/upload' : '/login?redirect=/upload'
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-accent/20 via-nova-800 to-nova-900 px-8 py-12 text-center ring-1 ring-white/5">
|
||||
{/* Decorative blobs */}
|
||||
<div className="pointer-events-none absolute -top-12 -right-12 h-40 w-40 rounded-full bg-accent/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-10 -left-10 h-32 w-32 rounded-full bg-sky-500/10 blur-2xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-accent">Join the community</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-white sm:text-3xl">
|
||||
Ready to share your creativity?
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-md text-sm text-nova-300">
|
||||
Upload your artworks, wallpapers, and skins to reach thousands of enthusiasts around the world.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<a
|
||||
href={uploadHref}
|
||||
className="btn-accent-solid rounded-xl px-6 py-2.5 text-sm font-semibold"
|
||||
>
|
||||
Upload your artwork
|
||||
</a>
|
||||
{!isLoggedIn && (
|
||||
<a
|
||||
href="/register"
|
||||
className="rounded-xl border border-white/10 bg-nova-700 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-nova-600"
|
||||
>
|
||||
Create account
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
|
||||
const CATEGORIES = [
|
||||
{
|
||||
label: 'Wallpapers',
|
||||
description: 'Desktop & mobile backgrounds',
|
||||
href: '/wallpapers',
|
||||
icon: '🖥️',
|
||||
mascot: '/gfx/mascot_wallpapers.webp',
|
||||
color: 'from-sky-500/20 to-sky-900/40',
|
||||
},
|
||||
{
|
||||
label: 'Photography',
|
||||
description: 'Real-world captures & edits',
|
||||
href: '/photography',
|
||||
icon: '📷',
|
||||
mascot: '/gfx/mascot_photography.webp',
|
||||
color: 'from-emerald-500/20 to-emerald-900/40',
|
||||
},
|
||||
{
|
||||
label: 'Skins',
|
||||
description: 'App & game skins',
|
||||
href: '/skins',
|
||||
icon: '🎨',
|
||||
mascot: '/gfx/mascot_skins.webp',
|
||||
color: 'from-purple-500/20 to-purple-900/40',
|
||||
},
|
||||
{
|
||||
label: 'Digital Art',
|
||||
description: 'Illustrations & concept art',
|
||||
href: '/other',
|
||||
icon: '✏️',
|
||||
mascot: '/gfx/mascot_other.webp',
|
||||
color: 'from-rose-500/20 to-rose-900/40',
|
||||
},
|
||||
{
|
||||
label: 'Tags Hub',
|
||||
description: 'Browse by theme or style',
|
||||
href: '/tags',
|
||||
icon: '🏷️',
|
||||
mascot: '/gfx/mascot_other.webp',
|
||||
color: 'from-amber-500/20 to-amber-900/40',
|
||||
},
|
||||
]
|
||||
|
||||
function CategoryTile({ cat }) {
|
||||
return (
|
||||
<a
|
||||
href={cat.href}
|
||||
className={`group relative flex flex-col justify-end overflow-hidden rounded-2xl bg-gradient-to-br ${cat.color} ring-1 ring-white/5 transition hover:-translate-y-1 hover:ring-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70`}
|
||||
style={{ minHeight: '7rem' }}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 bg-nova-900/20 transition group-hover:bg-nova-900/10" />
|
||||
|
||||
{/* Mascot image — bottom-right, partially overflowing bottom edge */}
|
||||
{cat.mascot && (
|
||||
<img
|
||||
src={cat.mascot}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-0 right-0 h-24 w-auto translate-y-2 object-contain drop-shadow-xl transition-transform duration-300 group-hover:-translate-y-1 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Text label — bottom-left, always readable */}
|
||||
<div className="relative z-10 p-3 pr-24">
|
||||
{!cat.mascot && (
|
||||
<span className="mb-2 block text-2xl" role="img" aria-label={cat.label}>{cat.icon}</span>
|
||||
)}
|
||||
<p className="font-semibold leading-tight text-white">{cat.label}</p>
|
||||
<p className="mt-0.5 text-xs text-nova-300">{cat.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Static category quick-links. No backend data needed — these are fixed routes.
|
||||
*/
|
||||
export default function HomeCategories() {
|
||||
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">🗂️ Explore Categories</h2>
|
||||
<a href="/browse" className="text-sm text-nova-300 hover:text-white transition">
|
||||
Browse all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 5 tiles: 2 rows on mobile, single row on xl */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<CategoryTile key={cat.href} cat={cat} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
|
||||
function normalizeItems(items) {
|
||||
if (!Array.isArray(items)) return []
|
||||
|
||||
return items.filter((item) => item && typeof item === 'object')
|
||||
}
|
||||
|
||||
export default function HomeCollections({
|
||||
featured,
|
||||
recent,
|
||||
trending,
|
||||
editorial,
|
||||
community,
|
||||
}) {
|
||||
const featuredItems = normalizeItems(featured)
|
||||
const recentItems = normalizeItems(recent)
|
||||
const trendingItems = normalizeItems(trending)
|
||||
const editorialItems = normalizeItems(editorial)
|
||||
const communityItems = normalizeItems(community)
|
||||
const displayItems = (
|
||||
trendingItems.length ? trendingItems :
|
||||
featuredItems.length ? featuredItems :
|
||||
recentItems.length ? recentItems :
|
||||
editorialItems.length ? editorialItems :
|
||||
communityItems
|
||||
).slice(0, 3)
|
||||
|
||||
if (!displayItems.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Trending Collections</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-nova-300">
|
||||
Collections getting the strongest mix of follows, saves, and engagement right now.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/collections/trending" className="shrink-0 text-sm text-nova-300 transition hover:text-white">
|
||||
All collections →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{displayItems.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function CreatorCard({ creator }) {
|
||||
return (
|
||||
<article className="group relative flex flex-col items-center gap-3 overflow-hidden rounded-xl bg-panel p-5 shadow-sm text-center transition hover:ring-1 hover:ring-nova-500">
|
||||
{/* Background artwork thumbnail */}
|
||||
{creator.bg_thumb && (
|
||||
<>
|
||||
<img
|
||||
src={creator.bg_thumb}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-50 transition duration-500 group-hover:opacity-20 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-panel via-panel/80 to-panel/60" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<a href={creator.url} className="relative block">
|
||||
<img
|
||||
src={creator.avatar}
|
||||
alt=""
|
||||
className="mx-auto h-16 w-16 rounded-full object-cover ring-4 bg-nova-800/80 ring-nova-800"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||
/>
|
||||
<h3 className="mt-2 text-sm font-semibold text-white">{creator.name}</h3>
|
||||
</a>
|
||||
<div className="relative flex flex-wrap justify-center gap-3 text-xs text-soft">
|
||||
<span title="Total uploads">📁 {creator.uploads}</span>
|
||||
{creator.weekly_uploads > 0 && (
|
||||
<span title="Uploads this week" className="text-accent font-semibold">↑{creator.weekly_uploads} this week</span>
|
||||
)}
|
||||
<span title="Views">👁 {creator.views.toLocaleString()}</span>
|
||||
{creator.awards > 0 && <span title="Awards">🏆 {creator.awards}</span>}
|
||||
</div>
|
||||
<a
|
||||
href={creator.url}
|
||||
className="relative mt-1 rounded-lg bg-nova-700 px-4 py-1.5 text-xs font-semibold text-white transition hover:bg-nova-600"
|
||||
>
|
||||
View Profile
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomeCreators({ creators }) {
|
||||
if (!Array.isArray(creators) || creators.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">👤 Creator Spotlight</h2>
|
||||
<a href="/members" className="text-sm text-nova-300 hover:text-white transition">
|
||||
All creators →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{creators.map((c) => (
|
||||
<CreatorCard key={c.id} creator={c} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||
|
||||
export default function HomeFresh({ 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">🆕 Fresh Uploads</h2>
|
||||
<a href="/discover/fresh" className="text-sm text-nova-300 hover:text-white transition">
|
||||
See all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ArtworkGalleryGrid
|
||||
items={items.slice(0, 8)}
|
||||
showStats={false}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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>
|
||||
<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">
|
||||
<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 }}
|
||||
/>
|
||||
<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 opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover: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-5 h-5 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 HomeFromFollowing({ items }) {
|
||||
// Empty state: user follows nobody
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
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">👥 From Creators You Follow</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/5 bg-nova-800/40 px-6 py-10 text-center">
|
||||
<p className="text-sm text-soft">You're not following anyone yet.</p>
|
||||
<p className="mt-1 text-xs text-nova-400">
|
||||
Follow creators you love to see their latest uploads here.
|
||||
</p>
|
||||
<a
|
||||
href="/creators/top"
|
||||
className="mt-4 inline-flex items-center rounded-xl bg-nova-700 px-4 py-2 text-sm font-medium text-white hover:bg-nova-600 transition"
|
||||
>
|
||||
Discover creators →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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">👥 From Creators You Follow</h2>
|
||||
<a href="/discover/following" className="text-sm text-nova-300 hover:text-white transition">
|
||||
See all →
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
|
||||
<ArtCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react'
|
||||
|
||||
function GroupSpotlightCard({ group }) {
|
||||
if (!group) return null
|
||||
|
||||
const stats = [
|
||||
{ key: 'artworks', label: 'artworks', value: Number(group.counts?.artworks || 0) },
|
||||
{ key: 'members', label: 'members', value: Number(group.counts?.members || 0) },
|
||||
{ key: 'followers', label: 'followers', value: Number(group.counts?.followers || 0) },
|
||||
].filter((item) => item.value > 0)
|
||||
|
||||
return (
|
||||
<article className="group relative flex flex-col overflow-hidden rounded-xl bg-panel p-5 shadow-sm transition hover:ring-1 hover:ring-nova-500">
|
||||
{group.banner_url ? (
|
||||
<>
|
||||
<img
|
||||
src={group.banner_url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-40 transition duration-500 group-hover:scale-105 group-hover:opacity-20"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-panel via-panel/85 to-panel/70" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<a href={group.urls?.public || '/groups'} className="relative block">
|
||||
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl bg-nova-800/80 ring-4 ring-nova-800">
|
||||
{group.avatar_url ? (
|
||||
<img
|
||||
src={group.avatar_url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<i className="fa-solid fa-people-group text-2xl text-white" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-3 text-base font-semibold text-white">{group.name}</h3>
|
||||
</a>
|
||||
|
||||
<p className="relative mt-2 line-clamp-3 text-sm text-soft">
|
||||
{group.headline || group.bio_excerpt || 'Shared publishing identity for collaborative releases and artwork.'}
|
||||
</p>
|
||||
|
||||
<div className="relative mt-3 flex flex-wrap gap-2 text-xs text-soft">
|
||||
{group.is_recruiting ? <span className="rounded-full bg-emerald-400/15 px-2.5 py-1 font-semibold text-emerald-200">Recruiting</span> : null}
|
||||
{group.is_verified ? <span className="rounded-full bg-sky-400/15 px-2.5 py-1 font-semibold text-sky-200">Verified</span> : null}
|
||||
{group.owner?.username || group.owner?.name ? <span>Led by {group.owner?.username || group.owner?.name}</span> : null}
|
||||
</div>
|
||||
|
||||
{stats.length > 0 ? (
|
||||
<div className="relative mt-4 flex flex-wrap gap-3 text-xs text-soft">
|
||||
{stats.map((item) => (
|
||||
<span key={item.key}>
|
||||
{item.value.toLocaleString()} {item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<a
|
||||
href={group.urls?.public || '/groups'}
|
||||
className="relative mt-4 inline-flex w-fit rounded-lg bg-nova-700 px-4 py-1.5 text-xs font-semibold text-white transition hover:bg-nova-600"
|
||||
>
|
||||
View Group
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomeGroups({ groups }) {
|
||||
const spotlightGroups = [
|
||||
groups?.spotlight,
|
||||
...(Array.isArray(groups?.featured) ? groups.featured : []),
|
||||
...(Array.isArray(groups?.recruiting) ? groups.recruiting : []),
|
||||
...(Array.isArray(groups?.rising) ? groups.rising : []),
|
||||
].filter(Boolean)
|
||||
|
||||
const uniqueGroups = spotlightGroups.filter((group, index, items) => (
|
||||
items.findIndex((candidate) => candidate?.id === group?.id) === index
|
||||
)).slice(0, 4)
|
||||
|
||||
if (uniqueGroups.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">Group Spotlight</h2>
|
||||
<a href="/groups" className="text-sm text-nova-300 transition hover:text-white">
|
||||
All groups ->
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{uniqueGroups.map((group) => (
|
||||
<GroupSpotlightCard key={group.id} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_lg.webp'
|
||||
const HERO_SIZES = '100vw'
|
||||
|
||||
export default function HomeHero({ artwork }) {
|
||||
if (!artwork) {
|
||||
return (
|
||||
<section className="relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent" />
|
||||
<div className="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-4xl">
|
||||
Skinbase Nova
|
||||
</h1>
|
||||
<p className="mt-2 max-w-xl text-sm text-soft">
|
||||
Discover. Create. Inspire.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<a href="/discover/trending" className="btn-accent-solid rounded-xl px-5 py-2 text-sm font-semibold">Explore Trending</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const src = artwork.thumb_lg || artwork.thumb || FALLBACK
|
||||
const srcSet = artwork.thumb_srcset || null
|
||||
|
||||
return (
|
||||
<section className="group relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
|
||||
{/* Background image */}
|
||||
<img
|
||||
src={src}
|
||||
srcSet={srcSet || undefined}
|
||||
sizes={srcSet ? HERO_SIZES : undefined}
|
||||
alt={artwork.title}
|
||||
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/55 to-transparent" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-widest text-accent">
|
||||
Featured Artwork
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white drop-shadow sm:text-4xl lg:text-5xl">
|
||||
{artwork.title}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-soft">
|
||||
by <a href={artwork.url} className="text-nova-200 hover:text-white transition">{artwork.author}</a>
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<a
|
||||
href="/discover/trending"
|
||||
className="btn-accent-solid rounded-xl px-5 py-2 text-sm font-semibold"
|
||||
>
|
||||
Explore Trending
|
||||
</a>
|
||||
<a
|
||||
href={artwork.url}
|
||||
className="rounded-xl border border-nova-600 px-5 py-2 text-sm font-semibold text-nova-200 shadow transition hover:border-nova-400 hover:text-white"
|
||||
>
|
||||
View Artwork
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||
|
||||
export default function HomeMedalHighlights({ title, href = null, items, description = '' }) {
|
||||
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-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{title}</h2>
|
||||
{description ? <p className="mt-2 max-w-2xl text-sm text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
{href ? (
|
||||
<a href={href} className="text-sm text-nova-300 transition hover:text-white">
|
||||
See all →
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ArtworkGalleryGrid items={items.slice(0, 8)} className="xl:grid-cols-4" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export default function HomeNews({ 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">News & Updates</h2>
|
||||
<a href="/news" className="text-sm text-nova-300 hover:text-white transition">
|
||||
All news →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-nova-800 overflow-hidden rounded-[24px] border border-white/10 bg-panel">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.url}
|
||||
className="grid gap-3 px-5 py-4 transition hover:bg-nova-800 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
{item.eyebrow ? <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-nova-300">{item.eyebrow}</div> : null}
|
||||
<div className="mt-1 text-sm font-medium text-white line-clamp-2">{item.title}</div>
|
||||
{item.excerpt ? <p className="mt-2 text-sm leading-6 text-soft line-clamp-2">{item.excerpt}</p> : null}
|
||||
</div>
|
||||
{item.date ? <span className="flex-shrink-0 text-xs text-soft">{formatDate(item.date)}</span> : null}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { lazy, Suspense } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
// Below-fold — lazy-loaded to keep initial bundle small
|
||||
const HomeWelcomeRow = lazy(() => import('./HomeWelcomeRow'))
|
||||
const HomeFromFollowing = lazy(() => import('./HomeFromFollowing'))
|
||||
const HomeTrendingForYou = lazy(() => import('./HomeTrendingForYou'))
|
||||
const HomeBecauseYouLike = lazy(() => import('./HomeBecauseYouLike'))
|
||||
const HomeSuggestedCreators = lazy(() => import('./HomeSuggestedCreators'))
|
||||
const HomeTrending = lazy(() => import('./HomeTrending'))
|
||||
const HomeMedalHighlights = lazy(() => import('./HomeMedalHighlights'))
|
||||
const HomeRising = lazy(() => import('./HomeRising'))
|
||||
const HomeFresh = lazy(() => import('./HomeFresh'))
|
||||
const HomeCollections = lazy(() => import('./HomeCollections'))
|
||||
const HomeGroups = lazy(() => import('./HomeGroups'))
|
||||
const HomeCategories = lazy(() => import('./HomeCategories'))
|
||||
const HomeTags = lazy(() => import('./HomeTags'))
|
||||
const HomeCreators = lazy(() => import('./HomeCreators'))
|
||||
const HomeNews = lazy(() => import('./HomeNews'))
|
||||
const HomeCTA = lazy(() => import('./HomeCTA'))
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function SectionFallback({ variant = 'gallery' }) {
|
||||
if (variant === 'welcome') {
|
||||
return (
|
||||
<div className="mt-10 px-4 sm:px-6 lg:px-8" aria-hidden="true">
|
||||
<div className="h-20 animate-pulse rounded-[28px] border border-white/10 bg-nova-800/70" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'tags') {
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8" aria-hidden="true">
|
||||
<div className="mb-5 h-8 w-48 animate-pulse rounded-xl bg-nova-800/70" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-9 animate-pulse rounded-full bg-nova-800/70"
|
||||
style={{ width: `${88 + (index % 4) * 16}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'cta') {
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8" aria-hidden="true">
|
||||
<div className="h-40 animate-pulse rounded-[28px] border border-white/10 bg-nova-800/70" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const cardClassName = variant === 'categories'
|
||||
? 'h-28 rounded-2xl'
|
||||
: variant === 'news'
|
||||
? 'h-24 rounded-2xl'
|
||||
: variant === 'creators'
|
||||
? 'h-64 rounded-2xl'
|
||||
: variant === 'collections'
|
||||
? 'h-80 rounded-[28px]'
|
||||
: variant === 'groups'
|
||||
? 'h-80 rounded-[28px]'
|
||||
: 'aspect-[4/3] rounded-2xl'
|
||||
const gridClassName = variant === 'creators'
|
||||
? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6'
|
||||
: variant === 'news'
|
||||
? 'grid-cols-1'
|
||||
: variant === 'categories'
|
||||
? 'grid-cols-2 lg:grid-cols-4'
|
||||
: variant === 'collections'
|
||||
? 'grid-cols-1 lg:grid-cols-2 xl:grid-cols-3'
|
||||
: variant === 'groups'
|
||||
? 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4'
|
||||
: 'grid-cols-2 xl:grid-cols-4'
|
||||
const cardCount = variant === 'creators' ? 6 : variant === 'news' ? 4 : 4
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8" aria-hidden="true">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="h-8 w-48 animate-pulse rounded-xl bg-nova-800/70" />
|
||||
{(variant === 'collections' || variant === 'groups' || variant === 'news') && (
|
||||
<div className="mt-3 h-4 w-80 max-w-full animate-pulse rounded bg-nova-800/60" />
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden h-5 w-24 animate-pulse rounded bg-nova-800/60 sm:block" />
|
||||
</div>
|
||||
<div className={cx('grid gap-4', gridClassName)}>
|
||||
{Array.from({ length: cardCount }).map((_, index) => (
|
||||
<div key={index} className={cx('animate-pulse bg-nova-800/70', cardClassName)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function GuestHomePage(props) {
|
||||
const { rising, trending, community_favorites, hall_of_fame, fresh, tags, creators, news, collections_featured, collections_trending, collections_editorial, collections_community, groups } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeRising items={rising} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeTrending items={trending} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeMedalHighlights
|
||||
title="Community Favorites"
|
||||
href="/explore/top-rated"
|
||||
description="Recent medal momentum from the community. This rail highlights the strongest 30-day medal signal."
|
||||
items={community_favorites}
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeMedalHighlights
|
||||
title="Hall of Fame"
|
||||
href="/explore/best"
|
||||
description="All-time medal standouts that keep being remembered long after publication."
|
||||
items={hall_of_fame}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* 3. Fresh Uploads */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeFresh items={fresh} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<SectionFallback variant="collections" />}>
|
||||
<HomeCollections
|
||||
featured={collections_featured}
|
||||
trending={collections_trending}
|
||||
editorial={collections_editorial}
|
||||
community={collections_community}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<SectionFallback variant="groups" />}>
|
||||
<HomeGroups groups={groups} />
|
||||
</Suspense>
|
||||
|
||||
{/* 4. Explore Categories */}
|
||||
<Suspense fallback={<SectionFallback variant="categories" />}>
|
||||
<HomeCategories />
|
||||
</Suspense>
|
||||
|
||||
{/* 5. Popular Tags */}
|
||||
<Suspense fallback={<SectionFallback variant="tags" />}>
|
||||
<HomeTags tags={tags} />
|
||||
</Suspense>
|
||||
|
||||
{/* 6. Top Creators */}
|
||||
<Suspense fallback={<SectionFallback variant="creators" />}>
|
||||
<HomeCreators creators={creators} />
|
||||
</Suspense>
|
||||
|
||||
{/* 7. News */}
|
||||
<Suspense fallback={<SectionFallback variant="news" />}>
|
||||
<HomeNews items={news} />
|
||||
</Suspense>
|
||||
|
||||
{/* 8. CTA Upload */}
|
||||
<Suspense fallback={<SectionFallback variant="cta" />}>
|
||||
<HomeCTA isLoggedIn={false} />
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthHomePage(props) {
|
||||
const {
|
||||
user_data,
|
||||
for_you,
|
||||
from_following,
|
||||
rising,
|
||||
trending,
|
||||
community_favorites,
|
||||
hall_of_fame,
|
||||
fresh,
|
||||
collections_featured,
|
||||
collections_recent,
|
||||
collections_trending,
|
||||
collections_editorial,
|
||||
collections_community,
|
||||
groups,
|
||||
by_categories,
|
||||
suggested_creators,
|
||||
tags,
|
||||
creators,
|
||||
news,
|
||||
preferences,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* P0. Welcome/status row — below hero so featured image sits at 0px */}
|
||||
<Suspense fallback={<SectionFallback variant="welcome" />}>
|
||||
<HomeWelcomeRow user_data={user_data} />
|
||||
</Suspense>
|
||||
|
||||
{/* P2. From Creators You Follow */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeFromFollowing items={from_following} />
|
||||
</Suspense>
|
||||
|
||||
{/* P3. Personalized For You preview */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeTrendingForYou items={for_you} preferences={preferences} />
|
||||
</Suspense>
|
||||
|
||||
{/* Rising Now */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeRising items={rising} />
|
||||
</Suspense>
|
||||
|
||||
{/* 2. Global Trending Now */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeTrending items={trending} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeMedalHighlights
|
||||
title="Community Favorites"
|
||||
href="/explore/top-rated"
|
||||
description="Recent medal momentum from the community. This rail highlights the strongest 30-day medal signal."
|
||||
items={community_favorites}
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeMedalHighlights
|
||||
title="Hall of Fame"
|
||||
href="/explore/best"
|
||||
description="All-time medal standouts that keep being remembered long after publication."
|
||||
items={hall_of_fame}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* P4. Because You Like {top tag} — uses by_categories for variety */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeBecauseYouLike items={by_categories} preferences={preferences} />
|
||||
</Suspense>
|
||||
|
||||
{/* 3. Fresh Uploads */}
|
||||
<Suspense fallback={<SectionFallback variant="gallery" />}>
|
||||
<HomeFresh items={fresh} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<SectionFallback variant="collections" />}>
|
||||
<HomeCollections
|
||||
featured={collections_featured}
|
||||
recent={collections_recent}
|
||||
trending={collections_trending}
|
||||
editorial={collections_editorial}
|
||||
community={collections_community}
|
||||
isLoggedIn
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<SectionFallback variant="groups" />}>
|
||||
<HomeGroups groups={groups} />
|
||||
</Suspense>
|
||||
|
||||
{/* 4. Explore Categories */}
|
||||
<Suspense fallback={<SectionFallback variant="categories" />}>
|
||||
<HomeCategories />
|
||||
</Suspense>
|
||||
|
||||
{/* P5. Suggested Creators */}
|
||||
<Suspense fallback={<SectionFallback variant="creators" />}>
|
||||
<HomeSuggestedCreators creators={suggested_creators} />
|
||||
</Suspense>
|
||||
|
||||
{/* 5. Popular Tags */}
|
||||
<Suspense fallback={<SectionFallback variant="tags" />}>
|
||||
<HomeTags tags={tags} />
|
||||
</Suspense>
|
||||
|
||||
{/* 6. Top Creators */}
|
||||
<Suspense fallback={<SectionFallback variant="creators" />}>
|
||||
<HomeCreators creators={creators} />
|
||||
</Suspense>
|
||||
|
||||
{/* 7. News */}
|
||||
<Suspense fallback={<SectionFallback variant="news" />}>
|
||||
<HomeNews items={news} />
|
||||
</Suspense>
|
||||
|
||||
{/* 8. CTA Upload */}
|
||||
<Suspense fallback={<SectionFallback variant="cta" />}>
|
||||
<HomeCTA isLoggedIn />
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function HomePage(props) {
|
||||
return (
|
||||
<div className="pb-24">
|
||||
{props.is_logged_in
|
||||
? <AuthHomePage {...props} />
|
||||
: <GuestHomePage {...props} />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-mount when the Blade view provides #homepage-root
|
||||
const mountEl = document.getElementById('homepage-root')
|
||||
if (mountEl) {
|
||||
let props = {}
|
||||
try {
|
||||
const propsEl = document.getElementById('homepage-props')
|
||||
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
|
||||
} catch {
|
||||
props = {}
|
||||
}
|
||||
|
||||
createRoot(mountEl).render(<HomePage {...props} />)
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function CreatorCard({ creator }) {
|
||||
return (
|
||||
<article className="group flex flex-col items-center rounded-2xl bg-nova-800/60 p-5 ring-1 ring-white/5 hover:ring-white/10 hover:bg-nova-800 transition">
|
||||
<a href={creator.url} className="block">
|
||||
<img
|
||||
src={creator.avatar || AVATAR_FALLBACK}
|
||||
alt={creator.name}
|
||||
className="mx-auto h-14 w-14 rounded-full object-cover ring-2 ring-white/10 group-hover:ring-accent/50 transition"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className="mt-3 w-full text-center">
|
||||
<a href={creator.url} className="block truncate text-sm font-semibold text-white hover:text-accent transition">
|
||||
{creator.name}
|
||||
</a>
|
||||
{creator.username && (
|
||||
<p className="truncate text-xs text-nova-400">@{creator.username}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-nova-500">
|
||||
{creator.followers_count > 0 && (
|
||||
<span title="Followers">{creator.followers_count.toLocaleString()} followers</span>
|
||||
)}
|
||||
{creator.artworks_count > 0 && (
|
||||
<span title="Artworks">{creator.artworks_count.toLocaleString()} artworks</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={creator.url}
|
||||
className="mt-4 w-full rounded-lg bg-nova-700 py-1.5 text-center text-xs font-medium text-white hover:bg-nova-600 transition"
|
||||
>
|
||||
View Profile
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomeSuggestedCreators({ creators }) {
|
||||
if (!Array.isArray(creators) || creators.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">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">💡 Suggested Creators</h2>
|
||||
<p className="mt-0.5 text-xs text-nova-400">Creators you might enjoy following</p>
|
||||
</div>
|
||||
<a href="/creators/top" className="text-sm text-nova-300 hover:text-white transition">
|
||||
Explore all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4">
|
||||
{creators.map((creator) => (
|
||||
<CreatorCard key={creator.id} creator={creator} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function HomeTags({ tags }) {
|
||||
if (!Array.isArray(tags) || tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="mb-5 text-xl font-bold text-white">🏷️ Popular Tags</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<a
|
||||
key={tag.id}
|
||||
href={`/tag/${tag.slug}`}
|
||||
className="rounded-full bg-nova-800 px-4 py-1.5 text-sm font-medium text-nova-200 transition hover:bg-nova-700 hover:text-white"
|
||||
>
|
||||
{tag.name}
|
||||
{tag.count > 0 && (
|
||||
<span className="ml-1.5 text-xs text-soft">{tag.count.toLocaleString()}</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||
|
||||
export default function HomeTrending({ 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">
|
||||
🔥 Trending This Week
|
||||
</h2>
|
||||
<a href="/discover/trending" className="text-sm text-nova-300 hover:text-white transition">
|
||||
See all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ArtworkGalleryGrid
|
||||
items={items.slice(0, 8)}
|
||||
className="xl:grid-cols-4"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||
|
||||
export default function HomeTrendingForYou({ items, preferences }) {
|
||||
if (!Array.isArray(items) || items.length === 0) return null
|
||||
|
||||
const topTag = preferences?.top_tags?.[0]
|
||||
const heading = 'Picked For You'
|
||||
const subheading = topTag
|
||||
? `Fresh recommendations informed by your recent interest in #${topTag}.`
|
||||
: 'A live preview of your personalized discovery feed.'
|
||||
const link = '/discover/for-you'
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.28em] text-sky-200/70">Personalized feed</p>
|
||||
<h2 className="mt-2 text-xl font-bold text-white">{heading}</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-slate-300">{subheading}</p>
|
||||
</div>
|
||||
<a href={link} className="text-sm text-nova-300 transition hover:text-white">
|
||||
Open full feed →
|
||||
</a>
|
||||
</div>
|
||||
<ArtworkGalleryGrid items={items.slice(0, 8)} compact />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
export default function HomeWelcomeRow({ user_data }) {
|
||||
if (!user_data) return null
|
||||
|
||||
const { name, avatar, messages_unread, notifications_unread, url } = user_data
|
||||
|
||||
const firstName = name?.split(' ')[0] || name || 'there'
|
||||
|
||||
return (
|
||||
<section className="border-b border-white/5 bg-nova-900/60 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
|
||||
{/* Left: greeting */}
|
||||
<div className="flex items-center gap-3">
|
||||
<a href={url || '/profile'}>
|
||||
<img
|
||||
src={avatar || AVATAR_FALLBACK}
|
||||
alt={name}
|
||||
className="h-9 w-9 rounded-full object-cover ring-2 ring-white/10 hover:ring-accent/60 transition"
|
||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||
/>
|
||||
</a>
|
||||
<div>
|
||||
<p className="text-sm text-soft">Welcome back,</p>
|
||||
<p className="text-sm font-semibold text-white leading-tight">{firstName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: action badges */}
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{messages_unread > 0 && (
|
||||
<a
|
||||
href="/messages"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-nova-800 px-3 py-1.5 text-xs font-medium text-white ring-1 ring-white/10 hover:bg-nova-700 transition"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{messages_unread} new
|
||||
</a>
|
||||
)}
|
||||
|
||||
{notifications_unread > 0 && (
|
||||
<a
|
||||
href="/notifications"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-nova-800 px-3 py-1.5 text-xs font-medium text-white ring-1 ring-white/10 hover:bg-nova-700 transition"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 text-yellow-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{notifications_unread}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="/upload"
|
||||
className="btn-accent-solid inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Upload
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import LeaderboardTabs from '../../components/leaderboard/LeaderboardTabs'
|
||||
import LeaderboardList from '../../components/leaderboard/LeaderboardList'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
const TYPE_TABS = [
|
||||
{ value: 'creator', label: 'Creators' },
|
||||
{ value: 'artwork', label: 'Artworks' },
|
||||
{ value: 'group', label: 'Groups' },
|
||||
{ value: 'story', label: 'Stories' },
|
||||
]
|
||||
|
||||
const PERIOD_TABS = [
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'all_time', label: 'All-time' },
|
||||
]
|
||||
|
||||
const API_BY_TYPE = {
|
||||
creator: '/api/leaderboard/creators',
|
||||
artwork: '/api/leaderboard/artworks',
|
||||
group: '/api/leaderboard/groups',
|
||||
story: '/api/leaderboard/stories',
|
||||
}
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const { props } = usePage()
|
||||
const { initialType = 'creator', initialPeriod = 'weekly', initialData = { items: [] }, seo = {} } = props
|
||||
|
||||
const [type, setType] = useState(initialType)
|
||||
const [period, setPeriod] = useState(initialPeriod)
|
||||
const [data, setData] = useState(initialData)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (type === initialType && period === initialPeriod) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await window.axios.get(`${API_BY_TYPE[type]}?period=${period}`)
|
||||
if (!cancelled && response.data) {
|
||||
setData(response.data)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('type', type === 'creator' ? 'creators' : `${type}s`)
|
||||
url.searchParams.set('period', period === 'all_time' ? 'all' : period)
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
} catch (_) {}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [type, period, initialType, initialPeriod])
|
||||
|
||||
const items = Array.isArray(data?.items) ? data.items : []
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoHead seo={seo} title={seo?.title || 'Leaderboard — Skinbase'} description={seo?.description || 'Top creators, groups, artworks, and stories on Skinbase.'} />
|
||||
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100">
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<header className="rounded-[2rem] border border-white/10 bg-slate-950/70 px-6 py-8 shadow-[0_35px_120px_rgba(2,6,23,0.75)] backdrop-blur">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-300">Skinbase Competition Board</p>
|
||||
<h1 className="mt-4 max-w-3xl text-4xl font-black tracking-tight text-white sm:text-5xl">
|
||||
Top creators, groups, standout artworks, and stories with momentum.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base">
|
||||
Switch between creators, groups, artworks, and stories, then filter by daily, weekly, monthly, or all-time performance.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<LeaderboardTabs items={TYPE_TABS} active={type} onChange={setType} sticky label="Leaderboard type" />
|
||||
<LeaderboardTabs items={PERIOD_TABS} active={period} onChange={setPeriod} label="Leaderboard period" />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-6 rounded-3xl border border-white/10 bg-white/[0.03] px-6 py-5 text-sm text-slate-400">
|
||||
Refreshing leaderboard...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8">
|
||||
<LeaderboardList items={items} type={type} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { getEcho } from '../../bootstrap'
|
||||
import ConversationList from '../../components/messaging/ConversationList'
|
||||
import ConversationThread from '../../components/messaging/ConversationThread'
|
||||
import NewConversationModal from '../../components/messaging/NewConversationModal'
|
||||
|
||||
function getCsrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
|
||||
}
|
||||
|
||||
async function apiFetch(url, options = {}) {
|
||||
const isFormData = options.body instanceof FormData
|
||||
const socketId = getEcho()?.socketId?.()
|
||||
const headers = {
|
||||
'X-CSRF-TOKEN': getCsrf(),
|
||||
Accept: 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
if (socketId) {
|
||||
headers['X-Socket-ID'] = socketId
|
||||
}
|
||||
|
||||
if (!isFormData) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.message ?? `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function relativeTime(iso) {
|
||||
if (!iso) return 'No activity yet'
|
||||
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000
|
||||
if (diff < 60) return 'Just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
||||
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function buildSearchPreview(item) {
|
||||
const body = (item?.body || '').trim()
|
||||
return body || '(attachment only)'
|
||||
}
|
||||
|
||||
function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
const [conversations, setConversations] = useState([])
|
||||
const [unreadTotal, setUnreadTotal] = useState(null)
|
||||
const [loadingConvs, setLoadingConvs] = useState(true)
|
||||
const [activeId, setActiveId] = useState(initialId ?? null)
|
||||
const [realtimeEnabled, setRealtimeEnabled] = useState(false)
|
||||
const [realtimeStatus, setRealtimeStatus] = useState('offline')
|
||||
const [onlineUserIds, setOnlineUserIds] = useState([])
|
||||
const [typingByConversation, setTypingByConversation] = useState({})
|
||||
const [showNewModal, setShowNewModal] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/messages/conversations')
|
||||
setConversations(data.data ?? [])
|
||||
setUnreadTotal(Number.isFinite(Number(data?.summary?.unread_total)) ? Number(data.summary.unread_total) : null)
|
||||
} catch (e) {
|
||||
console.error('Failed to load conversations', e)
|
||||
} finally {
|
||||
setLoadingConvs(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations()
|
||||
|
||||
apiFetch('/api/messages/settings')
|
||||
.then((data) => setRealtimeEnabled(!!data?.realtime_enabled))
|
||||
.catch(() => setRealtimeEnabled(false))
|
||||
}, [loadConversations])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const match = window.location.pathname.match(/^\/messages\/(\d+)$/)
|
||||
setActiveId(match ? Number(match[1]) : null)
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
|
||||
return () => window.removeEventListener('popstate', handlePopState)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (realtimeEnabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const poll = window.setInterval(loadConversations, 15000)
|
||||
|
||||
return () => window.clearInterval(poll)
|
||||
}, [loadConversations, realtimeEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeEnabled || !userId) {
|
||||
setRealtimeStatus('offline')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const echo = getEcho()
|
||||
if (!echo) {
|
||||
setRealtimeStatus('offline')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const connection = echo.connector?.pusher?.connection
|
||||
let heartbeatId = null
|
||||
const mapConnectionState = (state) => {
|
||||
if (state === 'connected') {
|
||||
return 'connected'
|
||||
}
|
||||
|
||||
if (state === 'connecting' || state === 'initialized' || state === 'connecting_in') {
|
||||
return 'connecting'
|
||||
}
|
||||
|
||||
return 'offline'
|
||||
}
|
||||
|
||||
const syncConnectionState = (payload = null) => {
|
||||
const nextState = typeof payload?.current === 'string'
|
||||
? payload.current
|
||||
: connection?.state
|
||||
|
||||
if (echo.socketId?.()) {
|
||||
setRealtimeStatus('connected')
|
||||
return
|
||||
}
|
||||
|
||||
setRealtimeStatus(mapConnectionState(nextState))
|
||||
}
|
||||
|
||||
const handleVisibilitySync = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
syncConnectionState()
|
||||
}
|
||||
}
|
||||
|
||||
syncConnectionState()
|
||||
connection?.bind?.('state_change', syncConnectionState)
|
||||
connection?.bind?.('connected', syncConnectionState)
|
||||
connection?.bind?.('unavailable', syncConnectionState)
|
||||
connection?.bind?.('disconnected', syncConnectionState)
|
||||
heartbeatId = window.setInterval(syncConnectionState, 1000)
|
||||
window.addEventListener('focus', syncConnectionState)
|
||||
document.addEventListener('visibilitychange', handleVisibilitySync)
|
||||
|
||||
const channel = echo.private(`user.${userId}`)
|
||||
const handleConversationUpdated = (payload) => {
|
||||
const nextConversation = payload?.conversation
|
||||
if (!nextConversation?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
setConversations((prev) => mergeConversationSummary(prev, nextConversation))
|
||||
|
||||
const nextUnreadTotal = Number(payload?.summary?.unread_total)
|
||||
if (Number.isFinite(nextUnreadTotal)) {
|
||||
setUnreadTotal(nextUnreadTotal)
|
||||
}
|
||||
}
|
||||
|
||||
channel.listen('.conversation.updated', handleConversationUpdated)
|
||||
|
||||
return () => {
|
||||
connection?.unbind?.('state_change', syncConnectionState)
|
||||
connection?.unbind?.('connected', syncConnectionState)
|
||||
connection?.unbind?.('unavailable', syncConnectionState)
|
||||
connection?.unbind?.('disconnected', syncConnectionState)
|
||||
if (heartbeatId) {
|
||||
window.clearInterval(heartbeatId)
|
||||
}
|
||||
window.removeEventListener('focus', syncConnectionState)
|
||||
document.removeEventListener('visibilitychange', handleVisibilitySync)
|
||||
channel.stopListening('.conversation.updated', handleConversationUpdated)
|
||||
echo.leaveChannel(`private-user.${userId}`)
|
||||
}
|
||||
}, [realtimeEnabled, userId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeEnabled || !userId) {
|
||||
setOnlineUserIds([])
|
||||
return undefined
|
||||
}
|
||||
|
||||
const echo = getEcho()
|
||||
if (!echo) {
|
||||
setOnlineUserIds([])
|
||||
return undefined
|
||||
}
|
||||
|
||||
const setMembers = (users) => {
|
||||
const nextIds = (users ?? [])
|
||||
.map((user) => Number(user?.id))
|
||||
.filter((id) => Number.isFinite(id) && id !== Number(userId))
|
||||
|
||||
setOnlineUserIds(Array.from(new Set(nextIds)))
|
||||
}
|
||||
|
||||
const channel = echo.join('messaging')
|
||||
channel
|
||||
.here(setMembers)
|
||||
.joining((user) => setOnlineUserIds((prev) => (
|
||||
prev.includes(Number(user?.id)) || Number(user?.id) === Number(userId)
|
||||
? prev
|
||||
: [...prev, Number(user.id)]
|
||||
)))
|
||||
.leaving((user) => setOnlineUserIds((prev) => prev.filter((id) => id !== Number(user?.id))))
|
||||
|
||||
return () => {
|
||||
echo.leave('messaging')
|
||||
}
|
||||
}, [realtimeEnabled, userId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let intervalId = null
|
||||
|
||||
const sendHeartbeat = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return
|
||||
}
|
||||
|
||||
apiFetch('/api/messages/presence/heartbeat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(activeId ? { conversation_id: activeId } : {}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleVisibilitySync = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
sendHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
sendHeartbeat()
|
||||
intervalId = window.setInterval(sendHeartbeat, 25000)
|
||||
window.addEventListener('focus', sendHeartbeat)
|
||||
document.addEventListener('visibilitychange', handleVisibilitySync)
|
||||
|
||||
return () => {
|
||||
if (intervalId) {
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
window.removeEventListener('focus', sendHeartbeat)
|
||||
document.removeEventListener('visibilitychange', handleVisibilitySync)
|
||||
}
|
||||
}, [activeId, userId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeEnabled) {
|
||||
setTypingByConversation({})
|
||||
return undefined
|
||||
}
|
||||
|
||||
const echo = getEcho()
|
||||
if (!echo || conversations.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timers = new Map()
|
||||
const joinedChannels = []
|
||||
|
||||
const removeTypingUser = (conversationId, userIdToRemove) => {
|
||||
const timerKey = `${conversationId}:${userIdToRemove}`
|
||||
const existingTimer = timers.get(timerKey)
|
||||
if (existingTimer) {
|
||||
window.clearTimeout(existingTimer)
|
||||
timers.delete(timerKey)
|
||||
}
|
||||
|
||||
setTypingByConversation((prev) => {
|
||||
const current = prev[conversationId] ?? []
|
||||
const nextUsers = current.filter((user) => String(user.user_id ?? user.id) !== String(userIdToRemove))
|
||||
|
||||
if (nextUsers.length === current.length) {
|
||||
return prev
|
||||
}
|
||||
|
||||
if (nextUsers.length === 0) {
|
||||
const next = { ...prev }
|
||||
delete next[conversationId]
|
||||
return next
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: nextUsers,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
conversations.forEach((conversation) => {
|
||||
if (!conversation?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const conversationId = conversation.id
|
||||
const channel = echo.join(`conversation.${conversationId}`)
|
||||
joinedChannels.push(conversationId)
|
||||
|
||||
channel
|
||||
.listen('.typing.started', (payload) => {
|
||||
const user = payload?.user
|
||||
if (!user?.id || user.id === userId) {
|
||||
return
|
||||
}
|
||||
|
||||
setTypingByConversation((prev) => {
|
||||
const current = prev[conversationId] ?? []
|
||||
const index = current.findIndex((entry) => String(entry.user_id ?? entry.id) === String(user.id))
|
||||
const nextUser = { user_id: user.id, username: user.username }
|
||||
|
||||
if (index === -1) {
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: [...current, nextUser],
|
||||
}
|
||||
}
|
||||
|
||||
const nextUsers = [...current]
|
||||
nextUsers[index] = { ...nextUsers[index], ...nextUser }
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: nextUsers,
|
||||
}
|
||||
})
|
||||
|
||||
const timerKey = `${conversationId}:${user.id}`
|
||||
const existingTimer = timers.get(timerKey)
|
||||
if (existingTimer) {
|
||||
window.clearTimeout(existingTimer)
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => removeTypingUser(conversationId, user.id), Number(payload?.expires_in_ms ?? 3500))
|
||||
timers.set(timerKey, timeout)
|
||||
})
|
||||
.listen('.typing.stopped', (payload) => {
|
||||
const typingUserId = payload?.user?.id
|
||||
if (!typingUserId) {
|
||||
return
|
||||
}
|
||||
|
||||
removeTypingUser(conversationId, typingUserId)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
timers.forEach((timer) => window.clearTimeout(timer))
|
||||
joinedChannels.forEach((conversationId) => {
|
||||
echo.leave(`conversation.${conversationId}`)
|
||||
})
|
||||
}
|
||||
}, [conversations, realtimeEnabled, userId])
|
||||
|
||||
const handleSelectConversation = useCallback((id) => {
|
||||
setActiveId(id)
|
||||
history.replaceState(null, '', `/messages/${id}`)
|
||||
}, [])
|
||||
|
||||
const handleConversationCreated = useCallback((conv) => {
|
||||
setShowNewModal(false)
|
||||
loadConversations()
|
||||
setActiveId(conv.id)
|
||||
history.replaceState(null, '', `/messages/${conv.id}`)
|
||||
}, [loadConversations])
|
||||
|
||||
const handleMarkRead = useCallback((conversationId, nextUnreadTotal = null) => {
|
||||
setConversations((prev) => prev.map((conversation) => (
|
||||
conversation.id === conversationId
|
||||
? { ...conversation, unread_count: 0 }
|
||||
: conversation
|
||||
)))
|
||||
|
||||
if (Number.isFinite(Number(nextUnreadTotal))) {
|
||||
setUnreadTotal(Number(nextUnreadTotal))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConversationPatched = useCallback((patch) => {
|
||||
if (!patch?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
setConversations((prev) => mergeConversationSummary(prev, patch))
|
||||
}, [])
|
||||
|
||||
const handleUnreadTotalPatched = useCallback((nextUnreadTotal) => {
|
||||
if (!Number.isFinite(Number(nextUnreadTotal))) {
|
||||
return
|
||||
}
|
||||
|
||||
setUnreadTotal(Math.max(0, Number(nextUnreadTotal)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const run = async () => {
|
||||
const q = searchQuery.trim()
|
||||
if (q.length < 2) {
|
||||
setSearchResults([])
|
||||
setSearching(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSearching(true)
|
||||
|
||||
try {
|
||||
const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}`)
|
||||
if (!cancelled) {
|
||||
setSearchResults(data.data ?? [])
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSearchResults([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(run, 250)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
const openSearchResult = useCallback((item) => {
|
||||
if (!item?.conversation_id) return
|
||||
setActiveId(item.conversation_id)
|
||||
history.replaceState(null, '', `/messages/${item.conversation_id}?focus=${item.id}`)
|
||||
}, [])
|
||||
|
||||
const activeConversation = conversations.find((conversation) => conversation.id === activeId) ?? null
|
||||
const unreadCount = Number.isFinite(Number(unreadTotal))
|
||||
? Number(unreadTotal)
|
||||
: conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0)
|
||||
const pinnedCount = conversations.reduce((sum, conversation) => {
|
||||
const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId)
|
||||
return sum + (me?.is_pinned ? 1 : 0)
|
||||
}, 0)
|
||||
const archivedCount = conversations.reduce((sum, conversation) => {
|
||||
const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId)
|
||||
return sum + (me?.is_archived ? 1 : 0)
|
||||
}, 0)
|
||||
const activeSearch = searchQuery.trim().length >= 2
|
||||
const activeConversationLabel = activeConversation?.title
|
||||
|| activeConversation?.all_participants?.find((participant) => participant.user_id !== userId)?.user?.username
|
||||
|| 'Conversation'
|
||||
|
||||
return (
|
||||
<div className="messages-page px-4 pb-16 pt-4 md:px-6 lg:px-8 lg:pt-6">
|
||||
<div className="grid gap-5 lg:items-start lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[360px_minmax(0,1fr)] xl:gap-6">
|
||||
<aside className={`flex min-h-[calc(100vh-18rem)] flex-col overflow-hidden rounded-[30px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.96),rgba(7,11,18,0.92))] shadow-[0_20px_60px_rgba(0,0,0,0.28)] lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] ${activeId ? 'hidden lg:flex' : 'flex'}`}>
|
||||
<div className="border-b border-white/[0.06] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/35">Private inbox</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Messages</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-white/50">Keep direct chats, group threads, and file drops in one focused workspace.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewModal(true)}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/12 px-4 text-sm font-medium text-sky-200 transition hover:bg-sky-500/18"
|
||||
title="New message"
|
||||
>
|
||||
<i className="fa-solid fa-pen-to-square text-xs" />
|
||||
Compose
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||
<StatChip label="Unread" value={unreadCount} tone="sky" />
|
||||
<StatChip label="Pinned" value={pinnedCount} tone="amber" />
|
||||
<StatChip label="Archived" value={archivedCount} tone="slate" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-white/45">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 ${connectionBadgeClass(realtimeEnabled, realtimeStatus)}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${connectionDotClass(realtimeEnabled, realtimeStatus)}`} />
|
||||
{connectionBadgeLabel(realtimeEnabled, realtimeStatus)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/55">
|
||||
<i className="fa-solid fa-comments text-[10px]" />
|
||||
{conversations.length} conversations
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-white/[0.06] p-4">
|
||||
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Search all messages</label>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-black/15 px-3 py-2.5 transition focus-within:border-sky-400/30 focus-within:bg-black/25">
|
||||
<div className="flex items-center gap-3">
|
||||
<i className="fa-solid fa-magnifying-glass text-xs text-white/30" />
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Find text, attachments, or senders…"
|
||||
className="w-full bg-transparent text-sm text-white outline-none placeholder:text-white/25"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{searching ? <p className="mt-2 text-[11px] text-white/35">Searching across your inbox…</p> : null}
|
||||
</div>
|
||||
|
||||
{activeSearch ? (
|
||||
<div className="border-b border-white/[0.06] px-3 py-3">
|
||||
<div className="mb-2 flex items-center justify-between px-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Search results</p>
|
||||
<span className="text-[11px] text-white/30">{searchResults.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto pr-1">
|
||||
{searchResults.length === 0 && !searching ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-4 text-sm text-white/40">
|
||||
No results matched “{searchQuery.trim()}”.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchResults.map((item) => (
|
||||
<button
|
||||
key={`search-${item.id}`}
|
||||
onClick={() => openSearchResult(item)}
|
||||
className="block w-full rounded-2xl border border-white/[0.06] bg-white/[0.03] px-4 py-3 text-left transition hover:border-sky-400/20 hover:bg-sky-500/[0.08]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-[11px] text-white/35">
|
||||
<span>@{item.sender?.username ?? 'unknown'}</span>
|
||||
<span>{relativeTime(item.created_at)}</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-6 text-white/78">{buildSearchPreview(item)}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
loading={loadingConvs}
|
||||
activeId={activeId}
|
||||
currentUserId={userId}
|
||||
onlineUserIds={onlineUserIds}
|
||||
typingByConversation={typingByConversation}
|
||||
onSelect={handleSelectConversation}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main className={`flex min-h-[calc(100vh-18rem)] min-w-0 flex-col overflow-hidden rounded-[30px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.96),rgba(7,11,18,0.92))] shadow-[0_20px_60px_rgba(0,0,0,0.28)] lg:h-[calc(100vh-3rem)] lg:max-h-[calc(100vh-3rem)] ${activeId ? 'flex' : 'hidden lg:flex'}`}>
|
||||
{activeId ? (
|
||||
<ConversationThread
|
||||
key={activeId}
|
||||
conversationId={activeId}
|
||||
conversation={activeConversation}
|
||||
realtimeEnabled={realtimeEnabled}
|
||||
realtimeStatus={realtimeStatus}
|
||||
currentUserId={userId}
|
||||
currentUsername={username}
|
||||
onlineUserIds={onlineUserIds}
|
||||
apiFetch={apiFetch}
|
||||
onBack={() => {
|
||||
setActiveId(null)
|
||||
history.replaceState(null, '', '/messages')
|
||||
}}
|
||||
onMarkRead={handleMarkRead}
|
||||
onConversationPatched={handleConversationPatched}
|
||||
onUnreadTotalPatched={handleUnreadTotalPatched}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="max-w-xl text-center">
|
||||
<div className="mx-auto flex h-18 w-18 items-center justify-center rounded-[26px] border border-white/[0.08] bg-white/[0.03] text-white/35 shadow-[0_18px_45px_rgba(0,0,0,0.22)]">
|
||||
<i className="fa-solid fa-comments text-3xl" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-semibold text-white">Choose a conversation</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-white/55">Jump back into a direct message, catch up on a group thread, or start a new conversation with creators and collaborators.</p>
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-2 text-sm text-white/55">
|
||||
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2">Search your full message history</span>
|
||||
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2">Share files inline</span>
|
||||
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2">Track seen status</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{activeId ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] px-4 py-3 text-xs text-white/45 lg:hidden">
|
||||
Viewing <span className="font-medium text-white/75">{activeConversationLabel}</span>. Use the back button to return to your inbox list.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showNewModal ? (
|
||||
<NewConversationModal
|
||||
currentUserId={userId}
|
||||
apiFetch={apiFetch}
|
||||
onCreated={handleConversationCreated}
|
||||
onClose={() => setShowNewModal(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatChip({ label, value, tone = 'sky' }) {
|
||||
const tones = {
|
||||
sky: 'border-sky-400/20 bg-sky-500/10 text-sky-200',
|
||||
amber: 'border-amber-400/20 bg-amber-500/10 text-amber-200',
|
||||
slate: 'border-white/[0.08] bg-white/[0.04] text-white/65',
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid={`messages-stat-${String(label).toLowerCase()}`} className={`rounded-2xl border px-3 py-3 ${tones[tone] || tones.sky}`}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">{label}</p>
|
||||
<p className="mt-2 text-lg font-semibold">{Number(value || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function mergeConversationSummary(existing, incoming) {
|
||||
const next = [...existing]
|
||||
const index = next.findIndex((conversation) => conversation.id === incoming.id)
|
||||
|
||||
if (index >= 0) {
|
||||
next[index] = { ...next[index], ...incoming }
|
||||
} else {
|
||||
next.unshift(incoming)
|
||||
}
|
||||
|
||||
return next.sort((left, right) => {
|
||||
const leftPinned = left.my_participant?.is_pinned ? 1 : 0
|
||||
const rightPinned = right.my_participant?.is_pinned ? 1 : 0
|
||||
if (leftPinned !== rightPinned) {
|
||||
return rightPinned - leftPinned
|
||||
}
|
||||
|
||||
const leftPinnedAt = left.my_participant?.pinned_at ? new Date(left.my_participant.pinned_at).getTime() : 0
|
||||
const rightPinnedAt = right.my_participant?.pinned_at ? new Date(right.my_participant.pinned_at).getTime() : 0
|
||||
if (leftPinnedAt !== rightPinnedAt) {
|
||||
return rightPinnedAt - leftPinnedAt
|
||||
}
|
||||
|
||||
const leftTime = left.last_message_at ? new Date(left.last_message_at).getTime() : 0
|
||||
const rightTime = right.last_message_at ? new Date(right.last_message_at).getTime() : 0
|
||||
return rightTime - leftTime
|
||||
})
|
||||
}
|
||||
|
||||
function connectionBadgeClass(realtimeEnabled, realtimeStatus) {
|
||||
if (!realtimeEnabled) {
|
||||
return 'border-white/[0.08] bg-white/[0.04] text-white/55'
|
||||
}
|
||||
|
||||
if (realtimeStatus === 'connected') {
|
||||
return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
|
||||
}
|
||||
|
||||
if (realtimeStatus === 'connecting') {
|
||||
return 'border-amber-400/20 bg-amber-500/10 text-amber-200'
|
||||
}
|
||||
|
||||
return 'border-rose-400/18 bg-rose-500/10 text-rose-200'
|
||||
}
|
||||
|
||||
function connectionDotClass(realtimeEnabled, realtimeStatus) {
|
||||
if (!realtimeEnabled) {
|
||||
return 'bg-white/30'
|
||||
}
|
||||
|
||||
if (realtimeStatus === 'connected') {
|
||||
return 'bg-emerald-300'
|
||||
}
|
||||
|
||||
if (realtimeStatus === 'connecting') {
|
||||
return 'bg-amber-300'
|
||||
}
|
||||
|
||||
return 'bg-rose-300'
|
||||
}
|
||||
|
||||
function connectionBadgeLabel(realtimeEnabled, realtimeStatus) {
|
||||
if (!realtimeEnabled) {
|
||||
return 'Polling every 15s'
|
||||
}
|
||||
|
||||
if (realtimeStatus === 'connected') {
|
||||
return 'Realtime connected'
|
||||
}
|
||||
|
||||
if (realtimeStatus === 'connecting') {
|
||||
return 'Realtime connecting'
|
||||
}
|
||||
|
||||
return 'Realtime disconnected'
|
||||
}
|
||||
|
||||
const el = document.getElementById('messages-root')
|
||||
|
||||
if (el) {
|
||||
function parse(key, fallback = null) {
|
||||
try {
|
||||
return JSON.parse(el.dataset[key] ?? 'null') ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(el).render(
|
||||
<MessagesPage
|
||||
userId={parse('userId')}
|
||||
username={parse('username', '')}
|
||||
activeConversationId={parse('activeConversationId')}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
export default MessagesPage
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import ArtworkViewer from '../../components/viewer/ArtworkViewer'
|
||||
|
||||
function requestJson(url, { method = 'GET', body } = {}) {
|
||||
return fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Request failed')
|
||||
}
|
||||
return payload
|
||||
})
|
||||
}
|
||||
|
||||
function Badge({ children, tone = 'slate' }) {
|
||||
const tones = {
|
||||
slate: 'border-white/10 bg-white/[0.05] text-slate-200',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
rose: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
}
|
||||
|
||||
return <span className={`inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tones[tone] || tones.slate}`}>{children}</span>
|
||||
}
|
||||
|
||||
export default function ArtworkMaturityQueue() {
|
||||
const { props } = usePage()
|
||||
const [items, setItems] = useState(props.initialItems || [])
|
||||
const [stats, setStats] = useState(props.stats || {})
|
||||
const [status, setStatus] = useState(props.initialFilters?.status || 'suspected')
|
||||
const [aiAction, setAiAction] = useState(props.initialFilters?.ai_action || 'all')
|
||||
const [aiStatus, setAiStatus] = useState(props.initialFilters?.ai_status || 'all')
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [noteById, setNoteById] = useState({})
|
||||
const [error, setError] = useState('')
|
||||
const [previewItem, setPreviewItem] = useState(null)
|
||||
|
||||
const endpoints = props.endpoints || {}
|
||||
const filterOptions = props.filterOptions || {}
|
||||
const reviewActions = props.reviewActions || []
|
||||
|
||||
function queueStatusKey(key) {
|
||||
return key === 'mature' ? 'reviewed' : key
|
||||
}
|
||||
|
||||
async function load(nextStatus, nextAiAction = aiAction, nextAiStatus = aiStatus) {
|
||||
setStatus(nextStatus)
|
||||
setAiAction(nextAiAction)
|
||||
setAiStatus(nextAiStatus)
|
||||
setError('')
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
status: nextStatus,
|
||||
ai_action: nextAiAction,
|
||||
ai_status: nextAiStatus,
|
||||
})
|
||||
const payload = await requestJson(`${endpoints.list}?${query.toString()}`)
|
||||
setItems(payload.data || [])
|
||||
setStats(payload.meta?.stats || {})
|
||||
} catch (loadError) {
|
||||
setError(loadError.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function review(itemId, action) {
|
||||
setBusyId(itemId)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(String(endpoints.reviewPattern || '').replace('__ARTWORK__', String(itemId)), {
|
||||
method: 'POST',
|
||||
body: {
|
||||
action,
|
||||
note: noteById[itemId] || '',
|
||||
},
|
||||
})
|
||||
|
||||
setStats(payload.stats || {})
|
||||
setItems((current) => current.filter((item) => item.id !== itemId).concat(status === 'reviewed' ? [payload.artwork] : []))
|
||||
|
||||
if (status !== 'reviewed') {
|
||||
setItems((current) => current.filter((item) => item.id !== itemId))
|
||||
}
|
||||
} catch (reviewError) {
|
||||
setError(reviewError.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const statusSummary = useMemo(() => [
|
||||
{ key: 'suspected', label: 'Suspected', value: Number(stats.suspected || 0) },
|
||||
{ key: 'audit', label: 'Audit candidates', value: Number(stats.audit || 0) },
|
||||
{ key: 'reviewed', label: 'Reviewed', value: Number(stats.reviewed || 0) },
|
||||
{ key: 'mature', label: 'Marked mature', value: Number(stats.mature || 0) },
|
||||
], [stats])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Artwork Maturity Queue" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-amber-200/80">Moderator surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Artwork maturity review</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review uploads where the uploader declaration and AI suspicion do not match, plus legacy artworks detected by the non-mutating thumbnail audit. Audit candidates stay read-only until a moderator confirms the final maturity state.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{statusSummary.map((entry) => (
|
||||
(() => {
|
||||
const queueKey = queueStatusKey(entry.key)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
onClick={() => load(queueKey)}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${status === queueKey ? 'border-amber-300/30 bg-amber-400/10 text-white' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.07]'}`}
|
||||
>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em]">{entry.label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight">{entry.value.toLocaleString()}</div>
|
||||
</button>
|
||||
)
|
||||
})()
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
||||
<label className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">AI action hint</div>
|
||||
<select
|
||||
value={aiAction}
|
||||
onChange={(event) => load(status, event.target.value, aiStatus)}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
{(filterOptions.aiAction || []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">AI processing status</div>
|
||||
<select
|
||||
value={aiStatus}
|
||||
onChange={(event) => load(status, aiAction, event.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
{(filterOptions.aiStatus || []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">{status === 'audit' ? 'No legacy artworks are currently flagged by the thumbnail audit.' : 'No artworks are waiting in this queue.'}</div>
|
||||
) : items.map((item) => (
|
||||
(() => {
|
||||
const evidence = item.audit || item.maturity || {}
|
||||
|
||||
return (
|
||||
<article key={item.id} className="grid gap-5 rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)] lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => item.preview_image ? setPreviewItem(item) : null}
|
||||
className="group overflow-hidden rounded-[22px] border border-white/10 bg-slate-950/85 text-left transition hover:border-sky-300/30"
|
||||
>
|
||||
{item.thumbnail ? (
|
||||
<div className="relative flex min-h-[360px] items-center justify-center p-3">
|
||||
<img src={item.thumbnail} alt={item.title} className="max-h-[480px] w-full object-contain" />
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 flex items-center justify-between rounded-full border border-white/10 bg-[#07101bdd] px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100 opacity-0 transition group-hover:opacity-100">
|
||||
<span>Preview full image</span>
|
||||
<i className="fa-solid fa-expand text-[10px]" />
|
||||
</div>
|
||||
</div>
|
||||
) : <div className="min-h-[360px]" />}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.audit ? <Badge tone="sky">audit candidate</Badge> : null}
|
||||
<Badge tone={item.maturity?.is_flagged ? 'rose' : 'amber'}>{item.maturity?.status || 'unknown'}</Badge>
|
||||
{item.maturity?.is_mature_effective ? <Badge tone="amber">effective mature</Badge> : <Badge tone="emerald">currently safe</Badge>}
|
||||
{item.maturity?.source ? <Badge tone="sky">source: {String(item.maturity.source).replaceAll('_', ' ')}</Badge> : null}
|
||||
{item.audit?.legacy_unset ? <Badge tone="slate">legacy unset</Badge> : null}
|
||||
{evidence.ai_action_hint ? <Badge tone={evidence.ai_action_hint === 'flag_high' ? 'rose' : evidence.ai_action_hint === 'review' ? 'amber' : 'emerald'}>AI: {String(evidence.ai_action_hint).replaceAll('_', ' ')}</Badge> : null}
|
||||
{evidence.ai_status ? <Badge tone="slate">status: {String(evidence.ai_status).replaceAll('_', ' ')}</Badge> : null}
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{item.title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">{item.publisher} {item.category ? `• ${item.category}` : ''} {item.content_type ? `• ${item.content_type}` : ''}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href={item.url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">
|
||||
<i className="fa-solid fa-arrow-up-right-from-square text-[10px]" />
|
||||
Open artwork
|
||||
</a>
|
||||
{item.admin_url ? (
|
||||
<a href={item.admin_url} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-amber-50 transition hover:bg-amber-400/15">
|
||||
<i className="fa-solid fa-screwdriver-wrench text-[10px]" />
|
||||
Open in cPad
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{Array.isArray(evidence.ai_labels) && evidence.ai_labels.length > 0 ? evidence.ai_labels.map((label) => <Badge key={`${item.id}-${label}`} tone="rose">{label}</Badge>) : <Badge tone="slate">no AI labels</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">AI score</div>
|
||||
<div className="mt-2 text-xl font-semibold text-white">{evidence.ai_score != null ? Number(evidence.ai_score).toFixed(4) : 'n/a'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">AI label</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-slate-200">{evidence.ai_label || 'n/a'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">{item.audit ? 'Audit detected' : 'Published'}</div>
|
||||
<div className="mt-2 text-sm text-slate-200">{item.audit?.detected_at ? new Date(item.audit.detected_at).toLocaleString() : item.published_at ? new Date(item.published_at).toLocaleString() : 'Draft / unavailable'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Confidence</div>
|
||||
<div className="mt-2 text-sm text-slate-200">{evidence.ai_confidence != null ? Number(evidence.ai_confidence).toFixed(4) : 'n/a'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Vision model</div>
|
||||
<div className="mt-2 text-sm text-slate-200">{evidence.ai_model || 'n/a'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Current DB state</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-slate-200">{String(item.maturity?.source || 'legacy').replaceAll('_', ' ')} • {String(item.maturity?.status || 'clear').replaceAll('_', ' ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{evidence.ai_advisory ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm leading-relaxed text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">AI advisory</div>
|
||||
<div className="mt-2">{evidence.ai_advisory}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<label className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Moderator note</label>
|
||||
<textarea
|
||||
value={noteById[item.id] ?? item.review?.reviewer_note ?? ''}
|
||||
onChange={(event) => setNoteById((current) => ({ ...current, [item.id]: event.target.value }))}
|
||||
rows={3}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm text-white outline-none transition focus:border-amber-300/40"
|
||||
placeholder="Explain why you are confirming or changing the maturity state."
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{reviewActions.map((action) => (
|
||||
<button
|
||||
key={`${item.id}-${action.value}`}
|
||||
type="button"
|
||||
disabled={busyId === item.id}
|
||||
onClick={() => review(item.id, action.value)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busyId === item.id ? 'Saving…' : action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})()
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ArtworkViewer
|
||||
isOpen={Boolean(previewItem)}
|
||||
onClose={() => setPreviewItem(null)}
|
||||
artwork={previewItem ? { title: previewItem.title, thumb: previewItem.thumbnail } : null}
|
||||
presentXl={previewItem?.preview_image ? { url: previewItem.preview_image } : null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import ProfileHero from '../../components/profile/ProfileHero'
|
||||
import ProfileGalleryPanel from '../../components/profile/ProfileGalleryPanel'
|
||||
|
||||
export default function ProfileGallery() {
|
||||
const { props } = usePage()
|
||||
const {
|
||||
user,
|
||||
profile,
|
||||
artworks,
|
||||
featuredArtworks,
|
||||
followerCount,
|
||||
viewerIsFollowing,
|
||||
heroBgUrl,
|
||||
leaderboardRank,
|
||||
countryName,
|
||||
isOwner,
|
||||
profileUrl,
|
||||
} = props
|
||||
|
||||
const username = user.username || user.name
|
||||
const displayName = user.name || user.username || 'Creator'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-16">
|
||||
<ProfileHero
|
||||
user={user}
|
||||
profile={profile}
|
||||
isOwner={isOwner}
|
||||
viewerIsFollowing={viewerIsFollowing}
|
||||
followerCount={followerCount}
|
||||
heroBgUrl={heroBgUrl}
|
||||
countryName={countryName}
|
||||
leaderboardRank={leaderboardRank}
|
||||
extraActions={profileUrl ? (
|
||||
<a
|
||||
href={profileUrl}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-user fa-fw" />
|
||||
View Profile
|
||||
</a>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
<div className="border-y border-white/10 bg-white/[0.02]">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-4 px-4 py-5 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-300/80">Public Gallery</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold tracking-tight text-white md:text-3xl">
|
||||
{displayName}'s artworks
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-400">
|
||||
Browse published work with the same infinite-scroll gallery used across the profile experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={profileUrl || '#'}
|
||||
className="inline-flex items-center gap-2 self-start rounded-xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.06] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left fa-fw" />
|
||||
Back to profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full pt-6">
|
||||
<ProfileGalleryPanel
|
||||
artworks={artworks}
|
||||
username={username}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import ProfileHero from '../../components/profile/ProfileHero'
|
||||
import ProfileTabs from '../../components/profile/ProfileTabs'
|
||||
import TabArtworks from '../../components/profile/tabs/TabArtworks'
|
||||
import TabAchievements from '../../components/profile/tabs/TabAchievements'
|
||||
import TabAbout from '../../components/profile/tabs/TabAbout'
|
||||
import TabStats from '../../components/profile/tabs/TabStats'
|
||||
import TabFavourites from '../../components/profile/tabs/TabFavourites'
|
||||
import TabCollections from '../../components/profile/tabs/TabCollections'
|
||||
import TabActivity from '../../components/profile/tabs/TabActivity'
|
||||
import TabPosts from '../../components/profile/tabs/TabPosts'
|
||||
import TabStories from '../../components/profile/tabs/TabStories'
|
||||
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
||||
|
||||
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'collections', 'about', 'stats', 'favourites', 'activity']
|
||||
|
||||
function getInitialTab(initialTab = 'posts') {
|
||||
if (typeof window === 'undefined') {
|
||||
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
|
||||
}
|
||||
|
||||
try {
|
||||
const pathname = window.location.pathname.replace(/\/+$/, '')
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
const lastSegment = segments.at(-1)
|
||||
|
||||
if (VALID_TABS.includes(lastSegment)) {
|
||||
return lastSegment
|
||||
}
|
||||
} catch {
|
||||
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
|
||||
}
|
||||
|
||||
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileShow – Inertia page for /@username
|
||||
*
|
||||
* Props injected by ProfileController::renderUserProfile()
|
||||
*/
|
||||
export default function ProfileShow() {
|
||||
const { props } = usePage()
|
||||
|
||||
const {
|
||||
user,
|
||||
profile,
|
||||
artworks,
|
||||
featuredArtworks,
|
||||
favourites,
|
||||
stats,
|
||||
socialLinks,
|
||||
followerCount,
|
||||
recentFollowers,
|
||||
followContext,
|
||||
followAnalytics,
|
||||
suggestedUsers,
|
||||
viewerIsFollowing,
|
||||
heroBgUrl,
|
||||
profileComments,
|
||||
creatorStories,
|
||||
collections,
|
||||
achievements,
|
||||
leaderboardRank,
|
||||
groupContributionHistory,
|
||||
countryName,
|
||||
isOwner,
|
||||
auth,
|
||||
initialTab,
|
||||
profileUrl,
|
||||
galleryUrl,
|
||||
collectionCreateUrl,
|
||||
collectionReorderUrl,
|
||||
collectionsFeaturedUrl,
|
||||
collectionFeatureLimit,
|
||||
profileTabUrls,
|
||||
} = props
|
||||
|
||||
const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab))
|
||||
|
||||
const handleTabChange = useCallback((tab) => {
|
||||
if (!VALID_TABS.includes(tab)) return
|
||||
setActiveTab(tab)
|
||||
|
||||
try {
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const targetBase = profileTabUrls?.[tab] || `${profileUrl || `${window.location.origin}`}/${tab}`
|
||||
const nextUrl = new URL(targetBase, window.location.origin)
|
||||
const sharedPostId = currentUrl.searchParams.get('post')
|
||||
|
||||
if (sharedPostId) {
|
||||
nextUrl.searchParams.set('post', sharedPostId)
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', nextUrl.toString())
|
||||
} catch (_) {}
|
||||
}, [profileTabUrls, profileUrl])
|
||||
|
||||
useEffect(() => {
|
||||
const onPop = () => setActiveTab(getInitialTab(initialTab))
|
||||
window.addEventListener('popstate', onPop)
|
||||
return () => window.removeEventListener('popstate', onPop)
|
||||
}, [initialTab])
|
||||
|
||||
const isLoggedIn = !!(auth?.user)
|
||||
|
||||
// Normalise artwork list (SSR may send cursor-paginated object)
|
||||
const artworkList = Array.isArray(artworks)
|
||||
? artworks
|
||||
: (artworks?.data ?? [])
|
||||
const artworkNextCursor = artworks?.next_cursor ?? null
|
||||
const favouriteList = Array.isArray(favourites)
|
||||
? favourites
|
||||
: (favourites?.data ?? [])
|
||||
const favouriteNextCursor = favourites?.next_cursor ?? null
|
||||
|
||||
// Normalise social links (may be object keyed by platform, or array)
|
||||
const socialLinksObj = Array.isArray(socialLinks)
|
||||
? socialLinks.reduce((acc, l) => { acc[l.platform] = l; return acc }, {})
|
||||
: (socialLinks ?? {})
|
||||
|
||||
const contentShellClassName = activeTab === 'artworks'
|
||||
? 'w-full px-4 md:px-6'
|
||||
: activeTab === 'posts'
|
||||
? 'mx-auto max-w-7xl px-4 md:px-6'
|
||||
: 'max-w-6xl mx-auto px-4'
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"
|
||||
style={{
|
||||
background: 'radial-gradient(circle at top left, rgba(56,189,248,0.18), transparent 32%), radial-gradient(circle at 82% 10%, rgba(249,115,22,0.16), transparent 28%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #0a1220 100%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 -z-10 opacity-[0.06]"
|
||||
style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }}
|
||||
/>
|
||||
|
||||
<ProfileHero
|
||||
user={user}
|
||||
profile={profile}
|
||||
isOwner={isOwner}
|
||||
viewerIsFollowing={viewerIsFollowing}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
followContext={followContext}
|
||||
heroBgUrl={heroBgUrl}
|
||||
countryName={countryName}
|
||||
leaderboardRank={leaderboardRank}
|
||||
extraActions={galleryUrl ? (
|
||||
<a
|
||||
href={galleryUrl}
|
||||
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-xl border border-white/15 px-3.5 py-2 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-images fa-fw" />
|
||||
View Gallery
|
||||
</a>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<ProfileTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GroupProfileSummary contributions={groupContributionHistory} href={profileTabUrls?.about} />
|
||||
|
||||
<div className={`${contentShellClassName} pt-6`}>
|
||||
{activeTab === 'artworks' && (
|
||||
<TabArtworks
|
||||
artworks={{ data: artworkList, next_cursor: artworkNextCursor }}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={user.username || user.name}
|
||||
galleryUrl={galleryUrl}
|
||||
isActive
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'posts' && (
|
||||
<TabPosts
|
||||
username={user.username || user.name}
|
||||
isOwner={isOwner}
|
||||
authUser={auth?.user ?? null}
|
||||
user={user}
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
suggestedUsers={suggestedUsers}
|
||||
socialLinks={socialLinksObj}
|
||||
countryName={countryName}
|
||||
profileUrl={profileUrl}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'stories' && (
|
||||
<TabStories
|
||||
stories={creatorStories}
|
||||
username={user.username || user.name}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'achievements' && (
|
||||
<TabAchievements achievements={achievements} />
|
||||
)}
|
||||
{activeTab === 'collections' && (
|
||||
<TabCollections
|
||||
collections={collections}
|
||||
isOwner={isOwner}
|
||||
createUrl={collectionCreateUrl}
|
||||
reorderUrl={collectionReorderUrl}
|
||||
featuredUrl={collectionsFeaturedUrl}
|
||||
featureLimit={collectionFeatureLimit}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'about' && (
|
||||
<TabAbout
|
||||
user={user}
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
achievements={achievements}
|
||||
artworks={artworkList}
|
||||
creatorStories={creatorStories}
|
||||
profileComments={profileComments}
|
||||
socialLinks={socialLinksObj}
|
||||
countryName={countryName}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
leaderboardRank={leaderboardRank}
|
||||
groupContributionHistory={groupContributionHistory}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'stats' && (
|
||||
<TabStats
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
followAnalytics={followAnalytics}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'favourites' && (
|
||||
<TabFavourites
|
||||
favourites={{ data: favouriteList, next_cursor: favouriteNextCursor }}
|
||||
isOwner={isOwner}
|
||||
username={user.username || user.name}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'activity' && (
|
||||
<TabActivity
|
||||
profileComments={profileComments}
|
||||
user={user}
|
||||
isOwner={isOwner}
|
||||
isLoggedIn={isLoggedIn}
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
creatorStories={creatorStories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
|
||||
async function requestJson(url, method = 'POST') {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Request failed')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unknown'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Unknown'
|
||||
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export default function StudioActivity() {
|
||||
const { props } = usePage()
|
||||
const listing = props.listing || {}
|
||||
const filters = listing.filters || {}
|
||||
const items = listing.items || []
|
||||
const meta = listing.meta || {}
|
||||
const summary = listing.summary || {}
|
||||
const typeOptions = listing.type_options || []
|
||||
const moduleOptions = listing.module_options || []
|
||||
const endpoints = props.endpoints || {}
|
||||
const [marking, setMarking] = useState(false)
|
||||
|
||||
const updateFilters = (patch) => {
|
||||
const next = { ...filters, ...patch }
|
||||
if (patch.page == null) next.page = 1
|
||||
|
||||
trackStudioEvent('studio_activity_opened', {
|
||||
surface: studioSurface(),
|
||||
module: 'activity',
|
||||
meta: patch,
|
||||
})
|
||||
|
||||
router.get(window.location.pathname, next, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
const markAllRead = async () => {
|
||||
setMarking(true)
|
||||
try {
|
||||
await requestJson(endpoints.markAllRead)
|
||||
router.reload({ only: ['listing'] })
|
||||
} catch (error) {
|
||||
window.alert(error?.message || 'Unable to mark activity as read.')
|
||||
} finally {
|
||||
setMarking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout
|
||||
title={props.title}
|
||||
subtitle={props.description}
|
||||
actions={
|
||||
<button type="button" onClick={markAllRead} disabled={marking} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-100 disabled:opacity-50">
|
||||
<i className="fa-solid fa-check-double" />
|
||||
{marking ? 'Updating...' : 'Mark all read'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">New since last read</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{Number(summary.new_items || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Unread notifications</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{Number(summary.unread_notifications || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Last inbox reset</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">{summary.last_read_at ? formatDate(summary.last_read_at) : 'Not yet'}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search activity</span>
|
||||
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Message, actor, or module" />
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Type</span>
|
||||
<select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
|
||||
{typeOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Content type</span>
|
||||
<select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
|
||||
{moduleOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-end">
|
||||
<button type="button" onClick={() => updateFilters({ q: '', type: 'all', module: 'all' })} className="w-full rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
{items.length > 0 ? items.map((item) => (
|
||||
<article key={item.id} className={`rounded-[28px] border p-5 ${item.is_new ? 'border-sky-300/25 bg-sky-300/10' : 'border-white/10 bg-white/[0.03]'}`}>
|
||||
<div className="flex gap-4">
|
||||
{item.actor?.avatar_url ? (
|
||||
<img src={item.actor.avatar_url} alt={item.actor.name || 'Activity actor'} className="h-12 w-12 rounded-2xl object-cover" />
|
||||
) : (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-black/20 text-slate-400">
|
||||
<i className="fa-solid fa-bell" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<span>{item.module_label}</span>
|
||||
<span>{formatDate(item.created_at)}</span>
|
||||
{item.is_new && <span className="rounded-full bg-sky-300/20 px-2 py-1 text-sky-100">New</span>}
|
||||
</div>
|
||||
<h2 className="mt-2 text-lg font-semibold text-white">{item.title}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{item.body}</p>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-slate-400">
|
||||
{item.actor?.name && <span>{item.actor.name}</span>}
|
||||
<a href={item.url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-slate-200">Open</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)) : <div className="rounded-[28px] border border-dashed border-white/15 px-6 py-16 text-center text-slate-400">No activity matches this filter.</div>}
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
|
||||
<button type="button" disabled={(meta.current_page || 1) <= 1} onClick={() => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) })} className="rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">Previous</button>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Page {meta.current_page || 1} of {meta.last_page || 1}</span>
|
||||
<button type="button" disabled={(meta.current_page || 1) >= (meta.last_page || 1)} onClick={() => updateFilters({ page: (meta.current_page || 1) + 1 })} className="rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
|
||||
const kpiItems = [
|
||||
{ key: 'views', label: 'Views', icon: 'fa-eye', color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
|
||||
{ key: 'appreciation', label: 'Reactions', icon: 'fa-heart', color: 'text-pink-400', bg: 'bg-pink-500/10' },
|
||||
{ key: 'shares', label: 'Shares', icon: 'fa-share-nodes', color: 'text-amber-400', bg: 'bg-amber-500/10' },
|
||||
{ key: 'saves', label: 'Saves', icon: 'fa-bookmark', color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ key: 'comments', label: 'Comments', icon: 'fa-comment', color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ key: 'followers', label: 'Followers', icon: 'fa-user-group', color: 'text-cyan-300', bg: 'bg-cyan-400/10' },
|
||||
]
|
||||
|
||||
const rangeOptions = [7, 14, 30, 60, 90]
|
||||
|
||||
function formatShortDate(value) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function TrendChart({ title, subtitle, points, colorClass, fillClass, icon }) {
|
||||
const values = (points || []).map((point) => Number(point.value || 0))
|
||||
const maxValue = Math.max(...values, 1)
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">{subtitle}</p>
|
||||
</div>
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl ${fillClass} ${colorClass}`}>
|
||||
<i className={`fa-solid ${icon}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex h-52 items-end gap-2">
|
||||
{(points || []).map((point) => {
|
||||
const height = `${Math.max(8, Math.round((Number(point.value || 0) / maxValue) * 100))}%`
|
||||
|
||||
return (
|
||||
<div key={point.date} className="flex min-w-0 flex-1 flex-col items-center justify-end gap-2">
|
||||
<div className="text-[10px] font-medium text-slate-500">{Number(point.value || 0).toLocaleString()}</div>
|
||||
<div className="flex h-full w-full items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
|
||||
<div className={`w-full rounded-t-[16px] ${fillClass}`} style={{ height }} />
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-500">{formatShortDate(point.date)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioAnalytics() {
|
||||
const { props } = usePage()
|
||||
const {
|
||||
totals,
|
||||
topContent,
|
||||
moduleBreakdown,
|
||||
recentComments,
|
||||
publishingTimeline,
|
||||
viewsTrend,
|
||||
engagementTrend,
|
||||
comparison,
|
||||
insightBlocks,
|
||||
rangeDays,
|
||||
} = props
|
||||
|
||||
const updateRange = (days) => {
|
||||
trackStudioEvent('studio_filter_used', {
|
||||
surface: studioSurface(),
|
||||
module: 'analytics',
|
||||
meta: {
|
||||
range_days: days,
|
||||
},
|
||||
})
|
||||
|
||||
router.get(window.location.pathname, { range_days: days }, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title="Analytics" subtitle="Cross-module insights for the whole creator workspace, not just artwork uploads.">
|
||||
<section className="mb-6 rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(244,114,182,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Analytics window</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Performance over the last {rangeDays || 30} days</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">This view compares module output, shows views and engagement trends over time, and keeps publishing rhythm in the same window.</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex rounded-full border border-white/10 bg-black/20 p-1">
|
||||
{rangeOptions.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
onClick={() => updateRange(days)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition ${Number(rangeDays || 30) === days ? 'bg-white text-slate-950' : 'text-slate-300 hover:text-white'}`}
|
||||
>
|
||||
{days}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-6">
|
||||
{kpiItems.map((item) => (
|
||||
<div key={item.key} className="rounded-[26px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-10 h-10 rounded-xl ${item.bg} flex items-center justify-center ${item.color}`}>
|
||||
<i className={`fa-solid ${item.icon}`} />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider leading-tight">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white tabular-nums">
|
||||
{(totals?.[item.key] ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<TrendChart
|
||||
title="Views over time"
|
||||
subtitle="Cross-module reach across the current analytics window."
|
||||
points={viewsTrend}
|
||||
colorClass="text-emerald-300"
|
||||
fillClass="bg-emerald-400/60"
|
||||
icon="fa-eye"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Engagement over time"
|
||||
subtitle="Combined engagement score so you can see momentum shifts, not just raw traffic."
|
||||
points={engagementTrend}
|
||||
colorClass="text-pink-300"
|
||||
fillClass="bg-pink-400/60"
|
||||
icon="fa-bolt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Module breakdown</h2>
|
||||
<div className="mt-5 space-y-3">
|
||||
{(moduleBreakdown || []).map((item) => (
|
||||
<div key={item.key} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 text-slate-200">
|
||||
<i className={item.icon} />
|
||||
<div>
|
||||
<div className="font-semibold text-white">{item.label}</div>
|
||||
<div className="text-xs text-slate-400">{Number(item.count || 0).toLocaleString()} items</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href={item.index_url} className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Open</a>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-slate-400 md:grid-cols-4">
|
||||
<div><div>Views</div><div className="mt-1 font-semibold text-white">{Number(item.views || 0).toLocaleString()}</div></div>
|
||||
<div><div>Reactions</div><div className="mt-1 font-semibold text-white">{Number(item.appreciation || 0).toLocaleString()}</div></div>
|
||||
<div><div>Comments</div><div className="mt-1 font-semibold text-white">{Number(item.comments || 0).toLocaleString()}</div></div>
|
||||
<div><div>Shares</div><div className="mt-1 font-semibold text-white">{Number(item.shares || 0).toLocaleString()}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Publishing rhythm</h2>
|
||||
<div className="mt-5 space-y-3">
|
||||
{(publishingTimeline || []).map((point) => (
|
||||
<div key={point.date}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>{new Date(point.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>
|
||||
<span>{point.count}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/5">
|
||||
<div className="h-full rounded-full bg-sky-300/60" style={{ width: `${Math.min(100, point.count * 18)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold text-white">Module comparison</h2>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Last {rangeDays || 30} days</span>
|
||||
</div>
|
||||
<div className="mt-5 space-y-4">
|
||||
{(comparison || []).map((item) => {
|
||||
const viewMax = Math.max(...(comparison || []).map((entry) => Number(entry.views || 0)), 1)
|
||||
const engagementMax = Math.max(...(comparison || []).map((entry) => Number(entry.engagement || 0)), 1)
|
||||
|
||||
return (
|
||||
<div key={item.key} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 text-slate-200">
|
||||
<i className={item.icon} />
|
||||
<div>
|
||||
<div className="font-semibold text-white">{item.label}</div>
|
||||
<div className="text-xs text-slate-400">{Number(item.published_count || 0).toLocaleString()} published</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href={moduleBreakdown?.find((entry) => entry.key === item.key)?.index_url} className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Open</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Views</span>
|
||||
<span>{Number(item.views || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/5">
|
||||
<div className="h-full rounded-full bg-emerald-400/60" style={{ width: `${Math.max(4, Math.round((Number(item.views || 0) / viewMax) * 100))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Engagement</span>
|
||||
<span>{Number(item.engagement || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/5">
|
||||
<div className="h-full rounded-full bg-pink-400/60" style={{ width: `${Math.max(4, Math.round((Number(item.engagement || 0) / engagementMax) * 100))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Readable insights</h2>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-400">
|
||||
{(insightBlocks || []).map((item) => (
|
||||
<a key={item.key} href={item.href} className="block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/[0.04] text-sky-100">
|
||||
<i className={item.icon} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-2 leading-6 text-slate-400">{item.body}</p>
|
||||
<span className="mt-3 inline-flex items-center gap-2 text-sm font-medium text-sky-100">{item.cta}<i className="fa-solid fa-arrow-right" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Top content</h2>
|
||||
<div className="mt-5 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5 text-left text-[11px] uppercase tracking-[0.18em] text-slate-500">
|
||||
<th className="pb-3 pr-4">Module</th>
|
||||
<th className="pb-3 pr-4">Title</th>
|
||||
<th className="pb-3 pr-4 text-right">Views</th>
|
||||
<th className="pb-3 pr-4 text-right">Reactions</th>
|
||||
<th className="pb-3 pr-4 text-right">Comments</th>
|
||||
<th className="pb-3 text-right">Open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{(topContent || []).map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="py-3 pr-4 text-slate-300">{item.module_label}</td>
|
||||
<td className="py-3 pr-4 text-white">{item.title}</td>
|
||||
<td className="py-3 pr-4 text-right text-slate-300">{Number(item.metrics?.views || 0).toLocaleString()}</td>
|
||||
<td className="py-3 pr-4 text-right text-slate-300">{Number(item.metrics?.appreciation || 0).toLocaleString()}</td>
|
||||
<td className="py-3 pr-4 text-right text-slate-300">{Number(item.metrics?.comments || 0).toLocaleString()}</td>
|
||||
<td className="py-3 text-right"><a href={item.analytics_url || item.view_url} className="text-sky-100">Open</a></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Recent comments</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(recentComments || []).map((comment) => (
|
||||
<article key={comment.id} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{comment.module_label}</p>
|
||||
<p className="mt-2 text-sm text-white">{comment.author_name} on {comment.item_title}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{comment.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import StudioContentBrowser from '../../Components/Studio/StudioContentBrowser'
|
||||
|
||||
export default function StudioArchived() {
|
||||
const { props } = usePage()
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<StudioContentBrowser
|
||||
listing={props.listing}
|
||||
quickCreate={props.quickCreate}
|
||||
hideBucketFilter
|
||||
emptyTitle="No archived content"
|
||||
emptyBody="Nothing is currently hidden or archived across your creator modules."
|
||||
/>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import React from 'react'
|
||||
import { usePage, Link } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
const kpiItems = [
|
||||
{ key: 'views', label: 'Views', icon: 'fa-eye', color: 'text-emerald-400' },
|
||||
{ key: 'favourites', label: 'Favourites', icon: 'fa-heart', color: 'text-pink-400' },
|
||||
{ key: 'shares', label: 'Shares', icon: 'fa-share-nodes', color: 'text-amber-400' },
|
||||
{ key: 'comments', label: 'Comments', icon: 'fa-comment', color: 'text-blue-400' },
|
||||
{ key: 'downloads', label: 'Downloads', icon: 'fa-download', color: 'text-purple-400' },
|
||||
]
|
||||
|
||||
const metricCards = [
|
||||
{ key: 'ranking_score', label: 'Ranking Score', icon: 'fa-trophy', color: 'text-yellow-400' },
|
||||
{ key: 'heat_score', label: 'Heat Score', icon: 'fa-fire', color: 'text-orange-400' },
|
||||
{ key: 'engagement_velocity', label: 'Engagement Velocity', icon: 'fa-bolt', color: 'text-cyan-400' },
|
||||
]
|
||||
|
||||
export default function StudioArtworkAnalytics() {
|
||||
const { props } = usePage()
|
||||
const { artwork, analytics } = props
|
||||
|
||||
return (
|
||||
<StudioLayout title={`Analytics: ${artwork?.title || 'Artwork'}`}>
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/studio/artworks"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white mb-6 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
Back to Artworks
|
||||
</Link>
|
||||
|
||||
{/* Artwork header */}
|
||||
<div className="flex items-center gap-4 mb-8 bg-nova-900/60 border border-white/10 rounded-2xl p-4">
|
||||
{artwork?.thumb_url && (
|
||||
<img
|
||||
src={artwork.thumb_url}
|
||||
alt={artwork.title}
|
||||
className="w-20 h-20 rounded-xl object-cover bg-nova-800"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">{artwork?.title}</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">/{artwork?.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||
{kpiItems.map((item) => (
|
||||
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<i className={`fa-solid ${item.icon} ${item.color}`} />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white tabular-nums">
|
||||
{(analytics?.[item.key] ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Performance metrics */}
|
||||
<h3 className="text-base font-bold text-white mb-4">Performance Metrics</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||
{metricCards.map((item) => (
|
||||
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className={`w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center ${item.color}`}>
|
||||
<i className={`fa-solid ${item.icon} text-lg`} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-300">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white tabular-nums">
|
||||
{(analytics?.[item.key] ?? 0).toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Placeholder sections for future features */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-3">
|
||||
<i className="fa-solid fa-chart-line mr-2 text-slate-500" />
|
||||
Traffic Sources
|
||||
</h4>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<i className="fa-solid fa-chart-pie text-3xl text-slate-700 mb-3" />
|
||||
<p className="text-xs text-slate-500">Coming soon</p>
|
||||
<p className="text-[10px] text-slate-600 mt-1">Traffic source tracking is on the roadmap</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-3">
|
||||
<i className="fa-solid fa-share-from-square mr-2 text-slate-500" />
|
||||
Shares by Platform
|
||||
</h4>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<i className="fa-solid fa-share-nodes text-3xl text-slate-700 mb-3" />
|
||||
<p className="text-xs text-slate-500">Coming soon</p>
|
||||
<p className="text-[10px] text-slate-600 mt-1">Platform-level share tracking coming in v2</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-6 lg:col-span-2">
|
||||
<h4 className="text-sm font-semibold text-white mb-3">
|
||||
<i className="fa-solid fa-trophy mr-2 text-slate-500" />
|
||||
Ranking History
|
||||
</h4>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<i className="fa-solid fa-chart-area text-3xl text-slate-700 mb-3" />
|
||||
<p className="text-xs text-slate-500">Coming soon</p>
|
||||
<p className="text-[10px] text-slate-600 mt-1">Historical ranking data will be tracked in a future update</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+648
@@ -0,0 +1,648 @@
|
||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import { usePage, Link } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import MarkdownEditor from '../../components/ui/MarkdownEditor'
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '—'
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1048576).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function getContentTypeVisualKey(slug) {
|
||||
const map = { skins: 'skins', wallpapers: 'wallpapers', photography: 'photography', other: 'other', members: 'members' }
|
||||
return map[slug] || 'other'
|
||||
}
|
||||
|
||||
function buildCategoryTree(contentTypes) {
|
||||
return (contentTypes || []).map((ct) => ({
|
||||
...ct,
|
||||
rootCategories: (ct.categories || ct.root_categories || []).map((rc) => ({
|
||||
...rc,
|
||||
children: rc.children || [],
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export default function StudioArtworkEdit() {
|
||||
const { props } = usePage()
|
||||
const { artwork, contentTypes: rawContentTypes } = props
|
||||
|
||||
const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes])
|
||||
|
||||
// --- State ---
|
||||
const [contentTypeId, setContentTypeId] = useState(artwork?.content_type_id || null)
|
||||
const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null)
|
||||
const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null)
|
||||
const [title, setTitle] = useState(artwork?.title || '')
|
||||
const [description, setDescription] = useState(artwork?.description || '')
|
||||
const [tags, setTags] = useState(() => (artwork?.tags || []).map((t) => ({ id: t.id, name: t.name, slug: t.slug || t.name })))
|
||||
const [isPublic, setIsPublic] = useState(artwork?.is_public ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [errors, setErrors] = useState({})
|
||||
|
||||
// Tag picker state
|
||||
const [tagQuery, setTagQuery] = useState('')
|
||||
const [tagResults, setTagResults] = useState([])
|
||||
const [tagLoading, setTagLoading] = useState(false)
|
||||
const tagInputRef = useRef(null)
|
||||
const tagSearchTimer = useRef(null)
|
||||
|
||||
// File replace state
|
||||
const fileInputRef = useRef(null)
|
||||
const [replacing, setReplacing] = useState(false)
|
||||
const [thumbUrl, setThumbUrl] = useState(artwork?.thumb_url_lg || artwork?.thumb_url || null)
|
||||
const [fileMeta, setFileMeta] = useState({
|
||||
name: artwork?.file_name || '—',
|
||||
size: artwork?.file_size || 0,
|
||||
width: artwork?.width || 0,
|
||||
height: artwork?.height || 0,
|
||||
})
|
||||
const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1)
|
||||
const [requiresReapproval, setRequiresReapproval] = useState(artwork?.requires_reapproval ?? false)
|
||||
const [changeNote, setChangeNote] = useState('')
|
||||
const [showChangeNote, setShowChangeNote] = useState(false)
|
||||
|
||||
// Version history modal state
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [historyData, setHistoryData] = useState(null)
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [restoring, setRestoring] = useState(null) // version id being restored
|
||||
|
||||
// --- Tag search ---
|
||||
const searchTags = useCallback(async (q) => {
|
||||
setTagLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
const res = await fetch(`/api/studio/tags/search?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const data = await res.json()
|
||||
setTagResults(data || [])
|
||||
} catch {
|
||||
setTagResults([])
|
||||
} finally {
|
||||
setTagLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(tagSearchTimer.current)
|
||||
tagSearchTimer.current = setTimeout(() => searchTags(tagQuery), 250)
|
||||
return () => clearTimeout(tagSearchTimer.current)
|
||||
}, [tagQuery, searchTags])
|
||||
|
||||
const toggleTag = (tag) => {
|
||||
setTags((prev) => {
|
||||
const exists = prev.find((t) => t.id === tag.id)
|
||||
return exists ? prev.filter((t) => t.id !== tag.id) : [...prev, { id: tag.id, name: tag.name, slug: tag.slug }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeTag = (id) => {
|
||||
setTags((prev) => prev.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
// --- Derived data ---
|
||||
const selectedCT = contentTypes.find((ct) => ct.id === contentTypeId) || null
|
||||
const rootCategories = selectedCT?.rootCategories || []
|
||||
const selectedRoot = rootCategories.find((c) => c.id === categoryId) || null
|
||||
const subCategories = selectedRoot?.children || []
|
||||
|
||||
// --- Handlers ---
|
||||
const handleContentTypeChange = (id) => {
|
||||
setContentTypeId(id)
|
||||
setCategoryId(null)
|
||||
setSubCategoryId(null)
|
||||
}
|
||||
|
||||
const handleCategoryChange = (id) => {
|
||||
setCategoryId(id)
|
||||
setSubCategoryId(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
setErrors({})
|
||||
try {
|
||||
const payload = {
|
||||
title,
|
||||
description,
|
||||
is_public: isPublic,
|
||||
category_id: subCategoryId || categoryId || null,
|
||||
tags: tags.map((t) => t.slug || t.name),
|
||||
}
|
||||
const res = await fetch(`/api/studio/artworks/${artwork.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (res.ok) {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
if (data.errors) setErrors(data.errors)
|
||||
console.error('Save failed:', data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileReplace = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setReplacing(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (changeNote.trim()) fd.append('change_note', changeNote.trim())
|
||||
const res = await fetch(`/api/studio/artworks/${artwork.id}/replace-file`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: fd,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.thumb_url) {
|
||||
setThumbUrl(data.thumb_url)
|
||||
setFileMeta({ name: file.name, size: file.size, width: data.width || 0, height: data.height || 0 })
|
||||
if (data.version_number) setVersionCount(data.version_number)
|
||||
if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval)
|
||||
setChangeNote('')
|
||||
setShowChangeNote(false)
|
||||
} else {
|
||||
alert(data.error || 'File replacement failed.')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('File replace failed:', err)
|
||||
} finally {
|
||||
setReplacing(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const loadVersionHistory = async () => {
|
||||
setHistoryLoading(true)
|
||||
setShowHistory(true)
|
||||
try {
|
||||
const res = await fetch(`/api/studio/artworks/${artwork.id}/versions`, {
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const data = await res.json()
|
||||
setHistoryData(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load version history:', err)
|
||||
} finally {
|
||||
setHistoryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestoreVersion = async (versionId) => {
|
||||
if (!window.confirm('Restore this version? It will be cloned as the new current version.')) return
|
||||
setRestoring(versionId)
|
||||
try {
|
||||
const res = await fetch(`/api/studio/artworks/${artwork.id}/restore/${versionId}`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.success) {
|
||||
alert(data.message)
|
||||
setVersionCount((n) => n + 1)
|
||||
setShowHistory(false)
|
||||
} else {
|
||||
alert(data.error || 'Restore failed.')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Restore failed:', err)
|
||||
} finally {
|
||||
setRestoring(null)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<StudioLayout title="Edit Artwork">
|
||||
<Link
|
||||
href="/studio/artworks"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white mb-6 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
Back to Artworks
|
||||
</Link>
|
||||
|
||||
<div className="max-w-3xl space-y-8">
|
||||
{/* ── Uploaded Asset ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Uploaded Asset</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{requiresReapproval && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">
|
||||
<i className="fa-solid fa-triangle-exclamation" /> Under Review
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-accent/20 text-accent border border-accent/30">
|
||||
v{versionCount}
|
||||
</span>
|
||||
{versionCount > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadVersionHistory}
|
||||
className="text-xs text-slate-400 hover:text-white transition-colors flex items-center gap-1"
|
||||
>
|
||||
<i className="fa-solid fa-clock-rotate-left text-[10px]" /> History
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-5">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={title} className="w-32 h-32 rounded-xl object-cover bg-nova-800 flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-xl bg-nova-800 flex items-center justify-center text-slate-600 flex-shrink-0">
|
||||
<i className="fa-solid fa-image text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm text-white font-medium truncate">{fileMeta.name}</p>
|
||||
<p className="text-xs text-slate-400">{formatBytes(fileMeta.size)}</p>
|
||||
{fileMeta.width > 0 && (
|
||||
<p className="text-xs text-slate-400">{fileMeta.width} × {fileMeta.height} px</p>
|
||||
)}
|
||||
{showChangeNote && (
|
||||
<textarea
|
||||
value={changeNote}
|
||||
onChange={(e) => setChangeNote(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
placeholder="What changed? (optional)"
|
||||
className="mt-2 w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50 resize-none"
|
||||
/>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileReplace} />
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowChangeNote((s) => !s)
|
||||
if (!showChangeNote) fileInputRef.current?.click()
|
||||
}}
|
||||
disabled={replacing}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} />
|
||||
{replacing ? 'Replacing…' : 'Replace file'}
|
||||
</button>
|
||||
{showChangeNote && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={replacing}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-accent/20 hover:bg-accent/30 text-accent px-2.5 py-1 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-upload" /> Choose file
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Content Type ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Content Type</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||
{contentTypes.map((ct) => {
|
||||
const active = ct.id === contentTypeId
|
||||
const vk = getContentTypeVisualKey(ct.slug)
|
||||
return (
|
||||
<button
|
||||
key={ct.id}
|
||||
type="button"
|
||||
onClick={() => handleContentTypeChange(ct.id)}
|
||||
className={`relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all cursor-pointer
|
||||
${active ? 'border-emerald-400/70 bg-emerald-400/15 shadow-lg shadow-emerald-400/10' : 'border-white/10 bg-white/5 hover:border-white/20'}`}
|
||||
>
|
||||
<img src={`/gfx/mascot_${vk}.webp`} alt={ct.name} className="w-14 h-14 object-contain" />
|
||||
<span className={`text-xs font-semibold ${active ? 'text-emerald-300' : 'text-slate-300'}`}>{ct.name}</span>
|
||||
{active && (
|
||||
<span className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center">
|
||||
<i className="fa-solid fa-check text-[10px] text-white" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Category ── */}
|
||||
{rootCategories.length > 0 && (
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Category</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rootCategories.map((cat) => {
|
||||
const active = cat.id === categoryId
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => handleCategoryChange(cat.id)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
|
||||
${active ? 'border-purple-600/90 bg-purple-700/35 text-purple-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subcategory */}
|
||||
{subCategories.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Subcategory</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subCategories.map((sub) => {
|
||||
const active = sub.id === subCategoryId
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
onClick={() => setSubCategoryId(active ? null : sub.id)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
|
||||
${active ? 'border-cyan-600/90 bg-cyan-700/35 text-cyan-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
|
||||
>
|
||||
{sub.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Basics ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-1">Basics</h3>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
/>
|
||||
{errors.title && <p className="text-xs text-red-400 mt-1">{errors.title[0]}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Description</label>
|
||||
<MarkdownEditor
|
||||
id="studio-edit-description"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Describe your artwork…"
|
||||
rows={5}
|
||||
error={errors.description}
|
||||
/>
|
||||
{errors.description && <p className="text-xs text-red-400 mt-1">{errors.description[0]}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Tags ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Tags</h3>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
|
||||
<input
|
||||
ref={tagInputRef}
|
||||
type="text"
|
||||
value={tagQuery}
|
||||
onChange={(e) => setTagQuery(e.target.value)}
|
||||
className="w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
style={{ paddingLeft: '2.5rem' }}
|
||||
placeholder="Search tags…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected tag chips */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium bg-accent/20 text-accent"
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
onClick={() => removeTag(tag.id)}
|
||||
className="ml-0.5 w-4 h-4 rounded-full hover:bg-white/10 flex items-center justify-center"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-[10px]" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
<div className="max-h-48 overflow-y-auto sb-scrollbar space-y-0.5 rounded-xl bg-white/[0.02] border border-white/5 p-1">
|
||||
{tagLoading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!tagLoading && tagResults.length === 0 && (
|
||||
<p className="text-center text-sm text-slate-500 py-4">
|
||||
{tagQuery ? 'No tags found' : 'Type to search tags'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!tagLoading &&
|
||||
tagResults.map((tag) => {
|
||||
const isSelected = tags.some((t) => t.id === tag.id)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
|
||||
isSelected
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<i
|
||||
className={`fa-${isSelected ? 'solid fa-circle-check' : 'regular fa-circle'} text-xs ${
|
||||
isSelected ? 'text-accent' : 'text-slate-500'
|
||||
}`}
|
||||
/>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">{tags.length}/15 tags selected</p>
|
||||
{errors.tags && <p className="text-xs text-red-400">{errors.tags[0]}</p>}
|
||||
</section>
|
||||
|
||||
{/* ── Visibility ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Visibility</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" checked={isPublic} onChange={() => setIsPublic(true)} className="text-accent focus:ring-accent/50" />
|
||||
<span className="text-sm text-white">Published</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" checked={!isPublic} onChange={() => setIsPublic(false)} className="text-accent focus:ring-accent/50" />
|
||||
<span className="text-sm text-white">Draft</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white font-semibold text-sm transition-all shadow-lg shadow-accent/25 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
|
||||
{saved && (
|
||||
<span className="text-sm text-emerald-400 flex items-center gap-1">
|
||||
<i className="fa-solid fa-check" /> Saved
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/studio/artworks/${artwork?.id}/analytics`}
|
||||
className="ml-auto px-4 py-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 text-sm transition-all"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line mr-2" />
|
||||
Analytics
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Version History Modal ── */}
|
||||
{showHistory && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowHistory(false) }}
|
||||
>
|
||||
<div className="bg-nova-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<h2 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<i className="fa-solid fa-clock-rotate-left text-accent" />
|
||||
Version History
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowHistory(false)}
|
||||
className="w-7 h-7 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 sb-scrollbar p-4 space-y-3">
|
||||
{historyLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!historyLoading && historyData && historyData.versions.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`rounded-xl border p-4 transition-all ${
|
||||
v.is_current
|
||||
? 'border-accent/40 bg-accent/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-bold text-white">v{v.version_number}</span>
|
||||
{v.is_current && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/20 text-accent border border-accent/30">Current</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
{v.created_at ? new Date(v.created_at).toLocaleString() : ''}
|
||||
</p>
|
||||
{v.width && (
|
||||
<p className="text-[11px] text-slate-400">{v.width} × {v.height} px · {formatBytes(v.file_size)}</p>
|
||||
)}
|
||||
{v.change_note && (
|
||||
<p className="text-xs text-slate-300 mt-1 italic">“{v.change_note}”</p>
|
||||
)}
|
||||
</div>
|
||||
{!v.is_current && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={restoring === v.id}
|
||||
onClick={() => handleRestoreVersion(v.id)}
|
||||
className="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-white/5 hover:bg-accent/20 text-slate-300 hover:text-accent border border-white/10 hover:border-accent/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{restoring === v.id
|
||||
? <><i className="fa-solid fa-spinner fa-spin" /> Restoring…</>
|
||||
: <><i className="fa-solid fa-rotate-left" /> Restore</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!historyLoading && historyData && historyData.versions.length === 0 && (
|
||||
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/10">
|
||||
<p className="text-xs text-slate-500">
|
||||
Older versions are preserved. Restoring creates a new version—nothing is deleted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import StudioContentBrowser from '../../components/Studio/StudioContentBrowser'
|
||||
|
||||
function SummaryCard({ label, value, icon }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center gap-3 text-slate-300">
|
||||
<i className={icon} />
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-3xl font-semibold text-white">{Number(value || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioArtworks() {
|
||||
const { props } = usePage()
|
||||
const summary = props.summary || {}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
<SummaryCard label="Artworks" value={summary.count} icon="fa-solid fa-images" />
|
||||
<SummaryCard label="Drafts" value={summary.draft_count} icon="fa-solid fa-file-pen" />
|
||||
<SummaryCard label="Published" value={summary.published_count} icon="fa-solid fa-rocket" />
|
||||
<a href="/upload" className="rounded-[24px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em]">Upload artwork</p>
|
||||
<p className="mt-3 text-sm leading-6">Start a new visual upload flow without leaving Creator Studio.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter />
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unknown'
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Unknown'
|
||||
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function StudioAssets() {
|
||||
const { props } = usePage()
|
||||
const assets = props.assets || {}
|
||||
const items = assets.items || []
|
||||
const summary = assets.summary || []
|
||||
const highlights = assets.highlights || {}
|
||||
const filters = assets.filters || {}
|
||||
const meta = assets.meta || {}
|
||||
const typeOptions = assets.type_options || []
|
||||
const sourceOptions = assets.source_options || []
|
||||
const sortOptions = assets.sort_options || []
|
||||
|
||||
const trackReuse = (asset, destination) => {
|
||||
trackStudioEvent('studio_asset_reused', {
|
||||
surface: studioSurface(),
|
||||
module: 'assets',
|
||||
item_module: asset.source_key || 'assets',
|
||||
item_id: asset.numeric_id,
|
||||
meta: {
|
||||
asset_id: asset.id,
|
||||
asset_type: asset.type,
|
||||
destination,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const updateFilters = (patch) => {
|
||||
const next = {
|
||||
...filters,
|
||||
...patch,
|
||||
}
|
||||
|
||||
if (patch.page == null) {
|
||||
next.page = 1
|
||||
}
|
||||
|
||||
trackStudioEvent('studio_filter_used', {
|
||||
surface: studioSurface(),
|
||||
module: 'assets',
|
||||
meta: patch,
|
||||
})
|
||||
|
||||
router.get(window.location.pathname, next, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(34,197,94,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)] lg:p-6">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label className="space-y-2 text-sm text-slate-300 xl:col-span-2">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search assets</span>
|
||||
<input
|
||||
type="search"
|
||||
value={filters.q || ''}
|
||||
onChange={(event) => updateFilters({ q: event.target.value })}
|
||||
placeholder="Title, source, or description"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Type</span>
|
||||
<select
|
||||
value={filters.type || 'all'}
|
||||
onChange={(event) => updateFilters({ type: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
|
||||
>
|
||||
{typeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Source</span>
|
||||
<select
|
||||
value={filters.source || 'all'}
|
||||
onChange={(event) => updateFilters({ source: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
|
||||
>
|
||||
{sourceOptions.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
|
||||
<select
|
||||
value={filters.sort || 'recent'}
|
||||
onChange={(event) => updateFilters({ sort: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Library volume</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{Number(meta.total || 0).toLocaleString()}</div>
|
||||
<div className="text-xs text-slate-500">creator assets available</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
{summary.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => updateFilters({ type: item.key })}
|
||||
className={`rounded-[24px] border p-5 text-left transition ${filters.type === item.key ? 'border-sky-300/25 bg-sky-300/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 text-slate-200">
|
||||
<i className={item.icon} />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{Number(item.count || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-slate-400">
|
||||
<p>
|
||||
Showing <span className="font-semibold text-white">{items.length}</span> of <span className="font-semibold text-white">{Number(meta.total || 0).toLocaleString()}</span> assets
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateFilters({ type: 'all', source: 'all', sort: 'recent', q: '' })}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-slate-200"
|
||||
>
|
||||
<i className="fa-solid fa-rotate-left" />
|
||||
Reset filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<section className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((asset) => (
|
||||
<article key={asset.id} className="overflow-hidden rounded-[26px] border border-white/10 bg-white/[0.03] shadow-[0_18px_50px_rgba(3,7,18,0.18)]">
|
||||
<div className="relative aspect-[1.15/1] overflow-hidden bg-slate-950/70">
|
||||
{asset.image_url ? (
|
||||
<img src={asset.image_url} alt={asset.title} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-slate-500">
|
||||
<i className="fa-solid fa-photo-film text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-4 top-4 inline-flex items-center gap-2 rounded-full border border-black/10 bg-black/45 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white backdrop-blur-md">
|
||||
<span>{asset.type_label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{asset.source_label}</p>
|
||||
<h2 className="mt-1 truncate text-lg font-semibold text-white">{asset.title}</h2>
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{asset.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-500">
|
||||
<span>Used {Number(asset.usage_count || 0).toLocaleString()} times</span>
|
||||
<span>Updated {formatDate(asset.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{(asset.usage_references || []).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
{(asset.usage_references || []).slice(0, 2).map((reference) => (
|
||||
<a key={`${asset.id}-${reference.href}`} href={reference.href} className="rounded-full border border-white/10 px-2.5 py-1 transition hover:border-white/20 hover:text-white">
|
||||
{reference.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href={asset.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200">
|
||||
<i className="fa-solid fa-pen-to-square" />
|
||||
Manage
|
||||
</a>
|
||||
<a href={asset.view_url} onClick={() => trackReuse(asset, asset.view_url)} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100">
|
||||
<i className="fa-solid fa-repeat" />
|
||||
Reuse
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<section className="mt-6 rounded-[28px] border border-dashed border-white/15 bg-white/[0.02] px-6 py-16 text-center">
|
||||
<h3 className="text-xl font-semibold text-white">No assets match this view</h3>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm text-slate-400">Try another asset type or a broader search term. This library includes card backgrounds, story covers, collection covers, artwork previews, and profile branding.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-lg font-semibold text-white">Recent uploads</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(highlights.recent_uploads || []).slice(0, 5).map((asset) => (
|
||||
<a key={`${asset.id}-recent`} href={asset.manage_url} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
{asset.image_url ? <img src={asset.image_url} alt={asset.title} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 text-slate-500"><i className="fa-solid fa-photo-film" /></div>}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{asset.title}</div>
|
||||
<div className="text-xs text-slate-500">{asset.type_label}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-lg font-semibold text-white">Most reused</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(highlights.most_used || []).slice(0, 5).map((asset) => (
|
||||
<a key={`${asset.id}-used`} href={asset.manage_url} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{asset.title}</div>
|
||||
<div className="text-xs text-slate-500">{asset.source_label}</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{Number(asset.usage_count || 0).toLocaleString()}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
|
||||
<button
|
||||
type="button"
|
||||
disabled={(meta.current_page || 1) <= 1}
|
||||
onClick={() => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) })}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Page {meta.current_page || 1} of {meta.last_page || 1}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={(meta.current_page || 1) >= (meta.last_page || 1)}
|
||||
onClick={() => updateFilters({ page: (meta.current_page || 1) + 1 })}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
|
||||
async function requestJson(url, method = 'POST') {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(payload?.message || 'Request failed')
|
||||
return payload
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Not scheduled'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Not scheduled'
|
||||
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export default function StudioCalendar() {
|
||||
const { props } = usePage()
|
||||
const calendar = props.calendar || {}
|
||||
const filters = calendar.filters || {}
|
||||
const summary = calendar.summary || {}
|
||||
const [busyKey, setBusyKey] = useState(null)
|
||||
|
||||
const updateFilters = (patch) => {
|
||||
const next = { ...filters, ...patch }
|
||||
trackStudioEvent('studio_scheduled_opened', {
|
||||
surface: studioSurface(),
|
||||
module: next.module,
|
||||
meta: patch,
|
||||
})
|
||||
router.get(window.location.pathname, next, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
const runAction = async (pattern, item, key) => {
|
||||
const url = String(pattern || '').replace('__MODULE__', item.module).replace('__ID__', String(item.numeric_id))
|
||||
setBusyKey(`${key}:${item.id}`)
|
||||
try {
|
||||
await requestJson(url)
|
||||
router.reload({ preserveScroll: true, preserveState: true })
|
||||
} catch (error) {
|
||||
window.alert(error?.message || 'Unable to update schedule.')
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="space-y-6">
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Scheduled</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.scheduled_total || 0).toLocaleString()}</div></div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Unscheduled</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.unscheduled_total || 0).toLocaleString()}</div></div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Overloaded days</div><div className="mt-2 text-3xl font-semibold text-white">{Number(summary.overloaded_days || 0).toLocaleString()}</div></div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5"><div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Next publish</div><div className="mt-2 text-base font-semibold text-white">{formatDate(summary.next_publish_at)}</div></div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<label className="space-y-2 text-sm text-slate-300 xl:col-span-2">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search planning queue</span>
|
||||
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Title or module" />
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">View</span><select value={filters.view || 'month'} onChange={(event) => updateFilters({ view: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.view_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Queue</span><select value={filters.status || 'scheduled'} onChange={(event) => updateFilters({ status: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.status_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
|
||||
{filters.view === 'week' ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-white">{calendar.week?.label}</h2>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-7">
|
||||
{(calendar.week?.days || []).map((day) => (
|
||||
<div key={day.date} className="rounded-[22px] border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-sm font-semibold text-white">{day.label}</div>
|
||||
<div className="mt-3 space-y-2">{day.items.length > 0 ? day.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-2xl border border-white/10 px-3 py-2 text-xs text-slate-200">{item.title}</a>) : <div className="text-xs text-slate-500">No scheduled items</div>}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : filters.view === 'agenda' ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-white">Agenda</h2>
|
||||
<div className="mt-4 space-y-4">{(calendar.agenda || []).map((group) => <div key={group.date} className="rounded-[22px] border border-white/10 bg-black/20 p-4"><div className="flex items-center justify-between gap-3"><div className="text-base font-semibold text-white">{group.label}</div><div className="text-xs uppercase tracking-[0.18em] text-slate-500">{group.count} items</div></div><div className="mt-3 space-y-2">{group.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 px-3 py-2 text-sm text-slate-200"><span>{item.title}</span><span className="text-xs text-slate-500">{formatDate(item.scheduled_at)}</span></a>)}</div></div>)}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-white">{calendar.month?.label}</h2>
|
||||
<div className="mt-4 grid grid-cols-7 gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((label) => <div key={label} className="px-2 py-1">{label}</div>)}</div>
|
||||
<div className="mt-2 grid grid-cols-7 gap-2">{(calendar.month?.days || []).map((day) => <div key={day.date} className={`min-h-[120px] rounded-[22px] border p-3 ${day.is_current_month ? 'border-white/10 bg-black/20' : 'border-white/5 bg-black/10'}`}><div className="flex items-center justify-between gap-2"><span className={`text-sm font-semibold ${day.is_current_month ? 'text-white' : 'text-slate-500'}`}>{day.day}</span><span className="text-[10px] uppercase tracking-[0.18em] text-slate-500">{day.count}</span></div><div className="mt-3 space-y-2">{day.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-xl border border-white/10 px-2 py-1.5 text-[11px] text-slate-200">{item.title}</a>)}</div></div>)}</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between"><h2 className="text-lg font-semibold text-white">Coverage gaps</h2><a href="/studio/drafts" className="text-sm font-medium text-sky-100">Open drafts</a></div>
|
||||
<div className="mt-4 space-y-3">{(calendar.gaps || []).length > 0 ? (calendar.gaps || []).map((gap) => <div key={gap.date} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">{gap.label}</div>) : <div className="rounded-2xl border border-dashed border-white/15 px-4 py-8 text-sm text-slate-500">No empty days in the next two weeks.</div>}</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between"><h2 className="text-lg font-semibold text-white">Unscheduled queue</h2><span className="text-xs uppercase tracking-[0.18em] text-slate-500">{(calendar.unscheduled_items || []).length}</span></div>
|
||||
<div className="mt-4 space-y-3">{(calendar.unscheduled_items || []).map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-sm font-semibold text-white">{item.title}</div><div className="mt-1 text-xs text-slate-500">{item.module_label} · {item.workflow?.readiness?.label || 'Needs review'}</div></a>)}</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between"><h2 className="text-lg font-semibold text-white">Upcoming actions</h2><a href="/studio/scheduled" className="text-sm font-medium text-sky-100">Open list</a></div>
|
||||
<div className="mt-4 space-y-3">{(calendar.scheduled_items || []).slice(0, 5).map((item) => <div key={item.id} className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-sm font-semibold text-white">{item.title}</div><div className="mt-1 text-xs text-slate-500">{formatDate(item.scheduled_at)}</div><div className="mt-3 flex flex-wrap gap-2"><button type="button" disabled={busyKey === `publish:${item.id}`} onClick={() => runAction(props.endpoints.publishNowPattern, item, 'publish')} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100 disabled:opacity-50">Publish now</button><button type="button" disabled={busyKey === `unschedule:${item.id}`} onClick={() => runAction(props.endpoints.unschedulePattern, item, 'unschedule')} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200 disabled:opacity-50">Unschedule</button></div></div>)}</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user