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