Commit workspace changes

This commit is contained in:
2026-04-05 19:42:33 +02:00
parent 148a3bbe43
commit 08ad757bcb
312 changed files with 35149 additions and 399 deletions

View File

@@ -2,12 +2,13 @@ import React, { useEffect, useState } from 'react'
import { Link, usePage } from '@inertiajs/react'
import { studioModule, studioSurface, trackStudioEvent } from '../utils/studioEvents'
const navGroups = [
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' },
],
},
{
@@ -67,13 +68,82 @@ const navGroups = [
},
]
const quickCreateItems = [
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
@@ -91,10 +161,42 @@ function NavLink({ item, active }) {
}
export default function StudioLayout({ children, title, subtitle, actions }) {
const { url } = usePage()
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)
@@ -111,6 +213,31 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
})
}, [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)
@@ -127,6 +254,19 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
})
}
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">
@@ -151,14 +291,31 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
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 isActive={isActive} onNavigate={() => setMobileOpen(false)} onQuickCreate={handleQuickCreateClick} />
<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 isActive={isActive} onQuickCreate={handleQuickCreateClick} />
<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">
@@ -168,9 +325,11 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
<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
@@ -208,13 +367,48 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
)
}
function StudioSidebarContent({ isActive, onNavigate, onQuickCreate }) {
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}>

View File

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

View File

@@ -15,8 +15,32 @@ import ArtworkRecommendationsRails from '../components/artwork/ArtworkRecommenda
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 ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [] }) {
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 openViewer = useCallback(() => setViewerOpen(true), [])
const closeViewer = useCallback(() => setViewerOpen(false), [])
@@ -41,6 +65,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
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
@@ -70,6 +95,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
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
}, [])
@@ -201,6 +227,8 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
{/* Creator card */}
<CreatorSpotlight artwork={artwork} presentSq={presentSq} related={related} />
{groupSummary ? <GroupSummaryPanel group={groupSummary} artwork={artwork} /> : null}
{/* Details (collapsible) */}
<ArtworkDetailsPanel artwork={artwork} stats={liveStats} />
@@ -258,6 +286,7 @@ if (el) {
presentSq={parse('presentSq')}
canonicalUrl={parse('canonical', '')}
isAuthenticated={parse('isAuthenticated', false)}
groupSummary={parse('groupSummary')}
comments={parse('comments', [])}
/>,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 Groups 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 cant 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 someones role later?',
paragraphs: [
'Yes, if your role allows it. Many teams adjust roles over time as trust, responsibility, or activity changes.',
],
},
{
question: 'Why cant 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 cant 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 cant 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 dont 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 cant 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 cant 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 cant 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 dont understand why I cant 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',
},
]

View File

@@ -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.',
},
]

View File

@@ -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',
},
]

View File

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

View File

@@ -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">&rarr;</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>
)
}

View File

@@ -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">&rarr;</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>
)
}

View File

@@ -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">&rarr;</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>
)
}

View File

@@ -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">&rarr;</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>
)
}

View File

@@ -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">&rarr;</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>
)
}

View File

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

View File

@@ -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">&rarr;</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>
)
}

View File

@@ -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',
},
]

View File

@@ -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 didnt 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 cant 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 cant 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 didnt 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 cant 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: 'Im 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',
},
]

View File

@@ -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 cant 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 dont 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 cant 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',
},
]

View File

@@ -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 cant 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 cant 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 dont 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 doesnt 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',
},
]

View File

@@ -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 dont 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 doesnt 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 dont understand profile vs Group publishing',
body: 'If you are not sure whether the public identity should be yours or a Groups, 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',
},
]

View File

@@ -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 cant 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 dont 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 cant 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 cant 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 dont 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 dont 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 cant 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',
},
]

View File

@@ -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',
},
]

View File

@@ -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 cant 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 cant 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 cant 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',
},
]

View File

