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

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

View File

@@ -0,0 +1,59 @@
import React from 'react'
export default function HelpTopicCard({ item, links }) {
const primaryHref = item.primaryLinkKey ? links[item.primaryLinkKey] : null
const secondaryHref = item.secondaryLinkKey ? links[item.secondaryLinkKey] : null
return (
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{item.eyebrow}</p>
<h3 className="mt-2 text-lg font-semibold 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-400">
{item.status}
</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p>
{item.plannedPath ? (
<p className="mt-3 text-xs uppercase tracking-[0.16em] text-slate-500">Planned route: {item.plannedPath}</p>
) : null}
{Array.isArray(item.tags) && item.tags.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{item.tags.map((tag) => (
<span key={tag} className="rounded-full border border-white/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-400">
{tag}
</span>
))}
</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.5 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15">
{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>
{Array.isArray(item.linkItems) && item.linkItems.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-3">
{item.linkItems.map((linkItem) => (
<a key={linkItem.label} href={links[linkItem.linkKey]} className="text-sm font-semibold text-sky-200 underline underline-offset-4">
{linkItem.label}
</a>
))}
</div>
) : null}
</article>
)
}

View File

@@ -1,5 +1,6 @@
import React from 'react'
import LevelBadge from '../xp/LevelBadge'
import GroupBadgePill from '../groups/GroupBadgePill'
const PODIUM_STYLES = {
1: 'border-yellow-300/40 bg-[linear-gradient(180deg,rgba(250,204,21,0.18),rgba(15,23,42,0.84))]',
@@ -20,6 +21,7 @@ export default function LeaderboardItem({ item, type, highlight = false }) {
const rank = Number(item?.rank || 0)
const tone = highlight ? PODIUM_STYLES[rank] || PODIUM_STYLES[3] : 'border-white/10 bg-white/[0.03]'
const image = entity.avatar || entity.image || null
const groupSignals = Array.isArray(entity.trust_signals) ? entity.trust_signals.slice(0, 2) : []
return (
<article className={cx('rounded-3xl border p-4 shadow-lg transition', tone)}>
@@ -39,6 +41,7 @@ export default function LeaderboardItem({ item, type, highlight = false }) {
by {entity.creator_name}
</a>
) : null}
{type === 'group' && entity.headline ? <p className="mt-1 text-sm text-slate-400">{entity.headline}</p> : null}
{entity.username ? <p className="mt-1 text-sm text-slate-500">@{entity.username}</p> : null}
</div>
@@ -50,11 +53,18 @@ export default function LeaderboardItem({ item, type, highlight = false }) {
<div className="mt-4 flex flex-wrap items-center gap-3">
{type === 'creator' ? <LevelBadge level={entity.level} rank={entity.rank} compact /> : null}
{type === 'group' ? groupSignals.map((signal) => <GroupBadgePill key={signal.key} label={signal.label} tone={signal.tone} />) : null}
{type === 'group' && entity.is_recruiting ? <GroupBadgePill label="Recruiting" tone="emerald" /> : null}
{type !== 'creator' && entity.creator_name ? (
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] uppercase tracking-[0.14em] text-slate-300">
{type}
</span>
) : null}
{type === 'group' ? (
<span className="text-xs text-slate-400">
{Number(entity.artworks_count || 0).toLocaleString()} artworks, {Number(entity.members_count || 0).toLocaleString()} members, {Number(entity.followers_count || 0).toLocaleString()} followers
</span>
) : null}
</div>
</div>

View File

@@ -69,6 +69,19 @@ function truncateText(value, maxLength = 140) {
return `${text.slice(0, maxLength).trimEnd()}...`
}
function formatContributionDate(value) {
if (!value) return null
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return null
}
}
function buildInterestGroups(artworks = []) {
const categoryMap = new Map()
const contentTypeMap = new Map()
@@ -156,7 +169,7 @@ function SectionCard({ icon, eyebrow, title, children, className = '' }) {
* TabAbout
* Bio, social links, metadata - replaces old sidebar profile card.
*/
export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank }) {
export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory }) {
const uname = user.username || user.name
const displayName = user.name || uname
const about = profile?.about
@@ -193,6 +206,7 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
const recentAchievements = Array.isArray(achievements?.recent) ? achievements.recent : []
const stories = Array.isArray(creatorStories) ? creatorStories : []
const comments = Array.isArray(profileComments) ? profileComments : []
const contributionHistory = Array.isArray(groupContributionHistory) ? groupContributionHistory : []
const interestGroups = buildInterestGroups(Array.isArray(artworks) ? artworks : [])
const summaryCards = [
{ icon: 'fa-user-group', label: 'Followers', value: formatNumber(followerCount), tone: 'sky' },
@@ -271,6 +285,59 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
</div>
</SectionCard>
{contributionHistory.length > 0 ? (
<SectionCard icon="fa-solid fa-people-group" eyebrow="Collaborative work" title="Group contribution history">
<div className="grid gap-4">
{contributionHistory.map((entry) => (
<a
key={entry.group?.slug}
href={entry.group?.profile_url || '#'}
className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4 transition-colors hover:border-white/14 hover:bg-white/[0.06]"
>
<div className="flex items-start gap-3">
{entry.group?.avatar_url ? (
<img src={entry.group.avatar_url} alt={entry.group?.name} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-400">
<i className="fa-solid fa-people-group" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-sm font-semibold text-white">{entry.group?.name}</div>
{entry.role ? <span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{String(entry.role).replaceAll('_', ' ')}</span> : null}
{entry.trusted_indicator ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Trusted</span> : null}
</div>
{entry.group?.headline ? <p className="mt-1 text-sm text-slate-400">{truncateText(entry.group.headline, 100)}</p> : null}
{entry.summary ? <p className="mt-3 text-sm text-slate-300">{entry.summary}</p> : null}
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
<span>{Number(entry.counts?.credited_artworks || 0).toLocaleString()} credited artworks</span>
<span>{Number(entry.counts?.releases || 0).toLocaleString()} releases</span>
<span>{Number(entry.counts?.projects || 0).toLocaleString()} projects</span>
{entry.joined_at ? <span>Joined {formatContributionDate(entry.joined_at)}</span> : null}
</div>
{Array.isArray(entry.role_labels) && entry.role_labels.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{entry.role_labels.map((label) => (
<span key={`${entry.group?.slug}-${label}`} className="rounded-full border border-sky-300/15 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">
{label}
</span>
))}
</div>
) : null}
{Array.isArray(entry.recent_release_titles) && entry.recent_release_titles.length > 0 ? (
<div className="mt-3 text-xs text-slate-400">
Recent releases: {entry.recent_release_titles.join(' • ')}
</div>
) : null}
</div>
</div>
</a>
))}
</div>
</SectionCard>
) : null}
{followers.length > 0 ? (
<SectionCard icon="fa-solid fa-user-group" eyebrow="Community" title="Recent followers">
<div className="grid gap-3 sm:grid-cols-2">

View File

@@ -64,6 +64,8 @@ export default function PublishPanel({
// Navigation helpers (for checklist quick-links)
onGoToStep,
allRootCategoryOptions = [],
actionLabel = 'Publish now',
showScheduleControls = true,
}) {
const pill = STATUS_PILL[machineState] ?? null
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
@@ -93,10 +95,11 @@ export default function PublishPanel({
]
const publishLabel = useCallback(() => {
if (isPublishing) return 'Publishing…'
if (isPublishing) return `${actionLabel}`
if (!showScheduleControls) return actionLabel
if (publishMode === 'schedule') return 'Schedule publish'
return 'Publish now'
}, [isPublishing, publishMode])
return actionLabel
}, [isPublishing, publishMode, actionLabel, showScheduleControls])
const canSchedulePublish =
publishMode === 'schedule' ? Boolean(scheduledAt) && canPublish : canPublish
@@ -224,7 +227,7 @@ export default function PublishPanel({
)}
{/* Schedule picker only shows when enabled for this panel */}
{showVisibility && uploadReady && machineState !== 'complete' && (
{showVisibility && showScheduleControls && uploadReady && machineState !== 'complete' && (
<SchedulePublishPicker
mode={publishMode}
scheduledAt={scheduledAt}

View File

@@ -25,6 +25,7 @@ export default function UploadActions({
showSaveDraft = false,
mobileSticky = true,
resetLabel = 'Reset',
publishLabel = 'Publish',
}) {
const [confirmCancel, setConfirmCancel] = useState(false)
@@ -81,11 +82,11 @@ export default function UploadActions({
<button
type="button"
disabled={disabled}
title={disabled ? disableReason : 'Publish artwork'}
title={disabled ? disableReason : publishLabel}
onClick={() => onPublish?.()}
className={`btn-primary text-sm ${disabled ? 'cursor-not-allowed opacity-60' : ''}`}
>
{isPublishing ? 'Publishing…' : 'Publish'}
{isPublishing ? `${publishLabel}` : publishLabel}
</button>
)
}

View File

@@ -36,6 +36,61 @@ const wizardSteps = [
{ key: 'publish', label: 'Publish' },
]
function createInitialMetadata(initialGroupSlug = '', currentUserId = null, contributorOptionsByGroup = {}) {
const normalizedGroupSlug = String(initialGroupSlug || '').trim()
const contributors = Array.isArray(contributorOptionsByGroup?.[normalizedGroupSlug])
? contributorOptionsByGroup[normalizedGroupSlug]
: []
const defaultPrimaryAuthor = contributors.some((user) => Number(user.id) === Number(currentUserId))
? Number(currentUserId)
: Number(contributors[0]?.id || 0) || null
return {
title: '',
rootCategoryId: '',
subCategoryId: '',
tags: [],
description: '',
isMature: false,
rightsAccepted: false,
contentType: '',
group: normalizedGroupSlug,
primaryAuthorUserId: defaultPrimaryAuthor,
contributorUserIds: [],
contributorCredits: {},
}
}
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
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors) {
if (!primaryFile) return false
@@ -44,17 +99,6 @@ function isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotError
return true
}
const initialMetadata = {
title: '',
rootCategoryId: '',
subCategoryId: '',
tags: [],
description: '',
isMature: false,
rightsAccepted: false,
contentType: '',
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function UploadWizard({
onValidationStateChange,
@@ -62,6 +106,10 @@ export default function UploadWizard({
chunkSize,
contentTypes = [],
suggestedTags = [],
groupOptions = [],
contributorOptionsByGroup = {},
initialGroupSlug = '',
currentUserId = null,
}) {
const [notices, setNotices] = useState([])
// ── UI state ──────────────────────────────────────────────────────────────
@@ -88,7 +136,7 @@ export default function UploadWizard({
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0)
// ── Metadata state ────────────────────────────────────────────────────────
const [metadata, setMetadata] = useState(initialMetadata)
const [metadata, setMetadata] = useState(() => createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup))
// ── Refs ──────────────────────────────────────────────────────────────────
const prefersReducedMotion = useReducedMotion()
@@ -189,6 +237,27 @@ export default function UploadWizard({
return categoryTreeByType[selected] || []
}, [categoryTreeByType, metadata.contentType])
const selectedGroupOption = useMemo(() => {
const selectedSlug = String(metadata.group || '')
if (!selectedSlug) return null
return (Array.isArray(groupOptions) ? groupOptions : []).find((group) => String(group.slug || '') === selectedSlug) || null
}, [groupOptions, metadata.group])
const reviewSubmissionMode = Boolean(
selectedGroupOption &&
!selectedGroupOption?.permissions?.can_publish_artworks &&
selectedGroupOption?.permissions?.can_submit_artwork_for_review
)
const publishActionLabel = reviewSubmissionMode
? 'Submit for review'
: (publishMode === 'schedule' ? 'Schedule publish' : 'Publish now')
const currentContributorOptions = useMemo(() => {
const selectedSlug = String(metadata.group || '')
return Array.isArray(contributorOptionsByGroup?.[selectedSlug]) ? contributorOptionsByGroup[selectedSlug] : []
}, [contributorOptionsByGroup, metadata.group])
const allRootCategoryOptions = useMemo(() => {
const items = []
Object.entries(categoryTreeByType).forEach(([contentTypeValue, roots]) => {
@@ -213,6 +282,45 @@ export default function UploadWizard({
selectedRootCategory.children.length > 0
)
useEffect(() => {
const selectedSlug = String(metadata.group || '')
if (!selectedSlug) {
if (metadata.primaryAuthorUserId || metadata.contributorUserIds.length > 0 || Object.keys(metadata.contributorCredits || {}).length > 0) {
setMetadata((current) => ({ ...current, primaryAuthorUserId: null, contributorUserIds: [], contributorCredits: {} }))
}
return
}
const validGroup = (Array.isArray(groupOptions) ? groupOptions : []).some((group) => String(group.slug || '') === selectedSlug)
if (!validGroup) {
setMetadata((current) => ({ ...current, group: '', primaryAuthorUserId: null, contributorUserIds: [], contributorCredits: {} }))
return
}
const validContributorIds = currentContributorOptions.map((user) => Number(user.id)).filter((id) => Number.isFinite(id) && id > 0)
const nextPrimaryAuthorId = validContributorIds.includes(Number(metadata.primaryAuthorUserId))
? Number(metadata.primaryAuthorUserId)
: (validContributorIds.includes(Number(currentUserId)) ? Number(currentUserId) : (validContributorIds[0] || null))
const nextContributorIds = (Array.isArray(metadata.contributorUserIds) ? metadata.contributorUserIds : [])
.map((id) => Number(id))
.filter((id) => validContributorIds.includes(id) && id !== nextPrimaryAuthorId)
const nextContributorCredits = normalizeContributorCredits(nextContributorIds, metadata.contributorCredits)
const currentPrimary = metadata.primaryAuthorUserId ? Number(metadata.primaryAuthorUserId) : null
const currentContributors = (Array.isArray(metadata.contributorUserIds) ? metadata.contributorUserIds : []).map((id) => Number(id))
const contributorsChanged = nextContributorIds.length !== currentContributors.length || nextContributorIds.some((id, index) => id !== currentContributors[index])
const contributorCreditsChanged = JSON.stringify(nextContributorCredits) !== JSON.stringify(normalizeContributorCredits(currentContributors, metadata.contributorCredits))
if (currentPrimary !== nextPrimaryAuthorId || contributorsChanged || contributorCreditsChanged) {
setMetadata((current) => ({
...current,
primaryAuthorUserId: nextPrimaryAuthorId,
contributorUserIds: nextContributorIds,
contributorCredits: nextContributorCredits,
}))
}
}, [groupOptions, currentContributorOptions, currentUserId, metadata.group, metadata.primaryAuthorUserId, metadata.contributorUserIds, metadata.contributorCredits])
// ── Metadata validation ───────────────────────────────────────────────────
const metadataErrors = useMemo(() => {
const errors = {}
@@ -274,9 +382,10 @@ export default function UploadWizard({
const canScheduleSubmit = useMemo(() => {
if (!canPublish) return false
if (reviewSubmissionMode) return true
if (publishMode === 'schedule') return Boolean(scheduledAt)
return true
}, [canPublish, publishMode, scheduledAt])
}, [canPublish, reviewSubmissionMode, publishMode, scheduledAt])
// ── Validation surface for parent ────────────────────────────────────────
const validationErrors = useMemo(
@@ -338,7 +447,7 @@ export default function UploadWizard({
setPrimaryFile(null)
setScreenshots([])
setSelectedScreenshotIndex(0)
setMetadata(initialMetadata)
setMetadata(createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup))
setIsUploadLocked(false)
hasAutoAdvancedRef.current = false
setPublishMode('now')
@@ -350,7 +459,7 @@ export default function UploadWizard({
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
})
setActiveStep(1)
}, [resetMachine, initialDraftId])
}, [resetMachine, initialDraftId, initialGroupSlug, currentUserId, contributorOptionsByGroup])
const goToStep = useCallback((step) => {
if (step >= 1 && step <= highestUnlockedStep) setActiveStep(step)
@@ -454,6 +563,60 @@ export default function UploadWizard({
onContentTypeChange={(value) => setMeta({ contentType: value, rootCategoryId: '', subCategoryId: '' })}
onRootCategoryChange={(rootId) => setMeta({ rootCategoryId: rootId, subCategoryId: '' })}
onSubCategoryChange={(subId) => setMeta({ subCategoryId: subId })}
groupOptions={groupOptions}
currentContributorOptions={currentContributorOptions}
onGroupChange={(groupSlug) => setMeta({ group: groupSlug })}
onPrimaryAuthorChange={(authorId) => setMeta({ primaryAuthorUserId: authorId ? Number(authorId) : null })}
onContributorToggle={(contributorId) => setMetadata((current) => {
const normalizedId = Number(contributorId)
const nextIds = new Set((Array.isArray(current.contributorUserIds) ? current.contributorUserIds : []).map((id) => Number(id)).filter((id) => id !== Number(current.primaryAuthorUserId)))
if (nextIds.has(normalizedId)) {
nextIds.delete(normalizedId)
} else {
nextIds.add(normalizedId)
}
const contributorUserIds = Array.from(nextIds).filter((id) => id !== Number(current.primaryAuthorUserId))
return {
...current,
contributorUserIds,
contributorCredits: normalizeContributorCredits(contributorUserIds, current.contributorCredits),
}
})}
onContributorRoleChange={(contributorId, creditRole) => setMetadata((current) => {
const contributorUserIds = (Array.isArray(current.contributorUserIds) ? current.contributorUserIds : []).map((id) => Number(id))
if (!contributorUserIds.includes(Number(contributorId))) return current
const contributorCredits = normalizeContributorCredits(contributorUserIds, current.contributorCredits)
return {
...current,
contributorCredits: {
...contributorCredits,
[Number(contributorId)]: {
...(contributorCredits[Number(contributorId)] || { isPrimary: false }),
creditRole,
},
},
}
})}
onContributorPrimaryChange={(contributorId) => setMetadata((current) => {
const contributorUserIds = (Array.isArray(current.contributorUserIds) ? current.contributorUserIds : []).map((id) => Number(id))
const contributorCredits = normalizeContributorCredits(contributorUserIds, current.contributorCredits)
contributorUserIds.forEach((id) => {
contributorCredits[id] = {
...(contributorCredits[id] || { creditRole: '' }),
isPrimary: id === Number(contributorId),
}
})
return {
...current,
contributorCredits,
}
})}
suggestedTags={mergedSuggestedTags}
publishMode={publishMode}
scheduledAt={scheduledAt}
@@ -486,7 +649,11 @@ export default function UploadWizard({
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleSummary={!reviewSubmissionMode}
onVisibilityChange={setVisibility}
selectedGroup={selectedGroupOption}
currentContributorOptions={currentContributorOptions}
allRootCategoryOptions={allRootCategoryOptions}
filteredCategoryTree={filteredCategoryTree}
/>
@@ -608,14 +775,15 @@ export default function UploadWizard({
disableReason={disableReason}
onStart={runUploadFlow}
onContinue={() => detailsValid && setActiveStep(3)}
onPublish={() => handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
onPublish={() => handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
onCancel={handleCancel}
onReset={handleReset}
onRetry={() => handleRetry(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
onRetry={() => handleRetry(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onSaveDraft={() => {}}
showSaveDraft={activeStep === 2}
resetLabel={isUploadLocked ? 'Reset upload' : 'Reset'}
publishLabel={publishActionLabel}
mobileSticky
/>
</div>
@@ -641,13 +809,15 @@ export default function UploadWizard({
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleControls={!reviewSubmissionMode}
showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
onPublish={() => handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onCancel={handleCancel}
onGoToStep={goToStep}
allRootCategoryOptions={allRootCategoryOptions}
@@ -668,7 +838,7 @@ export default function UploadWizard({
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Publish
{reviewSubmissionMode ? 'Review' : 'Publish'}
{!canPublish && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{[
@@ -725,6 +895,8 @@ export default function UploadWizard({
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleControls={!reviewSubmissionMode}
showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode}
@@ -733,7 +905,7 @@ export default function UploadWizard({
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => {
setShowMobilePublishPanel(false)
handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })
handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })
}}
onCancel={() => {
setShowMobilePublishPanel(false)

View File

@@ -367,6 +367,94 @@ describe('UploadWizard step flow', () => {
})
})
it('includes contributor credit metadata in the final publish payload', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({
initialDraftId: 313,
currentUserId: 11,
initialGroupSlug: 'warp-collective',
groupOptions: [{ slug: 'warp-collective', name: 'Warp Collective' }],
contributorOptionsByGroup: {
'warp-collective': [
{ id: 10, name: 'Owner User', username: 'owner-user' },
{ id: 11, name: 'Editor User', username: 'editor-user' },
],
},
contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }],
})
await completeStep1ToReady()
await screen.findByText(/artwork details/i)
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /add credit/i }))
await userEvent.type(screen.getByLabelText(/credit role for owner user/i), 'Color assist')
await userEvent.click(screen.getByRole('button', { name: /mark owner user as lead supporting credit/i }))
})
await completeRequiredDetails({ title: 'Collaborative Piece' })
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
await waitFor(() => {
const publish = screen.getByRole('button', { name: /^publish$/i })
expect(publish.disabled).toBe(false)
})
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
})
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith(
'/api/uploads/313/publish',
expect.objectContaining({
contributor_user_ids: [10],
contributor_credits: [
expect.objectContaining({
user_id: 10,
credit_role: 'Color assist',
is_primary: true,
}),
],
}),
expect.anything(),
)
})
})
it('shows personal and group publish options when group publishing is available', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({
initialDraftId: 314,
currentUserId: 11,
groupOptions: [{ slug: 'warp-collective', name: 'Warp Collective' }],
contributorOptionsByGroup: {
'warp-collective': [
{ id: 11, name: 'Editor User', username: 'editor-user' },
{ id: 12, name: 'Owner User', username: 'owner-user' },
],
},
contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }],
})
await completeStep1ToReady()
await screen.findByText(/artwork details/i)
const publishAs = screen.getByRole('combobox', { name: /publishing identity/i })
expect(screen.getByRole('option', { name: /personal profile/i })).not.toBeNull()
expect(screen.getByRole('option', { name: /warp collective/i })).not.toBeNull()
await act(async () => {
await userEvent.selectOptions(publishAs, 'warp-collective')
})
expect(await screen.findByRole('combobox', { name: /primary author/i })).not.toBeNull()
expect(screen.getByText(/contributors/i)).not.toBeNull()
})
it('keeps mobile sticky action bar visible class', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 306 })

View File

@@ -30,6 +30,13 @@ export default function Step2Details({
onContentTypeChange,
onRootCategoryChange,
onSubCategoryChange,
groupOptions,
currentContributorOptions,
onGroupChange,
onPrimaryAuthorChange,
onContributorToggle,
onContributorRoleChange,
onContributorPrimaryChange,
// Sidebar (title / tags / description / rights)
suggestedTags,
publishMode,
@@ -93,6 +100,7 @@ export default function Step2Details({
const q = subCategorySearch.trim().toLowerCase()
return q ? sorted.filter((s) => s.name.toLowerCase().includes(q)) : sorted
}, [subCategories, subCategorySearch])
const contributorCredits = metadata.contributorCredits || {}
useEffect(() => {
if (!metadata.contentType) {
@@ -469,6 +477,128 @@ export default function Step2Details({
)}
</section>
{Array.isArray(groupOptions) && groupOptions.length > 0 && (
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(ellipse_at_top_left,_rgba(56,189,248,0.08),_transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))] p-5 sm:p-6">
<div className="mb-5 flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">Publisher attribution</h3>
<p className="mt-1 text-xs text-white/55">Publish personally or switch into a group identity while preserving author and contributor credits.</p>
</div>
{metadata.group ? <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 publish</span> : <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">Personal publish</span>}
</div>
<label className="block">
<span className="text-sm font-medium text-white/90">Publishing identity</span>
<select
value={metadata.group || ''}
onChange={(event) => onGroupChange?.(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>
</label>
{metadata.group && (
<div className="mt-5 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={metadata.primaryAuthorUserId || ''}
onChange={(event) => onPrimaryAuthorChange?.(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"
>
{currentContributorOptions.map((user) => (
<option key={user.id} value={user.id}>{user.name || user.username}</option>
))}
</select>
</label>
<p className="mt-2 text-xs text-slate-400">The primary author is shown as the lead creator for this group-published artwork.</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(metadata.primaryAuthorUserId)).map((user) => {
const active = Array.isArray(metadata.contributorUserIds) && metadata.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={() => onContributorToggle?.(user.id)}
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) => onContributorRoleChange?.(user.id, event.target.value)}
placeholder="Colorist, concept support, layout..."
aria-label={`Credit role for ${user.name || user.username}`}
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={() => onContributorPrimaryChange?.(user.id)}
aria-pressed={creditMeta.isPrimary ? 'true' : 'false'}
aria-label={`Mark ${user.name || user.username} as lead supporting credit`}
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>
</div>
</div>
)}
</section>
)}
{/* Title, tags, description, rights */}
<UploadSidebar
showHeader={false}

View File

@@ -60,6 +60,10 @@ export default function Step3Publish({
timezone = null,
visibility = 'public',
onVisibilityChange,
selectedGroup = null,
currentContributorOptions = [],
actionLabel = 'Publish now',
showScheduleSummary = true,
// Category tree (for label lookup)
allRootCategoryOptions = [],
filteredCategoryTree = [],
@@ -79,6 +83,21 @@ export default function Step3Publish({
) ?? null
const subLabel = subCategory?.name ?? null
const descriptionPreview = stripHtml(metadata.description)
const primaryAuthor = (Array.isArray(currentContributorOptions) ? currentContributorOptions : []).find(
(user) => Number(user.id) === Number(metadata.primaryAuthorUserId)
) ?? null
const contributorCredits = metadata.contributorCredits || {}
const contributors = (Array.isArray(currentContributorOptions) ? currentContributorOptions : [])
.filter((user) => Array.isArray(metadata.contributorUserIds) && metadata.contributorUserIds.some((id) => Number(id) === Number(user.id)))
.map((user) => {
const creditMeta = contributorCredits?.[user.id] || contributorCredits?.[String(user.id)] || { creditRole: '', isPrimary: false }
return {
...user,
creditRole: creditMeta.creditRole || '',
isPrimary: Boolean(creditMeta.isPrimary),
}
})
const checks = [
{ label: 'File uploaded', ok: uploadReady },
@@ -151,6 +170,7 @@ export default function Step3Publish({
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/55">
<span>Tags: <span className="text-white/75">{(metadata.tags || []).length}</span></span>
<span>Audience: <span className="text-white/75">{metadata.isMature ? 'Mature' : 'General'}</span></span>
{selectedGroup ? <span>Publisher: <span className="text-white/75">{selectedGroup.name}</span></span> : <span>Publisher: <span className="text-white/75">Personal profile</span></span>}
{!isArchive && fileMetadata?.resolution && fileMetadata.resolution !== '—' && (
<span>Resolution: <span className="text-white/75">{fileMetadata.resolution}</span></span>
)}
@@ -159,6 +179,26 @@ export default function Step3Publish({
)}
</div>
{(selectedGroup || primaryAuthor || contributors.length > 0) && (
<div className="space-y-2 text-xs text-white/55">
{primaryAuthor ? <span>Primary author: <span className="text-white/75">{primaryAuthor.name || primaryAuthor.username}</span></span> : null}
{contributors.length > 0 ? (
<div>
<span>Contributors:</span>
<div className="mt-1 flex flex-wrap gap-2">
{contributors.map((user) => (
<span key={user.id} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white/80">
<span>{user.name || user.username}</span>
{user.creditRole ? <span className="text-white/50">{user.creditRole}</span> : null}
{user.isPrimary ? <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>
</div>
) : null}
</div>
)}
{descriptionPreview && (
<p className="line-clamp-2 text-xs text-white/50">{descriptionPreview}</p>
)}
@@ -221,7 +261,7 @@ export default function Step3Publish({
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/15 bg-white/6 px-2.5 py-1 text-xs text-white/60">
👁 {visibility === 'public' ? 'Public' : visibility === 'unlisted' ? 'Unlisted' : 'Private'}
</span>
{publishMode === 'schedule' && scheduledAt ? (
{showScheduleSummary && publishMode === 'schedule' && scheduledAt ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-violet-300/30 bg-violet-500/15 px-2.5 py-1 text-xs text-violet-200">
🕐 Scheduled
{timezone && (
@@ -237,7 +277,7 @@ export default function Step3Publish({
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/30 bg-emerald-500/12 px-2.5 py-1 text-xs text-emerald-200">
Publish immediately
{actionLabel}
</span>
)}
</div>