Commit workspace changes
This commit is contained in:
@@ -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