Commit workspace changes
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
9
resources/js/components/common/loadEmojiMartData.js
Normal file
9
resources/js/components/common/loadEmojiMartData.js
Normal 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
|
||||
}
|
||||
43
resources/js/components/docs/DocsCallout.jsx
Normal file
43
resources/js/components/docs/DocsCallout.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
resources/js/components/docs/DocsComparisonTable.jsx
Normal file
33
resources/js/components/docs/DocsComparisonTable.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
resources/js/components/docs/DocsFaqAccordion.jsx
Normal file
53
resources/js/components/docs/DocsFaqAccordion.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
resources/js/components/docs/DocsSection.jsx
Normal file
14
resources/js/components/docs/DocsSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
resources/js/components/docs/DocsSidebarNav.jsx
Normal file
50
resources/js/components/docs/DocsSidebarNav.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
resources/js/components/docs/DocsStepList.jsx
Normal file
19
resources/js/components/docs/DocsStepList.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
resources/js/components/docs/FaqSearchInput.jsx
Normal file
35
resources/js/components/docs/FaqSearchInput.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
resources/js/components/docs/QuickstartChecklist.jsx
Normal file
21
resources/js/components/docs/QuickstartChecklist.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
resources/js/components/docs/QuickstartNextSteps.jsx
Normal file
25
resources/js/components/docs/QuickstartNextSteps.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
12
resources/js/components/groups/GroupBadgePill.jsx
Normal file
12
resources/js/components/groups/GroupBadgePill.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
resources/js/components/groups/GroupBrowseFilters.jsx
Normal file
19
resources/js/components/groups/GroupBrowseFilters.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
resources/js/components/groups/GroupDiscoveryCard.jsx
Normal file
82
resources/js/components/groups/GroupDiscoveryCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
resources/js/components/groups/GroupLeaderboardCard.jsx
Normal file
43
resources/js/components/groups/GroupLeaderboardCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
resources/js/components/groups/GroupProfileSummary.jsx
Normal file
51
resources/js/components/groups/GroupProfileSummary.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
resources/js/components/groups/GroupPromoCard.jsx
Normal file
66
resources/js/components/groups/GroupPromoCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
resources/js/components/groups/GroupStudioPromoCard.jsx
Normal file
27
resources/js/components/groups/GroupStudioPromoCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
resources/js/components/groups/GroupSummaryPanel.jsx
Normal file
48
resources/js/components/groups/GroupSummaryPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
resources/js/components/groups/GroupTrendingSection.jsx
Normal file
24
resources/js/components/groups/GroupTrendingSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
resources/js/components/groups/groupStyles.js
Normal file
19
resources/js/components/groups/groupStyles.js
Normal 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
|
||||
}
|
||||
56
resources/js/components/help/HelpGuideCard.jsx
Normal file
56
resources/js/components/help/HelpGuideCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
resources/js/components/help/HelpSearchBar.jsx
Normal file
48
resources/js/components/help/HelpSearchBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
resources/js/components/help/HelpSupportCta.jsx
Normal file
15
resources/js/components/help/HelpSupportCta.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
resources/js/components/help/HelpTopicCard.jsx
Normal file
59
resources/js/components/help/HelpTopicCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user