minor fixes

This commit is contained in:
2026-04-09 08:50:36 +02:00
parent 23d363a50c
commit a2457f4e49
75 changed files with 3848 additions and 387 deletions

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'
import { router } from '@inertiajs/react'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import ConfirmDangerModal from './ConfirmDangerModal'
function formatDate(value) {
if (!value) return 'Unscheduled'
@@ -39,6 +40,23 @@ function statusClasses(status) {
}
}
function itemReadiness(item) {
if (item?.status === 'published') return null
return item?.workflow?.readiness ?? null
}
function bulkErrorMessage(payload, fallback = 'Bulk action failed.') {
if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
return payload.errors[0]
}
return payload?.message
|| payload?.error
|| payload?.errors?.confirm?.[0]
|| payload?.errors?.action?.[0]
|| fallback
}
function ActionLink({ href, icon, label, onClick }) {
if (!href) return null
@@ -79,6 +97,8 @@ function PreviewLink({ item }) {
}
function GridCard({ item, onExecuteAction, busyKey }) {
const readiness = itemReadiness(item)
const handleEditClick = () => {
trackStudioEvent('studio_item_edited', {
surface: studioSurface(),
@@ -117,10 +137,10 @@ function GridCard({ item, onExecuteAction, busyKey }) {
</span>
</div>
{item.workflow?.readiness && (
{readiness && (
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(item.workflow.readiness)}`}>
{item.workflow.readiness.label}
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
{readiness.label}
</span>
{item.workflow.is_stale_draft && (
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-[11px] font-medium text-amber-100">
@@ -128,7 +148,7 @@ function GridCard({ item, onExecuteAction, busyKey }) {
</span>
)}
<span className="text-[11px] uppercase tracking-[0.16em] text-slate-500">
{item.workflow.readiness.score}/{item.workflow.readiness.max} ready
{readiness.score}/{readiness.max} ready
</span>
</div>
)}
@@ -137,9 +157,9 @@ function GridCard({ item, onExecuteAction, busyKey }) {
{item.description || 'No description yet.'}
</p>
{Array.isArray(item.workflow?.readiness?.missing) && item.workflow.readiness.missing.length > 0 && (
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
<div className="rounded-2xl border border-white/5 bg-slate-950/35 p-3 text-xs text-slate-400">
{item.workflow.readiness.missing.slice(0, 2).join(' • ')}
{readiness.missing.slice(0, 2).join(' • ')}
</div>
)}
@@ -186,6 +206,8 @@ function GridCard({ item, onExecuteAction, busyKey }) {
}
function ListRow({ item, onExecuteAction, busyKey }) {
const readiness = itemReadiness(item)
const handleEditClick = () => {
trackStudioEvent('studio_item_edited', {
surface: studioSurface(),
@@ -224,9 +246,9 @@ function ListRow({ item, onExecuteAction, busyKey }) {
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{item.description || 'No description yet.'}</p>
<div className="mt-3 flex flex-wrap gap-2">
{item.workflow?.readiness && (
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(item.workflow.readiness)}`}>
{item.workflow.readiness.label}
{readiness && (
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
{readiness.label}
</span>
)}
{item.workflow?.is_stale_draft && (
@@ -241,8 +263,8 @@ function ListRow({ item, onExecuteAction, busyKey }) {
<span>{metricValue(item, 'comments')} comments</span>
<span>Updated {formatDate(item.updated_at)}</span>
</div>
{Array.isArray(item.workflow?.readiness?.missing) && item.workflow.readiness.missing.length > 0 && (
<div className="mt-3 text-xs text-slate-500">{item.workflow.readiness.missing.slice(0, 2).join(' • ')}</div>
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
<div className="mt-3 text-xs text-slate-500">{readiness.missing.slice(0, 2).join(' • ')}</div>
)}
</div>
@@ -262,13 +284,15 @@ function ListRow({ item, onExecuteAction, busyKey }) {
)
}
function AdvancedFilterControl({ filter, onChange }) {
function AdvancedFilterControl({ filter, onChange, value }) {
const controlValue = value ?? filter.value
if (filter.type === 'select') {
return (
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
<select
value={filter.value || 'all'}
value={controlValue || 'all'}
onChange={(event) => onChange(filter.key, event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
@@ -287,7 +311,7 @@ function AdvancedFilterControl({ filter, onChange }) {
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
<input
type="search"
value={filter.value || ''}
value={controlValue || ''}
onChange={(event) => onChange(filter.key, event.target.value)}
placeholder={filter.placeholder || filter.label}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
@@ -306,23 +330,77 @@ export default function StudioContentBrowser({
}) {
const [viewMode, setViewMode] = useState('grid')
const [busyKey, setBusyKey] = useState(null)
const [selectedIds, setSelectedIds] = useState([])
const [bulkBusy, setBulkBusy] = useState(false)
const [optimisticRemovedIds, setOptimisticRemovedIds] = useState([])
const [pendingFilters, setPendingFilters] = useState({
q: '',
bucket: 'all',
sort: 'updated_desc',
category: 'all',
tag: '',
})
const [deleteDialog, setDeleteDialog] = useState({
open: false,
title: '',
message: '',
target: null,
})
const filters = listing?.filters || {}
const items = listing?.items || []
const meta = listing?.meta || {}
const advancedFilters = listing?.advanced_filters || []
const visibleItems = items.filter((item) => !optimisticRemovedIds.includes(Number(item.numeric_id)))
const currentModule = filters.module || listing?.module || items[0]?.module || null
const visibleQuickCreate = hideModuleFilter && currentModule && currentModule !== 'all'
? quickCreate.filter((action) => action.key === currentModule)
: quickCreate
const supportsArtworkBulk = currentModule === 'artworks' && items.every((item) => item.module === 'artworks')
const selectableIds = supportsArtworkBulk
? visibleItems.map((item) => Number(item.numeric_id)).filter((value) => Number.isInteger(value) && value > 0)
: []
const allVisibleSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id))
const selectedOnPage = selectedIds.filter((id) => selectableIds.includes(id))
const visibleTotal = Math.max(0, Number(meta.total || 0) - optimisticRemovedIds.length)
const filterControlCount = 1 + (hideModuleFilter ? 0 : 1) + (hideBucketFilter ? 0 : 1) + 1 + advancedFilters.length + 1
const filterGridClass = filterControlCount <= 4
? 'xl:grid-cols-4'
: filterControlCount === 5
? 'xl:grid-cols-5'
: filterControlCount === 6
? 'xl:grid-cols-6'
: 'xl:grid-cols-6 2xl:grid-cols-7'
useEffect(() => {
const stored = window.localStorage.getItem('studio-content-view')
if (stored === 'grid' || stored === 'list') {
if (stored === 'grid' || stored === 'list' || stored === 'table') {
setViewMode(stored)
return
}
if (listing?.default_view === 'grid' || listing?.default_view === 'list') {
if (listing?.default_view === 'grid' || listing?.default_view === 'list' || listing?.default_view === 'table') {
setViewMode(listing.default_view)
}
}, [listing?.default_view])
useEffect(() => {
setSelectedIds((current) => current.filter((id) => selectableIds.includes(id)))
}, [visibleItems, supportsArtworkBulk])
useEffect(() => {
setOptimisticRemovedIds([])
}, [items])
useEffect(() => {
setPendingFilters({
q: filters.q || '',
bucket: filters.bucket || 'all',
sort: filters.sort || 'updated_desc',
category: filters.category || 'all',
tag: filters.tag || '',
})
}, [filters.q, filters.bucket, filters.sort, filters.category, filters.tag])
const updateQuery = (patch) => {
const next = {
...filters,
@@ -360,11 +438,251 @@ export default function StudioContentBrowser({
})
}
const setPendingFilter = (key, value) => {
setPendingFilters((current) => ({
...current,
[key]: value,
}))
}
const submitSearch = () => {
updateQuery({
q: pendingFilters.q,
bucket: pendingFilters.bucket,
sort: pendingFilters.sort,
category: pendingFilters.category,
tag: pendingFilters.tag,
})
}
const addOptimisticallyRemovedIds = (ids) => {
setOptimisticRemovedIds((current) => Array.from(new Set([
...current,
...ids.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0),
])))
}
const toggleSelected = (numericId) => {
setSelectedIds((current) => current.includes(numericId)
? current.filter((id) => id !== numericId)
: [...current, numericId])
}
const toggleSelectAllVisible = () => {
if (!supportsArtworkBulk || selectableIds.length === 0) {
return
}
setSelectedIds((current) => {
if (allVisibleSelected) {
return current.filter((id) => !selectableIds.includes(id))
}
return Array.from(new Set([...current, ...selectableIds]))
})
}
const executeBulkAction = async (actionKey) => {
if (!supportsArtworkBulk || selectedIds.length === 0 || bulkBusy) {
return
}
const labels = {
publish: 'publish',
unpublish: 'move to draft',
archive: 'archive',
unarchive: 'restore',
delete: 'delete permanently',
}
if (actionKey === 'delete') {
setDeleteDialog({
open: true,
title: `Delete ${selectedIds.length} artwork${selectedIds.length === 1 ? '' : 's'}?`,
message: 'This permanently removes the selected artworks. This cannot be undone.',
target: {
kind: 'bulk',
actionKey,
ids: [...selectedIds],
},
})
return
}
if (!window.confirm(`Are you sure you want to ${labels[actionKey] || actionKey} ${selectedIds.length} artwork${selectedIds.length === 1 ? '' : 's'}?`)) {
return
}
setBulkBusy(true)
try {
const response = await fetch('/api/studio/artworks/bulk', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
action: actionKey,
artwork_ids: selectedIds,
}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(bulkErrorMessage(payload))
}
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'artworks',
meta: {
bulk_action: actionKey,
count: selectedIds.length,
},
})
setSelectedIds([])
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Bulk action failed.')
} finally {
setBulkBusy(false)
}
}
const closeDeleteDialog = () => {
setDeleteDialog({
open: false,
title: '',
message: '',
target: null,
})
}
const confirmDeleteDialog = async () => {
const target = deleteDialog.target
if (!target) {
closeDeleteDialog()
return
}
if (target.kind === 'bulk') {
setBulkBusy(true)
try {
const response = await fetch('/api/studio/artworks/bulk', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
action: 'delete',
artwork_ids: target.ids,
confirm: 'DELETE',
}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(bulkErrorMessage(payload))
}
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'artworks',
meta: {
bulk_action: 'delete',
count: target.ids.length,
},
})
addOptimisticallyRemovedIds(target.ids)
setSelectedIds([])
closeDeleteDialog()
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Bulk action failed.')
} finally {
setBulkBusy(false)
}
return
}
if (target.kind === 'single') {
const action = target.action
const requestKey = `${action.key}:${action.url}`
setBusyKey(requestKey)
try {
const response = await fetch(action.url, {
method: String(action.method || 'post').toUpperCase(),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: action.payload ? JSON.stringify(action.payload) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Request failed')
}
addOptimisticallyRemovedIds(action.payload?.artwork_ids || [])
closeDeleteDialog()
if (action.redirect_pattern && payload?.data?.id) {
window.location.assign(action.redirect_pattern.replace('__ID__', String(payload.data.id)))
return
}
if (payload?.redirect) {
window.location.assign(payload.redirect)
return
}
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Action failed.')
} finally {
setBusyKey(null)
}
}
}
const executeAction = async (action) => {
if (!action?.url || action.type !== 'request') {
return
}
if (action.key === 'delete') {
setDeleteDialog({
open: true,
title: 'Delete artwork permanently?',
message: action.confirm || 'This artwork will be permanently removed and cannot be restored.',
target: {
kind: 'single',
action,
},
})
return
}
if (action.confirm && !window.confirm(action.confirm)) {
return
}
@@ -439,13 +757,13 @@ export default function StudioContentBrowser({
<div className="space-y-6">
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)] lg:p-6">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className={`grid gap-3 md:grid-cols-2 ${advancedFilters.length > 0 ? 'xl:grid-cols-5' : 'xl:grid-cols-4'}`}>
<div className={`grid gap-3 md:grid-cols-2 ${filterGridClass}`}>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
<input
type="search"
value={filters.q || ''}
onChange={(event) => updateQuery({ q: event.target.value })}
value={pendingFilters.q}
onChange={(event) => setPendingFilter('q', event.target.value)}
placeholder="Title, description, module"
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
/>
@@ -472,8 +790,8 @@ export default function StudioContentBrowser({
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</span>
<select
value={filters.bucket || 'all'}
onChange={(event) => updateQuery({ bucket: event.target.value })}
value={pendingFilters.bucket}
onChange={(event) => setPendingFilter('bucket', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(listing?.bucket_options || []).map((option) => (
@@ -488,8 +806,8 @@ export default function StudioContentBrowser({
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
<select
value={filters.sort || 'updated_desc'}
onChange={(event) => updateQuery({ sort: event.target.value })}
value={pendingFilters.sort}
onChange={(event) => setPendingFilter('sort', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(listing?.sort_options || []).map((option) => (
@@ -501,8 +819,31 @@ export default function StudioContentBrowser({
</label>
{advancedFilters.map((filter) => (
<AdvancedFilterControl key={filter.key} filter={filter} onChange={(key, value) => updateQuery({ [key]: value })} />
<AdvancedFilterControl
key={filter.key}
filter={filter}
value={filter.key === 'category' || filter.key === 'tag' ? pendingFilters[filter.key] : undefined}
onChange={(key, value) => {
if (key === 'category' || key === 'tag') {
setPendingFilter(key, value)
return
}
updateQuery({ [key]: value })
}}
/>
))}
<div className="flex items-end">
<button
type="button"
onClick={submitSearch}
className="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-300/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15"
>
<i className="fa-solid fa-magnifying-glass" />
<span>Search</span>
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
@@ -510,6 +851,7 @@ export default function StudioContentBrowser({
{[
{ value: 'grid', icon: 'fa-solid fa-table-cells-large', label: 'Grid view' },
{ value: 'list', icon: 'fa-solid fa-list', label: 'List view' },
{ value: 'table', icon: 'fa-solid fa-table-list', label: 'Table view' },
].map((option) => (
<button
key={option.value}
@@ -523,7 +865,7 @@ export default function StudioContentBrowser({
))}
</div>
{quickCreate.map((action) => (
{visibleQuickCreate.map((action) => (
<a
key={action.key}
href={action.url}
@@ -547,19 +889,179 @@ export default function StudioContentBrowser({
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-slate-400">
<p>
Showing <span className="font-semibold text-white">{items.length}</span> of <span className="font-semibold text-white">{Number(meta.total || 0).toLocaleString()}</span> items
Showing <span className="font-semibold text-white">{visibleItems.length}</span> of <span className="font-semibold text-white">{visibleTotal.toLocaleString()}</span> items
</p>
<p>Page {meta.current_page || 1} of {meta.last_page || 1}</p>
</div>
{items.length > 0 ? (
{viewMode === 'table' && supportsArtworkBulk && (
<section className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
<div className="flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={toggleSelectAllVisible}
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
/>
<span>Select page</span>
</label>
<span className="text-slate-500">
{selectedIds.length > 0 ? `${selectedIds.length} selected` : 'Select artworks to run bulk actions'}
</span>
</div>
<div className="flex flex-wrap gap-2">
{[
{ key: 'publish', label: 'Publish', icon: 'fa-solid fa-rocket' },
{ key: 'unpublish', label: 'Draft', icon: 'fa-solid fa-file-pen' },
{ key: 'archive', label: 'Archive', icon: 'fa-solid fa-box-archive' },
{ key: 'unarchive', label: 'Restore', icon: 'fa-solid fa-rotate-left' },
{ key: 'delete', label: 'Delete', icon: 'fa-solid fa-trash' },
].map((action) => (
<button
key={action.key}
type="button"
disabled={selectedIds.length === 0 || bulkBusy}
onClick={() => executeBulkAction(action.key)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-2 text-xs font-semibold text-slate-200 transition hover:border-white/20 hover:bg-black/30 disabled:cursor-not-allowed disabled:opacity-40"
>
<i className={action.icon} />
<span>{bulkBusy ? 'Working...' : action.label}</span>
</button>
))}
</div>
</section>
)}
{visibleItems.length > 0 ? (
viewMode === 'grid' ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{items.map((item) => <GridCard key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
{visibleItems.map((item) => <GridCard key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
</div>
) : viewMode === 'list' ? (
<div className="space-y-4">
{visibleItems.map((item) => <ListRow key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
</div>
) : (
<div className="space-y-4">
{items.map((item) => <ListRow key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.03]">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300">
<thead className="bg-black/20 text-[11px] uppercase tracking-[0.18em] text-slate-500">
<tr>
{supportsArtworkBulk && (
<th scope="col" className="w-12 px-4 py-3">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={toggleSelectAllVisible}
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
aria-label="Select all artworks on this page"
/>
</th>
)}
<th scope="col" className="px-4 py-3">Item</th>
<th scope="col" className="px-4 py-3">Status</th>
<th scope="col" className="px-4 py-3">Category</th>
<th scope="col" className="px-4 py-3">Updated</th>
<th scope="col" className="px-4 py-3">Stats</th>
<th scope="col" className="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{visibleItems.map((item) => {
const isSelected = selectedOnPage.includes(Number(item.numeric_id))
return (
<tr key={item.id} className="align-top transition hover:bg-white/[0.03]">
{supportsArtworkBulk && (
<td className="px-4 py-4">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelected(Number(item.numeric_id))}
className="mt-1 h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
aria-label={`Select ${item.title}`}
/>
</td>
)}
<td className="px-4 py-4">
<div className="flex min-w-[280px] items-start gap-3">
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-2xl bg-slate-950/60">
{item.image_url ? (
<img src={item.image_url} alt={item.title} className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="flex h-full items-center justify-center text-slate-500">
<i className={`${item.module_icon} text-lg`} />
</div>
)}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">
<i className={`${item.module_icon} text-[10px]`} />
{item.module_label}
</span>
<span className="text-xs text-slate-500">#{item.numeric_id}</span>
</div>
<div className="mt-2 truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 text-xs text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</div>
{Array.isArray(item.taxonomies?.tags) && item.taxonomies.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{item.taxonomies.tags.slice(0, 3).map((tag) => (
<span key={`${item.id}-${tag.slug}`} className="rounded-full border border-white/10 px-2 py-1 text-[10px] text-slate-400">
{tag.name}
</span>
))}
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-4">
<div className="space-y-2">
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium capitalize ${statusClasses(item.status)}`}>
{String(item.status || 'unknown').replace('_', ' ')}
</span>
{itemReadiness(item) && (
<div>
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(itemReadiness(item))}`}>
{itemReadiness(item).label}
</span>
</div>
)}
</div>
</td>
<td className="px-4 py-4 text-sm text-slate-400">
<div>{item.subtitle || item.taxonomies?.categories?.[0]?.name || 'Uncategorized'}</div>
<div className="mt-1 text-xs text-slate-500">{item.visibility || 'private'}</div>
</td>
<td className="px-4 py-4 text-sm text-slate-400">
<div>Updated {formatDate(item.updated_at)}</div>
<div className="mt-1 text-xs text-slate-500">Created {formatDate(item.created_at)}</div>
{item.published_at && <div className="mt-1 text-xs text-slate-500">Published {formatDate(item.published_at)}</div>}
</td>
<td className="px-4 py-4 text-sm text-slate-400">
<div>{metricValue(item, 'views')} views</div>
<div className="mt-1">{metricValue(item, 'appreciation')} reactions</div>
<div className="mt-1">{metricValue(item, 'comments')} comments</div>
</td>
<td className="px-4 py-4">
<div className="flex min-w-[220px] flex-wrap gap-2">
<ActionLink href={item.edit_url || item.manage_url} icon="fa-solid fa-pen-to-square" label="Edit" />
<PreviewLink item={item} />
<ActionLink href={item.view_url} icon="fa-solid fa-arrow-up-right-from-square" label="Open" />
{(item.actions || []).slice(0, 2).map((action) => (
<RequestActionButton key={`${item.id}-${action.key}`} action={{ ...action, item_id: item.numeric_id, item_module: item.module }} onExecute={executeAction} busyKey={busyKey} />
))}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
) : (
@@ -592,6 +1094,15 @@ export default function StudioContentBrowser({
<i className="fa-solid fa-arrow-right" />
</button>
</div>
<ConfirmDangerModal
open={deleteDialog.open}
onClose={closeDeleteDialog}
onConfirm={confirmDeleteDialog}
title={deleteDialog.title}
message={deleteDialog.message}
confirmText="DELETE"
/>
</div>
)
}

View File

@@ -119,6 +119,24 @@ function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function swapImageToFallbackOnce(event, fallbackSrc, { clearResponsive = false } = {}) {
const image = event.currentTarget
if (!image || image.dataset.fallbackApplied === '1') {
return
}
image.dataset.fallbackApplied = '1'
image.onerror = null
if (clearResponsive) {
image.removeAttribute('srcset')
image.removeAttribute('sizes')
}
image.src = fallbackSrc
}
function sendDiscoveryEvent(endpoint, payload) {
if (!endpoint) return
@@ -437,18 +455,27 @@ export default function ArtworkCard({
const item = artwork || {}
const rawAuthor = item.author || item.creator
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null
const isGroupPublisher = (publisher?.type === 'group') || item.published_as_type === 'group'
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
const author = decodeHtml(
(typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
(isGroupPublisher ? publisher?.name : null)
|| (typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
|| item.author_name
|| item.uname
|| 'Skinbase Artist'
)
const username = rawAuthor?.username || item.author_username || item.username || null
const authorLevel = Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
const authorRank = rawAuthor?.rank || item.author_rank || item.creator?.rank || ''
const username = isGroupPublisher ? null : (rawAuthor?.username || item.author_username || item.username || null)
const authorLevel = isGroupPublisher ? 0 : Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
const authorRank = isGroupPublisher ? '' : (rawAuthor?.rank || item.author_rank || item.creator?.rank || '')
const image = item.image || item.thumb || item.thumb_url || item.thumbnail_url || IMAGE_FALLBACK
const avatar = rawAuthor?.avatar_url || rawAuthor?.avatar || item.avatar || item.author_avatar || item.avatar_url || AVATAR_FALLBACK
const avatar = (isGroupPublisher ? publisher?.avatar_url : null)
|| rawAuthor?.avatar_url
|| rawAuthor?.avatar
|| item.avatar
|| item.author_avatar
|| item.avatar_url
|| AVATAR_FALLBACK
const likes = item.likes ?? item.favourites ?? 0
const views = item.views ?? item.views_count ?? item.view_count ?? 0
const downloads = item.downloads ?? item.downloads_count ?? item.download_count ?? 0
@@ -470,7 +497,7 @@ export default function ArtworkCard({
const aspectClass = compact ? 'aspect-square' : 'aspect-[4/5]'
const titleClass = compact ? 'text-[0.96rem]' : 'text-[1rem] sm:text-[1.08rem]'
const metadataLine = [contentType, category, resolution].filter(Boolean).join(' • ')
const authorHref = username ? `/@${username}` : null
const authorHref = publisher?.profile_url || rawAuthor?.profile_url || item.profile_url || item.author_url || (username ? `/@${username}` : null)
const resolvedMetricBadge = metricBadge || item.metric_badge || null
const relativePublishedAt = useMemo(
() => formatRelativeTime(item.published_at || item.publishedAt || null),
@@ -750,7 +777,7 @@ export default function ArtworkCard({
decoding={decoding}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
onError={(event) => {
event.currentTarget.src = IMAGE_FALLBACK
swapImageToFallbackOnce(event, IMAGE_FALLBACK)
}}
/>
</div>
@@ -761,7 +788,7 @@ export default function ArtworkCard({
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
{authorHref ? (
<span>
by {author} <span className="text-slate-500">@{username}</span>
by {author} {username ? <span className="text-slate-500">@{username}</span> : null}
</span>
) : (
<span>by {author}</span>
@@ -810,7 +837,7 @@ export default function ArtworkCard({
fetchPriority={fetchPriority || undefined}
className={cx('h-full w-full object-cover transition duration-500 group-hover:scale-110 group-focus-within:scale-110', imageClassName)}
onError={(event) => {
event.currentTarget.src = IMAGE_FALLBACK
swapImageToFallbackOnce(event, IMAGE_FALLBACK, { clearResponsive: true })
}}
/>
@@ -880,14 +907,14 @@ export default function ArtworkCard({
decoding="async"
className="h-9 w-9 shrink-0 rounded-full object-cover"
onError={(event) => {
event.currentTarget.src = AVATAR_FALLBACK
swapImageToFallbackOnce(event, AVATAR_FALLBACK)
}}
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="block min-w-0 truncate text-sm font-medium text-white/90">
{author}
{username && <span className="text-[11px] text-white/60"> @{username}</span>}
{username ? <span className="text-[11px] text-white/60"> @{username}</span> : null}
</span>
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact className="shrink-0" /> : null}
</span>

View File

@@ -6,6 +6,9 @@ const FALLBACK_XL = 'https://files.skinbase.org/default/missing_xl.webp'
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, mediaWidth = null, mediaHeight = null, mediaKey = 'cover', onOpenViewer, hasPrev, hasNext, onPrev, onNext }) {
const [isLoaded, setIsLoaded] = useState(false)
const [mainImageMode, setMainImageMode] = useState('primary')
const [previewImageMode, setPreviewImageMode] = useState('primary')
const [showBackdrop, setShowBackdrop] = useState(true)
const mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
const lgSource = presentLg?.url || artwork?.thumbs?.lg?.url || null
@@ -17,6 +20,19 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
const blurBackdropSrc = mdSource || lgSource || xlSource || null
const primaryMainSrc = lgSource || xlSource || mdSource || FALLBACK_LG
const primaryPreviewSrc = mdSource || lgSource || xlSource || FALLBACK_MD
const srcSet = [
mdSource ? `${mdSource} 640w` : null,
lgSource ? `${lgSource} 1280w` : null,
xlSource ? `${xlSource} 1920w` : null,
].filter(Boolean).join(', ')
const resolvedMainSrc = mainImageMode === 'fallback'
? FALLBACK_LG
: (mainImageMode === 'hidden' ? null : primaryMainSrc)
const resolvedPreviewSrc = previewImageMode === 'fallback'
? FALLBACK_MD
: (previewImageMode === 'hidden' ? null : primaryPreviewSrc)
const dbWidth = Number(mediaWidth ?? artwork?.width)
const dbHeight = Number(mediaHeight ?? artwork?.height)
@@ -30,6 +46,10 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
useEffect(() => {
setIsLoaded(false)
setMainImageMode('primary')
setPreviewImageMode('primary')
setShowBackdrop(true)
if (hasDbDims) {
setNaturalDims({ w: dbWidth, h: dbHeight })
return
@@ -47,16 +67,15 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
setNaturalDims({ w: img.naturalWidth, h: img.naturalHeight })
}
}
img.onerror = null
img.src = xlSource
}, [xlSource, naturalDims])
const aspectRatio = naturalDims ? `${naturalDims.w} / ${naturalDims.h}` : '16 / 9'
const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w`
return (
<figure className="relative w-full overflow-hidden rounded-[2rem] border border-white/10 bg-gradient-to-b from-nova-950 via-nova-900 to-nova-900 p-2 shadow-[0_35px_90px_-35px_rgba(15,23,36,0.9)] sm:p-4">
{blurBackdropSrc && (
{blurBackdropSrc && showBackdrop && (
<>
<img
src={blurBackdropSrc}
@@ -65,6 +84,10 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
loading="eager"
decoding="async"
onError={(event) => {
event.currentTarget.onerror = null
setShowBackdrop(false)
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-nova-950/55 via-nova-900/40 to-nova-950/70" />
<div className="pointer-events-none absolute inset-0 backdrop-blur-sm" />
@@ -102,29 +125,52 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
}
} : undefined}
>
<img
src={md}
alt={artwork?.title ?? 'Artwork'}
className="absolute inset-0 h-full w-full object-contain rounded-xl"
loading="eager"
decoding="async"
fetchPriority="high"
/>
{resolvedPreviewSrc ? (
<img
src={resolvedPreviewSrc}
alt={artwork?.title ?? 'Artwork'}
className="absolute inset-0 h-full w-full object-contain rounded-xl"
loading="eager"
decoding="async"
fetchPriority="high"
onError={(event) => {
event.currentTarget.onerror = null
<img
src={lg}
srcSet={srcSet}
sizes="(min-width: 1536px) 1400px, (min-width: 1024px) 92vw, 100vw"
alt={artwork?.title ?? 'Artwork'}
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
loading="eager"
decoding="async"
fetchPriority="high"
onLoad={() => setIsLoaded(true)}
onError={(event) => {
event.currentTarget.src = FALLBACK_LG
}}
/>
if (previewImageMode === 'primary') {
setPreviewImageMode('fallback')
return
}
setPreviewImageMode('hidden')
}}
/>
) : null}
{resolvedMainSrc ? (
<img
src={resolvedMainSrc}
srcSet={mainImageMode === 'primary' && srcSet !== '' ? srcSet : undefined}
sizes={mainImageMode === 'primary' && srcSet !== '' ? '(min-width: 1536px) 1400px, (min-width: 1024px) 92vw, 100vw' : undefined}
alt={artwork?.title ?? 'Artwork'}
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
loading="eager"
decoding="async"
fetchPriority="high"
onLoad={() => setIsLoaded(true)}
onError={(event) => {
event.currentTarget.onerror = null
if (mainImageMode === 'primary') {
setMainImageMode('fallback')
setIsLoaded(false)
return
}
setMainImageMode('hidden')
setIsLoaded(true)
}}
/>
) : null}
{onOpenViewer && (
<button

View File

@@ -150,14 +150,18 @@ function mapRankApiArtwork(item) {
const h = item.dimensions?.height ?? null;
const thumb = item.thumbnail_url ?? null;
const webUrl = item.urls?.web ?? item.category?.url ?? null;
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null;
return {
id: item.id ?? null,
name: item.title ?? item.name ?? null,
thumb: thumb,
thumb_url: thumb,
uname: item.author?.name ?? '',
username: item.author?.username ?? item.author?.name ?? '',
username: publisher?.type === 'group' ? '' : (item.author?.username ?? ''),
avatar_url: item.author?.avatar_url ?? null,
profile_url: publisher?.profile_url ?? item.author?.profile_url ?? null,
published_as_type: publisher?.type ?? null,
publisher: publisher,
content_type_name: item.category?.content_type_name ?? item.category?.content_type_slug ?? item.category?.content_type ?? '',
content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '',
category_name: item.category?.name ?? '',

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
const DEFAULT_MAX_TAGS = 15
const DEFAULT_MIN_LENGTH = 2
@@ -27,6 +28,49 @@ function parseTagList(input) {
.filter(Boolean)
}
function analyzePastedTags(rawText, selectedTags, minLength, maxLength, maxTags) {
const parts = parseTagList(rawText)
const tagsToAdd = []
const skippedDuplicates = []
const skippedInvalid = []
const skippedOverflow = []
for (const part of parts) {
const normalized = normalizeTag(part)
if (!normalized) {
skippedInvalid.push(String(part ?? '').trim())
continue
}
if (selectedTags.includes(normalized) || tagsToAdd.includes(normalized)) {
skippedDuplicates.push(normalized)
continue
}
if (normalized.length < minLength || normalized.length > maxLength || !/^[a-z0-9_-]+$/.test(normalized)) {
skippedInvalid.push(String(part ?? '').trim())
continue
}
if (selectedTags.length + tagsToAdd.length >= maxTags) {
skippedOverflow.push(normalized)
continue
}
tagsToAdd.push(normalized)
}
return {
parsedCount: parts.length,
tagsToAdd,
nextTags: [...selectedTags, ...tagsToAdd],
skippedDuplicates,
skippedInvalid,
skippedOverflow,
}
}
function validateTag(tag, selectedTags, minLength, maxLength, maxTags) {
if (selectedTags.length >= maxTags) return 'Max tags reached'
if (tag.length < minLength) return 'Tag too short'
@@ -240,13 +284,124 @@ function StatusHints({ error, count, maxTags }) {
return (
<div className="flex items-center justify-between gap-3 text-xs">
<span className={error ? 'text-amber-200' : 'text-white/55'} role="status" aria-live="polite">
{error || 'Type and press Enter, comma, or Tab to add'}
{error || 'Type and press Enter, comma, or Tab to add. Paste a comma-separated list to review multiple tags.'}
</span>
<span className="text-white/50">{count}/{maxTags}</span>
</div>
)
}
function PastedTagsDialog({ open, preview, maxTags, onClose, onConfirm }) {
const backdropRef = useRef(null)
const cancelButtonRef = useRef(null)
useEffect(() => {
if (!open) return undefined
const timeoutId = window.setTimeout(() => cancelButtonRef.current?.focus(), 60)
return () => window.clearTimeout(timeoutId)
}, [open])
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose?.()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
if (!open || !preview) return null
const duplicateCount = preview.skippedDuplicates.length
const invalidCount = preview.skippedInvalid.length
const overflowCount = preview.skippedOverflow.length
return createPortal(
<div
ref={backdropRef}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
onClick={(event) => {
if (event.target === backdropRef.current) {
onClose?.()
}
}}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="tag-input-paste-title"
className="w-full max-w-lg overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
>
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Tag Import</p>
<h3 id="tag-input-paste-title" className="mt-2 text-lg font-semibold text-white">
Add {preview.tagsToAdd.length} pasted tag{preview.tagsToAdd.length === 1 ? '' : 's'}?
</h3>
<p className="mt-2 text-sm leading-6 text-white/65">
Parsed {preview.parsedCount} item{preview.parsedCount === 1 ? '' : 's'} from your paste. Confirm before adding them to the artwork.
</p>
</div>
<div className="space-y-4 px-6 py-5">
{overflowCount > 0 && (
<div className="rounded-2xl border border-amber-300/25 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
Only {preview.tagsToAdd.length} tag{preview.tagsToAdd.length === 1 ? '' : 's'} can be added because the limit is {maxTags}. {overflowCount} pasted tag{overflowCount === 1 ? '' : 's'} will be skipped.
</div>
)}
{(duplicateCount > 0 || invalidCount > 0) && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-white/70">
{duplicateCount > 0 ? `${duplicateCount} duplicate tag${duplicateCount === 1 ? '' : 's'} ignored.` : ''}
{duplicateCount > 0 && invalidCount > 0 ? ' ' : ''}
{invalidCount > 0 ? `${invalidCount} invalid tag${invalidCount === 1 ? '' : 's'} ignored.` : ''}
</div>
)}
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.18em] text-white/40">Tags to add</p>
<div className="max-h-56 overflow-auto rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex flex-wrap gap-2">
{preview.tagsToAdd.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-3 py-1.5 text-xs font-medium text-sky-100"
>
{tag}
</span>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button
ref={cancelButtonRef}
type="button"
onClick={() => onClose?.()}
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
>
Cancel
</button>
<button
type="button"
onClick={() => onConfirm?.()}
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/40"
>
Add tags
</button>
</div>
</div>
</div>,
document.body,
)
}
export default function TagInput({
value,
onChange,
@@ -268,6 +423,7 @@ export default function TagInput({
const [searchError, setSearchError] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [pastePreview, setPastePreview] = useState(null)
const queryCacheRef = useRef(new Map())
const abortControllerRef = useRef(null)
@@ -304,23 +460,38 @@ export default function TagInput({
}, [selectedTags, updateTags])
const applyPastedTags = useCallback((rawText) => {
const parts = parseTagList(rawText)
if (parts.length === 0) return
const preview = analyzePastedTags(rawText, selectedTags, minLength, maxLength, maxTags)
let next = [...selectedTags]
for (const part of parts) {
const normalized = normalizeTag(part)
const validation = validateTag(normalized, next, minLength, maxLength, maxTags)
if (!validation) {
next.push(normalized)
if (preview.parsedCount === 0) return false
if (preview.tagsToAdd.length === 0) {
if (selectedTags.length >= maxTags || preview.skippedOverflow.length > 0) {
setError('Max tags reached')
} else {
setError('No new tags found in pasted text')
}
return false
}
next = Array.from(new Set(next))
updateTags(next)
setError('')
setIsOpen(false)
setHighlightedIndex(-1)
setPastePreview(preview)
return true
}, [selectedTags, minLength, maxLength, maxTags])
const closePastePreview = useCallback(() => {
setPastePreview(null)
}, [])
const confirmPastePreview = useCallback(() => {
if (!pastePreview) return
updateTags(pastePreview.nextTags)
setInputValue('')
setError('')
}, [selectedTags, minLength, maxLength, maxTags, updateTags])
setPastePreview(null)
}, [pastePreview, updateTags])
const runSearch = useCallback(async (query) => {
const normalizedQuery = normalizeTag(query)
@@ -452,7 +623,10 @@ export default function TagInput({
const handlePaste = useCallback((event) => {
const raw = event.clipboardData?.getData('text')
if (!raw || !raw.includes(',')) return
if (!raw) return
const parts = parseTagList(raw)
if (parts.length <= 1) return
event.preventDefault()
applyPastedTags(raw)
@@ -465,43 +639,53 @@ export default function TagInput({
}, [inputValue, runSearch])
return (
<div className="flex h-full flex-col gap-3" data-testid="tag-input-root">
<TagPillList tags={selectedTags} onRemove={removeTag} disabled={disabled} />
<>
<div className="flex h-full flex-col gap-3" data-testid="tag-input-root">
<TagPillList tags={selectedTags} onRemove={removeTag} disabled={disabled} />
<SearchInput
inputValue={inputValue}
onInputChange={(next) => {
setInputValue(next)
setError('')
}}
onKeyDown={handleInputKeyDown}
onPaste={handlePaste}
onFocus={handleFocus}
disabled={disabled}
expanded={isOpen}
listboxId={listboxId}
placeholder={placeholder}
<SearchInput
inputValue={inputValue}
onInputChange={(next) => {
setInputValue(next)
setError('')
}}
onKeyDown={handleInputKeyDown}
onPaste={handlePaste}
onFocus={handleFocus}
disabled={disabled}
expanded={isOpen}
listboxId={listboxId}
placeholder={placeholder}
/>
<SuggestionDropdown
isOpen={isOpen}
loading={loading}
error={searchError}
suggestions={suggestions}
highlightedIndex={highlightedIndex}
onSelect={addTag}
query={inputValue.trim()}
listboxId={listboxId}
/>
<SuggestedTagsPanel
items={aiSuggestedItems}
selectedTags={selectedTags}
onAdd={addTag}
disabled={disabled}
/>
<StatusHints error={error} count={selectedTags.length} maxTags={maxTags} />
</div>
<PastedTagsDialog
open={Boolean(pastePreview)}
preview={pastePreview}
maxTags={maxTags}
onClose={closePastePreview}
onConfirm={confirmPastePreview}
/>
<SuggestionDropdown
isOpen={isOpen}
loading={loading}
error={searchError}
suggestions={suggestions}
highlightedIndex={highlightedIndex}
onSelect={addTag}
query={inputValue.trim()}
listboxId={listboxId}
/>
<SuggestedTagsPanel
items={aiSuggestedItems}
selectedTags={selectedTags}
onAdd={addTag}
disabled={disabled}
/>
<StatusHints error={error} count={selectedTags.length} maxTags={maxTags} />
</div>
</>
)
}

View File

@@ -85,16 +85,37 @@ describe('TagInput', () => {
})
})
it('supports comma-separated paste', async () => {
it('shows a confirmation dialog before adding comma-separated pasted tags', async () => {
render(<Harness />)
const input = screen.getByLabelText('Tag input')
await userEvent.click(input)
await userEvent.paste('art, city, night')
expect(screen.getByText('art')).not.toBeNull()
expect(screen.getByText('city')).not.toBeNull()
expect(screen.getByText('night')).not.toBeNull()
expect(screen.getByRole('dialog')).not.toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag art' })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Add tags' }))
expect(screen.getByRole('button', { name: 'Remove tag art' })).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag city' })).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag night' })).not.toBeNull()
})
it('warns when pasted tags exceed the max and only adds the allowed tags', async () => {
render(<Harness initial={['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8', 't9', 't10', 't11', 't12', 't13', 't14']} />)
const input = screen.getByLabelText('Tag input')
await userEvent.click(input)
await userEvent.paste('alpha, beta, gamma')
expect(screen.getByText('Only 1 tag can be added because the limit is 15. 2 pasted tags will be skipped.')).not.toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Add tags' }))
expect(screen.getByRole('button', { name: 'Remove tag alpha' })).not.toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag beta' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag gamma' })).toBeNull()
})
it('handles API failure gracefully', async () => {

View File

@@ -13,6 +13,7 @@
* Value format: string[] of tag slugs
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
const MAX_TAGS = 15
const DEBOUNCE_MS = 250
@@ -33,6 +34,55 @@ function normalizeSlug(raw) {
.slice(0, MAX_LENGTH)
}
function parseTagList(input) {
if (Array.isArray(input)) return input
if (typeof input !== 'string') return []
return input
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function analyzePastedTags(rawText, selectedSlugs, maxTags) {
const parts = parseTagList(rawText)
const itemsToAdd = []
const skippedDuplicates = []
const skippedInvalid = []
const skippedOverflow = []
for (const part of parts) {
const name = String(part ?? '').trim()
const slug = normalizeSlug(name)
if (!slug || slug.length < MIN_LENGTH || slug.length > MAX_LENGTH) {
skippedInvalid.push(name)
continue
}
if (selectedSlugs.includes(slug) || itemsToAdd.some((item) => item.slug === slug)) {
skippedDuplicates.push(slug)
continue
}
if (selectedSlugs.length + itemsToAdd.length >= maxTags) {
skippedOverflow.push(slug)
continue
}
itemsToAdd.push({ slug, name })
}
return {
parsedCount: parts.length,
itemsToAdd,
nextSlugs: [...selectedSlugs, ...itemsToAdd.map((item) => item.slug)],
skippedDuplicates,
skippedInvalid,
skippedOverflow,
}
}
function toListItem(item) {
if (!item) return null
if (typeof item === 'string') {
@@ -55,7 +105,7 @@ function toListItem(item) {
// ─── sub-components ───────────────────────────────────────────────────────────
function SearchInput({ value, onChange, onKeyDown, inputRef, disabled, hint }) {
function SearchInput({ value, onChange, onKeyDown, onPaste, inputRef, disabled, hint }) {
return (
<div className="relative">
<input
@@ -64,6 +114,7 @@ function SearchInput({ value, onChange, onKeyDown, inputRef, disabled, hint }) {
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
disabled={disabled}
className="w-full rounded-xl border border-white/10 bg-white/5 py-2.5 pl-3 pr-9 text-sm text-white placeholder:text-white/40 focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-60"
placeholder={hint || 'Search or add tags…'}
@@ -170,6 +221,117 @@ function ListRow({ item, isSelected, onToggle, disabled }) {
)
}
function PastedTagsDialog({ open, preview, maxTags, onClose, onConfirm }) {
const backdropRef = useRef(null)
const cancelButtonRef = useRef(null)
useEffect(() => {
if (!open) return undefined
const timeoutId = window.setTimeout(() => cancelButtonRef.current?.focus(), 60)
return () => window.clearTimeout(timeoutId)
}, [open])
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose?.()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
if (!open || !preview) return null
const duplicateCount = preview.skippedDuplicates.length
const invalidCount = preview.skippedInvalid.length
const overflowCount = preview.skippedOverflow.length
return createPortal(
<div
ref={backdropRef}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
onClick={(event) => {
if (event.target === backdropRef.current) {
onClose?.()
}
}}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="tag-picker-paste-title"
className="w-full max-w-lg overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
>
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Tag Import</p>
<h3 id="tag-picker-paste-title" className="mt-2 text-lg font-semibold text-white">
Add {preview.itemsToAdd.length} pasted tag{preview.itemsToAdd.length === 1 ? '' : 's'}?
</h3>
<p className="mt-2 text-sm leading-6 text-white/65">
Parsed {preview.parsedCount} item{preview.parsedCount === 1 ? '' : 's'} from your paste. Confirm before applying them.
</p>
</div>
<div className="space-y-4 px-6 py-5">
{overflowCount > 0 && (
<div className="rounded-2xl border border-amber-300/25 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
Only {preview.itemsToAdd.length} tag{preview.itemsToAdd.length === 1 ? '' : 's'} can be added because the limit is {maxTags}. {overflowCount} pasted tag{overflowCount === 1 ? '' : 's'} will be skipped.
</div>
)}
{(duplicateCount > 0 || invalidCount > 0) && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-white/70">
{duplicateCount > 0 ? `${duplicateCount} duplicate tag${duplicateCount === 1 ? '' : 's'} ignored.` : ''}
{duplicateCount > 0 && invalidCount > 0 ? ' ' : ''}
{invalidCount > 0 ? `${invalidCount} invalid tag${invalidCount === 1 ? '' : 's'} ignored.` : ''}
</div>
)}
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.18em] text-white/40">Tags to add</p>
<div className="max-h-56 overflow-auto rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex flex-wrap gap-2">
{preview.itemsToAdd.map((item) => (
<span
key={item.slug}
className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-3 py-1.5 text-xs font-medium text-sky-100"
>
{item.name}
</span>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button
ref={cancelButtonRef}
type="button"
onClick={() => onClose?.()}
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
>
Cancel
</button>
<button
type="button"
onClick={() => onConfirm?.()}
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/40"
>
Add tags
</button>
</div>
</div>
</div>,
document.body,
)
}
// ─── main component ───────────────────────────────────────────────────────────
export default function TagPicker({
@@ -193,6 +355,7 @@ export default function TagPicker({
const [loading, setLoading] = useState(false)
const [fetchError, setFetchError] = useState(false)
const [inputError, setInputError] = useState('')
const [pastePreview, setPastePreview] = useState(null)
// slug → display name (for chips)
const [nameMap, setNameMap] = useState({})
@@ -288,6 +451,20 @@ export default function TagPicker({
onChange?.(selectedSlugs.filter((s) => s !== slug))
}, [selectedSlugs, onChange])
const closePastePreview = useCallback(() => {
setPastePreview(null)
}, [])
const confirmPastePreview = useCallback(() => {
if (!pastePreview) return
updateNameMap(pastePreview.itemsToAdd)
onChange?.(pastePreview.nextSlugs)
setQuery('')
setInputError('')
setPastePreview(null)
}, [onChange, pastePreview, updateNameMap])
// Commit on Enter / comma / Tab
const handleKeyDown = useCallback((e) => {
const commit = e.key === 'Enter' || e.key === ',' || e.key === 'Tab'
@@ -306,6 +483,29 @@ export default function TagPicker({
addTag(candidate, candidate)
}, [query, selectedSlugs, addTag, removeTag])
const handlePaste = useCallback((event) => {
const raw = event.clipboardData?.getData('text')
if (!raw) return
const parts = parseTagList(raw)
if (parts.length <= 1) return
event.preventDefault()
const preview = analyzePastedTags(raw, selectedSlugs, maxTags)
if (preview.itemsToAdd.length === 0) {
if (selectedSlugs.length >= maxTags || preview.skippedOverflow.length > 0) {
setInputError('Maximum tags reached')
} else {
setInputError('No new tags found in pasted text')
}
return
}
setInputError('')
setPastePreview(preview)
}, [maxTags, selectedSlugs])
// Show "Add 'query'" row when the query doesn't exactly match any result
const querySlug = normalizeSlug(query)
const showAddNew = Boolean(
@@ -341,6 +541,7 @@ export default function TagPicker({
value={query}
onChange={(v) => { setQuery(v); setInputError('') }}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
inputRef={inputRef}
disabled={disabled}
hint={placeholder}
@@ -402,11 +603,19 @@ export default function TagPicker({
<span>{selectedSlugs.length}/{maxTags} tags selected</span>
{atMax
? <span className="text-amber-300/80">Maximum tags reached</span>
: <span className="text-white/30">Enter, comma or Tab to add</span>
: <span className="text-white/30">Enter, comma or Tab to add. Paste a comma-separated list to review multiple tags.</span>
}
</div>
{error && <p className="text-xs text-red-300">{error}</p>}
<PastedTagsDialog
open={Boolean(pastePreview)}
preview={pastePreview}
maxTags={maxTags}
onClose={closePastePreview}
onConfirm={confirmPastePreview}
/>
</div>
)
}

View File

@@ -73,4 +73,37 @@ describe('TagPicker', () => {
expect(screen.getByText('High Contrast')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag High Contrast' })).not.toBeNull()
})
it('shows a confirmation dialog before adding comma-separated pasted tags', async () => {
render(<Harness />)
const input = screen.getByLabelText('Search or add tags')
await userEvent.click(input)
await userEvent.paste('space art, galaxy, robot mascot')
expect(screen.getByRole('dialog')).not.toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag space art' })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Add tags' }))
expect(screen.getByRole('button', { name: 'Remove tag space art' })).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag galaxy' })).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag robot mascot' })).not.toBeNull()
})
it('warns when pasted tags exceed the max and only applies the allowed tags', async () => {
render(<Harness initial={['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8', 't9', 't10', 't11', 't12', 't13', 't14']} />)
const input = screen.getByLabelText('Search or add tags')
await userEvent.click(input)
await userEvent.paste('alpha, beta, gamma')
expect(screen.getByText('Only 1 tag can be added because the limit is 15. 2 pasted tags will be skipped.')).not.toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Add tags' }))
expect(screen.getByRole('button', { name: 'Remove tag alpha' })).not.toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag beta' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Remove tag gamma' })).toBeNull()
})
})

View File

@@ -104,6 +104,7 @@ export default function UploadWizard({
onValidationStateChange,
initialDraftId = null,
chunkSize,
chunkRequestTimeoutMs,
contentTypes = [],
suggestedTags = [],
groupOptions = [],
@@ -193,6 +194,7 @@ export default function UploadWizard({
initialDraftId,
metadata,
chunkSize,
chunkRequestTimeoutMs,
onArtworkCreated: (id) => setResolvedArtworkId(id),
onNotice: (notice) => {
if (!notice?.message) return