minor fixes
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user