Implement creator studio and upload updates
This commit is contained in:
146
resources/js/components/upload/ArchiveScreenshotPicker.jsx
Normal file
146
resources/js/components/upload/ArchiveScreenshotPicker.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
function getScreenshotName(item, fallbackIndex) {
|
||||
if (item && typeof item === 'object' && typeof item.name === 'string' && item.name.trim()) {
|
||||
return item.name.trim()
|
||||
}
|
||||
|
||||
return `Screenshot ${fallbackIndex + 1}`
|
||||
}
|
||||
|
||||
function resolveScreenshotSource(item) {
|
||||
if (!item) return { src: null, revoke: null }
|
||||
|
||||
if (typeof item === 'string') {
|
||||
return { src: item, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof item === 'object') {
|
||||
if (typeof item.preview === 'string' && item.preview) {
|
||||
return { src: item.preview, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof item.src === 'string' && item.src) {
|
||||
return { src: item.src, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof item.url === 'string' && item.url) {
|
||||
return { src: item.url, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof File !== 'undefined' && item instanceof File) {
|
||||
const objectUrl = URL.createObjectURL(item)
|
||||
return {
|
||||
src: objectUrl,
|
||||
revoke: () => URL.revokeObjectURL(objectUrl),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { src: null, revoke: null }
|
||||
}
|
||||
|
||||
export default function ArchiveScreenshotPicker({
|
||||
screenshots = [],
|
||||
selectedIndex = 0,
|
||||
onSelect,
|
||||
compact = false,
|
||||
title = 'Screenshots',
|
||||
description = 'Choose which screenshot should be used as the default preview.',
|
||||
}) {
|
||||
const [resolvedScreenshots, setResolvedScreenshots] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = []
|
||||
const next = (Array.isArray(screenshots) ? screenshots : []).map((item, index) => {
|
||||
const { src, revoke } = resolveScreenshotSource(item)
|
||||
if (revoke) cleanup.push(revoke)
|
||||
|
||||
return {
|
||||
src,
|
||||
alt: getScreenshotName(item, index),
|
||||
}
|
||||
}).filter((item) => Boolean(item.src))
|
||||
|
||||
setResolvedScreenshots(next)
|
||||
|
||||
return () => {
|
||||
cleanup.forEach((revoke) => revoke())
|
||||
}
|
||||
}, [screenshots])
|
||||
|
||||
const normalizedIndex = useMemo(() => {
|
||||
if (resolvedScreenshots.length === 0) return 0
|
||||
if (!Number.isFinite(selectedIndex)) return 0
|
||||
return Math.min(Math.max(0, Math.floor(selectedIndex)), resolvedScreenshots.length - 1)
|
||||
}, [resolvedScreenshots.length, selectedIndex])
|
||||
|
||||
const selectedScreenshot = resolvedScreenshots[normalizedIndex] ?? null
|
||||
|
||||
if (!selectedScreenshot) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-3' : 'space-y-4'}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">{title}</p>
|
||||
<p className="mt-1 text-xs text-white/55">{description}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-emerald-300/30 bg-emerald-400/10 px-2.5 py-1 text-[11px] text-emerald-100">
|
||||
Default preview
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={compact ? 'overflow-hidden rounded-2xl border border-white/10 bg-black/25' : 'overflow-hidden rounded-3xl border border-white/10 bg-black/25'}>
|
||||
<img
|
||||
src={selectedScreenshot.src}
|
||||
alt={selectedScreenshot.alt}
|
||||
className={compact ? 'h-40 w-full object-cover' : 'h-56 w-full object-cover sm:h-72'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={compact ? 'grid grid-cols-4 gap-2' : 'grid grid-cols-2 gap-3 sm:grid-cols-4'}>
|
||||
{resolvedScreenshots.map((item, index) => {
|
||||
const isSelected = index === normalizedIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${item.src}-${index}`}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(index)}
|
||||
aria-label={`Use ${item.alt} as default screenshot`}
|
||||
className={[
|
||||
'group relative overflow-hidden rounded-2xl border text-left transition',
|
||||
isSelected
|
||||
? 'border-emerald-300/45 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.22)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
|
||||
].join(' ')}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<img
|
||||
src={item.src}
|
||||
alt={item.alt}
|
||||
className={compact ? 'h-16 w-full object-cover' : 'h-20 w-full object-cover'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
|
||||
<span className="truncate text-[11px] text-white/70">{item.alt}</span>
|
||||
<span className={[
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-[10px]',
|
||||
isSelected ? 'bg-emerald-300/20 text-emerald-100' : 'bg-white/10 text-white/45',
|
||||
].join(' ')}>
|
||||
{isSelected ? 'Default' : 'Use'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import ArchiveScreenshotPicker from './ArchiveScreenshotPicker'
|
||||
import ReadinessChecklist from './ReadinessChecklist'
|
||||
import SchedulePublishPicker from './SchedulePublishPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
@@ -36,6 +37,8 @@ export default function PublishPanel({
|
||||
primaryPreviewUrl = null,
|
||||
isArchive = false,
|
||||
screenshots = [],
|
||||
selectedScreenshotIndex = 0,
|
||||
onSelectedScreenshotChange,
|
||||
// Metadata
|
||||
metadata = {},
|
||||
// Readiness
|
||||
@@ -64,8 +67,6 @@ export default function PublishPanel({
|
||||
}) {
|
||||
const pill = STATUS_PILL[machineState] ?? null
|
||||
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
|
||||
const hasAnyPreview = hasPreview || (isArchive && screenshots.length > 0)
|
||||
const previewSrc = hasPreview ? primaryPreviewUrl : (screenshots[0]?.preview ?? screenshots[0] ?? null)
|
||||
|
||||
const title = String(metadata.title || '').trim()
|
||||
const hasTitle = Boolean(title)
|
||||
@@ -126,9 +127,9 @@ export default function PublishPanel({
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0 h-[72px] w-[72px] overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 flex items-center justify-center">
|
||||
{previewSrc ? (
|
||||
{hasPreview ? (
|
||||
<img
|
||||
src={previewSrc}
|
||||
src={primaryPreviewUrl}
|
||||
alt="Artwork preview"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
@@ -162,6 +163,19 @@ export default function PublishPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.03] p-3">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
compact
|
||||
title="Preview screenshot"
|
||||
description="Choose which screenshot should represent this archive in the publish panel."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-white/8" />
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function UploadWizard({
|
||||
// ── File + screenshot state ───────────────────────────────────────────────
|
||||
const [primaryFile, setPrimaryFile] = useState(null)
|
||||
const [screenshots, setScreenshots] = useState([])
|
||||
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0)
|
||||
|
||||
// ── Metadata state ────────────────────────────────────────────────────────
|
||||
const [metadata, setMetadata] = useState(initialMetadata)
|
||||
@@ -112,6 +113,18 @@ export default function UploadWizard({
|
||||
|
||||
const canStartUpload = isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(screenshots) || screenshots.length === 0) {
|
||||
setSelectedScreenshotIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedScreenshotIndex((prev) => {
|
||||
if (!Number.isFinite(prev) || prev < 0) return 0
|
||||
return Math.min(prev, screenshots.length - 1)
|
||||
})
|
||||
}, [screenshots])
|
||||
|
||||
// ── Machine hook ──────────────────────────────────────────────────────────
|
||||
const {
|
||||
machine,
|
||||
@@ -124,6 +137,8 @@ export default function UploadWizard({
|
||||
clearPolling,
|
||||
} = useUploadMachine({
|
||||
primaryFile,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
canStartUpload,
|
||||
primaryType,
|
||||
isArchive,
|
||||
@@ -322,6 +337,7 @@ export default function UploadWizard({
|
||||
resetMachine()
|
||||
setPrimaryFile(null)
|
||||
setScreenshots([])
|
||||
setSelectedScreenshotIndex(0)
|
||||
setMetadata(initialMetadata)
|
||||
setIsUploadLocked(false)
|
||||
hasAutoAdvancedRef.current = false
|
||||
@@ -408,9 +424,11 @@ export default function UploadWizard({
|
||||
onPrimaryFileChange={setPrimaryFile}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
screenshotErrors={screenshotErrors}
|
||||
screenshotPerFileErrors={screenshotPerFileErrors}
|
||||
onScreenshotsChange={setScreenshots}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
machine={machine}
|
||||
/>
|
||||
)
|
||||
@@ -425,6 +443,8 @@ export default function UploadWizard({
|
||||
isArchive={isArchive}
|
||||
fileMetadata={fileMetadata}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
contentTypes={contentTypes}
|
||||
metadata={metadata}
|
||||
metadataErrors={metadataErrors}
|
||||
@@ -456,6 +476,8 @@ export default function UploadWizard({
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
fileMetadata={fileMetadata}
|
||||
metadata={metadata}
|
||||
canPublish={canPublish}
|
||||
@@ -607,6 +629,8 @@ export default function UploadWizard({
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
metadata={metadata}
|
||||
machineState={machine.state}
|
||||
uploadReady={uploadReady}
|
||||
@@ -689,6 +713,8 @@ export default function UploadWizard({
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
metadata={metadata}
|
||||
machineState={machine.state}
|
||||
uploadReady={uploadReady}
|
||||
|
||||
@@ -68,6 +68,15 @@ function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (url === '/api/tags/popular' || String(url).startsWith('/api/tags/search')) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unhandled GET ${url}`))
|
||||
}),
|
||||
}
|
||||
@@ -112,6 +121,20 @@ async function completeStep1ToReady() {
|
||||
})
|
||||
}
|
||||
|
||||
async function completeRequiredDetails({ title = 'My Art', mature = false } = {}) {
|
||||
await act(async () => {
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), title)
|
||||
await userEvent.click(screen.getByRole('button', { name: /art .* open/i }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: /root .* choose/i }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: /sub .* choose/i }))
|
||||
await userEvent.type(screen.getByLabelText(/search or add tags/i), 'fantasy{enter}')
|
||||
if (mature) {
|
||||
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
|
||||
}
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
})
|
||||
}
|
||||
|
||||
describe('UploadWizard step flow', () => {
|
||||
let originalImage
|
||||
let originalScrollTo
|
||||
@@ -216,6 +239,43 @@ describe('UploadWizard step flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the selected archive screenshot as the preview upload source', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 312 })
|
||||
|
||||
await uploadPrimary(new File(['zip'], 'bundle.zip', { type: 'application/zip' }))
|
||||
await uploadScreenshot(new File(['shot-1'], 'shot-1.png', { type: 'image/png' }))
|
||||
await uploadScreenshot(new File(['shot-2'], 'shot-2.png', { type: 'image/png' }))
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /use shot-2\.png as default screenshot/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Default').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.post).toHaveBeenCalledWith(
|
||||
'/api/uploads/finish',
|
||||
expect.objectContaining({
|
||||
file_name: 'shot-2.png',
|
||||
archive_file_name: 'bundle.zip',
|
||||
additional_screenshot_sessions: [
|
||||
expect.objectContaining({
|
||||
file_name: 'shot-1.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('allows navigation back to completed previous step', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 304 })
|
||||
@@ -227,7 +287,7 @@ describe('UploadWizard step flow', () => {
|
||||
await act(async () => {
|
||||
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
|
||||
})
|
||||
expect(await screen.findByText(/upload your artwork file/i)).not.toBeNull()
|
||||
expect(await screen.findByRole('heading', { level: 2, name: /upload your artwork/i })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('triggers scroll-to-top behavior on step change', async () => {
|
||||
@@ -253,11 +313,9 @@ describe('UploadWizard step flow', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
|
||||
|
||||
await completeRequiredDetails({ title: 'My Art' })
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'My Art')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
|
||||
})
|
||||
|
||||
@@ -284,14 +342,10 @@ describe('UploadWizard step flow', () => {
|
||||
await completeStep1ToReady()
|
||||
|
||||
await screen.findByText(/artwork details/i)
|
||||
const titleInput = screen.getByPlaceholderText(/give your artwork a clear title/i)
|
||||
|
||||
await completeRequiredDetails({ title: 'Mature Piece', mature: true })
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(titleInput, 'Mature Piece')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
|
||||
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
|
||||
})
|
||||
|
||||
@@ -361,7 +415,7 @@ describe('UploadWizard step flow', () => {
|
||||
const dropzoneButton = screen.getByTestId('upload-dropzone')
|
||||
expect(dropzoneButton.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
expect(screen.getByText(/file is locked after upload\. reset to change\./i)).not.toBeNull()
|
||||
expect(screen.getByText(/file is locked after upload starts\. reset to change the file\./i)).not.toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /reset upload/i }))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
import UploadDropzone from '../UploadDropzone'
|
||||
import ScreenshotUploader from '../ScreenshotUploader'
|
||||
|
||||
@@ -22,9 +23,11 @@ export default function Step1FileUpload({
|
||||
// Archive screenshots
|
||||
isArchive,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
screenshotErrors,
|
||||
screenshotPerFileErrors,
|
||||
onScreenshotsChange,
|
||||
onSelectedScreenshotChange,
|
||||
// Machine state (passed for potential future use)
|
||||
machine,
|
||||
}) {
|
||||
@@ -95,6 +98,19 @@ export default function Step1FileUpload({
|
||||
onFilesChange={onScreenshotsChange}
|
||||
/>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 sm:p-5">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
compact
|
||||
title="Choose default screenshot"
|
||||
description="Pick the screenshot that should be uploaded as the archive preview before you start the upload."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Subtle what-happens-next hints (shown only before a file is picked) */}
|
||||
{!fileSelected && (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
import UploadSidebar from '../UploadSidebar'
|
||||
import { getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils'
|
||||
|
||||
@@ -17,6 +18,8 @@ export default function Step2Details({
|
||||
isArchive,
|
||||
fileMetadata,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
onSelectedScreenshotChange,
|
||||
// Content type + category
|
||||
contentTypes,
|
||||
metadata,
|
||||
@@ -167,6 +170,18 @@ export default function Step2Details({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/8 pt-5">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
title="Archive screenshots"
|
||||
description="All selected screenshots are shown here. Pick the one that should become the main preview thumbnail."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Combined: Content type → Category → Subcategory ─────────────────── */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { motion, useReducedMotion } from 'framer-motion'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
|
||||
function stripHtml(value) {
|
||||
return String(value || '')
|
||||
@@ -45,6 +46,8 @@ export default function Step3Publish({
|
||||
primaryPreviewUrl,
|
||||
isArchive,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
onSelectedScreenshotChange,
|
||||
fileMetadata,
|
||||
// Metadata
|
||||
metadata,
|
||||
@@ -161,6 +164,18 @@ export default function Step3Publish({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/8 pt-5">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
title="Archive preview"
|
||||
description="This screenshot will be used as the default preview once the archive is published."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Visibility selector ────────────────────────────────────────── */}
|
||||
|
||||
Reference in New Issue
Block a user