Implement creator studio and upload updates
This commit is contained in:
@@ -5,8 +5,7 @@ import LinkPreviewCard from './LinkPreviewCard'
|
||||
import TagPeopleModal from './TagPeopleModal'
|
||||
|
||||
// Lazy-load the heavy emoji picker only when first opened
|
||||
// @emoji-mart/react only has a default export (the Picker); m.Picker is undefined
|
||||
const EmojiPicker = lazy(() => import('@emoji-mart/react'))
|
||||
const EmojiPicker = lazy(() => import('../common/EmojiMartPicker'))
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'public', icon: 'fa-globe', label: 'Public' },
|
||||
|
||||
597
resources/js/components/Studio/StudioContentBrowser.jsx
Normal file
597
resources/js/components/Studio/StudioContentBrowser.jsx
Normal file
@@ -0,0 +1,597 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { router } from '@inertiajs/react'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unscheduled'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Unscheduled'
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function metricValue(item, key) {
|
||||
return Number(item?.metrics?.[key] ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function readinessClasses(readiness) {
|
||||
if (!readiness) return 'border-white/15 bg-white/5 text-slate-300'
|
||||
if (readiness.can_publish && readiness.score >= readiness.max) return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
|
||||
if (readiness.can_publish) return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
|
||||
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
|
||||
}
|
||||
|
||||
function statusClasses(status) {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200'
|
||||
case 'draft':
|
||||
case 'pending_review':
|
||||
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
|
||||
case 'scheduled':
|
||||
case 'processing':
|
||||
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
|
||||
case 'archived':
|
||||
case 'hidden':
|
||||
case 'rejected':
|
||||
return 'border-white/15 bg-white/5 text-slate-300'
|
||||
default:
|
||||
return 'border-white/15 bg-white/5 text-slate-200'
|
||||
}
|
||||
}
|
||||
|
||||
function ActionLink({ href, icon, label, onClick }) {
|
||||
if (!href) return null
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06]"
|
||||
>
|
||||
<i className={`${icon} text-[11px]`} />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function RequestActionButton({ action, onExecute, busyKey }) {
|
||||
if (!action || action.type !== 'request') return null
|
||||
|
||||
const isBusy = busyKey === `${action.key}:${action.url}`
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExecute(action)}
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<i className={`${action.icon} text-[11px]`} />
|
||||
<span>{isBusy ? 'Working...' : action.label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewLink({ item }) {
|
||||
if (!item?.preview_url) return null
|
||||
|
||||
return <ActionLink href={item.preview_url} icon="fa-solid fa-eye" label="Preview" />
|
||||
}
|
||||
|
||||
function GridCard({ item, onExecuteAction, busyKey }) {
|
||||
const handleEditClick = () => {
|
||||
trackStudioEvent('studio_item_edited', {
|
||||
surface: studioSurface(),
|
||||
module: item.module,
|
||||
item_module: item.module,
|
||||
item_id: item.numeric_id,
|
||||
meta: {
|
||||
action: 'edit',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.03] shadow-[0_18px_50px_rgba(3,7,18,0.22)] transition hover:-translate-y-0.5 hover:border-white/20">
|
||||
<div className="relative aspect-[1.15/1] overflow-hidden bg-slate-950/70">
|
||||
{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 bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.18),_transparent_55%),linear-gradient(135deg,_rgba(15,23,42,0.9),_rgba(2,6,23,0.95))] text-slate-400">
|
||||
<i className={`${item.module_icon} text-3xl`} />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-4 top-4 inline-flex items-center gap-2 rounded-full border border-black/10 bg-black/45 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white backdrop-blur-md">
|
||||
<i className={`${item.module_icon} text-[10px]`} />
|
||||
<span>{item.module_label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-1 truncate text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
|
||||
</div>
|
||||
<span className={`inline-flex shrink-0 items-center rounded-full border px-2.5 py-1 text-[11px] font-medium capitalize ${statusClasses(item.status)}`}>
|
||||
{String(item.status || 'unknown').replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.workflow?.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>
|
||||
{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">
|
||||
Stale draft
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
||||
{item.workflow.readiness.score}/{item.workflow.readiness.max} ready
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="line-clamp-2 min-h-[2.5rem] text-sm text-slate-300/90">
|
||||
{item.description || 'No description yet.'}
|
||||
</p>
|
||||
|
||||
{Array.isArray(item.workflow?.readiness?.missing) && item.workflow.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(' • ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 rounded-2xl border border-white/5 bg-slate-950/40 p-3 text-xs text-slate-400">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.16em]">Views</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{metricValue(item, 'views')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.16em]">Reactions</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{metricValue(item, 'appreciation')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.16em]">Comments</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{metricValue(item, 'comments')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span>Updated {formatDate(item.updated_at)}</span>
|
||||
{item.published_at && <span>Published {formatDate(item.published_at)}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ActionLink href={item.edit_url || item.manage_url} icon="fa-solid fa-pen-to-square" label="Edit" onClick={handleEditClick} />
|
||||
<PreviewLink item={item} />
|
||||
<ActionLink href={item.analytics_url} icon="fa-solid fa-chart-line" label="Analytics" />
|
||||
<ActionLink href={item.view_url} icon="fa-solid fa-arrow-up-right-from-square" label="Open" />
|
||||
{(item.actions || []).map((action) => (
|
||||
<RequestActionButton key={`${item.id}-${action.key}`} action={{ ...action, item_id: item.numeric_id, item_module: item.module }} onExecute={onExecuteAction} busyKey={busyKey} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{Array.isArray(item.workflow?.cross_module_actions) && item.workflow.cross_module_actions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 border-t border-white/5 pt-3">
|
||||
{item.workflow.cross_module_actions.slice(0, 2).map((action) => (
|
||||
<ActionLink key={`${item.id}-${action.label}`} href={action.href} icon={action.icon} label={action.label} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function ListRow({ item, onExecuteAction, busyKey }) {
|
||||
const handleEditClick = () => {
|
||||
trackStudioEvent('studio_item_edited', {
|
||||
surface: studioSurface(),
|
||||
module: item.module,
|
||||
item_module: item.module,
|
||||
item_id: item.numeric_id,
|
||||
meta: {
|
||||
action: 'edit',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="grid gap-4 rounded-[24px] border border-white/10 bg-white/[0.03] p-4 transition hover:border-white/20 md:grid-cols-[120px_minmax(0,1fr)_auto] md:items-center">
|
||||
<div className="h-24 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-400">
|
||||
<i className={`${item.module_icon} text-2xl`} />
|
||||
</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-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
||||
<i className={`${item.module_icon} text-[10px]`} />
|
||||
{item.module_label}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
|
||||
<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}
|
||||
</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">
|
||||
Stale draft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-4 text-xs text-slate-500">
|
||||
<span>{metricValue(item, 'views')} views</span>
|
||||
<span>{metricValue(item, 'appreciation')} reactions</span>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 md:justify-end">
|
||||
<ActionLink href={item.edit_url || item.manage_url} icon="fa-solid fa-pen-to-square" label="Edit" onClick={handleEditClick} />
|
||||
<PreviewLink item={item} />
|
||||
<ActionLink href={item.analytics_url} icon="fa-solid fa-chart-line" label="Analytics" />
|
||||
<ActionLink href={item.view_url} icon="fa-solid fa-arrow-up-right-from-square" label="Open" />
|
||||
{(item.actions || []).map((action) => (
|
||||
<RequestActionButton key={`${item.id}-${action.key}`} action={{ ...action, item_id: item.numeric_id, item_module: item.module }} onExecute={onExecuteAction} busyKey={busyKey} />
|
||||
))}
|
||||
{(item.workflow?.cross_module_actions || []).slice(0, 2).map((action) => (
|
||||
<ActionLink key={`${item.id}-${action.label}`} href={action.href} icon={action.icon} label={action.label} />
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedFilterControl({ filter, onChange }) {
|
||||
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'}
|
||||
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"
|
||||
>
|
||||
{(filter.options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<input
|
||||
type="search"
|
||||
value={filter.value || ''}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioContentBrowser({
|
||||
listing,
|
||||
quickCreate = [],
|
||||
hideModuleFilter = false,
|
||||
hideBucketFilter = false,
|
||||
emptyTitle = 'Nothing here yet',
|
||||
emptyBody = 'Try adjusting filters or create something new.',
|
||||
}) {
|
||||
const [viewMode, setViewMode] = useState('grid')
|
||||
const [busyKey, setBusyKey] = useState(null)
|
||||
const filters = listing?.filters || {}
|
||||
const items = listing?.items || []
|
||||
const meta = listing?.meta || {}
|
||||
const advancedFilters = listing?.advanced_filters || []
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('studio-content-view')
|
||||
if (stored === 'grid' || stored === 'list') {
|
||||
setViewMode(stored)
|
||||
return
|
||||
}
|
||||
|
||||
if (listing?.default_view === 'grid' || listing?.default_view === 'list') {
|
||||
setViewMode(listing.default_view)
|
||||
}
|
||||
}, [listing?.default_view])
|
||||
|
||||
const updateQuery = (patch) => {
|
||||
const next = {
|
||||
...filters,
|
||||
...patch,
|
||||
}
|
||||
|
||||
if (patch.page == null) {
|
||||
next.page = 1
|
||||
}
|
||||
|
||||
trackStudioEvent('studio_filter_used', {
|
||||
surface: studioSurface(),
|
||||
module: filters.module || listing?.module || null,
|
||||
meta: {
|
||||
patch,
|
||||
},
|
||||
})
|
||||
|
||||
router.get(window.location.pathname, next, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
const updateView = (nextMode) => {
|
||||
setViewMode(nextMode)
|
||||
window.localStorage.setItem('studio-content-view', nextMode)
|
||||
trackStudioEvent('studio_filter_used', {
|
||||
surface: studioSurface(),
|
||||
module: filters.module || listing?.module || null,
|
||||
meta: {
|
||||
view_mode: nextMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const executeAction = async (action) => {
|
||||
if (!action?.url || action.type !== 'request') {
|
||||
return
|
||||
}
|
||||
|
||||
if (action.confirm && !window.confirm(action.confirm)) {
|
||||
return
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
if (action.key === 'archive') {
|
||||
trackStudioEvent('studio_item_archived', {
|
||||
surface: studioSurface(),
|
||||
module: filters.module || null,
|
||||
item_module: action.item_module || null,
|
||||
item_id: action.item_id || null,
|
||||
meta: {
|
||||
action: action.key,
|
||||
url: action.url,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (action.key === 'restore') {
|
||||
trackStudioEvent('studio_item_restored', {
|
||||
surface: studioSurface(),
|
||||
module: filters.module || null,
|
||||
item_module: action.item_module || null,
|
||||
item_id: action.item_id || null,
|
||||
meta: {
|
||||
action: action.key,
|
||||
url: action.url,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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'}`}>
|
||||
<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 })}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!hideModuleFilter && (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Module</span>
|
||||
<select
|
||||
value={filters.module || 'all'}
|
||||
onChange={(event) => updateQuery({ module: 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?.module_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!hideBucketFilter && (
|
||||
<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 })}
|
||||
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) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<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 })}
|
||||
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) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{advancedFilters.map((filter) => (
|
||||
<AdvancedFilterControl key={filter.key} filter={filter} onChange={(key, value) => updateQuery({ [key]: value })} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
||||
<div className="inline-flex rounded-full border border-white/10 bg-black/20 p-1">
|
||||
{[
|
||||
{ value: 'grid', icon: 'fa-solid fa-table-cells-large', label: 'Grid view' },
|
||||
{ value: 'list', icon: 'fa-solid fa-list', label: 'List view' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => updateView(option.value)}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-3 py-2 text-xs font-semibold transition ${viewMode === option.value ? 'bg-white text-slate-950' : 'text-slate-300 hover:text-white'}`}
|
||||
>
|
||||
<i className={option.icon} />
|
||||
<span className="hidden sm:inline">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{quickCreate.map((action) => (
|
||||
<a
|
||||
key={action.key}
|
||||
href={action.url}
|
||||
onClick={() => trackStudioEvent('studio_quick_create_used', {
|
||||
surface: studioSurface(),
|
||||
module: action.key,
|
||||
meta: {
|
||||
href: action.url,
|
||||
label: action.label,
|
||||
},
|
||||
})}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15"
|
||||
>
|
||||
<i className={action.icon} />
|
||||
<span>New {action.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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
|
||||
</p>
|
||||
<p>Page {meta.current_page || 1} of {meta.last_page || 1}</p>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
viewMode === 'grid' ? (
|
||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => <GridCard 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>
|
||||
)
|
||||
) : (
|
||||
<section className="rounded-[28px] border border-dashed border-white/15 bg-white/[0.02] px-6 py-16 text-center">
|
||||
<h3 className="text-xl font-semibold text-white">{emptyTitle}</h3>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm text-slate-400">{emptyBody}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
|
||||
<button
|
||||
type="button"
|
||||
disabled={(meta.current_page || 1) <= 1}
|
||||
onClick={() => updateQuery({ page: Math.max(1, (meta.current_page || 1) - 1) })}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Creator Studio</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={(meta.current_page || 1) >= (meta.last_page || 1)}
|
||||
onClick={() => updateQuery({ page: (meta.current_page || 1) + 1 })}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp'
|
||||
const FALLBACK_XL = 'https://files.skinbase.org/default/missing_xl.webp'
|
||||
|
||||
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, onOpenViewer, hasPrev, hasNext, onPrev, onNext }) {
|
||||
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 mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
|
||||
@@ -18,8 +18,8 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
||||
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
|
||||
const blurBackdropSrc = mdSource || lgSource || xlSource || null
|
||||
|
||||
const dbWidth = Number(artwork?.width)
|
||||
const dbHeight = Number(artwork?.height)
|
||||
const dbWidth = Number(mediaWidth ?? artwork?.width)
|
||||
const dbHeight = Number(mediaHeight ?? artwork?.height)
|
||||
const hasDbDims = dbWidth > 0 && dbHeight > 0
|
||||
|
||||
// Natural dimensions — seeded from DB if available, otherwise probed from
|
||||
@@ -28,6 +28,16 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
||||
hasDbDims ? { w: dbWidth, h: dbHeight } : null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false)
|
||||
if (hasDbDims) {
|
||||
setNaturalDims({ w: dbWidth, h: dbHeight })
|
||||
return
|
||||
}
|
||||
|
||||
setNaturalDims(null)
|
||||
}, [mediaKey, hasDbDims, dbWidth, dbHeight])
|
||||
|
||||
// Probe the xl image to discover real dimensions when DB has none
|
||||
useEffect(() => {
|
||||
if (naturalDims || !xlSource) return
|
||||
|
||||
69
resources/js/components/artwork/ArtworkMediaStrip.jsx
Normal file
69
resources/js/components/artwork/ArtworkMediaStrip.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ArtworkMediaStrip({ items = [], selectedId = null, onSelect }) {
|
||||
if (!Array.isArray(items) || items.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-4 shadow-[0_18px_60px_rgba(2,8,23,0.24)] backdrop-blur">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/45">Gallery</p>
|
||||
<p className="mt-1 text-sm text-white/60">Switch between the default cover and additional archive screenshots.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] text-white/65">
|
||||
{items.length} views
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{items.map((item) => {
|
||||
const active = item.id === selectedId
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(item.id)}
|
||||
aria-pressed={active}
|
||||
className={[
|
||||
'group shrink-0 rounded-2xl border p-2 text-left transition-all',
|
||||
active
|
||||
? 'border-sky-300/45 bg-sky-400/12 shadow-[0_0_0_1px_rgba(56,189,248,0.18)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="h-20 w-28 overflow-hidden rounded-xl bg-black/30 ring-1 ring-white/10 sm:h-24 sm:w-36">
|
||||
{item.thumbUrl ? (
|
||||
<img
|
||||
src={item.thumbUrl}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-white/30">
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 px-1">
|
||||
<span className="truncate text-xs font-medium text-white/80">{item.label}</span>
|
||||
<span className={[
|
||||
'rounded-full px-2 py-0.5 text-[10px]',
|
||||
active ? 'bg-sky-300/20 text-sky-100' : 'bg-white/10 text-white/45',
|
||||
].join(' ')}>
|
||||
{active ? 'Showing' : 'View'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -425,9 +425,7 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
? `/discover/trending`
|
||||
: '/discover/trending'
|
||||
|
||||
const similarHref = artwork?.name
|
||||
? `/search?q=${encodeURIComponent(artwork.name)}`
|
||||
: '/search'
|
||||
const similarHref = artwork?.id ? `/art/${artwork.id}/similar` : null
|
||||
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import EmojiMartPicker from '../common/EmojiMartPicker'
|
||||
|
||||
/**
|
||||
* A button that opens a floating emoji picker.
|
||||
@@ -76,7 +76,7 @@ export default function EmojiPickerButton({ onEmojiSelect, disabled = false, cla
|
||||
className="absolute bottom-full mb-2 right-0 z-50 shadow-2xl rounded-xl overflow-hidden"
|
||||
style={{ filter: 'drop-shadow(0 8px 32px rgba(0,0,0,0.6))' }}
|
||||
>
|
||||
<Picker
|
||||
<EmojiMartPicker
|
||||
data={data}
|
||||
onEmojiSelect={handleSelect}
|
||||
theme="dark"
|
||||
|
||||
116
resources/js/components/common/EmojiMartPicker.jsx
Normal file
116
resources/js/components/common/EmojiMartPicker.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
let emojiMartRegistrationPromise = null
|
||||
|
||||
function ensureEmojiMartRegistered() {
|
||||
if (!emojiMartRegistrationPromise) {
|
||||
emojiMartRegistrationPromise = import('emoji-mart')
|
||||
}
|
||||
|
||||
return emojiMartRegistrationPromise
|
||||
}
|
||||
|
||||
function applyPickerProps(element, props) {
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
element.data = props.data
|
||||
element.onEmojiSelect = props.onEmojiSelect
|
||||
element.theme = props.theme
|
||||
element.previewPosition = props.previewPosition
|
||||
element.skinTonePosition = props.skinTonePosition
|
||||
element.maxFrequentRows = props.maxFrequentRows
|
||||
element.perLine = props.perLine
|
||||
element.navPosition = props.navPosition
|
||||
element.set = props.set
|
||||
element.locale = props.locale
|
||||
element.autoFocus = props.autoFocus
|
||||
element.searchPosition = props.searchPosition
|
||||
element.dynamicWidth = props.dynamicWidth
|
||||
element.noCountryFlags = props.noCountryFlags
|
||||
}
|
||||
|
||||
export default function EmojiMartPicker({
|
||||
data,
|
||||
onEmojiSelect,
|
||||
theme = 'auto',
|
||||
previewPosition = 'bottom',
|
||||
skinTonePosition = 'preview',
|
||||
maxFrequentRows = 4,
|
||||
perLine = 9,
|
||||
navPosition = 'top',
|
||||
set = 'native',
|
||||
locale = 'en',
|
||||
autoFocus = false,
|
||||
searchPosition,
|
||||
dynamicWidth,
|
||||
noCountryFlags,
|
||||
className = '',
|
||||
}) {
|
||||
const hostRef = useRef(null)
|
||||
const pickerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
ensureEmojiMartRegistered().then(() => {
|
||||
if (cancelled || !hostRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!pickerRef.current) {
|
||||
pickerRef.current = document.createElement('em-emoji-picker')
|
||||
hostRef.current.replaceChildren(pickerRef.current)
|
||||
}
|
||||
|
||||
applyPickerProps(pickerRef.current, {
|
||||
data,
|
||||
onEmojiSelect,
|
||||
theme,
|
||||
previewPosition,
|
||||
skinTonePosition,
|
||||
maxFrequentRows,
|
||||
perLine,
|
||||
navPosition,
|
||||
set,
|
||||
locale,
|
||||
autoFocus,
|
||||
searchPosition,
|
||||
dynamicWidth,
|
||||
noCountryFlags,
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
data,
|
||||
onEmojiSelect,
|
||||
theme,
|
||||
previewPosition,
|
||||
skinTonePosition,
|
||||
maxFrequentRows,
|
||||
perLine,
|
||||
navPosition,
|
||||
set,
|
||||
locale,
|
||||
autoFocus,
|
||||
searchPosition,
|
||||
dynamicWidth,
|
||||
noCountryFlags,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hostRef.current) {
|
||||
hostRef.current.replaceChildren()
|
||||
}
|
||||
|
||||
pickerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div ref={hostRef} className={className} />
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import EmojiMartPicker from '../common/EmojiMartPicker'
|
||||
|
||||
/**
|
||||
* Emoji picker button for the forum rich-text editor.
|
||||
* Uses the same @emoji-mart/react picker as profile tweets / comments
|
||||
* Uses the same emoji-mart picker as profile tweets / comments
|
||||
* so the UI is consistent across the whole site.
|
||||
*
|
||||
* The panel is rendered through a React portal so it escapes any
|
||||
@@ -73,7 +73,7 @@ export default function EmojiPicker({ onSelect, editor }) {
|
||||
style={panelStyle}
|
||||
className="rounded-xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<Picker
|
||||
<EmojiMartPicker
|
||||
data={data}
|
||||
onEmojiSelect={handleSelect}
|
||||
theme="dark"
|
||||
|
||||
@@ -14,6 +14,82 @@ const placementStyles = {
|
||||
'bottom-right': { bottom: '12%', right: '12%' },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DraggableElement — wraps any canvas element with pointer-drag positioning.
|
||||
// Position is tracked locally during drag (avoids parent re-renders), then
|
||||
// committed to the parent via onMove(elementId, x%, y%) on pointer-up.
|
||||
// ---------------------------------------------------------------------------
|
||||
function DraggableElement({ elementId, canvasRef, freePos, savedWidth, onMove, style, className, children }) {
|
||||
const dragState = React.useRef(null)
|
||||
const [localPos, setLocalPos] = React.useState(null)
|
||||
const [localWidth, setLocalWidth] = React.useState(null)
|
||||
|
||||
function handlePointerDown(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const canvasRect = canvas.getBoundingClientRect()
|
||||
const elemRect = event.currentTarget.getBoundingClientRect()
|
||||
const offsetX = event.clientX - elemRect.left
|
||||
const offsetY = event.clientY - elemRect.top
|
||||
const startX = ((elemRect.left - canvasRect.left) / canvasRect.width) * 100
|
||||
const startY = ((elemRect.top - canvasRect.top) / canvasRect.height) * 100
|
||||
// Capture element width as % of canvas so it stays the same after going absolute.
|
||||
const widthPct = Math.round((elemRect.width / canvasRect.width) * 1000) / 10
|
||||
dragState.current = { canvasRect, offsetX, offsetY, widthPct }
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
setLocalPos({ x: startX, y: startY })
|
||||
setLocalWidth(widthPct)
|
||||
}
|
||||
|
||||
function calcPos(event) {
|
||||
const { canvasRect, offsetX, offsetY } = dragState.current
|
||||
return {
|
||||
x: Math.max(0, Math.min(94, ((event.clientX - canvasRect.left - offsetX) / canvasRect.width) * 100)),
|
||||
y: Math.max(0, Math.min(92, ((event.clientY - canvasRect.top - offsetY) / canvasRect.height) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
if (!dragState.current) return
|
||||
setLocalPos(calcPos(event))
|
||||
}
|
||||
|
||||
function handlePointerUp(event) {
|
||||
if (!dragState.current) return
|
||||
const pos = calcPos(event)
|
||||
const widthPct = dragState.current.widthPct
|
||||
dragState.current = null
|
||||
setLocalPos(null)
|
||||
setLocalWidth(null)
|
||||
onMove?.(elementId, Math.round(pos.x * 10) / 10, Math.round(pos.y * 10) / 10, widthPct)
|
||||
}
|
||||
|
||||
const isDragging = localPos !== null
|
||||
const absPos = localPos ?? freePos
|
||||
|
||||
// Width to use when the element is absolutely positioned.
|
||||
const resolvedWidth = localWidth ?? savedWidth
|
||||
|
||||
const posStyle = absPos
|
||||
? { position: 'absolute', left: `${absPos.x}%`, top: `${absPos.y}%`, zIndex: isDragging ? 50 : 10, width: resolvedWidth != null ? `${resolvedWidth}%` : undefined }
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ ...style, ...posStyle, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
|
||||
className={`${className} rounded ${isDragging ? 'ring-2 ring-sky-400/70 opacity-90' : 'ring-1 ring-transparent hover:ring-white/30'} transition-[box-shadow]`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function overlayStyle(style) {
|
||||
if (style === 'dark-strong') return 'linear-gradient(180deg, rgba(2,6,23,0.38), rgba(2,6,23,0.68))'
|
||||
if (style === 'light-soft') return 'linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.22))'
|
||||
@@ -30,9 +106,9 @@ function positionStyle(position) {
|
||||
}
|
||||
|
||||
function alignmentClass(alignment) {
|
||||
if (alignment === 'left') return 'items-start text-left'
|
||||
if (alignment === 'right') return 'items-end text-right'
|
||||
return 'items-center text-center'
|
||||
if (alignment === 'left') return 'justify-start items-start text-left'
|
||||
if (alignment === 'right') return 'justify-end items-end text-right'
|
||||
return 'justify-center items-center text-center'
|
||||
}
|
||||
|
||||
function focalPositionStyle(position) {
|
||||
@@ -76,32 +152,55 @@ function blockClass(type) {
|
||||
return 'font-semibold tracking-[-0.03em] sm:text-[1.65rem] lg:text-[2.1rem]'
|
||||
}
|
||||
|
||||
function blockStyle(type, typography, textColor, accentColor) {
|
||||
const quoteSize = Math.max(26, Math.min(typography.quote_size || 72, 120))
|
||||
function blockStyle(type, typography, textColor, accentColor, fontFamily) {
|
||||
const quoteSize = Math.max(10, Math.min(typography.quote_size || 72, 120))
|
||||
const authorSize = Math.max(14, Math.min(typography.author_size || 28, 42))
|
||||
const letterSpacing = Math.max(-1, Math.min(typography.letter_spacing || 0, 10))
|
||||
const lineHeight = Math.max(0.9, Math.min(typography.line_height || 1.2, 1.8))
|
||||
const shadowPreset = typography.shadow_preset || 'soft'
|
||||
// text_opacity: 10–100 (stored as integer percent), default 100
|
||||
const opacity = typography.text_opacity != null ? Math.max(10, Math.min(100, Number(typography.text_opacity))) / 100 : 1
|
||||
|
||||
// Convert canvas pixels (1080-normalised) to container-query width units so
|
||||
// the preview proportions always match the rendered image regardless of how
|
||||
// wide the preview panel is.
|
||||
const toCqw = (px) => `${(px / 1080 * 100).toFixed(2)}cqw`
|
||||
|
||||
const sizeMap = {
|
||||
title: toCqw(Math.max(16, quoteSize * 0.48)),
|
||||
quote: toCqw(quoteSize),
|
||||
author: toCqw(authorSize),
|
||||
source: toCqw(Math.max(12, authorSize * 0.82)),
|
||||
body: toCqw(Math.max(16, quoteSize * 0.54)),
|
||||
caption: toCqw(Math.max(12, authorSize * 0.74)),
|
||||
}
|
||||
|
||||
const fontSize = sizeMap[type] ?? sizeMap.quote
|
||||
|
||||
const font = fontFamily ? { fontFamily } : {}
|
||||
|
||||
if (type === 'title') {
|
||||
return { color: accentColor, letterSpacing: `${Math.max(letterSpacing, 0) / 10}em`, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: accentColor, fontSize, letterSpacing: `${Math.max(letterSpacing, 0) / 10}em`, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
if (type === 'author' || type === 'source') {
|
||||
return { color: accentColor, fontSize: `${authorSize / 4}px`, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: accentColor, fontSize, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
if (type === 'body' || type === 'caption') {
|
||||
return { color: textColor, lineHeight, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: textColor, fontSize, lineHeight, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
return { color: textColor, fontSize: `${quoteSize / 4}px`, lineHeight, letterSpacing: `${letterSpacing / 12}px`, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: textColor, fontSize, lineHeight, letterSpacing: `${letterSpacing / 12}px`, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
export default function NovaCardCanvasPreview({ card, fonts = [], className = '', editable = false, onElementMove = null, renderMode = false }) {
|
||||
const canvasRef = React.useRef(null)
|
||||
const project = card?.project_json || {}
|
||||
const layout = project.layout || {}
|
||||
const typography = project.typography || {}
|
||||
const resolvedFont = fonts.find((f) => f.key === (typography.font_preset || 'modern-sans'))
|
||||
const fontFamily = resolvedFont?.family || null
|
||||
const background = project.background || {}
|
||||
const backgroundImage = card?.background_image?.processed_url
|
||||
const colors = Array.isArray(background.gradient_colors) && background.gradient_colors.length >= 2
|
||||
@@ -113,39 +212,30 @@ export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
? background.solid_color || '#111827'
|
||||
: `linear-gradient(180deg, ${colors[0]}, ${colors[1]})`
|
||||
|
||||
const textBlocks = resolveTextBlocks(card, project)
|
||||
const allTextBlocks = resolveTextBlocks(card, project)
|
||||
// Blocks with a saved free position are rendered absolutely; others stay in normal flow.
|
||||
const flowTextBlocks = allTextBlocks.filter((b) => b.pos_x == null || b.pos_y == null)
|
||||
const freeTextBlocks = allTextBlocks.filter((b) => b.pos_x != null && b.pos_y != null)
|
||||
|
||||
const decorations = Array.isArray(project.decorations) ? project.decorations : []
|
||||
const assetItems = Array.isArray(project.assets?.items) ? project.assets.items : []
|
||||
const textColor = typography.text_color || '#ffffff'
|
||||
const accentColor = typography.accent_color || textColor
|
||||
const maxWidth = layout.max_width === 'compact' ? '62%' : layout.max_width === 'wide' ? '88%' : '76%'
|
||||
const layoutMaxWidth = layout.max_width === 'compact' ? '62%' : layout.max_width === 'wide' ? '88%' : '76%'
|
||||
const maxWidth = typography.quote_width != null ? `${typography.quote_width}%` : layoutMaxWidth
|
||||
const padding = layout.padding === 'tight' ? '8%' : layout.padding === 'airy' ? '14%' : '11%'
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-[28px] border border-white/10 bg-slate-950 shadow-[0_30px_80px_rgba(2,6,23,0.35)] ${className}`} style={{ aspectRatio: aspectRatios[card?.format || 'square'] || aspectRatios.square }}>
|
||||
<div ref={canvasRef} data-card-canvas className={`relative overflow-hidden bg-slate-950 [container-type:inline-size] ${renderMode ? '' : 'rounded-[28px] border border-white/10 shadow-[0_30px_80px_rgba(2,6,23,0.35)]'} ${className}`} style={{ aspectRatio: aspectRatios[card?.format || 'square'] || aspectRatios.square }}>
|
||||
<div className="absolute inset-0" style={{ background: backgroundStyle, filter: background.type === 'upload' && Number(background.blur_level || 0) > 0 ? `blur(${Math.max(Number(background.blur_level) / 8, 0)}px)` : undefined }} />
|
||||
<div className="absolute inset-0" style={{ background: overlayStyle(background.overlay_style), opacity: Math.max(0, Math.min(Number(background.opacity || 50), 100)) / 100 }} />
|
||||
<div className="absolute left-4 top-4 rounded-full border border-white/10 bg-black/25 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80 backdrop-blur">
|
||||
{(card?.format || 'square').replace('-', ' ')}
|
||||
</div>
|
||||
|
||||
{decorations.slice(0, 6).map((decoration, index) => {
|
||||
const placement = placementStyles[decoration.placement] || placementStyles['top-right']
|
||||
return (
|
||||
<div
|
||||
key={`${decoration.key || decoration.glyph || 'dec'}-${index}`}
|
||||
className="absolute text-white/85 drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
style={{
|
||||
...placement,
|
||||
color: accentColor,
|
||||
fontSize: `${Math.max(18, Math.min(decoration.size || 28, 64))}px`,
|
||||
}}
|
||||
>
|
||||
{decoration.glyph || '✦'}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!renderMode && (
|
||||
<div className="absolute left-4 top-4 rounded-full border border-white/10 bg-black/25 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80 backdrop-blur">
|
||||
{(card?.format || 'square').replace('-', ' ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset items (non-draggable) */}
|
||||
{assetItems.slice(0, 6).map((item, index) => {
|
||||
if (item?.type === 'frame') {
|
||||
const top = index % 2 === 0 ? '10%' : '88%'
|
||||
@@ -160,7 +250,7 @@ export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
top: `${12 + ((index % 3) * 18)}%`,
|
||||
left: `${10 + (Math.floor(index / 3) * 72)}%`,
|
||||
color: accentColor,
|
||||
fontSize: `${Math.max(18, Math.min(item.size || 26, 56))}px`,
|
||||
fontSize: `${(Math.max(18, Math.min(item.size || 26, 56)) / 1080 * 100).toFixed(2)}cqw`,
|
||||
}}
|
||||
>
|
||||
{item.glyph || item.label || '✦'}
|
||||
@@ -168,20 +258,122 @@ export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Flow-positioned text blocks */}
|
||||
<div className={`relative flex h-full w-full ${alignmentClass(layout.alignment)}`} style={{ padding, ...positionStyle(layout.position) }}>
|
||||
<div className="flex w-full flex-col gap-4" style={{ maxWidth }}>
|
||||
{textBlocks.map((block, index) => {
|
||||
{flowTextBlocks.map((block, index) => {
|
||||
const type = block?.type || 'body'
|
||||
const text = type === 'author' ? `— ${block.text}` : block.text
|
||||
const defStyle = blockStyle(type, typography, textColor, accentColor, fontFamily)
|
||||
const blockCls = blockClass(type)
|
||||
const blockKey = `${block.key || type}-${index}`
|
||||
|
||||
if (editable) {
|
||||
return (
|
||||
<DraggableElement
|
||||
key={blockKey}
|
||||
elementId={`block:${block.key || type}`}
|
||||
canvasRef={canvasRef}
|
||||
freePos={null}
|
||||
onMove={onElementMove}
|
||||
style={defStyle}
|
||||
className={blockCls}
|
||||
>
|
||||
{text}
|
||||
</DraggableElement>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${block.key || type}-${index}`} style={blockStyle(type, typography, textColor, accentColor)} className={blockClass(type)}>
|
||||
<div key={blockKey} style={defStyle} className={blockCls}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Absolutely-positioned text blocks (dragged out of flow) */}
|
||||
{freeTextBlocks.map((block, index) => {
|
||||
const type = block?.type || 'body'
|
||||
const text = type === 'author' ? `— ${block.text}` : block.text
|
||||
const defStyle = blockStyle(type, typography, textColor, accentColor, fontFamily)
|
||||
const blockCls = blockClass(type)
|
||||
const blockKey = `free-${block.key || type}-${index}`
|
||||
|
||||
if (editable) {
|
||||
return (
|
||||
<DraggableElement
|
||||
key={blockKey}
|
||||
elementId={`block:${block.key || type}`}
|
||||
canvasRef={canvasRef}
|
||||
freePos={{ x: block.pos_x, y: block.pos_y }}
|
||||
savedWidth={block.pos_width ?? null}
|
||||
onMove={onElementMove}
|
||||
style={defStyle}
|
||||
className={blockCls}
|
||||
>
|
||||
{text}
|
||||
</DraggableElement>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={blockKey}
|
||||
style={{ ...defStyle, position: 'absolute', left: `${block.pos_x}%`, top: `${block.pos_y}%`, ...(block.pos_width != null ? { width: `${block.pos_width}%` } : {}) }}
|
||||
className={blockCls}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Decorations — rendered last so they sit on top of all text content */}
|
||||
{decorations.slice(0, 6).map((decoration, index) => {
|
||||
const hasFreePos = decoration.pos_x != null && decoration.pos_y != null
|
||||
const placementPos = placementStyles[decoration.placement] || placementStyles['top-right']
|
||||
const decOpacity = decoration.opacity != null ? Math.max(10, Math.min(100, Number(decoration.opacity))) / 100 : 0.85
|
||||
const decStyle = {
|
||||
color: accentColor,
|
||||
fontSize: `${(Math.max(18, Math.min(decoration.size || 28, 64)) / 1080 * 100).toFixed(2)}cqw`,
|
||||
opacity: decOpacity,
|
||||
zIndex: 20,
|
||||
}
|
||||
const decKey = `${decoration.key || decoration.glyph || 'dec'}-${index}`
|
||||
const decContent = decoration.glyph || '✦'
|
||||
|
||||
if (editable) {
|
||||
const freePos = hasFreePos ? { x: decoration.pos_x, y: decoration.pos_y } : null
|
||||
const absStyle = hasFreePos
|
||||
? { position: 'absolute', left: `${decoration.pos_x}%`, top: `${decoration.pos_y}%` }
|
||||
: { position: 'absolute', ...placementPos }
|
||||
return (
|
||||
<DraggableElement
|
||||
key={decKey}
|
||||
elementId={`decoration:${index}`}
|
||||
canvasRef={canvasRef}
|
||||
freePos={freePos}
|
||||
savedWidth={null}
|
||||
onMove={onElementMove}
|
||||
style={{ ...absStyle, ...decStyle }}
|
||||
className="drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
>
|
||||
{decContent}
|
||||
</DraggableElement>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={decKey}
|
||||
className="absolute drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
style={{ ...placementPos, ...decStyle }}
|
||||
>
|
||||
{decContent}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
|
||||
export default function NovaCardFontPicker({ fonts = [], selectedKey = null, onSelect }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3">
|
||||
{fonts.map((font) => {
|
||||
const active = selectedKey === font.key
|
||||
return (
|
||||
@@ -10,10 +10,20 @@ export default function NovaCardFontPicker({ fonts = [], selectedKey = null, onS
|
||||
key={font.key}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(font)}
|
||||
className={`rounded-[22px] border p-4 text-left transition ${active ? 'border-sky-300/35 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.05]'}`}
|
||||
className={`rounded-[22px] border p-4 text-left transition ${active ? 'border-sky-300/35 bg-sky-400/12 ring-1 ring-sky-400/20 text-white' : 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.05]'}`}
|
||||
>
|
||||
<div className="text-lg font-semibold tracking-[-0.03em]" style={{ fontFamily: font.family }}>{font.label}</div>
|
||||
<div className="mt-2 text-sm text-slate-400">{font.recommended_use}</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500 mb-2">{font.label}</div>
|
||||
<div className="text-[1.65rem] font-semibold leading-tight text-white" style={{ fontFamily: font.family, fontWeight: font.weight || 600 }}>
|
||||
The quick brown fox
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-400 leading-relaxed" style={{ fontFamily: font.family }}>
|
||||
{font.recommended_use}
|
||||
</div>
|
||||
{active && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 rounded-full border border-sky-400/25 bg-sky-500/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-sky-300">
|
||||
Active
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
66
resources/js/components/seo/SeoHead.jsx
Normal file
66
resources/js/components/seo/SeoHead.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import { Head } from '@inertiajs/react'
|
||||
|
||||
function normalizeJsonLd(input) {
|
||||
if (!input) return []
|
||||
return (Array.isArray(input) ? input : [input]).filter((schema) => schema && typeof schema === 'object')
|
||||
}
|
||||
|
||||
export default function SeoHead({ seo = {}, title = null, description = null, jsonLd = null }) {
|
||||
const metaTitle = seo?.title || title || 'Skinbase'
|
||||
const metaDescription = seo?.description || description || ''
|
||||
const canonical = seo?.canonical || null
|
||||
const robots = seo?.robots || null
|
||||
const prev = seo?.prev || null
|
||||
const next = seo?.next || null
|
||||
const ogTitle = seo?.og_title || metaTitle
|
||||
const ogDescription = seo?.og_description || metaDescription
|
||||
const ogUrl = seo?.og_url || canonical
|
||||
const ogType = seo?.og_type || 'website'
|
||||
const ogImage = seo?.og_image || null
|
||||
const keywords = seo?.keywords || null
|
||||
const twitterCard = seo?.twitter_card || (ogImage ? 'summary_large_image' : 'summary')
|
||||
const twitterTitle = seo?.twitter_title || ogTitle
|
||||
const twitterDescription = seo?.twitter_description || ogDescription
|
||||
const twitterImage = seo?.twitter_image || ogImage || null
|
||||
const schemas = [...normalizeJsonLd(seo?.json_ld), ...normalizeJsonLd(jsonLd)]
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title>{metaTitle}</title>
|
||||
{metaDescription ? <meta head-key="description" name="description" content={metaDescription} /> : null}
|
||||
{keywords ? <meta head-key="keywords" name="keywords" content={keywords} /> : null}
|
||||
{robots ? <meta head-key="robots" name="robots" content={robots} /> : null}
|
||||
{canonical ? <link head-key="canonical" rel="canonical" href={canonical} /> : null}
|
||||
{prev ? <link head-key="prev" rel="prev" href={prev} /> : null}
|
||||
{next ? <link head-key="next" rel="next" href={next} /> : null}
|
||||
|
||||
<meta head-key="og:site_name" property="og:site_name" content={seo?.og_site_name || 'Skinbase'} />
|
||||
<meta head-key="og:type" property="og:type" content={ogType} />
|
||||
<meta head-key="og:title" property="og:title" content={ogTitle} />
|
||||
{ogDescription ? <meta head-key="og:description" property="og:description" content={ogDescription} /> : null}
|
||||
{ogUrl ? <meta head-key="og:url" property="og:url" content={ogUrl} /> : null}
|
||||
{ogImage ? <meta head-key="og:image" property="og:image" content={ogImage} /> : null}
|
||||
{seo?.og_image_alt ? <meta head-key="og:image:alt" property="og:image:alt" content={seo.og_image_alt} /> : null}
|
||||
|
||||
<meta head-key="twitter:card" name="twitter:card" content={twitterCard} />
|
||||
<meta head-key="twitter:title" name="twitter:title" content={twitterTitle} />
|
||||
{twitterDescription ? <meta head-key="twitter:description" name="twitter:description" content={twitterDescription} /> : null}
|
||||
{twitterImage ? <meta head-key="twitter:image" name="twitter:image" content={twitterImage} /> : null}
|
||||
|
||||
{schemas.map((schema, index) => {
|
||||
const schemaType = typeof schema?.['@type'] === 'string' ? schema['@type'] : 'schema'
|
||||
|
||||
return (
|
||||
<script
|
||||
key={`jsonld-${schemaType}-${index}`}
|
||||
head-key={`jsonld-${schemaType}-${index}`}
|
||||
type="application/ld+json"
|
||||
>
|
||||
{JSON.stringify(schema)}
|
||||
</script>
|
||||
)
|
||||
})}
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
146
resources/js/components/upload/ArchiveScreenshotPicker.jsx
Normal file
146
resources/js/components/upload/ArchiveScreenshotPicker.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
function getScreenshotName(item, fallbackIndex) {
|
||||
if (item && typeof item === 'object' && typeof item.name === 'string' && item.name.trim()) {
|
||||
return item.name.trim()
|
||||
}
|
||||
|
||||
return `Screenshot ${fallbackIndex + 1}`
|
||||
}
|
||||
|
||||
function resolveScreenshotSource(item) {
|
||||
if (!item) return { src: null, revoke: null }
|
||||
|
||||
if (typeof item === 'string') {
|
||||
return { src: item, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof item === 'object') {
|
||||
if (typeof item.preview === 'string' && item.preview) {
|
||||
return { src: item.preview, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof item.src === 'string' && item.src) {
|
||||
return { src: item.src, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof item.url === 'string' && item.url) {
|
||||
return { src: item.url, revoke: null }
|
||||
}
|
||||
|
||||
if (typeof File !== 'undefined' && item instanceof File) {
|
||||
const objectUrl = URL.createObjectURL(item)
|
||||
return {
|
||||
src: objectUrl,
|
||||
revoke: () => URL.revokeObjectURL(objectUrl),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { src: null, revoke: null }
|
||||
}
|
||||
|
||||
export default function ArchiveScreenshotPicker({
|
||||
screenshots = [],
|
||||
selectedIndex = 0,
|
||||
onSelect,
|
||||
compact = false,
|
||||
title = 'Screenshots',
|
||||
description = 'Choose which screenshot should be used as the default preview.',
|
||||
}) {
|
||||
const [resolvedScreenshots, setResolvedScreenshots] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = []
|
||||
const next = (Array.isArray(screenshots) ? screenshots : []).map((item, index) => {
|
||||
const { src, revoke } = resolveScreenshotSource(item)
|
||||
if (revoke) cleanup.push(revoke)
|
||||
|
||||
return {
|
||||
src,
|
||||
alt: getScreenshotName(item, index),
|
||||
}
|
||||
}).filter((item) => Boolean(item.src))
|
||||
|
||||
setResolvedScreenshots(next)
|
||||
|
||||
return () => {
|
||||
cleanup.forEach((revoke) => revoke())
|
||||
}
|
||||
}, [screenshots])
|
||||
|
||||
const normalizedIndex = useMemo(() => {
|
||||
if (resolvedScreenshots.length === 0) return 0
|
||||
if (!Number.isFinite(selectedIndex)) return 0
|
||||
return Math.min(Math.max(0, Math.floor(selectedIndex)), resolvedScreenshots.length - 1)
|
||||
}, [resolvedScreenshots.length, selectedIndex])
|
||||
|
||||
const selectedScreenshot = resolvedScreenshots[normalizedIndex] ?? null
|
||||
|
||||
if (!selectedScreenshot) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-3' : 'space-y-4'}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">{title}</p>
|
||||
<p className="mt-1 text-xs text-white/55">{description}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-emerald-300/30 bg-emerald-400/10 px-2.5 py-1 text-[11px] text-emerald-100">
|
||||
Default preview
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={compact ? 'overflow-hidden rounded-2xl border border-white/10 bg-black/25' : 'overflow-hidden rounded-3xl border border-white/10 bg-black/25'}>
|
||||
<img
|
||||
src={selectedScreenshot.src}
|
||||
alt={selectedScreenshot.alt}
|
||||
className={compact ? 'h-40 w-full object-cover' : 'h-56 w-full object-cover sm:h-72'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={compact ? 'grid grid-cols-4 gap-2' : 'grid grid-cols-2 gap-3 sm:grid-cols-4'}>
|
||||
{resolvedScreenshots.map((item, index) => {
|
||||
const isSelected = index === normalizedIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${item.src}-${index}`}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(index)}
|
||||
aria-label={`Use ${item.alt} as default screenshot`}
|
||||
className={[
|
||||
'group relative overflow-hidden rounded-2xl border text-left transition',
|
||||
isSelected
|
||||
? 'border-emerald-300/45 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.22)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
|
||||
].join(' ')}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<img
|
||||
src={item.src}
|
||||
alt={item.alt}
|
||||
className={compact ? 'h-16 w-full object-cover' : 'h-20 w-full object-cover'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
|
||||
<span className="truncate text-[11px] text-white/70">{item.alt}</span>
|
||||
<span className={[
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-[10px]',
|
||||
isSelected ? 'bg-emerald-300/20 text-emerald-100' : 'bg-white/10 text-white/45',
|
||||
].join(' ')}>
|
||||
{isSelected ? 'Default' : 'Use'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import ArchiveScreenshotPicker from './ArchiveScreenshotPicker'
|
||||
import ReadinessChecklist from './ReadinessChecklist'
|
||||
import SchedulePublishPicker from './SchedulePublishPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
@@ -36,6 +37,8 @@ export default function PublishPanel({
|
||||
primaryPreviewUrl = null,
|
||||
isArchive = false,
|
||||
screenshots = [],
|
||||
selectedScreenshotIndex = 0,
|
||||
onSelectedScreenshotChange,
|
||||
// Metadata
|
||||
metadata = {},
|
||||
// Readiness
|
||||
@@ -64,8 +67,6 @@ export default function PublishPanel({
|
||||
}) {
|
||||
const pill = STATUS_PILL[machineState] ?? null
|
||||
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
|
||||
const hasAnyPreview = hasPreview || (isArchive && screenshots.length > 0)
|
||||
const previewSrc = hasPreview ? primaryPreviewUrl : (screenshots[0]?.preview ?? screenshots[0] ?? null)
|
||||
|
||||
const title = String(metadata.title || '').trim()
|
||||
const hasTitle = Boolean(title)
|
||||
@@ -126,9 +127,9 @@ export default function PublishPanel({
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0 h-[72px] w-[72px] overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 flex items-center justify-center">
|
||||
{previewSrc ? (
|
||||
{hasPreview ? (
|
||||
<img
|
||||
src={previewSrc}
|
||||
src={primaryPreviewUrl}
|
||||
alt="Artwork preview"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
@@ -162,6 +163,19 @@ export default function PublishPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.03] p-3">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
compact
|
||||
title="Preview screenshot"
|
||||
description="Choose which screenshot should represent this archive in the publish panel."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-white/8" />
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function UploadWizard({
|
||||
// ── File + screenshot state ───────────────────────────────────────────────
|
||||
const [primaryFile, setPrimaryFile] = useState(null)
|
||||
const [screenshots, setScreenshots] = useState([])
|
||||
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0)
|
||||
|
||||
// ── Metadata state ────────────────────────────────────────────────────────
|
||||
const [metadata, setMetadata] = useState(initialMetadata)
|
||||
@@ -112,6 +113,18 @@ export default function UploadWizard({
|
||||
|
||||
const canStartUpload = isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(screenshots) || screenshots.length === 0) {
|
||||
setSelectedScreenshotIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedScreenshotIndex((prev) => {
|
||||
if (!Number.isFinite(prev) || prev < 0) return 0
|
||||
return Math.min(prev, screenshots.length - 1)
|
||||
})
|
||||
}, [screenshots])
|
||||
|
||||
// ── Machine hook ──────────────────────────────────────────────────────────
|
||||
const {
|
||||
machine,
|
||||
@@ -124,6 +137,8 @@ export default function UploadWizard({
|
||||
clearPolling,
|
||||
} = useUploadMachine({
|
||||
primaryFile,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
canStartUpload,
|
||||
primaryType,
|
||||
isArchive,
|
||||
@@ -322,6 +337,7 @@ export default function UploadWizard({
|
||||
resetMachine()
|
||||
setPrimaryFile(null)
|
||||
setScreenshots([])
|
||||
setSelectedScreenshotIndex(0)
|
||||
setMetadata(initialMetadata)
|
||||
setIsUploadLocked(false)
|
||||
hasAutoAdvancedRef.current = false
|
||||
@@ -408,9 +424,11 @@ export default function UploadWizard({
|
||||
onPrimaryFileChange={setPrimaryFile}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
screenshotErrors={screenshotErrors}
|
||||
screenshotPerFileErrors={screenshotPerFileErrors}
|
||||
onScreenshotsChange={setScreenshots}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
machine={machine}
|
||||
/>
|
||||
)
|
||||
@@ -425,6 +443,8 @@ export default function UploadWizard({
|
||||
isArchive={isArchive}
|
||||
fileMetadata={fileMetadata}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
contentTypes={contentTypes}
|
||||
metadata={metadata}
|
||||
metadataErrors={metadataErrors}
|
||||
@@ -456,6 +476,8 @@ export default function UploadWizard({
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
fileMetadata={fileMetadata}
|
||||
metadata={metadata}
|
||||
canPublish={canPublish}
|
||||
@@ -607,6 +629,8 @@ export default function UploadWizard({
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
metadata={metadata}
|
||||
machineState={machine.state}
|
||||
uploadReady={uploadReady}
|
||||
@@ -689,6 +713,8 @@ export default function UploadWizard({
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
metadata={metadata}
|
||||
machineState={machine.state}
|
||||
uploadReady={uploadReady}
|
||||
|
||||
@@ -68,6 +68,15 @@ function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (url === '/api/tags/popular' || String(url).startsWith('/api/tags/search')) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unhandled GET ${url}`))
|
||||
}),
|
||||
}
|
||||
@@ -112,6 +121,20 @@ async function completeStep1ToReady() {
|
||||
})
|
||||
}
|
||||
|
||||
async function completeRequiredDetails({ title = 'My Art', mature = false } = {}) {
|
||||
await act(async () => {
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), title)
|
||||
await userEvent.click(screen.getByRole('button', { name: /art .* open/i }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: /root .* choose/i }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: /sub .* choose/i }))
|
||||
await userEvent.type(screen.getByLabelText(/search or add tags/i), 'fantasy{enter}')
|
||||
if (mature) {
|
||||
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
|
||||
}
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
})
|
||||
}
|
||||
|
||||
describe('UploadWizard step flow', () => {
|
||||
let originalImage
|
||||
let originalScrollTo
|
||||
@@ -216,6 +239,43 @@ describe('UploadWizard step flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the selected archive screenshot as the preview upload source', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 312 })
|
||||
|
||||
await uploadPrimary(new File(['zip'], 'bundle.zip', { type: 'application/zip' }))
|
||||
await uploadScreenshot(new File(['shot-1'], 'shot-1.png', { type: 'image/png' }))
|
||||
await uploadScreenshot(new File(['shot-2'], 'shot-2.png', { type: 'image/png' }))
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /use shot-2\.png as default screenshot/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Default').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.post).toHaveBeenCalledWith(
|
||||
'/api/uploads/finish',
|
||||
expect.objectContaining({
|
||||
file_name: 'shot-2.png',
|
||||
archive_file_name: 'bundle.zip',
|
||||
additional_screenshot_sessions: [
|
||||
expect.objectContaining({
|
||||
file_name: 'shot-1.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('allows navigation back to completed previous step', async () => {
|
||||
installAxiosStubs({ statusValue: 'ready' })
|
||||
await renderWizard({ initialDraftId: 304 })
|
||||
@@ -227,7 +287,7 @@ describe('UploadWizard step flow', () => {
|
||||
await act(async () => {
|
||||
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
|
||||
})
|
||||
expect(await screen.findByText(/upload your artwork file/i)).not.toBeNull()
|
||||
expect(await screen.findByRole('heading', { level: 2, name: /upload your artwork/i })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('triggers scroll-to-top behavior on step change', async () => {
|
||||
@@ -253,11 +313,9 @@ describe('UploadWizard step flow', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
|
||||
|
||||
await completeRequiredDetails({ title: 'My Art' })
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'My Art')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
|
||||
})
|
||||
|
||||
@@ -284,14 +342,10 @@ describe('UploadWizard step flow', () => {
|
||||
await completeStep1ToReady()
|
||||
|
||||
await screen.findByText(/artwork details/i)
|
||||
const titleInput = screen.getByPlaceholderText(/give your artwork a clear title/i)
|
||||
|
||||
await completeRequiredDetails({ title: 'Mature Piece', mature: true })
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(titleInput, 'Mature Piece')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
|
||||
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
|
||||
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
|
||||
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
|
||||
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
|
||||
})
|
||||
|
||||
@@ -361,7 +415,7 @@ describe('UploadWizard step flow', () => {
|
||||
const dropzoneButton = screen.getByTestId('upload-dropzone')
|
||||
expect(dropzoneButton.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
expect(screen.getByText(/file is locked after upload\. reset to change\./i)).not.toBeNull()
|
||||
expect(screen.getByText(/file is locked after upload starts\. reset to change the file\./i)).not.toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /reset upload/i }))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
import UploadDropzone from '../UploadDropzone'
|
||||
import ScreenshotUploader from '../ScreenshotUploader'
|
||||
|
||||
@@ -22,9 +23,11 @@ export default function Step1FileUpload({
|
||||
// Archive screenshots
|
||||
isArchive,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
screenshotErrors,
|
||||
screenshotPerFileErrors,
|
||||
onScreenshotsChange,
|
||||
onSelectedScreenshotChange,
|
||||
// Machine state (passed for potential future use)
|
||||
machine,
|
||||
}) {
|
||||
@@ -95,6 +98,19 @@ export default function Step1FileUpload({
|
||||
onFilesChange={onScreenshotsChange}
|
||||
/>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 sm:p-5">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
compact
|
||||
title="Choose default screenshot"
|
||||
description="Pick the screenshot that should be uploaded as the archive preview before you start the upload."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Subtle what-happens-next hints (shown only before a file is picked) */}
|
||||
{!fileSelected && (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
import UploadSidebar from '../UploadSidebar'
|
||||
import { getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils'
|
||||
|
||||
@@ -17,6 +18,8 @@ export default function Step2Details({
|
||||
isArchive,
|
||||
fileMetadata,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
onSelectedScreenshotChange,
|
||||
// Content type + category
|
||||
contentTypes,
|
||||
metadata,
|
||||
@@ -167,6 +170,18 @@ export default function Step2Details({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/8 pt-5">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
title="Archive screenshots"
|
||||
description="All selected screenshots are shown here. Pick the one that should become the main preview thumbnail."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Combined: Content type → Category → Subcategory ─────────────────── */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { motion, useReducedMotion } from 'framer-motion'
|
||||
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
|
||||
|
||||
function stripHtml(value) {
|
||||
return String(value || '')
|
||||
@@ -45,6 +46,8 @@ export default function Step3Publish({
|
||||
primaryPreviewUrl,
|
||||
isArchive,
|
||||
screenshots,
|
||||
selectedScreenshotIndex,
|
||||
onSelectedScreenshotChange,
|
||||
fileMetadata,
|
||||
// Metadata
|
||||
metadata,
|
||||
@@ -161,6 +164,18 @@ export default function Step3Publish({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isArchive && screenshots.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/8 pt-5">
|
||||
<ArchiveScreenshotPicker
|
||||
screenshots={screenshots}
|
||||
selectedIndex={selectedScreenshotIndex}
|
||||
onSelect={onSelectedScreenshotChange}
|
||||
title="Archive preview"
|
||||
description="This screenshot will be used as the default preview once the archive is published."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Visibility selector ────────────────────────────────────────── */}
|
||||
|
||||
Reference in New Issue
Block a user