@@ -7,121 +7,49 @@ function normalizeItems(items) {
return items.filter((item) => item && typeof item === 'object')
}
function SectionHeader({ title, subtitle, href, ctaLabel = 'See all' }) {
return (
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<h3 className="text-lg font-bold text-white">{title}</h3>
{subtitle ? <p className="mt-1 text-xs text-nova-400">{subtitle}</p> : null}
</div>
{href ? (
<a href={href} className="shrink-0 text-sm text-nova-300 transition hover:text-white">
{ctaLabel}
</a>
) : null}
</div>
)
}
function CollectionStrip({ items }) {
if (!items.length) return null
return (
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
{items.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
))}
</div>
)
}
function CollectionSection({ title, subtitle, href, items, limit = 3, ctaLabel }) {
const normalized = normalizeItems(items).slice(0, limit)
if (!normalized.length) return null
return (
<section className="mt-10">
<SectionHeader title={title} subtitle={subtitle} href={href} ctaLabel={ctaLabel} />
<CollectionStrip items={normalized} />
</section>
)
}
export default function HomeCollections({
featured,
recent,
trending,
editorial,
community,
isLoggedIn = false,
}) {
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 (!featuredItems.length && !recentItems.length && !trendingItems.length && !editorialItems.length && !communityItems.length) {
if (!displayItems.length) {
return null
}
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-white">Curated Collections</h2>
<h2 className="text-xl font-bold text-white">Trending Collections</h2>
<p className="mt-1 max-w-2xl text-sm text-nova-300">
Hand-built galleries, smart collections, and community showcases worth opening next.
Collections getting the strongest mix of follows, saves, and engagement right now.
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-nova-400">
{isLoggedIn && recentItems.length ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">Recent</span> : null}
{featuredItems.length ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-amber-100">Featured</span> : null}
{communityItems.length ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-sky-100">Community</span> : null}
</div>
<a href="/collections/trending" className="shrink-0 text-sm text-nova-300 transition hover:text-white">
All collections
</a>
</div>
<CollectionSection
title="Featured Collections"
subtitle="Standout galleries with strong sequencing, presentation, or curator voice."
href="/collections/featured"
items={featuredItems}
limit={3}
/>
{isLoggedIn ? (
<CollectionSection
title="Recently Active"
subtitle="Fresh collection activity from around the site, including new updates and resurfacing galleries."
href="/collections/trending"
items={recentItems}
limit={3}
/>
) : null}
<CollectionSection
title="Trending Collections"
subtitle="Collections getting the strongest mix of follows, saves, and engagement right now."
href="/collections/trending"
items={trendingItems}
limit={3}
/>
<CollectionSection
title="Editorial Picks"
subtitle="Staff and premium editorial showcases with stronger themes and presentation rules."
href="/collections/editorial"
items={editorialItems}
limit={3}
/>
<CollectionSection
title="Community Highlights"
subtitle="Collaborative and submission-friendly collections that spotlight multiple creators together."
href="/collections/community"
items={communityItems}
limit={3}
/>
<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>
)
}

View File

@@ -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={group.name}
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 -&gt;
</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>
)
}

View File

@@ -17,23 +17,25 @@ export default function HomeNews({ items }) {
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 &amp; Updates</h2>
<a href="/forum/news" className="text-sm text-nova-300 hover:text-white transition">
<h2 className="text-xl font-bold text-white">News &amp; 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 rounded-xl bg-panel overflow-hidden">
<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="flex items-start justify-between gap-4 px-5 py-4 transition hover:bg-nova-800"
className="grid gap-3 px-5 py-4 transition hover:bg-nova-800 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"
>
<span className="text-sm font-medium text-white line-clamp-2">{item.title}</span>
{item.date && (
<span className="flex-shrink-0 text-xs text-soft">{formatDate(item.date)}</span>
)}
<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>

View File

@@ -11,6 +11,7 @@ const HomeTrending = lazy(() => import('./HomeTrending'))
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'))
@@ -24,7 +25,7 @@ function SectionFallback() {
}
function GuestHomePage(props) {
const { rising, trending, fresh, tags, creators, news, collections_featured, collections_trending, collections_editorial, collections_community } = props
const { rising, trending, fresh, tags, creators, news, collections_featured, collections_trending, collections_editorial, collections_community, groups } = props
return (
<>
@@ -49,6 +50,10 @@ function GuestHomePage(props) {
/>
</Suspense>
<Suspense fallback={<SectionFallback />}>
<HomeGroups groups={groups} />
</Suspense>
{/* 4. Explore Categories */}
<Suspense fallback={<SectionFallback />}>
<HomeCategories />
@@ -90,6 +95,7 @@ function AuthHomePage(props) {
collections_trending,
collections_editorial,
collections_community,
groups,
by_categories,
suggested_creators,
tags,
@@ -146,6 +152,10 @@ function AuthHomePage(props) {
/>
</Suspense>
<Suspense fallback={<SectionFallback />}>
<HomeGroups groups={groups} />
</Suspense>
{/* 4. Explore Categories */}
<Suspense fallback={<SectionFallback />}>
<HomeCategories />

View File

@@ -7,6 +7,7 @@ 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' },
]
@@ -20,6 +21,7 @@ const PERIOD_TABS = [
const API_BY_TYPE = {
creator: '/api/leaderboard/creators',
artwork: '/api/leaderboard/artworks',
group: '/api/leaderboard/groups',
story: '/api/leaderboard/stories',
}
@@ -71,17 +73,17 @@ export default function LeaderboardPage() {
return (
<>
<SeoHead seo={seo} title={seo?.title || 'Leaderboard — Skinbase'} description={seo?.description || 'Top creators, artworks, and stories on Skinbase.'} />
<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, standout artworks, and stories with momentum.
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, artworks, and stories, then filter by daily, weekly, monthly, or all-time performance.
Switch between creators, groups, artworks, and stories, then filter by daily, weekly, monthly, or all-time performance.
</p>
</header>

View File

@@ -11,6 +11,7 @@ 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']
@@ -62,6 +63,7 @@ export default function ProfileShow() {
collections,
achievements,
leaderboardRank,
groupContributionHistory,
countryName,
isOwner,
auth,
@@ -168,6 +170,8 @@ export default function ProfileShow() {
/>
</div>
<GroupProfileSummary contributions={groupContributionHistory} href={profileTabUrls?.about} />
<div className={`${contentShellClassName} pt-6`}>
{activeTab === 'artworks' && (
<TabArtworks
@@ -228,6 +232,7 @@ export default function ProfileShow() {
followerCount={followerCount}
recentFollowers={recentFollowers}
leaderboardRank={leaderboardRank}
groupContributionHistory={groupContributionHistory}
/>
)}
{activeTab === 'stats' && (

View File

@@ -102,6 +102,50 @@ function visibilityLabel(value) {
}
}
function normalizeContributorCredits(contributorIds = [], contributorCredits = {}) {
const normalized = {}
const ids = Array.isArray(contributorIds)
? contributorIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)
: []
ids.forEach((id) => {
const current = contributorCredits?.[id] || contributorCredits?.[String(id)] || {}
normalized[id] = {
creditRole: typeof current.creditRole === 'string' ? current.creditRole : '',
isPrimary: Boolean(current.isPrimary),
}
})
const leadIds = Object.entries(normalized)
.filter(([, value]) => value.isPrimary)
.map(([id]) => Number(id))
if (leadIds.length > 1) {
leadIds.slice(1).forEach((id) => {
normalized[id] = {
...normalized[id],
isPrimary: false,
}
})
}
return normalized
}
function mapContributorCredits(contributorCredits = []) {
return (Array.isArray(contributorCredits) ? contributorCredits : []).reduce((accumulator, contributor) => {
const userId = Number(contributor?.user_id)
if (!Number.isFinite(userId) || userId <= 0) return accumulator
accumulator[userId] = {
creditRole: typeof contributor?.credit_role === 'string' ? contributor.credit_role : '',
isPrimary: Boolean(contributor?.is_primary),
}
return accumulator
}, {})
}
// ─── Sub-components ──────────────────────────────────────────────────────────
/** Glass-morphism section card (Nova theme) */
@@ -160,6 +204,10 @@ function RightRailCard({ title, children, className = '' }) {
export default function StudioArtworkEdit() {
const { props } = usePage()
const { artwork, contentTypes: rawContentTypes } = props
const groupOptions = Array.isArray(props.groupOptions) ? props.groupOptions : []
const contributorOptionsByGroup = props.contributorOptionsByGroup && typeof props.contributorOptionsByGroup === 'object'
? props.contributorOptionsByGroup
: {}
const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes])
@@ -173,6 +221,10 @@ export default function StudioArtworkEdit() {
const [visibility, setVisibility] = useState(artwork?.visibility || (artwork?.is_public ? 'public' : 'private'))
const [publishMode, setPublishMode] = useState(artwork?.publish_mode || (artwork?.artwork_status === 'scheduled' ? 'schedule' : 'now'))
const [scheduledAt, setScheduledAt] = useState(artwork?.publish_at || null)
const [groupSlug, setGroupSlug] = useState(artwork?.group_slug || '')
const [primaryAuthorUserId, setPrimaryAuthorUserId] = useState(artwork?.primary_author_user_id || null)
const [contributorUserIds, setContributorUserIds] = useState(() => (Array.isArray(artwork?.contributor_user_ids) ? artwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : []))
const [contributorCredits, setContributorCredits] = useState(() => normalizeContributorCredits(artwork?.contributor_user_ids || [], mapContributorCredits(artwork?.contributor_credits || [])))
const [titleSource, setTitleSource] = useState(artwork?.title_source || 'manual')
const [descriptionSource, setDescriptionSource] = useState(artwork?.description_source || 'manual')
const [tagsSource, setTagsSource] = useState(artwork?.tags_source || 'manual')
@@ -218,6 +270,11 @@ export default function StudioArtworkEdit() {
const rootCategories = selectedCT?.rootCategories || []
const selectedRoot = rootCategories.find((c) => c.id === categoryId) || null
const subCategories = selectedRoot?.children || []
const selectedGroupOption = useMemo(() => groupOptions.find((group) => String(group.slug || '') === String(groupSlug || '')) || null, [groupOptions, groupSlug])
const currentContributorOptions = useMemo(() => {
const selectedSlug = String(groupSlug || '')
return Array.isArray(contributorOptionsByGroup?.[selectedSlug]) ? contributorOptionsByGroup[selectedSlug] : []
}, [contributorOptionsByGroup, groupSlug])
const aiStatus = aiData?.status || artwork?.ai_status || 'not_analyzed'
const aiSuggestedTags = useMemo(() => (aiData?.tag_suggestions || []).map((item) => item.tag).filter(Boolean), [aiData])
const selectedLeafCategoryId = subCategoryId || categoryId || null
@@ -491,6 +548,45 @@ export default function StudioArtworkEdit() {
return () => window.clearInterval(timer)
}, [aiStatus, loadAiData])
useEffect(() => {
const selectedSlug = String(groupSlug || '')
if (!selectedSlug) {
if (primaryAuthorUserId || contributorUserIds.length > 0 || Object.keys(contributorCredits || {}).length > 0) {
setPrimaryAuthorUserId(null)
setContributorUserIds([])
setContributorCredits({})
}
return
}
const validGroup = groupOptions.some((group) => String(group.slug || '') === selectedSlug)
if (!validGroup) {
setGroupSlug('')
setPrimaryAuthorUserId(null)
setContributorUserIds([])
setContributorCredits({})
return
}
const validContributorIds = currentContributorOptions.map((user) => Number(user.id)).filter((id) => Number.isFinite(id) && id > 0)
const nextPrimaryAuthorId = validContributorIds.includes(Number(primaryAuthorUserId))
? Number(primaryAuthorUserId)
: (validContributorIds[0] || null)
const nextContributorIds = contributorUserIds
.map((id) => Number(id))
.filter((id) => validContributorIds.includes(id) && id !== nextPrimaryAuthorId)
const nextContributorCredits = normalizeContributorCredits(nextContributorIds, contributorCredits)
const primaryChanged = (primaryAuthorUserId ? Number(primaryAuthorUserId) : null) !== nextPrimaryAuthorId
const contributorsChanged = nextContributorIds.length !== contributorUserIds.length || nextContributorIds.some((id, index) => id !== contributorUserIds[index])
const contributorCreditsChanged = JSON.stringify(nextContributorCredits) !== JSON.stringify(normalizeContributorCredits(contributorUserIds, contributorCredits))
if (primaryChanged) setPrimaryAuthorUserId(nextPrimaryAuthorId)
if (contributorsChanged) setContributorUserIds(nextContributorIds)
if (contributorCreditsChanged) setContributorCredits(nextContributorCredits)
}, [groupSlug, groupOptions, currentContributorOptions, primaryAuthorUserId, contributorUserIds, contributorCredits])
const handleSave = useCallback(async () => {
setSaving(true)
setSaved(false)
@@ -503,6 +599,16 @@ export default function StudioArtworkEdit() {
mode: publishMode,
publish_at: publishMode === 'schedule' ? scheduledAt : null,
timezone: userTimezone,
group: groupSlug || null,
primary_author_user_id: groupSlug ? primaryAuthorUserId : null,
contributor_user_ids: groupSlug ? contributorUserIds : [],
contributor_credits: groupSlug
? contributorUserIds.map((id) => ({
user_id: id,
credit_role: contributorCredits?.[id]?.creditRole?.trim() ? contributorCredits[id].creditRole.trim() : null,
is_primary: Boolean(contributorCredits?.[id]?.isPrimary),
}))
: [],
content_type_id: contentTypeId,
category_id: selectedLeafCategoryId,
tags: tagSlugs,
@@ -524,6 +630,10 @@ export default function StudioArtworkEdit() {
setVisibility(updatedArtwork.visibility || visibility)
setPublishMode(updatedArtwork.publish_mode || 'now')
setScheduledAt(updatedArtwork.publish_at || null)
setGroupSlug(updatedArtwork.group_slug || '')
setPrimaryAuthorUserId(updatedArtwork.primary_author_user_id || null)
setContributorUserIds(Array.isArray(updatedArtwork.contributor_user_ids) ? updatedArtwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : [])
setContributorCredits(normalizeContributorCredits(updatedArtwork.contributor_user_ids || [], mapContributorCredits(updatedArtwork.contributor_credits || [])))
}
setSaved(true)
setTimeout(() => setSaved(false), 3000)
@@ -536,7 +646,7 @@ export default function StudioArtworkEdit() {
} finally {
setSaving(false)
}
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, artwork?.id])
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, artwork?.id])
const handleFileReplace = async (e) => {
const file = e.target.files?.[0]
@@ -1050,6 +1160,159 @@ export default function StudioArtworkEdit() {
autofocus={false}
/>
</FormField>
<Section className="space-y-5 border-white/8 bg-white/[0.02]">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<SectionTitle icon="fa-solid fa-users">Attribution</SectionTitle>
<p className="-mt-2 text-sm text-slate-400">Switch between personal and group context, then maintain primary author and contributor credits without leaving the edit screen.</p>
</div>
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${groupSlug ? 'border-sky-300/20 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300'}`}>
{selectedGroupOption ? `Group: ${selectedGroupOption.name}` : 'Personal publish'}
</span>
</div>
<label className="block">
<span className="text-sm font-medium text-white/90">Publishing identity</span>
<select
value={groupSlug}
onChange={(event) => setGroupSlug(event.target.value)}
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
>
<option value="">Personal profile</option>
{groupOptions.map((group) => (
<option key={group.slug} value={group.slug}>{group.name}</option>
))}
</select>
{errors.group?.[0] ? <p className="mt-2 text-xs text-red-400">{errors.group[0]}</p> : null}
</label>
{groupSlug ? (
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<div>
<label className="block">
<span className="text-sm font-medium text-white/90">Primary author</span>
<select
value={primaryAuthorUserId || ''}
onChange={(event) => setPrimaryAuthorUserId(event.target.value ? Number(event.target.value) : null)}
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
>
{currentContributorOptions.map((user) => (
<option key={user.id} value={user.id}>{user.name || user.username}</option>
))}
</select>
</label>
{errors.primary_author_user_id?.[0] ? <p className="mt-2 text-xs text-red-400">{errors.primary_author_user_id[0]}</p> : <p className="mt-2 text-xs text-slate-400">Primary author remains the lead creator shown on the public artwork page.</p>}
</div>
<div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-white/90">Contributors</span>
<span className="text-xs text-slate-500">Optional</span>
</div>
<div className="mt-2 grid gap-2">
{currentContributorOptions.filter((user) => Number(user.id) !== Number(primaryAuthorUserId)).map((user) => {
const active = contributorUserIds.some((id) => Number(id) === Number(user.id))
const creditMeta = contributorCredits?.[user.id] || contributorCredits?.[String(user.id)] || { creditRole: '', isPrimary: false }
return (
<div
key={user.id}
className={[
'rounded-2xl border px-3 py-3 transition',
active
? 'border-sky-300/30 bg-sky-300/10 text-white'
: 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<div className="flex items-center gap-3">
{user.avatar_url ? <img src={user.avatar_url} alt={user.name || user.username} className="h-10 w-10 rounded-2xl object-cover" /> : <div className="flex h-10 w-10 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="truncate text-sm font-semibold">{user.name || user.username}</div>
<div className="truncate text-xs text-slate-400">@{user.username}</div>
</div>
<button
type="button"
onClick={() => {
setContributorUserIds((current) => {
const nextIds = new Set(current.map((id) => Number(id)).filter((id) => id !== Number(primaryAuthorUserId)))
if (nextIds.has(Number(user.id))) {
nextIds.delete(Number(user.id))
} else {
nextIds.add(Number(user.id))
}
const normalizedIds = Array.from(nextIds)
setContributorCredits((currentCredits) => normalizeContributorCredits(normalizedIds, currentCredits))
return normalizedIds
})
}}
className={[
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold transition',
active
? 'border-sky-300/40 bg-sky-300/20 text-sky-50'
: 'border-white/10 bg-white/[0.03] text-white/70 hover:border-white/20 hover:text-white',
].join(' ')}
>
<span className={['inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px]', active ? 'border-sky-300/40 bg-sky-300/20 text-sky-50' : 'border-white/10 bg-white/[0.03] text-white/35'].join(' ')}>{active ? '✓' : ''}</span>
{active ? 'Added' : 'Add credit'}
</button>
</div>
{active ? (
<div className="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<label className="block">
<span className="text-xs font-medium uppercase tracking-[0.16em] text-slate-300">Credit role</span>
<input
type="text"
value={creditMeta.creditRole || ''}
onChange={(event) => setContributorCredits((current) => ({
...normalizeContributorCredits(contributorUserIds, current),
[Number(user.id)]: {
...(normalizeContributorCredits(contributorUserIds, current)[Number(user.id)] || { isPrimary: false }),
creditRole: event.target.value,
},
}))}
placeholder="Colorist, concept support, layout..."
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
/>
</label>
<button
type="button"
onClick={() => setContributorCredits((current) => {
const nextCredits = normalizeContributorCredits(contributorUserIds, current)
contributorUserIds.forEach((id) => {
nextCredits[id] = {
...(nextCredits[id] || { creditRole: '' }),
isPrimary: id === Number(user.id),
}
})
return nextCredits
})}
className={[
'inline-flex items-center justify-center rounded-xl border px-3 py-3 text-sm font-medium transition',
creditMeta.isPrimary
? 'border-emerald-300/35 bg-emerald-400/12 text-emerald-100'
: 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.06] hover:text-white',
].join(' ')}
>
{creditMeta.isPrimary ? 'Lead support' : 'Set lead support'}
</button>
</div>
) : null}
</div>
)
})}
</div>
{errors.contributor_credits?.[0] ? <p className="mt-2 text-xs text-red-400">{errors.contributor_credits[0]}</p> : null}
</div>
</div>
) : (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">
Personal publishing uses your own creator profile as the primary author automatically.
</div>
)}
</Section>
</Section>
)}

View File

@@ -0,0 +1,32 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupActivity() {
const { props } = usePage()
const items = Array.isArray(props.activity) ? props.activity : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-4">
{items.length > 0 ? items.map((item) => (
<div key={item.id} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold text-white">{item.headline}</h2>
{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}
<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.visibility}</span>
</div>
{item.summary ? <p className="mt-2 text-sm leading-6 text-slate-400">{item.summary}</p> : null}
<div className="mt-3 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 subject</a> : null}
</div>
{props.pinPattern ? <button type="button" onClick={() => router.post(props.pinPattern.replace('__ITEM__', String(item.id)), { is_pinned: !item.is_pinned })} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-sm font-semibold text-white">{item.is_pinned ? 'Unpin' : 'Pin'}</button> : null}
</div>
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No activity yet.</div>}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioContentBrowser from '../../components/Studio/StudioContentBrowser'
export default function StudioGroupArtworks() {
const { props } = usePage()
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="mb-6 rounded-[28px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em]">Group publish flow</p>
<h2 className="mt-2 text-xl font-semibold">Upload into {props.studioGroup?.name}</h2>
<a href={props.uploadUrl} className="mt-4 inline-flex rounded-full border border-sky-200/20 bg-sky-200/10 px-4 py-2 text-sm font-semibold text-sky-50">New group artwork</a>
</div>
<StudioContentBrowser listing={props.listing} quickCreate={[{ key: 'artworks', label: 'Artwork', icon: 'fa-solid fa-cloud-arrow-up', url: props.uploadUrl }]} hideModuleFilter />
</StudioLayout>
)
}

View File

@@ -0,0 +1,106 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupAssets() {
const { props } = usePage()
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
const filters = useForm({
q: props.listing?.filters?.q || '',
category: props.listing?.filters?.category || 'all',
bucket: props.listing?.filters?.bucket || 'all',
})
const form = useForm({
title: '',
description: '',
category: props.categoryOptions?.[0]?.value || 'misc',
visibility: props.visibilityOptions?.[0]?.value || 'members_only',
status: props.statusOptions?.[0]?.value || 'active',
linked_project_id: '',
is_featured: false,
file: null,
})
const submit = (event) => {
event.preventDefault()
if (!props.storeUrl) return
form.post(props.storeUrl, { forceFormData: true, preserveScroll: true })
}
const applyFilters = (event) => {
event.preventDefault()
router.get(props.studioGroup?.urls?.studio_assets || window.location.pathname, {
q: filters.data.q || undefined,
category: filters.data.category !== 'all' ? filters.data.category : undefined,
bucket: filters.data.bucket !== 'all' ? filters.data.bucket : undefined,
}, {
preserveState: true,
preserveScroll: true,
replace: true,
})
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
{props.storeUrl ? (
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-4 lg:grid-cols-6">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Asset title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none lg:col-span-2" />
<select value={form.data.category} onChange={(event) => form.setData('category', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.categoryOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<input type="file" onChange={(event) => form.setData('file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="What is this asset for?" rows={3} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="mt-4 grid gap-4 md:grid-cols-2">
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> Featured asset</label>
</div>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Upload asset</button>
</form>
) : null}
<form onSubmit={applyFilters} className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-white">Browse library</h2>
<p className="mt-1 text-sm text-slate-400">Search and filter shared assets by visibility and category.</p>
</div>
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Apply filters</button>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<input value={filters.data.q} onChange={(event) => filters.setData('q', event.target.value)} placeholder="Search title, description, or filename" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={filters.data.category} onChange={(event) => filters.setData('category', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="all">All categories</option>
{(props.categoryOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select value={filters.data.bucket} onChange={(event) => filters.setData('bucket', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="all">All visibility levels</option>
{(props.listing?.bucket_options || []).filter((option) => option.value !== 'all').map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
</form>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{items.length > 0 ? items.map((asset) => (
<div key={asset.id} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">{asset.title}</h2>
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{asset.category} {asset.visibility} {asset.status}</p>
</div>
<a href={asset.download_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-sm font-semibold text-white">Download</a>
</div>
{asset.description ? <p className="mt-3 text-sm leading-6 text-slate-400">{asset.description}</p> : null}
{props.updatePattern ? (
<button type="button" onClick={() => router.patch(props.updatePattern.replace('__ASSET__', String(asset.id)), { title: asset.title, description: asset.description || '', category: asset.category, visibility: asset.visibility, status: asset.status === 'active' ? 'archived' : 'active', linked_project_id: asset.linked_project?.id || '', is_featured: asset.is_featured })} className="mt-4 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">{asset.status === 'active' ? 'Archive' : 'Reactivate'}</button>
) : null}
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No assets yet.</div>}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,95 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupChallengeEditor() {
const { props } = usePage()
const challenge = props.challenge || null
const form = useForm({
title: challenge?.title || '',
summary: challenge?.summary || '',
description: challenge?.description || '',
visibility: challenge?.visibility || props.visibilityOptions?.[0]?.value || 'public',
participation_scope: challenge?.participation_scope || props.participationScopeOptions?.[0]?.value || 'group_only',
status: challenge?.status || props.statusOptions?.[0]?.value || 'draft',
start_at: challenge?.start_at ? challenge.start_at.slice(0, 16) : '',
end_at: challenge?.end_at ? challenge.end_at.slice(0, 16) : '',
rules_text: challenge?.rules_text || '',
submission_instructions: challenge?.submission_instructions || '',
judging_mode: challenge?.judging_mode || '',
linked_collection_id: challenge?.linked_collection?.id || '',
linked_project_id: challenge?.linked_project?.id || '',
featured_artwork_id: challenge?.featured_artwork?.id || '',
cover_file: null,
})
const attachForm = useForm({ artwork_id: '' })
const submit = (event) => {
event.preventDefault()
const options = { forceFormData: true, preserveScroll: true }
if (props.updateUrl) {
form.post(props.updateUrl, { ...options, _method: 'patch' })
return
}
form.post(props.storeUrl, options)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-4">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Challenge title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Challenge description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.participation_scope} onChange={(event) => form.setData('participation_scope', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.participationScopeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="datetime-local" value={form.data.end_at} onChange={(event) => form.setData('end_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={form.data.rules_text} onChange={(event) => form.setData('rules_text', event.target.value)} placeholder="Rules" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.submission_instructions} onChange={(event) => form.setData('submission_instructions', event.target.value)} placeholder="Submission instructions" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={form.data.judging_mode} onChange={(event) => form.setData('judging_mode', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No judging mode</option>
{(props.judgingModeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.featured_artwork_id} onChange={(event) => form.setData('featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No featured artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<button type="submit" className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Save challenge</button>
</form>
<div className="space-y-6">
{props.publishUrl ? <form onSubmit={(event) => { event.preventDefault(); form.post(props.publishUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6"><button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Publish challenge</button></form> : null}
{props.attachArtworkUrl ? (
<form onSubmit={(event) => { event.preventDefault(); attachForm.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
<select value={attachForm.data.artwork_id} onChange={(event) => attachForm.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach</button>
</form>
) : null}
</div>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupChallenges() {
const { props } = usePage()
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-slate-400">Challenges keep the group active between releases and give members a focused creative prompt.</div>
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create challenge</a> : null}
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{items.length > 0 ? items.map((challenge) => (
<a key={challenge.id} href={challenge.urls?.edit || challenge.url} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-white">{challenge.title}</h2>
<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">{challenge.status}</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-400">{challenge.summary || 'Challenge page'}</p>
<div className="mt-4 text-xs text-slate-500">{challenge.entry_count || 0} linked entries</div>
</a>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No challenges yet.</div>}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioContentBrowser from '../../components/Studio/StudioContentBrowser'
export default function StudioGroupCollections() {
const { props } = usePage()
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="mb-6 rounded-[28px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em]">Shared curation</p>
<h2 className="mt-2 text-xl font-semibold">Create collections for {props.studioGroup?.name}</h2>
<a href={props.createUrl} className="mt-4 inline-flex rounded-full border border-sky-200/20 bg-sky-200/10 px-4 py-2 text-sm font-semibold text-sky-50">New group collection</a>
</div>
<StudioContentBrowser listing={props.listing} quickCreate={[{ key: 'collections', label: 'Collection', icon: 'fa-solid fa-layer-group', url: props.createUrl }]} hideModuleFilter />
</StudioLayout>
)
}

View File

@@ -0,0 +1,190 @@
import React, { useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
export default function StudioGroupCreate() {
const { props } = usePage()
const avatarInputRef = useRef(null)
const bannerInputRef = useRef(null)
const [form, setForm] = useState({
name: '',
slug: '',
headline: '',
bio: '',
type: '',
founded_at: '',
avatar_path: '',
banner_path: '',
visibility: 'public',
membership_policy: 'invite_only',
website_url: '',
links_json: [{ label: '', url: '' }],
avatar_file: null,
banner_file: null,
})
const [avatarPreview, setAvatarPreview] = useState('')
const [bannerPreview, setBannerPreview] = useState('')
const updateLink = (index, key, value) => {
setForm((current) => ({
...current,
links_json: current.links_json.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item),
}))
}
const addLink = () => {
setForm((current) => ({
...current,
links_json: [...current.links_json, { label: '', url: '' }],
}))
}
const removeLink = (index) => {
setForm((current) => ({
...current,
links_json: current.links_json.filter((_, itemIndex) => itemIndex !== index),
}))
}
const submit = () => {
router.post(props.endpoints?.store, {
...form,
links_json: form.links_json.filter((item) => item.label.trim() !== '' || item.url.trim() !== ''),
}, {
forceFormData: true,
})
}
const handleFileSelected = (field, setPreview) => (event) => {
const file = event.target.files?.[0] || null
setForm((current) => ({ ...current, [field]: file }))
setPreview(file ? URL.createObjectURL(file) : '')
}
const clearSelectedFile = (field, setPreview, inputRef) => {
setForm((current) => ({ ...current, [field]: null }))
setPreview('')
if (inputRef.current) {
inputRef.current.value = ''
}
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="mx-auto mb-6 max-w-5xl">
<GroupStudioPromoCard
title="Set up the public identity before the first release"
description="A strong group page makes collaborative publishing legible: who leads the team, what kind of work you make, and why contributors should join or follow."
bullets={[
{ title: 'Headline first', body: 'Use the headline to explain what the collective publishes and what makes the group distinct.' },
{ title: 'Recruit with clarity', body: 'After creation, configure recruitment so open roles surface across search and browse experiences.' },
{ title: 'Own the presentation', body: 'Avatar, cover art, and links shape how the group appears on artworks, profile summaries, and leaderboards.' },
]}
primaryLabel="Back to groups"
primaryHref="/studio/groups"
secondaryLabel="Browse public groups"
secondaryHref="/groups"
/>
</div>
<section className="mx-auto max-w-3xl rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-5">
<label className="grid gap-2 text-sm text-slate-200">
<span>Name</span>
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value, slug: current.slug || event.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') }))} 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-200">
<span>Slug</span>
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} 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-200">
<span>Short description</span>
<input value={form.headline} onChange={(event) => setForm((current) => ({ ...current, headline: event.target.value }))} 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-200">
<span>About</span>
<textarea value={form.bio} onChange={(event) => setForm((current) => ({ ...current, bio: event.target.value }))} rows={6} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-5 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-200">
<span>Type / category</span>
<input value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} 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-200">
<span>Founded date</span>
<input type="date" value={form.founded_at} onChange={(event) => setForm((current) => ({ ...current, founded_at: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-200">
<span>Website</span>
<input value={form.website_url} onChange={(event) => setForm((current) => ({ ...current, website_url: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-5 md:grid-cols-2">
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Avatar / logo</span>
<div className="flex h-28 w-28 items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{avatarPreview || form.avatar_path ? <img src={avatarPreview || form.avatar_path} alt="Avatar preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-image text-slate-500" />}
</div>
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('avatar_file', setAvatarPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => avatarInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload avatar</button>
{form.avatar_file ? <button type="button" onClick={() => clearSelectedFile('avatar_file', setAvatarPreview, avatarInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use URL instead</button> : null}
</div>
<label className="grid gap-2 text-sm text-slate-200">
<span>Or paste an image URL</span>
<input value={form.avatar_path} onChange={(event) => setForm((current) => ({ ...current, avatar_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Cover image</span>
<div className="flex h-28 w-full items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{bannerPreview || form.banner_path ? <img src={bannerPreview || form.banner_path} alt="Cover preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-panorama text-slate-500" />}
</div>
<input ref={bannerInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('banner_file', setBannerPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => bannerInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload cover</button>
{form.banner_file ? <button type="button" onClick={() => clearSelectedFile('banner_file', setBannerPreview, bannerInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use URL instead</button> : null}
</div>
<label className="grid gap-2 text-sm text-slate-200">
<span>Or paste an image URL</span>
<input value={form.banner_path} onChange={(event) => setForm((current) => ({ ...current, banner_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</div>
<label className="grid gap-2 text-sm text-slate-200">
<span>Visibility</span>
<select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-200">
<span>Membership policy</span>
<select value={form.membership_policy} onChange={(event) => setForm((current) => ({ ...current, membership_policy: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(props.membershipPolicyOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-200">Links</span>
<button type="button" onClick={addLink} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Add link</button>
</div>
{form.links_json.map((item, index) => (
<div key={`link-${index}`} className="grid gap-3 md:grid-cols-[0.8fr_1.2fr_auto]">
<input value={item.label} onChange={(event) => updateLink(index, 'label', event.target.value)} placeholder="Label" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={item.url} onChange={(event) => updateLink(index, 'url', event.target.value)} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => removeLink(index)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Remove</button>
</div>
))}
</div>
<div className="flex justify-end gap-3">
<a href="/studio/groups" className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white">Cancel</a>
<button type="button" onClick={submit} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Create group</button>
</div>
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,400 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function StatCard({ 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>{label}</span></div>
<div className="mt-3 text-3xl font-semibold text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
function ContentCard({ item, fallbackLabel }) {
return (
<a href={item.manage_url || item.urls?.edit || item.edit_url || item.preview_url || item.view_url || item.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 transition hover:border-white/20">
{item.image_url ? <img src={item.image_url} alt={item.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-image text-2xl" /></div>}
<div className="p-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-white">{item.title}</h3>
<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.status || item.event_type || fallbackLabel}</span>
</div>
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.description || item.summary || fallbackLabel}</p>
</div>
</a>
)
}
function EmptyCard({ title, description }) {
return (
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">
<p className="font-semibold text-white">{title}</p>
<p className="mt-2 leading-6">{description}</p>
</div>
)
}
function ActivityCard({ item }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-white">{item.headline}</div>
{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 text-slate-400">{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 subject</a> : null}
</div>
)
}
export default function StudioGroupDashboard() {
const { props } = usePage()
const group = props.studioGroup
const members = Array.isArray(props.members) ? props.members : []
const dashboard = props.dashboard || {}
const draftsPendingAction = Array.isArray(props.draftsPendingAction) ? props.draftsPendingAction : []
const recentArtworks = Array.isArray(props.recentArtworks) ? props.recentArtworks : []
const recentCollections = Array.isArray(props.recentCollections) ? props.recentCollections : []
const recentPosts = Array.isArray(props.recentPosts) ? props.recentPosts : []
const recentProjects = Array.isArray(props.recentProjects) ? props.recentProjects : []
const recentReleases = Array.isArray(props.recentReleases) ? props.recentReleases : []
const recentChallenges = Array.isArray(props.recentChallenges) ? props.recentChallenges : []
const recentEvents = Array.isArray(props.recentEvents) ? props.recentEvents : []
const recentActivity = Array.isArray(props.recentActivity) ? props.recentActivity : []
const trustSignals = Array.isArray(props.trustSignals) ? props.trustSignals : []
const reputationSummary = props.reputationSummary || {}
const pendingJoinRequests = Array.isArray(props.pendingJoinRequests) ? props.pendingJoinRequests : []
const reviewQueuePreview = Array.isArray(props.reviewQueuePreview) ? props.reviewQueuePreview : []
const recruitment = props.recruitment || null
const recentHistory = Array.isArray(props.recentHistory) ? props.recentHistory : []
const roleSummary = members.reduce((summary, member) => {
const role = String(member.role || 'member')
summary[role] = (summary[role] || 0) + 1
return summary
}, {})
const quickActions = [
{ label: 'Upload artwork', href: group?.urls?.upload, icon: 'fa-solid fa-cloud-arrow-up', tone: 'sky', detail: 'Start a new group-published artwork.' },
{ label: 'Invite member', href: group?.urls?.studio_invitations, icon: 'fa-solid fa-user-plus', tone: 'emerald', detail: `Manage invites${Number(dashboard.pending_invites_count || 0) > 0 ? ` (${Number(dashboard.pending_invites_count)})` : ''}.` },
{ label: 'Review queue', href: group?.urls?.studio_review, icon: 'fa-solid fa-list-check', tone: 'amber', detail: `${Number(dashboard.pending_reviews_count || 0)} submissions waiting.` },
{ label: 'Posts', href: group?.urls?.studio_posts, icon: 'fa-solid fa-bullhorn', tone: 'violet', detail: `${Number(dashboard.published_posts_count || 0)} published posts.` },
{ label: 'Projects', href: group?.urls?.studio_projects, icon: 'fa-solid fa-diagram-project', tone: 'sky', detail: `${Number(dashboard.projects_count || 0)} total projects.` },
{ label: 'Releases', href: group?.urls?.studio_releases, icon: 'fa-solid fa-rocket', tone: 'amber', detail: `${Number(dashboard.published_releases_count || 0)} published releases.` },
{ label: 'Challenges', href: group?.urls?.studio_challenges, icon: 'fa-solid fa-trophy', tone: 'amber', detail: `${Number(dashboard.active_challenges_count || 0)} active or published.` },
{ label: 'Events', href: group?.urls?.studio_events, icon: 'fa-solid fa-calendar-day', tone: 'emerald', detail: `${Number(dashboard.events_count || 0)} scheduled or archived.` },
{ label: 'Assets', href: group?.urls?.studio_assets, icon: 'fa-solid fa-box-archive', tone: 'violet', detail: `${Number(dashboard.assets_count || 0)} shared files.` },
{ label: 'Reputation', href: group?.urls?.studio_reputation, icon: 'fa-solid fa-shield-heart', tone: 'sky', detail: `${Number(reputationSummary?.counts?.contributors || 0)} contributors tracked.` },
{ label: 'Activity', href: group?.urls?.studio_activity, icon: 'fa-solid fa-wave-square', tone: 'sky', detail: `${Number(dashboard.activity_count || 0)} feed items recorded.` },
{ label: 'Edit profile', href: group?.urls?.studio_settings, icon: 'fa-solid fa-pen-to-square', tone: 'amber', detail: 'Update headline, visuals, and links.' },
{ label: 'Recruitment', href: group?.urls?.studio_recruitment, icon: 'fa-solid fa-user-plus', tone: 'emerald', detail: recruitment?.is_recruiting ? 'Recruitment is live.' : 'Configure recruiting status.' },
{ label: 'Create collection', href: group?.urls?.collection_create, icon: 'fa-solid fa-layer-group', tone: 'violet', detail: 'Publish a new collection under this group.' },
].filter((item) => Boolean(item.href))
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 (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
<StatCard label="Artworks" value={group?.counts?.artworks} icon="fa-solid fa-images" />
<StatCard label="Collections" value={group?.counts?.collections} icon="fa-solid fa-layer-group" />
<StatCard label="Followers" value={group?.counts?.followers} icon="fa-solid fa-user-group" />
<StatCard label="Active members" value={dashboard?.active_members_count || group?.counts?.members} icon="fa-solid fa-people-group" />
<StatCard label="Projects" value={dashboard?.projects_count} icon="fa-solid fa-diagram-project" />
<StatCard label="Releases" value={dashboard?.published_releases_count || dashboard?.releases_count} icon="fa-solid fa-rocket" />
<StatCard label="Assets" value={dashboard?.assets_count} icon="fa-solid fa-box-archive" />
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Quick actions</h2>
<p className="mt-1 text-sm text-slate-400">Run the most common group tasks without leaving the dashboard.</p>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{quickActions.map((action) => (
<a key={action.label} href={action.href} className={`rounded-[24px] border px-4 py-4 transition hover:translate-y-[-1px] hover:border-white/20 ${toneClasses[action.tone] || toneClasses.sky}`}>
<div className="flex items-center gap-3">
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-current/20 bg-black/10"><i className={action.icon} /></span>
<div>
<div className="text-sm font-semibold">{action.label}</div>
<div className="mt-1 text-xs opacity-80">{action.detail}</div>
</div>
</div>
</a>
))}
</div>
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-white">Pending action</h3>
<p className="mt-1 text-sm text-slate-400">Drafts and scheduled items that still need a publishing decision.</p>
</div>
<div className="text-right text-sm text-slate-300">
<div>{Number(dashboard?.draft_artworks_count || 0)} drafts</div>
<div>{Number(dashboard?.scheduled_artworks_count || 0)} scheduled</div>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{draftsPendingAction.length > 0 ? draftsPendingAction.map((artwork) => (
<ContentCard key={artwork.id} item={artwork} fallbackLabel="Draft" />
)) : <EmptyCard title="No drafts waiting" description="This group has no draft artworks waiting for review or completion right now." />}
</div>
</div>
{pendingJoinRequests.length > 0 ? (
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-white">Pending join requests</h3>
<p className="mt-1 text-sm text-slate-400">Applicants waiting for a review decision.</p>
</div>
{group?.urls?.studio_join_requests ? <a href={group.urls.studio_join_requests} className="text-sm font-semibold text-sky-200">Open queue</a> : null}
</div>
<div className="mt-4 space-y-3">
{pendingJoinRequests.map((item) => (
<div key={item.id} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="font-semibold text-white">{item.user?.name || item.user?.username}</div>
<div className="mt-1 text-sm text-slate-400">{item.desired_role_label || item.desired_role || 'Contributor'} {item.created_at ? new Date(item.created_at).toLocaleDateString() : 'New'}</div>
</div>
))}
</div>
</div>
) : null}
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-white">Members</h2>
<a href={group?.urls?.studio_members} className="text-sm font-semibold text-sky-200">Manage</a>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{Object.entries(roleSummary).map(([role, count]) => (
<div key={role} className="rounded-2xl 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.16em] text-slate-500">{role}</div>
<div className="mt-1 text-xl font-semibold text-white">{Number(count)}</div>
</div>
))}
</div>
<div className="mt-4 space-y-3">
{members.slice(0, 6).map((member) => (
<div key={member.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.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 flex-1">
<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}</div>
</div>
</div>
))}
</div>
<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">Recruitment</div>
<div className="mt-2 text-lg font-semibold text-white">{recruitment?.is_recruiting ? (recruitment.headline || 'Recruiting is active') : 'Recruitment is off'}</div>
<p className="mt-2 text-sm text-slate-400">{recruitment?.description || 'Set open roles, skills, and contact instructions from the recruitment page.'}</p>
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Releases</h2>
<p className="mt-1 text-sm text-slate-400">Track featured drops and current release pipelines.</p>
</div>
{group?.urls?.studio_releases ? <a href={group.urls.studio_releases} className="text-sm font-semibold text-sky-200">Manage</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentReleases.length > 0 ? recentReleases.map((release) => (
<ContentCard key={release.id} item={release} fallbackLabel="Release" />
)) : <EmptyCard title="No releases yet" description="Create a release to track milestones, contributors, and publication status." />}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Projects</h2>
<p className="mt-1 text-sm text-slate-400">Recent structured releases and collaboration hubs.</p>
</div>
{group?.urls?.studio_projects ? <a href={group.urls.studio_projects} className="text-sm font-semibold text-sky-200">Manage</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentProjects.length > 0 ? recentProjects.map((project) => (
<ContentCard key={project.id} item={project} fallbackLabel="Project" />
)) : <EmptyCard title="No projects yet" description="Create a project to bundle shared assets, linked artworks, and a release state." />}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Challenges</h2>
<p className="mt-1 text-sm text-slate-400">Current creative prompts and challenge arcs.</p>
</div>
{group?.urls?.studio_challenges ? <a href={group.urls.studio_challenges} className="text-sm font-semibold text-sky-200">Manage</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentChallenges.length > 0 ? recentChallenges.map((challenge) => (
<ContentCard key={challenge.id} item={challenge} fallbackLabel="Challenge" />
)) : <EmptyCard title="No challenges yet" description="Launch a challenge to keep the group active between major releases." />}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Trust summary</h2>
<p className="mt-1 text-sm text-slate-400">Public-facing trust labels and internal contributor health snapshot.</p>
</div>
{group?.urls?.studio_reputation ? <a href={group.urls.studio_reputation} className="text-sm font-semibold text-sky-200">Open dashboard</a> : null}
</div>
<div className="mt-4 flex flex-wrap gap-2">{trustSignals.map((signal) => <span key={signal.key} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">{signal.label}</span>)}</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-slate-300"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contributors</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputationSummary?.counts?.contributors || 0)}</div></div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-slate-300"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Group badges</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputationSummary?.counts?.group_badges || 0)}</div></div>
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Contributor highlights</h2>
<p className="mt-1 text-sm text-slate-400">Recent high-trust contributors and badge unlocks.</p>
</div>
</div>
<div className="mt-4 space-y-3">{Array.isArray(reputationSummary?.top_contributors) && reputationSummary.top_contributors.length > 0 ? reputationSummary.top_contributors.slice(0, 4).map((entry) => <div key={entry.user?.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="font-semibold text-white">{entry.user?.name || entry.user?.username}</div><div className="mt-1 text-sm text-slate-400">{entry.summary || 'Contributor'}</div><div className="mt-2 text-xs text-slate-500">{entry.counts?.releases || 0} releases {entry.counts?.credited_artworks || 0} artworks</div></div>) : <EmptyCard title="No contributor signals yet" description="Release and milestone activity will populate contributor reputation here." />}</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Recent artworks</h2>
<p className="mt-1 text-sm text-slate-400">Latest published work released under this group identity.</p>
</div>
<a href={group?.urls?.studio_artworks} className="text-sm font-semibold text-sky-200">View all</a>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentArtworks.length > 0 ? recentArtworks.map((artwork) => (
<ContentCard key={artwork.id} item={artwork} fallbackLabel="Published" />
)) : <EmptyCard title="No published artworks yet" description="Publish the first group artwork to start building this feed." />}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Events</h2>
<p className="mt-1 text-sm text-slate-400">Upcoming or recently updated moments on the group timeline.</p>
</div>
{group?.urls?.studio_events ? <a href={group.urls.studio_events} className="text-sm font-semibold text-sky-200">Manage</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentEvents.length > 0 ? recentEvents.map((event) => (
<ContentCard key={event.id} item={event} fallbackLabel="Event" />
)) : <EmptyCard title="No events yet" description="Schedule a launch, stream, or milestone to start the group timeline." />}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Recent collections</h2>
<p className="mt-1 text-sm text-slate-400">Collections most recently updated in this group workspace.</p>
</div>
<a href={group?.urls?.studio_collections} className="text-sm font-semibold text-sky-200">View all</a>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentCollections.length > 0 ? recentCollections.map((collection) => (
<ContentCard key={collection.id} item={collection} fallbackLabel="Collection" />
)) : <EmptyCard title="No collections yet" description="Create a collection to organize group work into campaigns, series, or themed sets." />}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Activity feed</h2>
<p className="mt-1 text-sm text-slate-400">Pinned and recent internal or public timeline items.</p>
</div>
{group?.urls?.studio_activity ? <a href={group.urls.studio_activity} className="text-sm font-semibold text-sky-200">Open feed</a> : null}
</div>
<div className="mt-4 space-y-3">
{recentActivity.length > 0 ? recentActivity.map((item) => (
<ActivityCard key={item.id} item={item} />
)) : <EmptyCard title="No activity items yet" description="Publishing projects, events, posts, and member milestones will populate this feed." />}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Review queue</h2>
<p className="mt-1 text-sm text-slate-400">Latest artwork submissions waiting for moderation.</p>
</div>
{group?.urls?.studio_review ? <a href={group.urls.studio_review} className="text-sm font-semibold text-sky-200">Open queue</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{reviewQueuePreview.length > 0 ? reviewQueuePreview.map((item) => (
<a key={item.id} href={item.urls?.edit} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
<div className="text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.group_review_status}</div>
</a>
)) : <EmptyCard title="No pending reviews" description="Contributor submissions will appear here when they are sent for review." />}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Recent posts</h2>
<p className="mt-1 text-sm text-slate-400">Announcements and updates published from the group.</p>
</div>
{group?.urls?.studio_posts ? <a href={group.urls.studio_posts} className="text-sm font-semibold text-sky-200">Manage posts</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{recentPosts.length > 0 ? recentPosts.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="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{post.type}</div>
<div className="mt-2 text-base font-semibold text-white">{post.title}</div>
<p className="mt-2 text-sm text-slate-400">{post.excerpt || 'Open post'}</p>
</a>
)) : <EmptyCard title="No posts yet" description="Create the first group announcement to add a public news feed." />}
</div>
</section>
</div>
<section className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Recent history</h2>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{recentHistory.length > 0 ? recentHistory.map((item) => (
<div key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-sm font-semibold text-white">{item.summary || item.action_type}</div>
<div className="mt-2 text-xs text-slate-400">{item.actor?.name || item.actor?.username || 'System'} {item.created_at ? new Date(item.created_at).toLocaleString() : 'Recently'}</div>
</div>
)) : <EmptyCard title="No history yet" description="Audit events will appear here as members review requests, posts, and submissions." />}
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,82 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupEventEditor() {
const { props } = usePage()
const eventRecord = props.event || null
const form = useForm({
title: eventRecord?.title || '',
summary: eventRecord?.summary || '',
description: eventRecord?.description || '',
event_type: eventRecord?.event_type || props.typeOptions?.[0]?.value || 'launch',
visibility: eventRecord?.visibility || props.visibilityOptions?.[0]?.value || 'public',
status: eventRecord?.status || props.statusOptions?.[0]?.value || 'draft',
start_at: eventRecord?.start_at ? eventRecord.start_at.slice(0, 16) : '',
end_at: eventRecord?.end_at ? eventRecord.end_at.slice(0, 16) : '',
timezone: eventRecord?.timezone || 'UTC',
location: eventRecord?.location || '',
external_url: eventRecord?.external_url || '',
linked_project_id: eventRecord?.linked_project?.id || '',
linked_collection_id: eventRecord?.linked_collection?.id || '',
linked_challenge_id: eventRecord?.linked_challenge?.id || '',
is_featured: Boolean(eventRecord?.is_featured),
cover_file: null,
})
const submit = (event) => {
event.preventDefault()
const options = { forceFormData: true, preserveScroll: true }
if (props.updateUrl) {
form.post(props.updateUrl, { ...options, _method: 'patch' })
return
}
form.post(props.storeUrl, options)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-4">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Event title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Event description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.event_type} onChange={(event) => form.setData('event_type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.typeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="datetime-local" value={form.data.end_at} onChange={(event) => form.setData('end_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input value={form.data.timezone} onChange={(event) => form.setData('timezone', event.target.value)} placeholder="Timezone" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={form.data.location} onChange={(event) => form.setData('location', event.target.value)} placeholder="Location" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<input value={form.data.external_url} onChange={(event) => form.setData('external_url', event.target.value)} placeholder="External link" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.linked_challenge_id} onChange={(event) => form.setData('linked_challenge_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked challenge</option>
{(props.challengeOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> Featured event</label>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<button type="submit" className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Save event</button>
</form>
{props.publishUrl ? <form onSubmit={(event) => { event.preventDefault(); form.post(props.publishUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6"><button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Publish event</button></form> : null}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupEvents() {
const { props } = usePage()
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-slate-400">Events let the group announce launches, sessions, milestones, and time-based updates.</div>
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create event</a> : null}
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{items.length > 0 ? items.map((event) => (
<a key={event.id} href={event.urls?.edit || event.url} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-white">{event.title}</h2>
<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">{event.status}</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-400">{event.summary || 'Event page'}</p>
<div className="mt-4 text-xs text-slate-500">{event.start_at ? new Date(event.start_at).toLocaleString() : 'Unscheduled'} {event.event_type}</div>
</a>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No events yet.</div>}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,129 @@
import React, { useMemo, useState } from 'react'
import { Link, router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function formatInviteTimestamp(value) {
if (!value) return null
try {
return new Date(value).toLocaleString()
} catch {
return value
}
}
export default function StudioGroupInvitations() {
const { props } = usePage()
const invitations = Array.isArray(props.invitations) ? props.invitations : []
const activeMembers = Array.isArray(props.members) ? props.members.filter((member) => member.status === 'active') : []
const [invite, setInvite] = useState({ username: '', role: 'contributor', note: '', expires_in_days: 7 })
const pendingInvites = useMemo(
() => invitations.filter((item) => item.status === 'pending'),
[invitations]
)
const revokedInvites = useMemo(
() => invitations.filter((item) => item.status === 'revoked'),
[invitations]
)
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="mb-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/75">Group invitations</p>
<h2 className="mt-2 text-xl font-semibold text-white">Invite collaborators into {props.studioGroup?.name}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-300">Pending invites stay separate from active members here, so owners and admins can review who was invited, when the invite expires, and revoke access before acceptance.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href={props.studioGroup?.urls?.studio_members} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Members</Link>
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">{pendingInvites.length} pending</span>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-[1.1fr_0.8fr_1fr_0.7fr_auto]">
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={invite.expires_in_days} onChange={(event) => setInvite((current) => ({ ...current, expires_in_days: event.target.value }))} type="number" min="1" max="30" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => router.post(props.endpoints?.invite, { ...invite, expires_in_days: Number(invite.expires_in_days || 7) || 7 })} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Send invite</button>
</div>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-white">Pending invitations</h2>
<span className="text-sm text-slate-400">{pendingInvites.length} outstanding</span>
</div>
<div className="mt-4 space-y-3">
{pendingInvites.length > 0 ? pendingInvites.map((inviteRow) => (
<article key={inviteRow.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="flex items-center gap-3">
{inviteRow.user?.avatar_url ? <img src={inviteRow.user.avatar_url} alt={inviteRow.user.name || inviteRow.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>
<div className="font-semibold text-white">{inviteRow.user?.name || inviteRow.user?.username}</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{inviteRow.role_label || inviteRow.role}</div>
</div>
</div>
<div className="md:ml-auto flex flex-wrap items-center gap-3 text-xs text-slate-400">
{inviteRow.invited_by ? <span>Invited by {inviteRow.invited_by.name || inviteRow.invited_by.username}</span> : null}
{inviteRow.invited_at ? <span>Sent {formatInviteTimestamp(inviteRow.invited_at)}</span> : null}
{inviteRow.expires_at ? <span>Expires {formatInviteTimestamp(inviteRow.expires_at)}</span> : null}
</div>
</div>
{inviteRow.note ? <p className="mt-3 text-sm text-slate-300">{inviteRow.note}</p> : null}
<div className="mt-4 flex flex-wrap gap-2">
{inviteRow.can_revoke && inviteRow.revoke_url ? <button type="button" onClick={() => router.delete(inviteRow.revoke_url)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100">Revoke invite</button> : null}
</div>
</article>
)) : <div className="rounded-[24px] border border-dashed border-white/10 px-6 py-12 text-center text-slate-400">No pending invites for this group.</div>}
</div>
</section>
<div className="space-y-6">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-white">Recent invite history</h2>
<span className="text-sm text-slate-400">{revokedInvites.length} revoked or expired</span>
</div>
<div className="mt-4 space-y-3">
{revokedInvites.length > 0 ? revokedInvites.map((inviteRow) => (
<article key={inviteRow.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="font-semibold text-white">{inviteRow.user?.name || inviteRow.user?.username}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{inviteRow.is_expired ? 'Expired' : 'Revoked'} {inviteRow.role_label || inviteRow.role}</div>
{inviteRow.invited_at ? <p className="mt-2 text-sm text-slate-400">Originally sent {formatInviteTimestamp(inviteRow.invited_at)}</p> : null}
</article>
)) : <div className="rounded-2xl border border-dashed border-white/10 px-4 py-8 text-center text-slate-400">No recent invite history yet.</div>}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-white">Active members</h2>
<span className="text-sm text-slate-400">{activeMembers.length} active</span>
</div>
<div className="mt-4 space-y-3">
{activeMembers.slice(0, 6).map((member) => (
<div key={member.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.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 flex-1">
<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>
</div>
))}
</div>
</section>
</div>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,96 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function HistoryList({ items }) {
if (!Array.isArray(items) || items.length === 0) {
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No recent history yet.</div>
}
return (
<div className="space-y-3">
{items.map((item) => (
<div key={item.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm font-semibold text-white">{item.summary || item.action_type}</div>
<div className="mt-1 text-xs text-slate-400">{item.actor?.name || item.actor?.username || 'System'} {item.created_at ? new Date(item.created_at).toLocaleString() : 'Recently'}</div>
</div>
))}
</div>
)
}
export default function StudioGroupJoinRequests() {
const { props } = usePage()
const listing = props.listing || {}
const items = Array.isArray(listing.items) ? listing.items : []
const approve = (request) => {
const role = window.prompt('Role to assign on approval? contributor, editor, or admin', request.desired_role || 'contributor') || request.desired_role || 'contributor'
const notes = window.prompt('Optional approval note', '') || ''
router.post(request.can_approve ? routeUrl(props.studioGroup?.urls?.studio_join_requests, request.id, 'approve') : '', { action: 'approve', role, review_notes: notes })
}
const reject = (request) => {
const notes = window.prompt('Optional rejection note', '') || ''
router.post(request.can_reject ? routeUrl(props.studioGroup?.urls?.studio_join_requests, request.id, 'reject') : '', { action: 'reject', review_notes: notes })
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Incoming requests</h2>
<p className="mt-1 text-sm text-slate-400">Approve, reject, and assign roles from one queue.</p>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{listing.filters?.bucket || 'pending'}</span>
</div>
<div className="mt-4 space-y-4">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
{item.user?.avatar_url ? <img src={item.user.avatar_url} alt={item.user.name || item.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>
<div className="font-semibold text-white">{item.user?.name || item.user?.username}</div>
<div className="text-sm text-slate-400">Requested role: {item.desired_role_label || item.desired_role || 'Contributor'}</div>
</div>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{item.status}</span>
</div>
{item.message ? <p className="mt-4 text-sm leading-6 text-slate-300">{item.message}</p> : null}
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-400">
{item.portfolio_url ? <a href={item.portfolio_url} className="text-sky-200 underline underline-offset-4">Portfolio</a> : null}
{Array.isArray(item.skills) && item.skills.length > 0 ? <span>{item.skills.join(', ')}</span> : null}
{item.created_at ? <span>{new Date(item.created_at).toLocaleString()}</span> : null}
</div>
{item.review_notes ? <p className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-300">{item.review_notes}</p> : null}
{item.can_approve || item.can_reject ? (
<div className="mt-4 flex flex-wrap gap-2">
{item.can_approve ? <button type="button" onClick={() => approve(item)} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Approve</button> : null}
{item.can_reject ? <button type="button" onClick={() => reject(item)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Reject</button> : null}
</div>
) : null}
</article>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No join requests in this bucket.</div>}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Recent history</h2>
<p className="mt-1 text-sm text-slate-400">Audit trail for moderation-sensitive group actions.</p>
<div className="mt-4">
<HistoryList items={props.recentHistory} />
</div>
</section>
</div>
</StudioLayout>
)
}
function routeUrl(baseUrl, id, action) {
if (!baseUrl) return ''
return `${String(baseUrl).replace(/\/$/, '')}/${id}/${action}`
}

View File

@@ -0,0 +1,193 @@
import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function overrideMap(member) {
const entries = Array.isArray(member.permission_overrides) ? member.permission_overrides : []
return entries.reduce((carry, item) => {
if (!item?.key) return carry
carry[item.key] = item.is_allowed === true ? 'allow' : 'deny'
return carry
}, {})
}
function prettifyPermission(value) {
return String(value || '').replaceAll('_', ' ')
}
export default function StudioGroupMembers() {
const { props } = usePage()
const canManageMembers = Boolean(props.canManageMembers)
const [invite, setInvite] = useState({ username: '', role: 'contributor', note: '' })
const [search, setSearch] = useState('')
const [editingMemberId, setEditingMemberId] = useState(null)
const [permissionDrafts, setPermissionDrafts] = useState({})
const members = Array.isArray(props.members) ? props.members : []
const permissionOptions = Array.isArray(props.permissionOverrideOptions) ? props.permissionOverrideOptions : []
const filteredMembers = members.filter((member) => {
const haystack = `${member.user?.name || ''} ${member.user?.username || ''} ${member.role_label || member.role || ''}`.toLowerCase()
return haystack.includes(search.trim().toLowerCase())
})
const confirmTransfer = (member) => {
if (!window.confirm(`Transfer ownership of this group to ${member.user?.name || member.user?.username}? This removes your owner privileges immediately.`)) {
return
}
router.post(props.endpoints?.transferPattern.replace('__MEMBER__', String(member.id)))
}
const confirmRemoval = (member) => {
if (!window.confirm(`Remove ${member.user?.name || member.user?.username} from this group?`)) {
return
}
router.delete(props.endpoints?.deletePattern.replace('__MEMBER__', String(member.id)))
}
const openPermissionEditor = (member) => {
setEditingMemberId(member.id)
setPermissionDrafts((current) => ({ ...current, [member.id]: overrideMap(member) }))
}
const setPermissionState = (memberId, key, value) => {
setPermissionDrafts((current) => ({
...current,
[memberId]: {
...(current[memberId] || {}),
[key]: value,
},
}))
}
const savePermissions = (member) => {
const state = permissionDrafts[member.id] || {}
const payload = permissionOptions
.filter((option) => state[option.value] === 'allow' || state[option.value] === 'deny')
.map((option) => ({ key: option.value, is_allowed: state[option.value] === 'allow' }))
router.patch(props.endpoints?.permissionsPattern.replace('__MEMBER__', String(member.id)), {
permission_overrides: payload,
}, {
onSuccess: () => setEditingMemberId(null),
})
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
{canManageMembers ? (
<section className="mb-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-white">Invite member</h2>
{props.endpoints?.invitations ? <a href={props.endpoints.invitations} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Manage invitations</a> : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_0.8fr_1fr_auto]">
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => router.post(props.endpoints?.invite, invite)} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Invite</button>
</div>
</section>
) : null}
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Member directory</h2>
<p className="mt-1 text-sm text-slate-400">Search the current roster, then adjust roles or membership status.</p>
</div>
<label className="grid gap-2 text-sm text-slate-300 md:min-w-[260px]">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search members</span>
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Name, username, or role" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="mt-5 overflow-hidden rounded-[24px] border border-white/10">
<div className="hidden grid-cols-[minmax(0,1.5fr)_160px_120px_minmax(0,220px)] gap-3 border-b border-white/10 bg-white/[0.04] px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400 md:grid">
<span>Member</span>
<span>Role</span>
<span>Status</span>
<span>Actions</span>
</div>
<div className="divide-y divide-white/10">
{filteredMembers.map((member) => (
<article key={member.id} className="grid gap-4 px-4 py-4 md:grid-cols-[minmax(0,1.5fr)_160px_120px_minmax(0,220px)] md:items-center">
<div className="flex items-center gap-3">
{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>
<div className="font-semibold text-white">{member.user?.name || member.user?.username}</div>
<div className="text-sm text-slate-400">@{member.user?.username || 'member'}</div>
</div>
</div>
<div>
{canManageMembers && member.role !== 'owner' ? (
<select value={member.role} onChange={(event) => router.patch(props.endpoints?.updatePattern.replace('__MEMBER__', String(member.id)), { role: event.target.value })} className="w-full rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-white outline-none">
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
) : <span className="inline-flex rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100">{member.role === 'owner' ? 'Owner' : (member.role_label || member.role)}</span>}
{Array.isArray(member.permission_overrides) && member.permission_overrides.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{member.permission_overrides.map((permission) => (
<span key={`${permission.key}-${permission.is_allowed ? 'allow' : 'deny'}`} className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] ${permission.is_allowed ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-rose-300/20 bg-rose-400/10 text-rose-100'}`}>
{permission.is_allowed ? 'Allow' : 'Deny'} {prettifyPermission(permission.key)}
</span>
))}
</div>
) : null}
</div>
<div>
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-300">{member.status}</span>
</div>
<div className="flex flex-wrap gap-2">
{member.can_manage_permissions ? <button type="button" onClick={() => openPermissionEditor(member)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Permissions</button> : null}
{canManageMembers && member.can_transfer ? <button type="button" onClick={() => confirmTransfer(member)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Transfer</button> : null}
{canManageMembers && member.can_revoke ? <button type="button" onClick={() => confirmRemoval(member)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100">Remove</button> : null}
</div>
{editingMemberId === member.id ? (
<div className="md:col-span-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">Permission overrides</h3>
<p className="mt-1 text-xs text-slate-400">Set each advanced capability to inherit, allow, or deny.</p>
</div>
<div className="flex gap-2">
<button type="button" onClick={() => setEditingMemberId(null)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Cancel</button>
<button type="button" onClick={() => savePermissions(member)} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-2 text-sm font-semibold text-sky-100">Save</button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{permissionOptions.map((option) => {
const current = permissionDrafts[member.id]?.[option.value] || 'inherit'
return (
<div key={option.value} className="rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="text-sm font-semibold text-white">{option.label}</div>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'inherit')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'inherit' ? 'border-white/20 bg-white/[0.08] text-white' : 'border-white/10 bg-transparent text-slate-300'}`}>Inherit</button>
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'allow')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'allow' ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-white/10 bg-transparent text-slate-300'}`}>Allow</button>
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'deny')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'deny' ? 'border-rose-300/20 bg-rose-400/10 text-rose-100' : 'border-white/10 bg-transparent text-slate-300'}`}>Deny</button>
</div>
</div>
)
})}
</div>
</div>
) : null}
</article>
))}
{filteredMembers.length === 0 ? <div className="px-4 py-8 text-sm text-slate-400">No members match the current search.</div> : null}
</div>
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,64 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupPostEditor() {
const { props } = usePage()
const post = props.post || {}
const form = useForm({
type: post.type || 'announcement',
title: post.title || '',
excerpt: post.excerpt || '',
content: post.content || '',
cover_path: post.cover_url || '',
})
const submit = (event) => {
event.preventDefault()
if (props.updateUrl) {
form.patch(props.updateUrl)
return
}
form.post(props.storeUrl)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} 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">Excerpt</span>
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={3} className="rounded-[24px] 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">Content</span>
<textarea value={form.data.content} onChange={(event) => form.setData('content', event.target.value)} rows={12} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Post controls</h2>
<div className="mt-5 space-y-3">
<button type="submit" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save</button>
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100">Publish</button> : null}
{props.pinUrl ? <button type="button" onClick={() => router.post(props.pinUrl)} className="w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">Toggle pinned</button> : null}
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Archive</button> : null}
</div>
</section>
</form>
</StudioLayout>
)
}

View File

@@ -0,0 +1,44 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupPosts() {
const { props } = usePage()
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Post library</h2>
<p className="mt-1 text-sm text-slate-400">Draft, publish, pin, and archive public group posts.</p>
</div>
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">New post</a> : null}
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{item.type}</div>
<h3 className="mt-2 text-lg font-semibold text-white">{item.title}</h3>
</div>
<div className="flex flex-col items-end gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{item.status}</span>
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-amber-100">Pinned</span> : null}
</div>
</div>
<p className="mt-3 text-sm leading-6 text-slate-300">{item.excerpt || item.content || 'No excerpt yet.'}</p>
<div className="mt-4 flex flex-wrap gap-2">
<a href={item.urls?.edit} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</a>
{item.urls?.public ? <a href={item.urls.public} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">View</a> : null}
</div>
</article>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No posts yet.</div>}
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,143 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function normalizeIds(values) {
return Array.from(values || []).map((option) => Number(option.value)).filter((value) => Number.isFinite(value) && value > 0)
}
export default function StudioGroupProjectEditor() {
const { props } = usePage()
const project = props.project || null
const form = useForm({
title: project?.title || '',
summary: project?.summary || '',
description: project?.description || '',
visibility: project?.visibility || props.visibilityOptions?.[0]?.value || 'public',
status: project?.status || props.statusOptions?.[0]?.value || 'planned',
start_date: project?.start_date || '',
target_date: project?.target_date || '',
lead_user_id: project?.lead?.id || '',
linked_collection_id: project?.linked_collection?.id || '',
linked_featured_artwork_id: '',
pinned_post_id: project?.pinned_post?.id || '',
member_user_ids: Array.isArray(project?.team) ? project.team.map((member) => member.id) : [],
cover_file: null,
})
const artworkAttach = useForm({ artwork_id: '' })
const assetAttach = useForm({ asset_id: '' })
const statusForm = useForm({ status: project?.status || props.statusOptions?.[0]?.value || 'planned' })
const milestoneForm = useForm({ title: '', summary: '', status: 'pending', due_date: '', owner_user_id: '', notes: '' })
const submit = (event) => {
event.preventDefault()
const options = { forceFormData: true, preserveScroll: true }
if (props.updateUrl) {
form.post(props.updateUrl, { ...options, _method: 'patch' })
return
}
form.post(props.storeUrl, options)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="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="grid gap-4">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Project title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Longer project description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="date" value={form.data.start_date} onChange={(event) => form.setData('start_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="date" value={form.data.target_date} onChange={(event) => form.setData('target_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.lead_user_id} onChange={(event) => form.setData('lead_user_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No lead</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<select multiple value={form.data.member_user_ids.map(String)} onChange={(event) => form.setData('member_user_ids', normalizeIds(event.target.selectedOptions))} className="min-h-40 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.linked_featured_artwork_id} onChange={(event) => form.setData('linked_featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No featured artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.pinned_post_id} onChange={(event) => form.setData('pinned_post_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No pinned post</option>
{(props.postOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<button type="submit" disabled={form.processing} className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">{form.processing ? 'Saving…' : 'Save project'}</button>
</section>
<div className="space-y-6">
{props.statusUrl ? (
<form onSubmit={(event) => { event.preventDefault(); statusForm.post(props.statusUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Status</h2>
<select value={statusForm.data.status} onChange={(event) => statusForm.setData('status', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Update status</button>
</form>
) : null}
{props.attachArtworkUrl ? (
<form onSubmit={(event) => { event.preventDefault(); artworkAttach.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
<select value={artworkAttach.data.artwork_id} onChange={(event) => artworkAttach.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach artwork</button>
</form>
) : null}
{props.attachAssetUrl ? (
<form onSubmit={(event) => { event.preventDefault(); assetAttach.post(props.attachAssetUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach asset</h2>
<select value={assetAttach.data.asset_id} onChange={(event) => assetAttach.setData('asset_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose asset</option>
{(props.assetOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach asset</button>
</form>
) : null}
{props.storeMilestoneUrl ? (
<form onSubmit={(event) => { event.preventDefault(); milestoneForm.post(props.storeMilestoneUrl, { preserveScroll: true, onSuccess: () => milestoneForm.reset('title', 'summary', 'due_date', 'owner_user_id', 'notes') }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Milestones</h2>
<div className="mt-4 space-y-3">
<input value={milestoneForm.data.title} onChange={(event) => milestoneForm.setData('title', event.target.value)} placeholder="Milestone title" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-3 md:grid-cols-2">
<select value={milestoneForm.data.status} onChange={(event) => milestoneForm.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<select value={milestoneForm.data.owner_user_id} onChange={(event) => milestoneForm.setData('owner_user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No owner</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add milestone</button>
</div>
{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><div className="font-semibold text-white">{milestone.title}</div><div className="mt-1 text-xs text-slate-500">{milestone.owner?.name || milestone.owner?.username || 'No owner'}{milestone.due_date ? ` • due ${milestone.due_date}` : ''}</div></div><button type="button" onClick={() => router.patch(props.updateMilestonePattern.replace('__MILESTONE__', String(milestone.id)), { title: milestone.title, summary: milestone.summary || '', status: milestone.status === 'completed' ? 'active' : 'completed', due_date: milestone.due_date || '', owner_user_id: milestone.owner?.id || '', notes: milestone.notes || '' }, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white">Mark {milestone.status === 'completed' ? 'active' : 'complete'}</button></div>{milestone.summary ? <p className="mt-2 text-sm text-slate-400">{milestone.summary}</p> : null}</div>)}</div> : null}
</form>
) : null}
</div>
</form>
</StudioLayout>
)
}

View File

@@ -0,0 +1,30 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupProjects() {
const { props } = usePage()
const listing = props.listing || {}
const items = Array.isArray(listing.items) ? listing.items : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-slate-400">Projects give the group a structured place for releases, teams, and linked outputs.</div>
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create project</a> : null}
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{items.length > 0 ? items.map((project) => (
<a key={project.id} href={project.urls?.edit || project.url} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-white">{project.title}</h2>
<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>
</div>
<p className="mt-3 text-sm leading-6 text-slate-400">{project.summary || 'Project page'}</p>
<div className="mt-4 text-xs text-slate-500">{project.counts?.artworks || 0} artworks {project.counts?.assets || 0} assets {project.counts?.team || 0} team {project.counts?.milestones || 0} milestones {project.counts?.releases || 0} releases</div>
</a>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No projects yet.</div>}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,101 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function toggleItem(list, value) {
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]
}
export default function StudioGroupRecruitment() {
const { props } = usePage()
const recruitment = props.recruitment || {}
const form = useForm({
is_recruiting: Boolean(recruitment.is_recruiting),
headline: recruitment.headline || '',
description: recruitment.description || '',
roles_json: Array.isArray(recruitment.roles) ? recruitment.roles : [],
skills_json: Array.isArray(recruitment.skills) ? recruitment.skills : [],
contact_mode: recruitment.contact_mode || 'join_request',
visibility: recruitment.visibility || 'public',
})
const submit = (event) => {
event.preventDefault()
form.patch(props.updateUrl)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Recruitment profile</h2>
<p className="mt-1 text-sm text-slate-400">Describe what the group is looking for and how applicants should reach you.</p>
</div>
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white">
<input type="checkbox" checked={form.data.is_recruiting} onChange={(event) => form.setData('is_recruiting', event.target.checked)} />
Recruiting now
</label>
</div>
<div className="mt-5 grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Headline</span>
<input value={form.data.headline} onChange={(event) => form.setData('headline', event.target.value)} 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">Description</span>
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} rows={7} className="rounded-[24px] 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">Roles</span>
<div className="flex flex-wrap gap-2 rounded-[24px] border border-white/10 bg-black/20 p-4">
{(Array.isArray(props.roleOptions) ? props.roleOptions : []).map((option) => {
const selected = form.data.roles_json.includes(option.value)
return <button key={option.value} type="button" onClick={() => form.setData('roles_json', toggleItem(form.data.roles_json, option.value))} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${selected ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-white/10 bg-white/[0.03] text-slate-300'}`}>{option.label}</button>
})}
</div>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Skills</span>
<div className="flex flex-wrap gap-2 rounded-[24px] border border-white/10 bg-black/20 p-4">
{(Array.isArray(props.skillOptions) ? props.skillOptions : []).map((option) => {
const selected = form.data.skills_json.includes(option.value)
return <button key={option.value} type="button" onClick={() => form.setData('skills_json', toggleItem(form.data.skills_json, option.value))} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${selected ? 'border-sky-300/20 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-white/[0.03] text-slate-300'}`}>{option.label}</button>
})}
</div>
</label>
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Application settings</h2>
<div className="mt-5 grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contact mode</span>
<select value={form.data.contact_mode} onChange={(event) => form.setData('contact_mode', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.contactModes) ? props.contactModes : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</span>
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.visibilityOptions) ? props.visibilityOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
<p className="font-semibold text-white">Public preview</p>
<p className="mt-2">{form.data.headline || 'No headline yet.'}</p>
<p className="mt-2 text-slate-400">{form.data.description || 'Recruitment copy will show here once you add it.'}</p>
{form.data.roles_json.length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{form.data.roles_json.map((role) => <span key={role} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-white">{role}</span>)}</div> : null}
</div>
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save recruitment profile</button>
</div>
</section>
</form>
</StudioLayout>
)
}

View File

@@ -0,0 +1,154 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function toDateTimeInput(value) {
return value ? String(value).slice(0, 16) : ''
}
export default function StudioGroupReleaseEditor() {
const { props } = usePage()
const release = props.release || null
const form = useForm({
title: release?.title || '',
summary: release?.summary || '',
description: release?.description || '',
release_notes: release?.release_notes || '',
visibility: release?.visibility || props.visibilityOptions?.[0]?.value || 'public',
status: release?.status || props.statusOptions?.[0]?.value || 'draft',
current_stage: release?.current_stage || props.stageOptions?.[0]?.value || 'concept',
planned_release_at: toDateTimeInput(release?.planned_release_at),
lead_user_id: release?.lead?.id || '',
linked_project_id: release?.linked_project?.id || '',
linked_collection_id: release?.linked_collection?.id || '',
featured_artwork_id: release?.featured_artwork?.id || '',
is_featured: Boolean(release?.is_featured),
cover_file: null,
})
const stageForm = useForm({ current_stage: release?.current_stage || props.stageOptions?.[0]?.value || 'concept' })
const artworkAttach = useForm({ artwork_id: '' })
const contributorForm = useForm({ user_id: '', role_label: '' })
const milestoneForm = useForm({ title: '', summary: '', status: 'pending', due_date: '', owner_user_id: '', notes: '' })
const submit = (event) => {
event.preventDefault()
const options = { forceFormData: true, preserveScroll: true }
if (props.updateUrl) {
form.transform((data) => ({ ...data, _method: 'patch' })).post(props.updateUrl, options)
return
}
form.post(props.storeUrl, options)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="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="grid gap-4">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Release title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Release overview" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.release_notes} onChange={(event) => form.setData('release_notes', event.target.value)} placeholder="Release notes" rows={7} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.current_stage} onChange={(event) => form.setData('current_stage', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.stageOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
</div>
<input type="datetime-local" value={form.data.planned_release_at} onChange={(event) => form.setData('planned_release_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.lead_user_id} onChange={(event) => form.setData('lead_user_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No release lead</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.featured_artwork_id} onChange={(event) => form.setData('featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No featured artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
Feature this release on the public group page
</label>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button type="submit" disabled={form.processing} className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">{form.processing ? 'Saving…' : 'Save release'}</button>
{release?.url ? <a href={release.url} className="rounded-full border border-white/10 bg-black/20 px-5 py-2.5 text-sm font-semibold text-white">View public page</a> : null}
</div>
</section>
<div className="space-y-6">
{props.stageUrl ? (
<form onSubmit={(event) => { event.preventDefault(); stageForm.post(props.stageUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Stage</h2>
<select value={stageForm.data.current_stage} onChange={(event) => stageForm.setData('current_stage', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.stageOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<div className="mt-4 flex flex-wrap gap-2">
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Update stage</button>
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl, {}, { preserveScroll: true })} className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100">Publish</button> : null}
</div>
</form>
) : null}
{props.attachArtworkUrl ? (
<form onSubmit={(event) => { event.preventDefault(); artworkAttach.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
<select value={artworkAttach.data.artwork_id} onChange={(event) => artworkAttach.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach artwork</button>
</form>
) : null}
{props.attachContributorUrl ? (
<form onSubmit={(event) => { event.preventDefault(); contributorForm.post(props.attachContributorUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Contributor credit</h2>
<div className="mt-4 space-y-3">
<select value={contributorForm.data.user_id} onChange={(event) => contributorForm.setData('user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose contributor</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<input value={contributorForm.data.role_label} onChange={(event) => contributorForm.setData('role_label', event.target.value)} placeholder="Role label" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach contributor</button>
</div>
{Array.isArray(release?.contributors) && release.contributors.length > 0 ? <div className="mt-6 space-y-3">{release.contributors.map((contributor) => <div key={contributor.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><div className="font-semibold">{contributor.name || contributor.username}</div><div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{contributor.role_label || 'Contributor'}</div></div>)}</div> : null}
</form>
) : null}
{props.storeMilestoneUrl ? (
<form onSubmit={(event) => { event.preventDefault(); milestoneForm.post(props.storeMilestoneUrl, { preserveScroll: true, onSuccess: () => milestoneForm.reset('title', 'summary', 'due_date', 'owner_user_id', 'notes') }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Milestones</h2>
<div className="mt-4 space-y-3">
<input value={milestoneForm.data.title} onChange={(event) => milestoneForm.setData('title', event.target.value)} placeholder="Milestone title" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-3 md:grid-cols-2">
<select value={milestoneForm.data.status} onChange={(event) => milestoneForm.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<select value={milestoneForm.data.owner_user_id} onChange={(event) => milestoneForm.setData('owner_user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No owner</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add milestone</button>
</div>
{Array.isArray(release?.milestones) && release.milestones.length > 0 ? <div className="mt-6 space-y-3">{release.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><div className="font-semibold text-white">{milestone.title}</div><div className="mt-1 text-xs text-slate-500">{milestone.owner?.name || milestone.owner?.username || 'No owner'}{milestone.due_date ? ` • due ${milestone.due_date}` : ''}</div></div><button type="button" onClick={() => router.patch(props.updateMilestonePattern.replace('__MILESTONE__', String(milestone.id)), { title: milestone.title, summary: milestone.summary || '', status: milestone.status === 'completed' ? 'active' : 'completed', due_date: milestone.due_date || '', owner_user_id: milestone.owner?.id || '', notes: milestone.notes || '' }, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white">Mark {milestone.status === 'completed' ? 'active' : 'complete'}</button></div>{milestone.summary ? <p className="mt-2 text-sm text-slate-400">{milestone.summary}</p> : null}</div>)}</div> : null}
</form>
) : null}
</div>
</form>
</StudioLayout>
)
}

View File

@@ -0,0 +1,47 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupReleases() {
const { props } = usePage()
const listing = props.listing || {}
const items = Array.isArray(listing.items) ? listing.items : []
const bucketOptions = Array.isArray(listing.bucket_options) ? listing.bucket_options : []
const currentBucket = listing.filters?.bucket || 'all'
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-sm text-slate-400">Track the release pipeline from draft through public launch, with milestones and contributor credits.</div>
<div className="flex items-center gap-3">
<select value={currentBucket} onChange={(event) => router.get(window.location.pathname, { bucket: event.target.value }, { preserveScroll: true, preserveState: true })} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white outline-none">
{bucketOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create release</a> : null}
</div>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{items.length > 0 ? items.map((release) => (
<div key={release.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.03]">
{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-5">
<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-[11px] 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-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.current_stage}</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">{release.visibility}</span>
</div>
<h2 className="mt-3 text-xl font-semibold text-white">{release.title}</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">{release.summary || 'Release page'}</p>
<div className="mt-4 text-xs text-slate-500">{release.counts?.artworks || 0} artworks {release.counts?.contributors || 0} contributors {release.counts?.milestones || 0} milestones</div>
<div className="mt-4 flex flex-wrap gap-2">
<a href={release.urls?.edit || release.url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Manage</a>
{release.urls?.public ? <a href={release.urls.public} className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm font-semibold text-white">View public</a> : null}
</div>
</div>
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No releases yet.</div>}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,105 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function MetricCard({ label, value }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</div>
<div className="mt-2 text-2xl font-semibold text-white">{Number(value || 0).toFixed(1)}</div>
</div>
)
}
export default function StudioGroupReputation() {
const { props } = usePage()
const reputation = props.reputation || {}
const trustSignals = Array.isArray(props.trustSignals) ? props.trustSignals : []
const metrics = props.metrics || {}
const topContributors = Array.isArray(reputation.top_contributors) ? reputation.top_contributors : []
const recentBadges = Array.isArray(reputation.recent_badges) ? reputation.recent_badges : []
const memberBadgeUnlocks = Array.isArray(reputation.member_badge_unlocks) ? reputation.member_badge_unlocks : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<MetricCard label="Freshness" value={metrics.freshness_score} />
<MetricCard label="Activity" value={metrics.activity_score} />
<MetricCard label="Release" value={metrics.release_score} />
<MetricCard label="Trust" value={metrics.trust_score} />
<MetricCard label="Collaboration" value={metrics.collaboration_score} />
</div>
<div className="mt-6 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.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Trust signals</h2>
<p className="mt-1 text-sm text-slate-400">Public-safe labels that shape discovery and confidence.</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">{trustSignals.map((signal) => <span key={signal.key} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">{signal.label}</span>)}</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contributors</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputation.counts?.contributors || 0)}</div></div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Member badges</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputation.counts?.member_badges || 0)}</div></div>
</div>
{metrics.last_calculated_at ? <div className="mt-4 text-xs text-slate-500">Last calculated {new Date(metrics.last_calculated_at).toLocaleString()}</div> : null}
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Top contributors</h2>
<p className="mt-1 text-sm text-slate-400">Reputation summaries derived from visible collaboration history.</p>
</div>
</div>
<div className="mt-4 space-y-3">
{topContributors.length > 0 ? topContributors.map((entry) => (
<div key={entry.user?.id} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<div className="flex items-center gap-3">
{entry.user?.avatar_url ? <img src={entry.user.avatar_url} alt={entry.user?.name || entry.user?.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 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>
<div className="mt-1 text-sm text-slate-400">{entry.summary || 'Contributor'}</div>
</div>
</div>
<div className="mt-3 text-xs text-slate-500">{entry.counts?.releases || 0} releases {entry.counts?.projects || 0} projects {entry.counts?.credited_artworks || 0} artworks {entry.counts?.review_actions || 0} reviews</div>
{Array.isArray(entry.badges) && entry.badges.length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{entry.badges.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>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No contributor reputation signals yet.</div>}
</div>
</section>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Group badges</h2>
<div className="mt-4 space-y-3">
{recentBadges.length > 0 ? recentBadges.map((badge) => (
<div key={badge.key} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
<div className="font-semibold text-white">{badge.label}</div>
<div className="mt-2 text-sm text-slate-400">{badge.reason}</div>
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No group badges awarded yet.</div>}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Recent member badge unlocks</h2>
<div className="mt-4 space-y-3">
{memberBadgeUnlocks.length > 0 ? memberBadgeUnlocks.map((entry) => (
<div key={`${entry.user?.id}-${entry.badge?.key}`} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
<div className="font-semibold text-white">{entry.user?.name || entry.user?.username}</div>
<div className="mt-1 text-sm text-sky-200">{entry.badge?.label}</div>
<div className="mt-2 text-sm text-slate-400">{entry.badge?.reason}</div>
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No member badge unlocks yet.</div>}
</div>
</section>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,74 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function actionUrl(item, key) {
return item?.urls?.[key] || ''
}
export default function StudioGroupReviewQueue() {
const { props } = usePage()
const listing = props.listing || {}
const items = Array.isArray(listing.items) ? listing.items : []
const sendAction = (item, action) => {
const notes = window.prompt('Optional reviewer note', '') || ''
router.post(actionUrl(item, action), { action, review_notes: notes })
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Submission queue</h2>
<p className="mt-1 text-sm text-slate-400">Review artwork drafts before they publish under the group identity.</p>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{listing.filters?.bucket || 'submitted'}</span>
</div>
<div className="mt-4 space-y-4">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-start gap-4">
{item.thumb ? <img src={item.thumb} alt={item.title} className="h-24 w-24 rounded-2xl object-cover" /> : <div className="flex h-24 w-24 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-image" /></div>}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{item.group_review_status}</span>
</div>
<div className="mt-2 flex flex-wrap gap-3 text-xs text-slate-400">
{item.primary_author ? <span>Author: {item.primary_author.name || item.primary_author.username}</span> : null}
{item.uploader ? <span>Uploader: {item.uploader.name || item.uploader.username}</span> : null}
{item.submitted_at ? <span>Submitted {new Date(item.submitted_at).toLocaleString()}</span> : null}
</div>
{item.group_review_notes ? <p className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-300">{item.group_review_notes}</p> : null}
<div className="mt-4 flex flex-wrap gap-2">
<a href={item.urls?.edit} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Open draft</a>
{item.can_review ? <button type="button" onClick={() => sendAction(item, 'approve')} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Approve</button> : null}
{item.can_review ? <button type="button" onClick={() => sendAction(item, 'needs_changes')} className="rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100">Needs changes</button> : null}
{item.can_review ? <button type="button" onClick={() => sendAction(item, 'reject')} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Reject</button> : null}
</div>
</div>
</div>
</article>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No submissions in this bucket.</div>}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Recent history</h2>
<div className="mt-4 space-y-3">
{(Array.isArray(props.recentHistory) ? props.recentHistory : []).map((item) => (
<div key={item.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm font-semibold text-white">{item.summary || item.action_type}</div>
<div className="mt-1 text-xs text-slate-400">{item.actor?.name || item.actor?.username || 'System'} {item.created_at ? new Date(item.created_at).toLocaleString() : 'Recently'}</div>
</div>
))}
</div>
</section>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,170 @@
import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
export default function StudioGroupSettings() {
const { props } = usePage()
const group = props.studioGroup || {}
const featuredArtworkOptions = Array.isArray(props.featuredArtworkOptions) ? props.featuredArtworkOptions : []
const avatarInputRef = useRef(null)
const bannerInputRef = useRef(null)
const [form, setForm] = useState({
name: group.name || '',
slug: group.slug || '',
headline: group.headline || '',
bio: group.bio || '',
type: group.type || '',
founded_at: group.founded_at ? String(group.founded_at).slice(0, 10) : '',
avatar_path: group.avatar_path || group.avatar_url || '',
banner_path: group.banner_path || group.banner_url || '',
visibility: group.visibility || 'public',
membership_policy: group.membership_policy || 'invite_only',
website_url: group.website_url || '',
links_json: Array.isArray(group.links) && group.links.length > 0 ? group.links : [{ label: '', url: '' }],
featured_artwork_id: group.featured_artwork_id || '',
avatar_file: null,
banner_file: null,
})
const [avatarPreview, setAvatarPreview] = useState('')
const [bannerPreview, setBannerPreview] = useState('')
const selectedFeaturedArtwork = useMemo(
() => featuredArtworkOptions.find((item) => Number(item.id) === Number(form.featured_artwork_id)) || null,
[featuredArtworkOptions, form.featured_artwork_id],
)
const updateLink = (index, key, value) => {
setForm((current) => ({
...current,
links_json: current.links_json.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item),
}))
}
const addLink = () => {
setForm((current) => ({
...current,
links_json: [...current.links_json, { label: '', url: '' }],
}))
}
const removeLink = (index) => {
setForm((current) => ({
...current,
links_json: current.links_json.filter((_, itemIndex) => itemIndex !== index),
}))
}
const submit = () => {
router.post(props.endpoints?.update, {
_method: 'patch',
...form,
links_json: form.links_json.filter((item) => item.label.trim() !== '' || item.url.trim() !== ''),
}, {
forceFormData: true,
})
}
const handleFileSelected = (field, setPreview) => (event) => {
const file = event.target.files?.[0] || null
setForm((current) => ({ ...current, [field]: file }))
setPreview(file ? URL.createObjectURL(file) : '')
}
const clearSelectedFile = (field, setPreview, inputRef) => {
setForm((current) => ({ ...current, [field]: null }))
setPreview('')
if (inputRef.current) {
inputRef.current.value = ''
}
}
const archiveGroup = () => {
if (!window.confirm('Archive this group? New group publishing will stop immediately until you reopen it through admin tooling.')) {
return
}
router.post(props.endpoints?.archive)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="mx-auto max-w-3xl rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-5">
<label className="grid gap-2 text-sm text-slate-200"><span>Name</span><input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} 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-200"><span>Slug</span><input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} 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-200"><span>Short description</span><input value={form.headline} onChange={(event) => setForm((current) => ({ ...current, headline: event.target.value }))} 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-200"><span>About</span><textarea value={form.bio} onChange={(event) => setForm((current) => ({ ...current, bio: event.target.value }))} rows={6} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<div className="grid gap-5 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-200"><span>Type / category</span><input value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} 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-200"><span>Founded date</span><input type="date" value={form.founded_at} onChange={(event) => setForm((current) => ({ ...current, founded_at: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Website</span><input value={form.website_url} onChange={(event) => setForm((current) => ({ ...current, website_url: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<div className="grid gap-5 md:grid-cols-2">
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Avatar / logo</span>
<div className="flex h-28 w-28 items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{avatarPreview || form.avatar_path || group.avatar_url ? <img src={avatarPreview || form.avatar_path || group.avatar_url} alt="Avatar preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-image text-slate-500" />}
</div>
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('avatar_file', setAvatarPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => avatarInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload avatar</button>
{form.avatar_file ? <button type="button" onClick={() => clearSelectedFile('avatar_file', setAvatarPreview, avatarInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use current path</button> : null}
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Or paste an image URL</span><input value={form.avatar_path} onChange={(event) => setForm((current) => ({ ...current, avatar_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
</div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<span className="text-sm font-semibold text-white">Cover image</span>
<div className="flex h-28 w-full items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
{bannerPreview || form.banner_path || group.banner_url ? <img src={bannerPreview || form.banner_path || group.banner_url} alt="Cover preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-panorama text-slate-500" />}
</div>
<input ref={bannerInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('banner_file', setBannerPreview)} className="hidden" />
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => bannerInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload cover</button>
{form.banner_file ? <button type="button" onClick={() => clearSelectedFile('banner_file', setBannerPreview, bannerInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use current path</button> : null}
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Or paste an image URL</span><input value={form.banner_path} onChange={(event) => setForm((current) => ({ ...current, banner_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
</div>
</div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
<label className="grid gap-2 text-sm text-slate-200">
<span>Featured artwork</span>
<select value={form.featured_artwork_id} onChange={(event) => setForm((current) => ({ ...current, featured_artwork_id: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Use latest published artwork</option>
{featuredArtworkOptions.map((item) => <option key={item.id} value={item.id}>{item.title}</option>)}
</select>
</label>
{selectedFeaturedArtwork ? (
<div className="flex items-center gap-3 rounded-[20px] border border-white/10 bg-white/[0.04] p-3">
{selectedFeaturedArtwork.thumb ? <img src={selectedFeaturedArtwork.thumb} alt={selectedFeaturedArtwork.title} className="h-16 w-16 rounded-2xl object-cover" /> : null}
<div>
<div className="font-semibold text-white">{selectedFeaturedArtwork.title}</div>
<div className="text-sm text-slate-400">{selectedFeaturedArtwork.author || 'Group member'}</div>
</div>
</div>
) : (
<p className="text-sm text-slate-400">When this is empty, the public overview falls back to the latest published works automatically.</p>
)}
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Visibility</span><select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
<label className="grid gap-2 text-sm text-slate-200"><span>Membership policy</span><select value={form.membership_policy} onChange={(event) => setForm((current) => ({ ...current, membership_policy: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.membershipPolicyOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-200">Links</span>
<button type="button" onClick={addLink} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Add link</button>
</div>
{form.links_json.map((item, index) => (
<div key={`link-${index}`} className="grid gap-3 md:grid-cols-[0.8fr_1.2fr_auto]">
<input value={item.label} onChange={(event) => updateLink(index, 'label', event.target.value)} placeholder="Label" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={item.url} onChange={(event) => updateLink(index, 'url', event.target.value)} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => removeLink(index)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Remove</button>
</div>
))}
</div>
<div className="flex justify-between gap-3"><button type="button" onClick={archiveGroup} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Archive group</button><button type="button" onClick={submit} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Save settings</button></div>
</div>
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,91 @@
import React from 'react'
import { Link, router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
function GroupCard({ group }) {
return (
<article className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(3,7,18,0.22)]">
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-slate-900/70 text-slate-300">
{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" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate text-lg font-semibold text-white">{group.name}</h2>
{group.viewer?.role ? <span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100">{group.viewer.role}</span> : null}
{Number(group.pending_invites_count || 0) > 0 ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">{Number(group.pending_invites_count)} pending invite{Number(group.pending_invites_count) === 1 ? '' : 's'}</span> : null}
</div>
{group.headline ? <p className="mt-2 text-sm text-slate-300">{group.headline}</p> : null}
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
<span>{Number(group.counts?.artworks || 0).toLocaleString()} artworks</span>
<span>{Number(group.counts?.collections || 0).toLocaleString()} collections</span>
<span>{Number(group.counts?.followers || 0).toLocaleString()} followers</span>
</div>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<a href={group.urls?.studio} className="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">Open Studio</a>
<a href={group.urls?.studio_invitations} className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.06]">Invitations</a>
<a href={group.urls?.public} className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.06]">Public page</a>
</div>
</article>
)
}
export default function StudioGroupsIndex() {
const { props } = usePage()
const groups = Array.isArray(props.groups) ? props.groups : []
const pendingInvites = Array.isArray(props.pendingInvites) ? props.pendingInvites : []
return (
<StudioLayout title={props.title} subtitle={props.description}>
<GroupStudioPromoCard
title="Publish as a team, not just an individual"
description="Groups let you share ownership across artworks, releases, collections, reviews, and recruiting while keeping one public identity for the whole collective."
bullets={[
{ title: 'Shared publishing', body: 'Release under one name while keeping credited contributors visible across the artwork and group pages.' },
{ title: 'Team workflow', body: 'Invite reviewers, managers, and contributors into one studio space with role-based permissions.' },
{ title: 'Public discovery', body: 'Groups now appear across search, homepage modules, leaderboards, and public browse surfaces.' },
]}
primaryLabel="Create a group"
primaryHref={props.endpoints?.create}
secondaryLabel="Browse public groups"
secondaryHref="/groups"
/>
<div className="mb-6 flex items-center justify-between gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Collective publishing</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Launch and manage shared identities</h2>
</div>
<Link href={props.endpoints?.create} className="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">Create group</Link>
</div>
{pendingInvites.length > 0 ? (
<section className="mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 p-5">
<h2 className="text-lg font-semibold text-amber-50">Pending invites</h2>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{pendingInvites.map((invite) => (
<article key={invite.id} className="rounded-2xl border border-white/10 bg-black/20 p-4 text-white">
<h3 className="text-base font-semibold">{invite.group?.name}</h3>
<p className="mt-2 text-sm text-amber-50/80">Role: {invite.role}</p>
{invite.invited_by ? <p className="mt-1 text-sm text-amber-50/70">Invited by {invite.invited_by.name || invite.invited_by.username}</p> : null}
<div className="mt-4 flex gap-2">
<button type="button" onClick={() => router.post(invite.accept_url)} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100">Accept</button>
<button type="button" onClick={() => router.post(invite.decline_url)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Decline</button>
</div>
</article>
))}
</div>
</section>
) : null}
<div className="grid gap-4 xl:grid-cols-2">
{groups.length > 0 ? groups.map((group) => <GroupCard key={group.slug} group={group} />) : (
<div className="rounded-[28px] border border-dashed border-white/10 px-6 py-16 text-center text-slate-400">No groups yet. Create one to start publishing collaboratively.</div>
)}
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,377 @@
import React, { useState } from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
if (!Array.isArray(items) || items.length === 0) {
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-3 text-xs text-slate-500">{emptyLabel}</div>
}
return (
<div className="grid gap-2">
{items.map((item) => (
<button
key={`${item.entity_type}-${item.id}`}
type="button"
onClick={() => onSelect(item)}
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-3 py-3 text-left transition hover:border-white/20"
>
{item.avatar ? <img src={item.avatar} alt={item.title} className="h-10 w-10 rounded-2xl border border-white/10 object-cover" /> : null}
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{item.title}</div>
{item.subtitle ? <div className="text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
{item.description ? <div className="mt-1 text-xs text-slate-400 line-clamp-2">{item.description}</div> : null}
</div>
</button>
))}
</div>
)
}
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-end">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={relation.entity_type} onChange={(event) => onChange(index, { ...relation, entity_type: event.target.value, entity_id: '', preview: null, query: '' })} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{relationTypeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
<div className="flex gap-2">
<input value={relation.query || ''} onChange={(event) => onChange(index, { ...relation, query: event.target.value })} placeholder="Search by name, slug, or title" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => onSearch(index)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
</div>
</label>
<button type="button" onClick={() => onRemove(index)} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Remove</button>
</div>
{relation.preview ? (
<div className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
<div className="font-semibold">Linked: {relation.preview.title}</div>
{relation.preview.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{relation.preview.subtitle}</div> : null}
</div>
) : null}
<div className="mt-4">
<SearchResultList items={results} onSelect={(item) => onChange(index, { ...relation, entity_id: item.id, preview: item, query: item.title })} emptyLabel="Search to attach a related entity." />
</div>
<label className="mt-4 grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Context label</span>
<input value={relation.context_label || ''} onChange={(event) => onChange(index, { ...relation, context_label: event.target.value })} placeholder="Featured release, Meet the creator, Join this challenge…" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
)
}
export default function StudioNewsEditor() {
const { props } = usePage()
const article = props.article || {}
const [authorResults, setAuthorResults] = useState([])
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
const [relationResults, setRelationResults] = useState({})
const form = useForm({
title: article.title || '',
slug: article.slug || '',
excerpt: article.excerpt || '',
content: article.content || '',
cover_image: article.cover_image || '',
type: article.type || (props.typeOptions?.[0]?.value || 'announcement'),
category_id: article.category_id || '',
author_id: article.author_id || props.defaultAuthor?.id || '',
editorial_status: article.editorial_status || 'draft',
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
is_featured: Boolean(article.is_featured),
is_pinned: Boolean(article.is_pinned),
tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [],
meta_title: article.meta_title || '',
meta_description: article.meta_description || '',
meta_keywords: article.meta_keywords || '',
canonical_url: article.canonical_url || '',
og_title: article.og_title || '',
og_description: article.og_description || '',
og_image: article.og_image || '',
relations: Array.isArray(article.relations) ? article.relations.map((relation) => ({
entity_type: relation.entity_type || 'group',
entity_id: relation.entity_id || '',
context_label: relation.context_label || '',
preview: relation.preview || null,
query: relation.preview?.title || '',
})) : [],
})
const submit = (event) => {
event.preventDefault()
if (props.updateUrl) {
form.patch(props.updateUrl)
return
}
form.post(props.storeUrl)
}
const searchEntities = async (type, query) => {
const url = new URL(props.entitySearchUrl, window.location.origin)
url.searchParams.set('type', type)
url.searchParams.set('q', query)
const response = await fetch(url.toString(), {
headers: {
Accept: 'application/json',
},
credentials: 'same-origin',
})
if (!response.ok) {
return []
}
const payload = await response.json()
return Array.isArray(payload.items) ? payload.items : []
}
const runAuthorSearch = async () => {
const items = await searchEntities('user', authorQuery)
setAuthorResults(items)
}
const addRelation = () => {
form.setData('relations', [
...form.data.relations,
{
entity_type: props.relationTypeOptions?.[0]?.value || 'group',
entity_id: '',
context_label: '',
preview: null,
query: '',
},
])
}
const updateRelation = (index, nextRelation) => {
form.setData('relations', form.data.relations.map((relation, relationIndex) => (relationIndex === index ? nextRelation : relation)))
}
const removeRelation = (index) => {
form.setData('relations', form.data.relations.filter((_, relationIndex) => relationIndex !== index))
setRelationResults((current) => {
const next = { ...current }
delete next[index]
return next
})
}
const runRelationSearch = async (index) => {
const relation = form.data.relations[index]
if (!relation) return
const items = await searchEntities(relation.entity_type, relation.query || '')
setRelationResults((current) => ({ ...current, [index]: items }))
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-4 md: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">Slug</span>
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} placeholder="optional-manual-slug" 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">Cover image URL or path</span>
<input value={form.data.cover_image} onChange={(event) => form.setData('cover_image', event.target.value)} placeholder="https://... or storage path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</span>
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={4} className="rounded-[24px] 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">Body</span>
<textarea value={form.data.content} onChange={(event) => form.setData('content', event.target.value)} rows={18} placeholder="Write in Markdown. Existing legacy HTML is still supported on render." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none" />
</label>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">Related entities</h2>
<p className="mt-1 text-sm text-slate-400">Attach Groups, artworks, collections, releases, projects, challenges, events, and profiles.</p>
</div>
<button type="button" onClick={addRelation} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Add relation</button>
</div>
<div className="mt-4 grid gap-4">
{form.data.relations.length > 0 ? form.data.relations.map((relation, index) => (
<RelationCard
key={`${relation.entity_type}-${index}`}
relation={relation}
index={index}
onChange={updateRelation}
onRemove={removeRelation}
onSearch={runRelationSearch}
results={relationResults[index] || []}
relationTypeOptions={Array.isArray(props.relationTypeOptions) ? props.relationTypeOptions : []}
/>
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No related entities attached yet.</div>}
</div>
</div>
</div>
</section>
<section className="space-y-6">
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Publishing</h2>
<div className="mt-5 grid gap-4">
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center gap-2 rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15"><i className="fa-regular fa-eye" />Preview article</a> : null}
<div className="grid gap-4 md: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">Type</span>
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
<select value={form.data.category_id || ''} onChange={(event) => form.setData('category_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No category</option>
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => <option key={option.id} value={option.id}>{option.name}</option>)}
</select>
</label>
</div>
<div className="grid gap-4 md: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">Workflow status</span>
<select value={form.data.editorial_status} onChange={(event) => form.setData('editorial_status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
<input type="datetime-local" value={form.data.published_at || ''} onChange={(event) => form.setData('published_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Author</div>
<div className="flex gap-2">
<input value={authorQuery} onChange={(event) => setAuthorQuery(event.target.value)} placeholder="Search for an author" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={runAuthorSearch} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
</div>
{selectedAuthor ? (
<div className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
<div className="font-semibold">Selected author: {selectedAuthor.title}</div>
{selectedAuthor.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedAuthor.subtitle}</div> : null}
</div>
) : null}
<SearchResultList items={authorResults} onSelect={(item) => {
setSelectedAuthor(item)
setAuthorQuery(item.title)
form.setData('author_id', item.id)
}} emptyLabel="Search to choose an author profile." />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tags</span>
<div className="grid gap-2 sm:grid-cols-2">
{(Array.isArray(props.tagOptions) ? props.tagOptions : []).map((tag) => {
const checked = form.data.tag_ids.includes(tag.id)
return (
<label key={tag.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input
type="checkbox"
checked={checked}
onChange={(event) => {
if (event.target.checked) {
form.setData('tag_ids', [...form.data.tag_ids, tag.id])
return
}
form.setData('tag_ids', form.data.tag_ids.filter((tagId) => tagId !== tag.id))
}}
/>
<span>{tag.name}</span>
</label>
)
})}
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
Feature on newsroom surfaces
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} />
Pin to the top of the newsroom
</label>
</div>
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">SEO &amp; social</h2>
<div className="mt-5 grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta title</span>
<input value={form.data.meta_title} onChange={(event) => form.setData('meta_title', event.target.value)} 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">Meta description</span>
<textarea value={form.data.meta_description} onChange={(event) => form.setData('meta_description', event.target.value)} rows={3} className="rounded-[24px] 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">Meta keywords</span>
<input value={form.data.meta_keywords} onChange={(event) => form.setData('meta_keywords', event.target.value)} placeholder="creator-story, release, tutorial" 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">Canonical URL</span>
<input value={form.data.canonical_url} onChange={(event) => form.setData('canonical_url', event.target.value)} placeholder="https://..." className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-4 md: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">OG title</span>
<input value={form.data.og_title} onChange={(event) => form.setData('og_title', event.target.value)} 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">OG image</span>
<input value={form.data.og_image} onChange={(event) => form.setData('og_image', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG description</span>
<textarea value={form.data.og_description} onChange={(event) => form.setData('og_description', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-3">
<button type="submit" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save article</button>
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100">Publish now</button> : null}
{props.featureUrl ? <button type="button" onClick={() => router.post(props.featureUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Toggle featured</button> : null}
{props.pinUrl ? <button type="button" onClick={() => router.post(props.pinUrl)} className="w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">Toggle pinned</button> : null}
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Archive article</button> : null}
</div>
</div>
</section>
</form>
</StudioLayout>
)
}

View File

@@ -0,0 +1,164 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function formatDate(value) {
if (!value) return 'Draft'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Draft'
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function statusTone(status) {
switch (status) {
case 'published':
return 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100'
case 'scheduled':
return 'border-sky-300/20 bg-sky-400/10 text-sky-100'
case 'in_review':
return 'border-amber-300/20 bg-amber-400/10 text-amber-100'
case 'archived':
return 'border-white/10 bg-white/[0.05] text-slate-300'
default:
return 'border-white/10 bg-white/[0.05] text-slate-300'
}
}
export default function StudioNewsIndex() {
const { props } = usePage()
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
const filters = props.listing?.filters || {}
const meta = props.listing?.meta || {}
const updateFilter = (next) => {
router.get('/studio/news', {
...filters,
...next,
page: 1,
}, {
preserveState: true,
preserveScroll: true,
})
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.9))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Editorial surface</p>
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Run a first-party newsroom for launches, tutorials, and community stories.</h2>
<p className="mt-3 text-sm leading-7 text-slate-300">Pinned stories drive the hero, featured pieces strengthen discovery, and related entity links keep News wired into Groups, releases, collections, and profiles.</p>
</div>
<div className="flex flex-wrap gap-3">
<a href={props.createUrl} 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-plus" />
New article
</a>
<a href={props.categoriesUrl} 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-tags" />
Taxonomies
</a>
</div>
</div>
</section>
<section className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px_220px_220px_auto] lg:items-center">
<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
defaultValue={filters.q || ''}
placeholder="Search titles, excerpts, and metadata"
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
onKeyDown={(event) => {
if (event.key === 'Enter') {
updateFilter({
q: event.currentTarget.value || '',
status: filters.status || '',
type: filters.type || '',
category_id: filters.category_id || '',
})
}
}}
/>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</span>
<select
value={filters.status || ''}
onChange={(event) => updateFilter({ status: event.target.value, q: filters.q || '', type: filters.type || '', category_id: filters.category_id || '' })}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
>
<option value="">All statuses</option>
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select
value={filters.type || ''}
onChange={(event) => updateFilter({ type: event.target.value, q: filters.q || '', status: filters.status || '', category_id: filters.category_id || '' })}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
>
<option value="">All types</option>
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
<select
value={filters.category_id || ''}
onChange={(event) => updateFilter({ category_id: event.target.value, q: filters.q || '', status: filters.status || '', type: filters.type || '' })}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
>
<option value="">All categories</option>
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => (
<option key={option.id} value={option.id}>{option.name}</option>
))}
</select>
</label>
<div className="text-sm text-slate-400 lg:text-right">{Number(meta.total || 0).toLocaleString()} articles</div>
</div>
</section>
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_40px_rgba(2,6,23,0.18)]">
<div className="aspect-[16/9] bg-slate-950/60">
{item.cover_url ? <img src={item.cover_url} alt={item.title} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-slate-500"><i className="fa-solid fa-newspaper text-3xl" /></div>}
</div>
<div className="p-5">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-white/70">{item.type_label}</span>
<span className={`rounded-full border px-2.5 py-1 ${statusTone(item.editorial_status)}`}>{item.editorial_status.replaceAll('_', ' ')}</span>
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-2.5 py-1 text-amber-100">Pinned</span> : null}
{item.is_featured ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-100">Featured</span> : null}
</div>
<h3 className="mt-3 text-xl font-semibold text-white">{item.title}</h3>
<div className="mt-3 flex flex-wrap gap-3 text-sm text-slate-400">
{item.category_name ? <span>{item.category_name}</span> : null}
<span>{item.author_name}</span>
<span>{formatDate(item.published_at)}</span>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<a href={item.edit_url} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Edit</a>
<a href={item.editorial_status === 'published' ? item.public_url : item.preview_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">{item.editorial_status === 'published' ? 'View' : 'Preview'}</a>
</div>
</div>
</article>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No News articles match the current filters.</div>}
</section>
</StudioLayout>
)
}

View File

@@ -0,0 +1,115 @@
import React, { useState } from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
function replacePattern(pattern, token, value) {
return String(pattern || '').replace(token, String(value))
}
export default function StudioNewsTaxonomies() {
const { props } = usePage()
const [categories, setCategories] = useState(Array.isArray(props.categories) ? props.categories : [])
const [tags, setTags] = useState(Array.isArray(props.tags) ? props.tags : [])
const categoryForm = useForm({ name: '', slug: '', description: '', position: 0, is_active: true })
const tagForm = useForm({ name: '', slug: '' })
const updateCategory = (index, field, value) => {
setCategories((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)))
}
const updateTag = (index, field, value) => {
setTags((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)))
}
const saveCategory = (category) => {
router.patch(replacePattern(props.updateCategoryUrlPattern, '__CATEGORY__', category.id), category, { preserveScroll: true })
}
const saveTag = (tag) => {
router.patch(replacePattern(props.updateTagUrlPattern, '__TAG__', tag.id), tag, { preserveScroll: true })
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex flex-wrap gap-3 text-sm font-semibold">
<a href="/studio/news/categories" className={`rounded-full px-4 py-2 ${props.activeTab === 'categories' ? 'border border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border border-white/10 bg-white/[0.04] text-white'}`}>Categories</a>
<a href="/studio/news/tags" className={`rounded-full px-4 py-2 ${props.activeTab === 'tags' ? 'border border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border border-white/10 bg-white/[0.04] text-white'}`}>Tags</a>
<a href="/studio/news" className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white">Back to newsroom</a>
</div>
</section>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Categories</h2>
<p className="mt-1 text-sm text-slate-400">Stable editorial buckets for the newsroom.</p>
</div>
<span className="text-sm text-slate-500">{categories.length} total</span>
</div>
<form onSubmit={(event) => { event.preventDefault(); categoryForm.post(props.storeCategoryUrl) }} className="mt-5 grid gap-3">
<div className="grid gap-3 md:grid-cols-2">
<input value={categoryForm.data.name} onChange={(event) => categoryForm.setData('name', event.target.value)} placeholder="Category name" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={categoryForm.data.slug} onChange={(event) => categoryForm.setData('slug', event.target.value)} placeholder="optional slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={categoryForm.data.description} onChange={(event) => categoryForm.setData('description', event.target.value)} rows={3} placeholder="Description" className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="flex flex-wrap items-center gap-3">
<input type="number" value={categoryForm.data.position} onChange={(event) => categoryForm.setData('position', event.target.value)} min="0" className="w-28 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<label className="flex items-center gap-2 text-sm text-white"><input type="checkbox" checked={categoryForm.data.is_active} onChange={(event) => categoryForm.setData('is_active', event.target.checked)} /> Active</label>
<button type="submit" className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Create category</button>
</div>
</form>
<div className="mt-6 grid gap-3">
{categories.map((category, index) => (
<div key={category.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-3 md:grid-cols-2">
<input value={category.name} onChange={(event) => updateCategory(index, 'name', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={category.slug} onChange={(event) => updateCategory(index, 'slug', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={category.description || ''} onChange={(event) => updateCategory(index, 'description', event.target.value)} rows={2} className="mt-3 w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-slate-300">
<input type="number" value={category.position || 0} min="0" onChange={(event) => updateCategory(index, 'position', event.target.value)} className="w-24 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(category.is_active)} onChange={(event) => updateCategory(index, 'is_active', event.target.checked)} /> Active</label>
<span className="text-xs uppercase tracking-[0.14em] text-slate-500">{Number(category.published_count || 0).toLocaleString()} published</span>
<button type="button" onClick={() => saveCategory(category)} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Save</button>
</div>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">Tags</h2>
<p className="mt-1 text-sm text-slate-400">Flexible labels for search, discovery, and internal linking.</p>
</div>
<span className="text-sm text-slate-500">{tags.length} total</span>
</div>
<form onSubmit={(event) => { event.preventDefault(); tagForm.post(props.storeTagUrl) }} className="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center">
<input value={tagForm.data.name} onChange={(event) => tagForm.setData('name', event.target.value)} placeholder="Tag name" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={tagForm.data.slug} onChange={(event) => tagForm.setData('slug', event.target.value)} placeholder="optional slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100">Create tag</button>
</form>
<div className="mt-6 grid gap-3">
{tags.map((tag, index) => (
<div key={tag.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] md:items-center">
<input value={tag.name} onChange={(event) => updateTag(index, 'name', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={tag.slug} onChange={(event) => updateTag(index, 'slug', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs uppercase tracking-[0.14em] text-slate-500">{Number(tag.published_count || 0).toLocaleString()} published</span>
<button type="button" onClick={() => saveTag(tag)} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Save</button>
</div>
</div>
))}
</div>
</section>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,96 @@
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import StudioGroupMembers from '../StudioGroupMembers'
const { routerMock } = vi.hoisted(() => ({
routerMock: {
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}))
let pageMock = { props: {} }
vi.mock('@inertiajs/react', () => ({
usePage: () => pageMock,
router: routerMock,
}))
vi.mock('../../../Layouts/StudioLayout', () => ({
default: ({ children }) => <div>{children}</div>,
}))
describe('StudioGroupMembers permissions', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('shows management controls for owners and admins', () => {
pageMock = {
props: {
title: 'Members',
description: 'Manage members',
canManageMembers: true,
endpoints: {
invite: '/studio/groups/warp/members',
invitations: '/studio/groups/warp/invitations',
updatePattern: '/studio/groups/warp/members/__MEMBER__',
transferPattern: '/studio/groups/warp/members/__MEMBER__/transfer',
deletePattern: '/studio/groups/warp/members/__MEMBER__',
},
members: [
{
id: 1,
role: 'editor',
role_label: 'editor',
status: 'active',
can_transfer: true,
can_revoke: true,
user: { name: 'Editor User', username: 'editor-user', avatar_url: null },
},
],
},
}
render(<StudioGroupMembers />)
expect(screen.getByText('Invite member')).not.toBeNull()
expect(screen.getByRole('link', { name: /manage invitations/i })).not.toBeNull()
expect(screen.getByPlaceholderText(/name, username, or role/i)).not.toBeNull()
expect(screen.getByRole('button', { name: /transfer/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /remove/i })).not.toBeNull()
})
it('hides management controls for non-managing members', () => {
pageMock = {
props: {
title: 'Members',
description: 'Manage members',
canManageMembers: false,
endpoints: null,
members: [
{
id: 1,
role: 'editor',
role_label: 'editor',
status: 'active',
can_transfer: false,
can_revoke: false,
user: { name: 'Editor User', username: 'editor-user', avatar_url: null },
},
],
},
}
render(<StudioGroupMembers />)
expect(screen.queryByText('Invite member')).toBeNull()
expect(screen.queryByRole('link', { name: /manage invitations/i })).toBeNull()
expect(screen.queryByRole('button', { name: /transfer/i })).toBeNull()
expect(screen.queryByRole('button', { name: /remove/i })).toBeNull()
expect(screen.getByText('editor')).not.toBeNull()
})
})

View File

@@ -620,6 +620,10 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
chunkSize={chunkSize}
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
groupOptions={Array.isArray(props?.group_options) ? props.group_options : []}
contributorOptionsByGroup={props?.contributor_options_by_group && typeof props.contributor_options_by_group === 'object' ? props.contributor_options_by_group : {}}
initialGroupSlug={props?.initial_group || ''}
currentUserId={props?.auth?.user?.id ?? null}
/>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'
import SearchOverlay from './SearchOverlay'
const ARTWORKS_API = '/api/search/artworks'
const GROUPS_API = '/api/search/groups'
const TAGS_API = '/api/tags/search'
const USERS_API = '/api/search/users'
const DEBOUNCE_MS = 300
@@ -17,10 +18,11 @@ function useDebounce(value, delay) {
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPod|iPad/.test(navigator.platform)
export default function SearchBar({ placeholder = 'Search artworks, artists, tags\u2026' }) {
export default function SearchBar({ placeholder = 'Search artworks, groups, artists, tags\u2026' }) {
const [phase, setPhase] = useState('idle') // idle | opening | open | closing
const [query, setQuery] = useState('')
const [artworks, setArtworks] = useState([])
const [groups, setGroups] = useState([])
const [tags, setTags] = useState([])
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)
@@ -40,12 +42,13 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
const debouncedQuery = useDebounce(query, DEBOUNCE_MS)
const isExpanded = phase === 'opening' || phase === 'open'
const isMobileOverlayVisible = mobileOverlayPhase !== 'closed'
const hasSuggestions = artworks.length > 0 || users.length > 0 || tags.length > 0
const hasSuggestions = artworks.length > 0 || groups.length > 0 || users.length > 0 || tags.length > 0
const suggestionListId = open && hasSuggestions ? 'sb-suggestions' : undefined
// flat list of navigable items: artworks → users → tags
// flat list of navigable items: artworks → groups → users → tags
const allItems = [
...artworks.map(a => ({ type: 'artwork', ...a })),
...groups.map(g => ({ type: 'group', ...g })),
...users.map(u => ({ type: 'user', ...u })),
...tags.map(t => ({ type: 'tag', ...t })),
]
@@ -70,6 +73,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
setPhase('idle')
setQuery('')
setArtworks([])
setGroups([])
setTags([])
setUsers([])
}, 160)
@@ -95,6 +99,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
setActiveIdx(-1)
setOpen(false)
setArtworks([])
setGroups([])
setTags([])
setUsers([])
}, 150)
@@ -150,7 +155,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
const fetchSuggestions = useCallback(async (q) => {
const bare = q?.replace(/^@+/, '') ?? ''
if (!bare || bare.length < 2) {
setArtworks([]); setTags([]); setUsers([]); setOpen(false); return
setArtworks([]); setGroups([]); setTags([]); setUsers([]); setOpen(false); return
}
if (abortRef.current) abortRef.current.abort()
abortRef.current = new AbortController()
@@ -162,22 +167,28 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
const fetchArt = isAtMention
? Promise.resolve(null)
: fetch(`${ARTWORKS_API}?q=${encodeURIComponent(bare)}&per_page=4`, { signal: sig })
const fetchGroups = isAtMention
? Promise.resolve(null)
: fetch(`${GROUPS_API}?q=${encodeURIComponent(bare)}&per_page=4`, { signal: sig })
const fetchUsers = fetch(`${USERS_API}?q=${encodeURIComponent(q)}&per_page=4`, { signal: sig })
const fetchTags = isAtMention
? Promise.resolve(null)
: fetch(`${TAGS_API}?q=${encodeURIComponent(bare)}&per_page=3`, { signal: sig })
const [artRes, userRes, tagRes] = await Promise.all([fetchArt, fetchUsers, fetchTags])
const [artRes, groupRes, userRes, tagRes] = await Promise.all([fetchArt, fetchGroups, fetchUsers, fetchTags])
const artJson = artRes && artRes.ok ? await artRes.json() : {}
const groupJson = groupRes && groupRes.ok ? await groupRes.json() : {}
const userJson = userRes && userRes.ok ? await userRes.json() : {}
const tagJson = tagRes && tagRes.ok ? await tagRes.json() : {}
const artItems = Array.isArray(artJson.data ?? artJson) ? (artJson.data ?? artJson).slice(0, 4) : []
const groupItems = Array.isArray(groupJson.data ?? groupJson) ? (groupJson.data ?? groupJson).slice(0, 4) : []
const userItems = Array.isArray(userJson.data ?? userJson) ? (userJson.data ?? userJson).slice(0, 4) : []
const tagItems = Array.isArray(tagJson.data ?? tagJson) ? (tagJson.data ?? tagJson).slice(0, 3) : []
setArtworks(artItems)
setGroups(groupItems)
setUsers(userItems)
setTags(tagItems)
setActiveIdx(-1)
setOpen(artItems.length > 0 || userItems.length > 0 || tagItems.length > 0)
setOpen(artItems.length > 0 || groupItems.length > 0 || userItems.length > 0 || tagItems.length > 0)
} catch (e) {
if (e.name !== 'AbortError') console.error('SearchBar fetch error', e)
} finally {
@@ -190,6 +201,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
// ── navigation helpers ───────────────────────────────────────────────────
function navigate(item) {
if (item.type === 'artwork') window.location.href = item.urls?.web ?? `/${item.slug ?? ''}`
else if (item.type === 'group') window.location.href = item.urls?.public ?? item.profile_url ?? `/groups/${item.slug ?? ''}`
else if (item.type === 'user') window.location.href = item.profile_url ?? `/@${item.username}`
else window.location.href = `/tags/${item.slug ?? item.name}`
}
@@ -240,6 +252,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
inputRef={mobileInputRef}
loading={loading}
artworks={artworks}
groups={groups}
users={users}
tags={tags}
activeIdx={activeIdx}
@@ -362,12 +375,39 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
</>
)}
{groups.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Groups</li>
{groups.map((group, j) => {
const flatIdx = artworks.length + j
return (
<li key={group.slug ?? group.id ?? j} role="option" id={`sb-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button type="button" onClick={() => navigate({ type: 'group', ...group })}
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${activeIdx === flatIdx ? 'bg-white/[0.09]' : 'hover:bg-white/[0.06]'}`}>
{group.avatar_url ? (
<img src={group.avatar_url} alt="" aria-hidden="true" className="w-9 h-9 rounded-2xl object-cover shrink-0 bg-white/[0.04] border border-white/[0.08]" loading="lazy" />
) : (
<span className="w-9 h-9 rounded-2xl bg-white/[0.04] border border-white/[0.07] inline-flex items-center justify-center shrink-0">
<i className="fa-solid fa-people-group text-white/40" />
</span>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-white truncate">{group.name}</div>
<div className="text-xs text-neutral-400 truncate">{group.headline || `${Number(group.counts?.followers || group.followers_count || 0).toLocaleString()} followers`}</div>
</div>
</button>
</li>
)
})}
</>
)}
{/* Users / Creators section */}
{users.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Creators</li>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 || groups.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Creators</li>
{users.map((user, j) => {
const flatIdx = artworks.length + j
const flatIdx = artworks.length + groups.length + j
return (
<li key={user.username} role="option" id={`sb-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button type="button" onClick={() => navigate({ type: 'user', ...user })}
@@ -388,9 +428,9 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
{/* Tags section */}
{tags.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 || users.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Tags</li>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/30 select-none ${artworks.length > 0 || groups.length > 0 || users.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2.5'}`}>Tags</li>
{tags.map((tag, j) => {
const flatIdx = artworks.length + users.length + j
const flatIdx = artworks.length + groups.length + users.length + j
return (
<li key={tag.slug ?? tag.name ?? j} role="option" id={`sb-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button type="button" onClick={() => navigate({ type: 'tag', ...tag })}

View File

@@ -8,6 +8,7 @@ export default function SearchOverlay({
inputRef,
loading,
artworks,
groups,
users,
tags,
activeIdx,
@@ -19,7 +20,7 @@ export default function SearchOverlay({
}) {
if (phase === 'closed') return null
const hasResults = artworks.length > 0 || users.length > 0 || tags.length > 0
const hasResults = artworks.length > 0 || groups.length > 0 || users.length > 0 || tags.length > 0
const isVisible = OPENING_OR_OPEN.has(phase)
return (
@@ -55,7 +56,7 @@ export default function SearchOverlay({
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Search artworks, creators, tags..."
placeholder="Search artworks, groups, creators, tags..."
aria-label="Search"
aria-autocomplete="list"
aria-controls="sb-mobile-suggestions"
@@ -116,11 +117,41 @@ export default function SearchOverlay({
</>
)}
{groups.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Groups</li>
{groups.map((group, j) => {
const flatIdx = artworks.length + j
return (
<li key={group.slug ?? group.id ?? j} role="option" id={`sb-mobile-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button
type="button"
onClick={() => onNavigate({ type: 'group', ...group })}
className={`w-full min-h-12 flex items-center gap-3 px-3 py-2 text-left rounded-lg transition-colors ${activeIdx === flatIdx ? 'bg-white/[0.14]' : 'hover:bg-white/[0.08]'}`}
>
{group.avatar_url ? (
<img src={group.avatar_url} alt="" aria-hidden="true" className="w-10 h-10 rounded-2xl object-cover shrink-0 bg-white/[0.04] border border-white/[0.08]" loading="lazy" />
) : (
<span className="w-10 h-10 rounded-2xl bg-white/[0.04] border border-white/[0.08] inline-flex items-center justify-center shrink-0" aria-hidden="true">
<i className="fa-solid fa-people-group text-white/45" />
</span>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-white truncate">{group.name}</div>
<div className="text-xs text-neutral-400 truncate">{group.headline || `${Number(group.counts?.followers || group.followers_count || 0).toLocaleString()} followers`}</div>
</div>
</button>
</li>
)
})}
</>
)}
{users.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Creators</li>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 || groups.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Creators</li>
{users.map((user, j) => {
const flatIdx = artworks.length + j
const flatIdx = artworks.length + groups.length + j
return (
<li key={user.username} role="option" id={`sb-mobile-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button
@@ -142,9 +173,9 @@ export default function SearchOverlay({
{tags.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 || users.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Tags</li>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 || groups.length > 0 || users.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Tags</li>
{tags.map((tag, j) => {
const flatIdx = artworks.length + users.length + j
const flatIdx = artworks.length + groups.length + users.length + j
return (
<li key={tag.slug ?? tag.name ?? j} role="option" id={`sb-mobile-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button
@@ -179,7 +210,7 @@ export default function SearchOverlay({
</ul>
) : (
<div className="mx-2 my-2 px-6 py-10 rounded-xl border border-white/[0.10] bg-nova-900/95 backdrop-blur-sm shadow-2xl text-sm text-white/60">
{query.trim().length >= 2 ? 'No results found.' : 'Start typing to search artworks, creators, and tags.'}
{query.trim().length >= 2 ? 'No results found.' : 'Start typing to search artworks, groups, creators, and tags.'}
</div>
)}
</div>

View File

@@ -2,36 +2,25 @@ import './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import CollectionShow from './Pages/Collection/CollectionShow'
import CollectionSeriesShow from './Pages/Collection/CollectionSeriesShow'
import CollectionManage from './Pages/Collection/CollectionManage'
import CollectionFeaturedIndex from './Pages/Collection/CollectionFeaturedIndex'
import SavedCollections from './Pages/Collection/SavedCollections'
import CollectionStaffSurfaces from './Pages/Collection/CollectionStaffSurfaces'
import CollectionStaffProgramming from './Pages/Collection/CollectionStaffProgramming'
import CollectionDashboard from './Pages/Collection/CollectionDashboard'
import CollectionAnalytics from './Pages/Collection/CollectionAnalytics'
import CollectionHistory from './Pages/Collection/CollectionHistory'
import NovaCardsAdminIndex from './Pages/Collection/NovaCardsAdminIndex'
import NovaCardsTemplateAdmin from './Pages/Collection/NovaCardsTemplateAdmin'
const pages = {
'Collection/CollectionShow': CollectionShow,
'Collection/CollectionSeriesShow': CollectionSeriesShow,
'Collection/CollectionManage': CollectionManage,
'Collection/CollectionFeaturedIndex': CollectionFeaturedIndex,
'Collection/SavedCollections': SavedCollections,
'Collection/CollectionStaffSurfaces': CollectionStaffSurfaces,
'Collection/CollectionStaffProgramming': CollectionStaffProgramming,
'Collection/CollectionDashboard': CollectionDashboard,
'Collection/CollectionAnalytics': CollectionAnalytics,
'Collection/CollectionHistory': CollectionHistory,
'Collection/NovaCardsAdminIndex': NovaCardsAdminIndex,
'Collection/NovaCardsTemplateAdmin': NovaCardsTemplateAdmin,
...import.meta.glob('./Pages/Help/**/*.jsx'),
...import.meta.glob('./Pages/Collection/**/*.jsx'),
...import.meta.glob('./Pages/Group/**/*.jsx'),
}
function resolvePage(name) {
const path = `./Pages/${name}.jsx`
const page = pages[path]
if (!page) {
throw new Error(`Unknown collections page: ${path}`)
}
return page().then((module) => module.default)
}
createInertiaApp({
resolve: (name) => pages[name],
resolve: resolvePage,
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)

View File

@@ -21,6 +21,7 @@ export default function Topbar({ user = null }) {
</div>
<div className="flex items-center gap-3 sm:gap-4">
<a href="/groups" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Groups</a>
<a href="/community/activity" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Community</a>
<a href="/forum" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Forum</a>

View File

@@ -2,9 +2,46 @@ import React from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
export default function ArtworkMeta({ artwork }) {
const publisher = artwork?.publisher || null
const credits = artwork?.credits || {}
const primaryAuthor = credits?.primary_author || artwork?.user || null
const contributors = Array.isArray(credits?.contributors) ? credits.contributors : []
return (
<div>
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">{artwork?.title}</h1>
<div className="mt-4 flex flex-wrap gap-3 text-sm text-slate-300">
{publisher?.type === 'group' ? (
<a href={publisher.profile_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-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Published by</span>
<span className="font-semibold">{publisher.name}</span>
</a>
) : null}
{primaryAuthor ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Primary author</span>
{primaryAuthor.profile_url ? <a href={primaryAuthor.profile_url} className="font-semibold text-white hover:text-sky-200">{primaryAuthor.name || primaryAuthor.username}</a> : <span className="font-semibold text-white">{primaryAuthor.name || primaryAuthor.username}</span>}
</span>
) : null}
{contributors.length > 0 ? (
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contributors</span>
</span>
{contributors.map((item) => {
const label = item.name || item.username
return (
<span key={item.id || label} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white">
{item.profile_url ? <a href={item.profile_url} className="font-semibold text-white hover:text-sky-200">{label}</a> : <span className="font-semibold text-white">{label}</span>}
{item.credit_role ? <span className="text-slate-400">{item.credit_role}</span> : null}
{item.is_primary ? <span className="rounded-full border border-emerald-300/30 bg-emerald-400/12 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Lead support</span> : null}
</span>
)
})}
</div>
) : null}
</div>
<div className="mt-3">
<ArtworkBreadcrumbs artwork={artwork} />
</div>

View File

@@ -22,14 +22,16 @@ function toCard(item) {
}
export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
const publisher = artwork?.publisher || null
const isGroupPublisher = publisher?.type === 'group'
const [following, setFollowing] = useState(Boolean(isGroupPublisher ? artwork?.viewer?.is_following_group : artwork?.viewer?.is_following_author))
const [followersCount, setFollowersCount] = useState(Number(isGroupPublisher ? publisher?.followers_count || 0 : artwork?.user?.followers_count || 0))
const user = artwork?.user || {}
const user = artwork?.credits?.primary_author || artwork?.user || {}
const isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
const authorName = user.name || user.username || 'Artist'
const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#')
const avatar = user.avatar_url || presentSq?.url || AVATAR_FALLBACK
const authorName = isGroupPublisher ? (publisher?.name || 'Group') : (user.name || user.username || 'Artist')
const profileUrl = isGroupPublisher ? (publisher?.profile_url || '#') : (user.profile_url || (user.username ? `/@${user.username}` : '#'))
const avatar = (isGroupPublisher ? publisher?.avatar_url : user.avatar_url) || presentSq?.url || AVATAR_FALLBACK
const creatorItems = useMemo(() => {
const filtered = (Array.isArray(related) ? related : []).filter((item) => {
@@ -63,7 +65,8 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
{authorName}
</a>
{user.username && <p className="text-xs text-white/40">@{user.username}</p>}
{!isGroupPublisher && user.username && <p className="text-xs text-white/40">@{user.username}</p>}
{isGroupPublisher && artwork?.credits?.primary_author ? <p className="text-xs text-white/40">Primary author: {artwork.credits.primary_author.name || artwork.credits.primary_author.username}</p> : null}
<p className="mt-1 text-xs font-medium text-white/30">
{followersCount.toLocaleString()} Followers
</p>
@@ -80,7 +83,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
</svg>
Profile
</a>
{!isOwnArtwork ? (
{!isOwnArtwork && !isGroupPublisher ? (
<FollowButton
username={user.username}
initialFollowing={following}
@@ -93,6 +96,31 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
}}
/>
) : null}
{!isOwnArtwork && isGroupPublisher ? (
<button
type="button"
onClick={async () => {
const method = following ? 'DELETE' : 'POST'
const response = await fetch(following ? artwork.publisher?.unfollow_url || `${publisher.profile_url}/follow` : artwork.publisher?.follow_url || `${publisher.profile_url}/follow`, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
})
const payload = await response.json().catch(() => ({}))
if (response.ok) {
setFollowing(Boolean(payload?.following))
setFollowersCount(Number(payload?.followers_count || 0))
}
}}
className={`flex-1 rounded-xl border px-3 py-2.5 text-sm font-medium transition ${following ? 'border-white/[0.12] bg-white/[0.05] text-white' : 'border-sky-300/25 bg-sky-300/10 text-sky-100 hover:border-sky-300/35 hover:bg-sky-300/15'}`}
>
{following ? 'Following' : 'Follow group'}
</button>
) : null}
</div>
</div>
@@ -100,7 +128,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
{creatorItems.length > 0 && (
<div className="mt-5 border-t border-white/[0.06] pt-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/80">More from {authorName}</h3>
<h3 className="text-sm font-semibold text-white/80">{isGroupPublisher ? 'More related works' : `More from ${authorName}`}</h3>
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import data from '@emoji-mart/data'
import EmojiMartPicker from '../common/EmojiMartPicker'
import loadEmojiMartData from '../common/loadEmojiMartData'
/**
* A button that opens a floating emoji picker.
@@ -14,8 +14,25 @@ import EmojiMartPicker from '../common/EmojiMartPicker'
*/
export default function EmojiPickerButton({ onEmojiSelect, disabled = false, className = '' }) {
const [open, setOpen] = useState(false)
const [pickerData, setPickerData] = useState(null)
const wrapRef = useRef(null)
useEffect(() => {
if (!open || pickerData) return
let cancelled = false
loadEmojiMartData().then((data) => {
if (!cancelled) {
setPickerData(data)
}
})
return () => {
cancelled = true
}
}, [open, pickerData])
// Close on outside click
useEffect(() => {
if (!open) return
@@ -76,15 +93,21 @@ export default function EmojiPickerButton({ onEmojiSelect, disabled = false, cla
className="absolute bottom-full mb-2 right-0 z-50 shadow-2xl rounded-xl overflow-hidden"
style={{ filter: 'drop-shadow(0 8px 32px rgba(0,0,0,0.6))' }}
>
<EmojiMartPicker
data={data}
onEmojiSelect={handleSelect}
theme="dark"
previewPosition="none"
skinTonePosition="none"
maxFrequentRows={2}
perLine={8}
/>
{pickerData ? (
<EmojiMartPicker
data={pickerData}
onEmojiSelect={handleSelect}
theme="dark"
previewPosition="none"
skinTonePosition="none"
maxFrequentRows={2}
perLine={8}
/>
) : (
<div className="flex h-24 w-56 items-center justify-center bg-zinc-900 px-4 text-sm text-zinc-300">
Loading emojis...
</div>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,9 @@
let emojiMartDataPromise = null
export default function loadEmojiMartData() {
if (!emojiMartDataPromise) {
emojiMartDataPromise = import('@emoji-mart/data').then((module) => module.default)
}
return emojiMartDataPromise
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
const TONES = {
tip: {
shell: 'border-sky-300/25 bg-sky-400/10 text-sky-50',
icon: 'fa-solid fa-lightbulb text-sky-200',
label: 'Tip',
},
note: {
shell: 'border-white/15 bg-white/[0.05] text-white',
icon: 'fa-solid fa-circle-info text-slate-200',
label: 'Note',
},
warning: {
shell: 'border-amber-300/25 bg-amber-400/10 text-amber-50',
icon: 'fa-solid fa-triangle-exclamation text-amber-200',
label: 'Warning',
},
practice: {
shell: 'border-emerald-300/25 bg-emerald-400/10 text-emerald-50',
icon: 'fa-solid fa-badge-check text-emerald-200',
label: 'Best Practice',
},
}
export default function DocsCallout({ tone = 'note', title, children }) {
const styles = TONES[tone] || TONES.note
return (
<aside className={`rounded-[24px] border px-4 py-4 md:px-5 ${styles.shell}`}>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/20">
<i className={styles.icon} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">{styles.label}</p>
{title ? <h3 className="mt-1 text-base font-semibold">{title}</h3> : null}
<div className="mt-2 text-sm leading-6 opacity-90">{children}</div>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,33 @@
import React from 'react'
export default function DocsComparisonTable({ columns, rows, caption }) {
return (
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20">
<div className="overflow-x-auto">
<table className="min-w-full border-collapse text-left">
{caption ? <caption className="sr-only">{caption}</caption> : null}
<thead>
<tr className="border-b border-white/10 bg-white/[0.04]">
{columns.map((column) => (
<th key={column.key} scope="col" className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300 first:min-w-[180px]">
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-white/5 last:border-b-0">
{columns.map((column, index) => (
<td key={`${row.id}-${column.key}`} className={`px-4 py-4 align-top text-sm leading-6 ${index === 0 ? 'font-semibold text-white' : 'text-slate-300'}`}>
{row[column.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import React, { useEffect, useId, useState } from 'react'
export default function DocsFaqAccordion({ items, initialOpenIndex = 0, renderAnswer }) {
const [openIndex, setOpenIndex] = useState(items.length > 0 ? initialOpenIndex : -1)
const baseId = useId()
useEffect(() => {
setOpenIndex(items.length > 0 ? Math.min(initialOpenIndex, items.length - 1) : -1)
}, [items.length, initialOpenIndex])
return (
<div className="space-y-3">
{items.map((item, index) => {
const buttonId = `${baseId}-button-${index}`
const panelId = `${baseId}-panel-${index}`
const isOpen = openIndex === index
const answerContent = renderAnswer ? renderAnswer(item) : item.answer
return (
<div key={item.question} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
<h3>
<button
id={buttonId}
type="button"
className="flex w-full items-center justify-between gap-4 px-4 py-4 text-left md:px-5"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setOpenIndex(isOpen ? -1 : index)}
>
<span className="text-base font-semibold text-white">{item.question}</span>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-slate-300">
<i className={`fa-solid ${isOpen ? 'fa-minus' : 'fa-plus'} text-xs`} />
</span>
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={buttonId}
className={isOpen ? 'block border-t border-white/10 px-4 py-4 md:px-5' : 'hidden'}
>
{typeof answerContent === 'string' ? (
<p className="text-sm leading-7 text-slate-300">{answerContent}</p>
) : (
<div className="space-y-4 text-sm leading-7 text-slate-300">{answerContent}</div>
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
export default function DocsSection({ id, eyebrow, title, summary, children, className = '' }) {
return (
<section id={id} aria-labelledby={`${id}-title`} className={`scroll-mt-24 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 ${className}`.trim()}>
<div className="max-w-3xl">
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p> : null}
<h2 id={`${id}-title`} className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-white md:text-[2rem]">{title}</h2>
{summary ? <p className="mt-4 text-sm leading-7 text-slate-300 md:text-[15px]">{summary}</p> : null}
</div>
<div className="mt-6">{children}</div>
</section>
)
}

View File

@@ -0,0 +1,50 @@
import React from 'react'
function jumpToSection(targetId) {
if (!targetId || typeof window === 'undefined') return
const element = document.getElementById(targetId)
if (!element) return
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
window.history.replaceState(null, '', `#${targetId}`)
}
export default function DocsSidebarNav({ sections, ariaLabel = 'Sections on this page', selectLabel = 'Jump to section', navTitle = 'On this page' }) {
return (
<>
<div className="lg:hidden">
<label htmlFor="groups-help-nav" className="sr-only">{selectLabel}</label>
<select
id="groups-help-nav"
className="w-full rounded-[20px] border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white outline-none"
defaultValue=""
onChange={(event) => {
jumpToSection(event.target.value)
event.target.value = ''
}}
>
<option value="">Jump to a section</option>
{sections.map((section) => (
<option key={section.id} value={section.id}>{section.label}</option>
))}
</select>
</div>
<nav aria-label={ariaLabel} className="hidden lg:block lg:sticky lg:top-24">
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">{navTitle}</p>
<ul className="mt-4 space-y-1.5">
{sections.map((section) => (
<li key={section.id}>
<a href={`#${section.id}`} className="block rounded-2xl px-3 py-2 text-sm text-slate-300 transition hover:bg-white/[0.05] hover:text-white">
{section.label}
</a>
</li>
))}
</ul>
</div>
</nav>
</>
)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
export default function DocsStepList({ items }) {
return (
<ol className="space-y-3">
{items.map((item, index) => (
<li key={item.title} className="flex gap-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-300/10 text-sm font-semibold text-sky-100">
{index + 1}
</div>
<div className="min-w-0">
<h3 className="text-base font-semibold text-white">{item.title}</h3>
<p className="mt-1 text-sm leading-6 text-slate-300">{item.description}</p>
</div>
</li>
))}
</ol>
)
}

View File

@@ -0,0 +1,35 @@
import React from 'react'
export default function FaqSearchInput({ value, onChange, onClear, resultCount }) {
return (
<div className="rounded-[26px] border border-white/10 bg-black/20 p-4 md:p-5">
<label htmlFor="groups-faq-search" className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
Search questions
</label>
<div className="mt-3 flex gap-3">
<div className="relative flex-1">
<span className="pointer-events-none absolute inset-y-0 left-4 flex items-center text-slate-500">
<i className="fa-solid fa-magnifying-glass" />
</span>
<input
id="groups-faq-search"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Search roles, invites, contributor credit, review, troubleshooting..."
className="w-full rounded-[20px] border border-white/10 bg-white/[0.04] py-3 pl-11 pr-4 text-sm text-white outline-none placeholder:text-slate-500"
/>
</div>
{value ? (
<button
type="button"
onClick={onClear}
className="rounded-[20px] border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.06]"
>
Clear
</button>
) : null}
</div>
<p className="mt-3 text-sm text-slate-400">{resultCount} question{resultCount === 1 ? '' : 's'} visible</p>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import React from 'react'
export default function QuickstartChecklist({ title, summary, items }) {
return (
<section className="rounded-[30px] border border-emerald-300/20 bg-emerald-400/10 p-5 shadow-[0_22px_70px_rgba(2,6,23,0.2)] md:p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100/80">Checklist</p>
<h3 className="mt-2 text-2xl font-semibold text-white">{title}</h3>
{summary ? <p className="mt-3 text-sm leading-7 text-emerald-50/90">{summary}</p> : null}
<ul className="mt-5 grid gap-3 md:grid-cols-2">
{items.map((item) => (
<li key={item} className="flex gap-3 rounded-[22px] border border-white/10 bg-black/20 px-4 py-4 text-sm leading-6 text-white">
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-emerald-300/20 bg-emerald-300/10 text-emerald-100">
<i className="fa-solid fa-check text-[10px]" />
</span>
<span>{item}</span>
</li>
))}
</ul>
</section>
)
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
const TONES = {
sky: 'border-sky-300/20 bg-sky-300/10 text-sky-100',
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
white: 'border-white/10 bg-black/20 text-white',
}
export default function QuickstartNextSteps({ items }) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{items.map((item) => (
<a
key={item.title}
href={item.href}
className={`rounded-[28px] border p-5 transition hover:-translate-y-0.5 hover:border-white/20 ${TONES[item.tone] || TONES.white}`}
>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">{item.eyebrow}</div>
<div className="mt-2 text-lg font-semibold text-white">{item.title}</div>
<p className="mt-3 text-sm leading-6 opacity-90">{item.body}</p>
</a>
))}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import data from '@emoji-mart/data'
import EmojiMartPicker from '../common/EmojiMartPicker'
import loadEmojiMartData from '../common/loadEmojiMartData'
/**
* Emoji picker button for the forum rich-text editor.
@@ -13,10 +13,27 @@ import EmojiMartPicker from '../common/EmojiMartPicker'
*/
export default function EmojiPicker({ onSelect, editor }) {
const [open, setOpen] = useState(false)
const [pickerData, setPickerData] = useState(null)
const [panelStyle, setPanelStyle] = useState({})
const panelRef = useRef(null)
const buttonRef = useRef(null)
useEffect(() => {
if (!open || pickerData) return
let cancelled = false
loadEmojiMartData().then((data) => {
if (!cancelled) {
setPickerData(data)
}
})
return () => {
cancelled = true
}
}, [open, pickerData])
// Position the portal panel relative to the trigger button
useEffect(() => {
if (!open || !buttonRef.current) return
@@ -73,15 +90,21 @@ export default function EmojiPicker({ onSelect, editor }) {
style={panelStyle}
className="rounded-xl shadow-2xl overflow-hidden"
>
<EmojiMartPicker
data={data}
onEmojiSelect={handleSelect}
theme="dark"
previewPosition="none"
skinTonePosition="search"
maxFrequentRows={2}
perLine={9}
/>
{pickerData ? (
<EmojiMartPicker
data={pickerData}
onEmojiSelect={handleSelect}
theme="dark"
previewPosition="none"
skinTonePosition="search"
maxFrequentRows={2}
perLine={9}
/>
) : (
<div className="flex h-24 w-[352px] items-center justify-center bg-zinc-900 px-4 text-sm text-zinc-300">
Loading emojis...
</div>
)}
</div>,
document.body,
) : null

View File

@@ -0,0 +1,12 @@
import React from 'react'
import { toneClasses } from './groupStyles'
export default function GroupBadgePill({ label, tone = 'slate', className = '' }) {
if (!label) return null
return (
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${toneClasses(tone)} ${className}`.trim()}>
{label}
</span>
)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
export default function GroupBrowseFilters({ surfaces = [], currentSurface = 'featured' }) {
if (!Array.isArray(surfaces) || surfaces.length === 0) return null
return (
<div className="mt-6 flex flex-wrap gap-2">
{surfaces.map((surface) => (
<a
key={surface.value}
href={`/groups?surface=${encodeURIComponent(surface.value)}`}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${currentSurface === surface.value ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-white/[0.03] text-white hover:border-white/20 hover:bg-white/[0.06]'}`}
>
{surface.label}
</a>
))}
</div>
)
}

View File

@@ -0,0 +1,82 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
import { cx, formatCompactNumber } from './groupStyles'
export default function GroupDiscoveryCard({ group, className = '', compact = false }) {
if (!group) return null
const primarySummary = group.headline || group.bio_excerpt || 'Collaborative publishing identity on Skinbase Nova.'
return (
<a
href={group.urls?.public || '/groups'}
className={cx(
'group block overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.34)] transition duration-200 hover:-translate-y-1 hover:border-white/20',
className,
)}
>
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]">
{group.avatar_url ? (
<img src={group.avatar_url} alt={group.name} className="h-full w-full object-cover" loading="lazy" />
) : (
<i className="fa-solid fa-people-group text-slate-300" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-lg font-semibold text-white">{group.name}</h3>
{group.is_recruiting ? <GroupBadgePill label="Recruiting" tone="emerald" /> : null}
{group.is_verified ? <GroupBadgePill label="Verified" tone="sky" /> : null}
</div>
<p className="mt-2 text-sm leading-6 text-slate-300">{primarySummary}</p>
{group.owner?.username || group.owner?.name ? (
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">
Led by {group.owner?.username || group.owner?.name}
</p>
) : null}
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
{(Array.isArray(group.trust_signals) ? group.trust_signals : []).slice(0, compact ? 2 : 3).map((signal) => (
<GroupBadgePill key={signal.key} label={signal.label} tone={signal.tone} />
))}
{(Array.isArray(group.badges) ? group.badges : []).slice(0, compact ? 1 : 2).map((badge) => (
<GroupBadgePill key={badge.key} label={badge.label} tone="amber" />
))}
</div>
{group.recruitment_headline && !compact ? (
<div className="mt-5 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/80">Open call</div>
<div className="mt-1">{group.recruitment_headline}</div>
</div>
) : null}
{group.featured_release?.title && !compact ? (
<div className="mt-5 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Featured release</div>
<div className="mt-2 text-base font-semibold text-white">{group.featured_release.title}</div>
{group.featured_release.summary ? <div className="mt-1 text-sm text-slate-400">{group.featured_release.summary}</div> : null}
</div>
) : null}
<div className="mt-5 grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-white/[0.03] p-3 text-center">
<div>
<div className="text-lg font-semibold text-white">{formatCompactNumber(group.counts?.artworks)}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Artworks</div>
</div>
<div>
<div className="text-lg font-semibold text-white">{formatCompactNumber(group.counts?.members)}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Members</div>
</div>
<div>
<div className="text-lg font-semibold text-white">{formatCompactNumber(group.counts?.followers)}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Followers</div>
</div>
</div>
</a>
)
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
export default function GroupLeaderboardCard({ item }) {
if (!item?.entity) return null
const entity = item.entity
return (
<article className="rounded-[26px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.3)]">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-slate-950/70 text-lg font-black text-white">
#{item.rank}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<a href={entity.url || '/groups'} className="block truncate text-lg font-semibold text-white transition hover:text-sky-300">{entity.name}</a>
{entity.headline ? <p className="mt-1 text-sm text-slate-400">{entity.headline}</p> : null}
</div>
<div className="text-right">
<div className="text-[11px] uppercase tracking-[0.18em] text-slate-500">Score</div>
<div className="mt-1 text-xl font-black text-white">{Number(item.score || 0).toLocaleString()}</div>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{(Array.isArray(entity.trust_signals) ? entity.trust_signals : []).slice(0, 2).map((signal) => (
<GroupBadgePill key={signal.key} label={signal.label} tone={signal.tone} />
))}
{entity.is_recruiting ? <GroupBadgePill label="Recruiting" tone="emerald" /> : null}
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
<span>{Number(entity.artworks_count || 0).toLocaleString()} artworks</span>
<span>{Number(entity.members_count || 0).toLocaleString()} members</span>
<span>{Number(entity.followers_count || 0).toLocaleString()} followers</span>
</div>
</div>
</div>
</article>
)
}

View File

@@ -0,0 +1,51 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
export default function GroupProfileSummary({ contributions = [], href = null }) {
if (!Array.isArray(contributions) || contributions.length === 0) return null
const featured = contributions.slice(0, 3)
return (
<section className="mx-auto mt-8 max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.12),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_18px_55px_rgba(2,6,23,0.28)] backdrop-blur-xl sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Group footprint</div>
<h2 className="mt-2 text-2xl font-semibold text-white">Collaborative work across public groups</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">See the groups this creator contributes to through releases, credited artworks, and shared publishing activity.</p>
</div>
{href ? <a href={href} className="text-sm font-semibold text-sky-200 transition hover:text-white">View full contribution history</a> : null}
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-3">
{featured.map((entry) => (
<a key={entry.group?.slug || entry.group?.id} href={entry.group?.profile_url || '/groups'} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20 hover:bg-black/30">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]">
{entry.group?.avatar_url ? <img src={entry.group.avatar_url} alt={entry.group?.name} className="h-full w-full object-cover" loading="lazy" /> : <i className="fa-solid fa-people-group text-slate-300" />}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-base font-semibold text-white">{entry.group?.name}</div>
{entry.role?.label ? <div className="mt-1 text-sm text-slate-400">{entry.role.label}</div> : null}
{entry.summary ? <div className="mt-2 text-sm text-slate-300">{entry.summary}</div> : null}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{entry.trusted ? <GroupBadgePill label="Trusted contributor" tone="sky" /> : null}
{entry.recent_release_titles?.length ? <GroupBadgePill label="Recent releases" tone="amber" /> : null}
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
<span>{Number(entry.counts?.artworks || 0).toLocaleString()} artworks</span>
<span>{Number(entry.counts?.releases || 0).toLocaleString()} releases</span>
<span>{Number(entry.counts?.projects || 0).toLocaleString()} projects</span>
</div>
</a>
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,66 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
export default function GroupPromoCard({ group, eyebrow = 'Groups spotlight', title, description, ctaLabel = 'Open group' }) {
if (!group) return null
return (
<section className="overflow-hidden rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_80%_20%,rgba(16,185,129,0.12),transparent_26%),linear-gradient(180deg,rgba(7,16,29,0.98),rgba(2,6,23,0.94))] shadow-[0_30px_90px_rgba(2,6,23,0.45)]">
<div className="grid gap-6 p-6 lg:grid-cols-[minmax(0,1.3fr)_320px] lg:p-8">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">{eyebrow}</p>
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.03em] text-white sm:text-4xl">{title || group.name}</h2>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300">{description || group.headline || group.bio_excerpt || 'Collective publishing for shared releases, artwork credits, and collaborative identity.'}</p>
<div className="mt-5 flex flex-wrap gap-2">
{(Array.isArray(group.trust_signals) ? group.trust_signals : []).slice(0, 3).map((signal) => (
<GroupBadgePill key={signal.key} label={signal.label} tone={signal.tone} />
))}
{group.is_recruiting ? <GroupBadgePill label="Actively recruiting" tone="emerald" /> : null}
</div>
<div className="mt-6 flex flex-wrap gap-3">
<a href={group.urls?.public || '/groups'} 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/40 hover:bg-sky-300/15">
{ctaLabel}
<i className="fa-solid fa-arrow-right text-xs" />
</a>
<a href="/groups" 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:border-white/20 hover:bg-white/[0.07]">
Browse all groups
</a>
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-black/25 p-5 backdrop-blur-sm">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]">
{group.avatar_url ? <img src={group.avatar_url} alt={group.name} className="h-full w-full object-cover" loading="lazy" /> : <i className="fa-solid fa-people-group text-slate-300" />}
</div>
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-white">{group.name}</div>
<div className="mt-1 text-sm text-slate-400">{group.owner?.username || group.owner?.name ? `Led by ${group.owner.username || group.owner.name}` : 'Shared publishing identity'}</div>
</div>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="text-2xl font-semibold text-white">{Number(group.counts?.artworks || 0).toLocaleString()}</div>
<div className="text-[11px] uppercase tracking-[0.18em] text-slate-500">Published artworks</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="text-2xl font-semibold text-white">{Number(group.counts?.followers || 0).toLocaleString()}</div>
<div className="text-[11px] uppercase tracking-[0.18em] text-slate-500">Followers</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="text-2xl font-semibold text-white">{Number(group.counts?.members || 0).toLocaleString()}</div>
<div className="text-[11px] uppercase tracking-[0.18em] text-slate-500">Members</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="text-2xl font-semibold text-white">{Number(group.counts?.collections || 0).toLocaleString()}</div>
<div className="text-[11px] uppercase tracking-[0.18em] text-slate-500">Collections</div>
</div>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,27 @@
import React from 'react'
export default function GroupStudioPromoCard({ title, description, bullets = [], primaryLabel, primaryHref, secondaryLabel, secondaryHref }) {
return (
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_18px_55px_rgba(2,6,23,0.28)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Studio groups</div>
<h2 className="mt-2 text-2xl font-semibold text-white">{title}</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-300">{description}</p>
{bullets.length > 0 ? (
<div className="mt-5 grid gap-3 md:grid-cols-3">
{bullets.map((bullet) => (
<div key={bullet.title} className="rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="text-sm font-semibold text-white">{bullet.title}</div>
<div className="mt-2 text-sm leading-6 text-slate-400">{bullet.body}</div>
</div>
))}
</div>
) : null}
<div className="mt-5 flex flex-wrap gap-3">
{primaryHref ? <a href={primaryHref} className="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">{primaryLabel}</a> : null}
{secondaryHref ? <a href={secondaryHref} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">{secondaryLabel}</a> : null}
</div>
</section>
)
}

View File

@@ -0,0 +1,48 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
export default function GroupSummaryPanel({ group, artwork }) {
if (!group) return null
return (
<section className="overflow-hidden rounded-[28px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_36%),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 items-start gap-4">
<a href={group.urls?.public || '/groups'} className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]">
{group.avatar_url ? <img src={group.avatar_url} alt={group.name} className="h-full w-full object-cover" loading="lazy" /> : <i className="fa-solid fa-people-group text-slate-300" />}
</a>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Published as a group</div>
<a href={group.urls?.public || '/groups'} className="mt-2 block text-xl font-semibold tracking-[-0.02em] text-white transition hover:text-sky-300">{group.name}</a>
<p className="mt-2 text-sm leading-6 text-white/65">{group.headline || group.bio_excerpt || `${artwork?.title || 'This artwork'} is credited to a collaborative group identity on Skinbase.`}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{(Array.isArray(group.trust_signals) ? group.trust_signals : []).slice(0, 3).map((signal) => (
<GroupBadgePill key={signal.key} label={signal.label} tone={signal.tone} />
))}
{group.is_recruiting ? <GroupBadgePill label="Recruiting" tone="emerald" /> : null}
</div>
<div className="mt-5 grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-black/20 p-3 text-center">
<div>
<div className="text-lg font-semibold text-white">{Number(group.counts?.artworks || 0).toLocaleString()}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Artworks</div>
</div>
<div>
<div className="text-lg font-semibold text-white">{Number(group.counts?.members || 0).toLocaleString()}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Members</div>
</div>
<div>
<div className="text-lg font-semibold text-white">{Number(group.counts?.followers || 0).toLocaleString()}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Followers</div>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<a href={group.urls?.public || '/groups'} className="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">Open group page</a>
{group.urls?.releases ? <a href={group.urls.releases} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Browse releases</a> : null}
</div>
</section>
)
}

View File

@@ -0,0 +1,24 @@
import React from 'react'
import GroupDiscoveryCard from './GroupDiscoveryCard'
export default function GroupTrendingSection({ title, description, items = [], href = '/groups', actionLabel = 'See more' }) {
if (!Array.isArray(items) || items.length === 0) return null
return (
<section className="mt-10">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-[-0.02em] text-white">{title}</h2>
{description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{description}</p> : null}
</div>
<a href={href} className="inline-flex items-center gap-2 text-sm font-semibold text-sky-200 transition hover:text-white">
{actionLabel}
<i className="fa-solid fa-arrow-right text-xs" />
</a>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{items.map((group) => <GroupDiscoveryCard key={group.slug || group.id} group={group} compact />)}
</div>
</section>
)
}

View File

@@ -0,0 +1,19 @@
export function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
export function formatCompactNumber(value) {
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(Number(value || 0))
}
const TONE_CLASSES = {
sky: 'border-sky-300/25 bg-sky-300/10 text-sky-100',
emerald: 'border-emerald-300/25 bg-emerald-300/10 text-emerald-100',
amber: 'border-amber-300/25 bg-amber-300/10 text-amber-100',
violet: 'border-violet-300/25 bg-violet-300/10 text-violet-100',
slate: 'border-white/10 bg-white/[0.04] text-slate-200',
}
export function toneClasses(tone = 'slate') {
return TONE_CLASSES[tone] || TONE_CLASSES.slate
}

View File

@@ -0,0 +1,56 @@
import React from 'react'
const TONES = {
sky: 'border-sky-300/20 bg-sky-300/10',
amber: 'border-amber-300/20 bg-amber-400/10',
white: 'border-white/10 bg-black/20',
}
export default function HelpGuideCard({ item, links }) {
const primaryHref = item.primaryLinkKey ? links[item.primaryLinkKey] : null
const secondaryHref = item.secondaryLinkKey ? links[item.secondaryLinkKey] : null
return (
<article className={`rounded-[30px] border p-5 shadow-[0_18px_50px_rgba(2,6,23,0.18)] transition hover:-translate-y-0.5 hover:border-white/20 ${TONES[item.tone] || TONES.white}`}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">{item.eyebrow}</p>
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{item.title}</h3>
</div>
<span className="rounded-full border border-white/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-300">
{item.status}
</span>
</div>
<p className="mt-4 text-sm leading-7 text-slate-200/90">{item.description}</p>
{item.plannedPath ? (
<p className="mt-4 text-xs uppercase tracking-[0.16em] text-slate-400">Planned help route: {item.plannedPath}</p>
) : null}
{Array.isArray(item.highlights) && item.highlights.length > 0 ? (
<ul className="mt-4 space-y-2 text-sm text-slate-200/90">
{item.highlights.map((highlight) => (
<li key={highlight} className="flex gap-3">
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-white/80" />
<span>{highlight}</span>
</li>
))}
</ul>
) : null}
<div className="mt-5 flex flex-wrap gap-3">
{primaryHref ? (
<a href={primaryHref} className="rounded-full border border-white/15 bg-white/[0.08] px-4 py-2.5 text-sm font-semibold text-white transition hover:border-white/25 hover:bg-white/[0.12]">
{item.primaryLabel}
</a>
) : null}
{secondaryHref ? (
<a href={secondaryHref} className="rounded-full border border-white/10 px-4 py-2.5 text-sm font-semibold text-slate-200 transition hover:border-white/20 hover:bg-white/[0.05]">
{item.secondaryLabel}
</a>
) : null}
</div>
</article>
)
}

View File

@@ -0,0 +1,48 @@
import React from 'react'
export default function HelpSearchBar({ value, onChange, onSelectSuggestion, onClear, resultSummary, suggestions = [] }) {
return (
<div className="rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.03))] p-4 shadow-[0_22px_70px_rgba(2,6,23,0.22)] md:p-5">
<label htmlFor="help-center-search" className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">
Search the help hub
</label>
<div className="mt-3 flex flex-col gap-3 lg:flex-row">
<div className="relative flex-1">
<span className="pointer-events-none absolute inset-y-0 left-4 flex items-center text-slate-500">
<i className="fa-solid fa-magnifying-glass" />
</span>
<input
id="help-center-search"
type="search"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Search upload image, group roles, create card, login issue..."
className="w-full rounded-[22px] border border-white/10 bg-black/20 py-3.5 pl-11 pr-4 text-sm text-white outline-none placeholder:text-slate-500"
/>
</div>
{value ? (
<button
type="button"
onClick={onClear}
className="rounded-[22px] border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
>
Clear search
</button>
) : null}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => onSelectSuggestion(suggestion)}
className="rounded-full border border-white/10 bg-black/20 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-300 transition hover:border-white/20 hover:text-white"
>
{suggestion}
</button>
))}
</div>
<p className="mt-4 text-sm text-slate-400">{resultSummary}</p>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import React from 'react'
export default function HelpSupportCta({ items }) {
return (
<div className="grid gap-4 md:grid-cols-3">
{items.map((item) => (
<a key={item.title} href={item.href} className="rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.03))] p-5 transition hover:-translate-y-0.5 hover:border-white/20">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{item.eyebrow}</p>
<h3 className="mt-2 text-lg font-semibold text-white">{item.title}</h3>
<p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p>
</a>
))}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More