Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render

This commit is contained in:
2026-06-04 07:52:57 +02:00
parent 0b33a1b074
commit 15870ddb1f
191 changed files with 15453 additions and 1786 deletions

View File

@@ -0,0 +1,172 @@
import React from 'react'
import { Head, Link, useForm, usePage } from '@inertiajs/react'
import EnhanceStubWarning from '../../components/enhance/EnhanceStubWarning'
export default function EnhanceCreate() {
const { props } = usePage()
const form = useForm({ image: null, scale: props.options?.scales?.[0]?.value || 2, mode: props.options?.modes?.[0]?.value || 'standard' })
const [previewUrl, setPreviewUrl] = React.useState(null)
const [sourceType, setSourceType] = React.useState(props.selectedArtwork ? 'artwork' : 'upload')
React.useEffect(() => () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl)
}
}, [previewUrl])
function handleFileChange(event) {
const file = event.target.files?.[0] || null
form.setData('image', file)
if (previewUrl) {
URL.revokeObjectURL(previewUrl)
}
setPreviewUrl(file ? URL.createObjectURL(file) : null)
}
function submit(event) {
event.preventDefault()
const action = sourceType === 'artwork' && props.selectedArtwork?.store_url
? props.selectedArtwork.store_url
: props.storeUrl
form.post(action, {
forceFormData: sourceType !== 'artwork',
preserveScroll: true,
})
}
return (
<div className="w-full pb-16 pt-8">
<Head title="Skinbase Enhance" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Skinbase Enhance</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Create an upscaled image</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Large images may take longer to process. The original image will stay unchanged.</p>
</div>
<Link href={props.indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
<i className="fa-solid fa-arrow-left text-[10px]" />
Back to jobs
</Link>
</div>
</section>
<EnhanceStubWarning config={props.enhanceConfig} className="mt-6" />
<form onSubmit={submit} className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.05fr)_380px]">
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Enhance source</div>
{props.selectedArtwork ? (
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.04] p-1 text-xs font-semibold text-slate-300">
<button
type="button"
onClick={() => setSourceType('artwork')}
className={`rounded-full px-4 py-2 transition ${sourceType === 'artwork' ? 'bg-sky-400/15 text-sky-50' : 'hover:bg-white/[0.06]'}`}
>
Existing artwork
</button>
<button
type="button"
onClick={() => setSourceType('upload')}
className={`rounded-full px-4 py-2 transition ${sourceType === 'upload' ? 'bg-sky-400/15 text-sky-50' : 'hover:bg-white/[0.06]'}`}
>
Upload image
</button>
</div>
) : null}
</div>
{sourceType === 'artwork' && props.selectedArtwork ? (
<div className="mt-4 rounded-[28px] border border-sky-300/20 bg-[linear-gradient(180deg,rgba(14,165,233,0.1),rgba(8,17,29,0.9))] p-6 text-left">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Existing artwork source</div>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{props.selectedArtwork.title}</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-300">Use the current artwork source without re-uploading a file. The original artwork remains untouched and the enhanced result will be stored as a separate job output.</p>
<div className="mt-5 flex flex-wrap gap-3">
<a href={props.selectedArtwork.show_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-image text-[10px]" />
View artwork
</a>
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100">
<i className="fa-solid fa-lock text-[10px]" />
Original stays unchanged
</span>
</div>
{form.errors.source ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{form.errors.source}</div> : null}
</div>
) : (
<>
<label className="mt-4 flex min-h-[420px] cursor-pointer flex-col items-center justify-center rounded-[28px] border border-dashed border-white/15 bg-black/20 px-6 py-8 text-center transition hover:border-sky-300/30 hover:bg-sky-400/[0.03]">
{previewUrl ? <img src={previewUrl} alt="Selected for enhance" className="max-h-[420px] w-full rounded-[20px] object-contain" /> : <>
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-white/10 bg-white/[0.05] text-white/70"><i className="fa-solid fa-cloud-arrow-up text-2xl" /></div>
<div className="mt-4 text-lg font-semibold text-white">Choose a JPEG, PNG, or WebP image</div>
<div className="mt-2 max-w-md text-sm text-slate-400">Upload an image up to {props.maxUploadMb} MB. SVG, GIF, and unsupported file types are rejected.</div>
</>}
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={handleFileChange} className="hidden" />
</label>
{form.errors.image ? <div className="mt-3 text-sm text-rose-300">{form.errors.image}</div> : null}
</>
)}
</section>
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Enhance settings</div>
<div className="mt-5 space-y-5">
<div>
<label className="text-sm font-semibold text-white">Scale</label>
<div className="mt-3 grid grid-cols-2 gap-3">
{(props.options?.scales || []).map((option) => (
<button
key={option.value}
type="button"
onClick={() => form.setData('scale', option.value)}
className={`rounded-2xl border px-4 py-4 text-left transition ${Number(form.data.scale) === Number(option.value) ? 'border-sky-300/30 bg-sky-400/12 text-sky-50' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.08]'}`}
>
<div className="text-sm font-semibold">{option.label}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-current/70">Upscale size</div>
</button>
))}
</div>
{form.errors.scale ? <div className="mt-2 text-sm text-rose-300">{form.errors.scale}</div> : null}
</div>
<div>
<label className="text-sm font-semibold text-white">Mode</label>
<div className="mt-3 space-y-3">
{(props.options?.modes || []).map((option) => (
<button
key={option.value}
type="button"
onClick={() => form.setData('mode', option.value)}
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${String(form.data.mode) === String(option.value) ? 'border-sky-300/30 bg-sky-400/12 text-sky-50' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.08]'}`}
>
<div className="text-sm font-semibold">{option.label}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-current/70">Optimized preset</div>
</button>
))}
</div>
{form.errors.mode ? <div className="mt-2 text-sm text-rose-300">{form.errors.mode}</div> : null}
</div>
</div>
<div className="mt-6 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-4 text-sm leading-6 text-amber-50">
The original file is preserved separately. Completed outputs can be reviewed and downloaded before you decide how to use them.
</div>
<button type="submit" disabled={form.processing} className="mt-6 inline-flex w-full items-center justify-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-3 text-sm font-semibold text-sky-50 transition hover:bg-sky-400/20 disabled:cursor-not-allowed disabled:opacity-60">
<i className="fa-solid fa-wand-magic-sparkles text-xs" />
{form.processing ? 'Starting enhance…' : sourceType === 'artwork' ? 'Enhance artwork image' : 'Start enhance'}
</button>
</section>
</form>
</div>
)
}

View File

@@ -0,0 +1,135 @@
import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
import EnhanceStatusBadge from '../../components/enhance/EnhanceStatusBadge'
import EnhanceStubWarning from '../../components/enhance/EnhanceStubWarning'
import { formatEnhanceDate, formatEnhanceInteger } from '../../utils/enhanceFormatting'
function formatDate(value) {
return formatEnhanceDate(value)
}
function JobCard({ job }) {
return (
<article className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<div className="aspect-square bg-black/30">
{job.preview_url || job.source_url ? <img src={job.preview_url || job.source_url} alt="Enhance preview" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-white/20"><i className="fa-solid fa-image text-4xl" /></div>}
</div>
<div className="p-5">
<div className="flex flex-wrap items-center gap-2">
<EnhanceStatusBadge status={job.status} />
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.scale}x</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.mode}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.engine}</span>
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-white">
{job.artwork?.title ? `Artwork enhance: ${job.artwork.title}` : `Enhance job #${job.id}`}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-300">Created {formatDate(job.created_at)} {job.processing_seconds ? `${job.processing_seconds}s processing` : ''}</p>
<div className="mt-2 text-sm text-slate-400">{job.input_width} × {job.input_height}{job.output_width && job.output_height ? `${job.output_width} × ${job.output_height}` : ''}</div>
{job.error_message ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{job.error_message}</div> : null}
<div className="mt-5 flex flex-wrap gap-2">
<Link href={job.show_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Open job</Link>
{job.artwork?.url ? <a href={job.artwork.url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Open artwork</a> : null}
</div>
</div>
</div>
</article>
)
}
export default function EnhanceIndex() {
const { props } = usePage()
const jobs = props.jobs?.data || []
const latestCompleted = props.latestCompleted || []
const flash = props.flash || {}
const enhanceConfig = props.enhanceConfig || {}
return (
<div className="w-full pb-16 pt-8">
<Head title="Skinbase Enhance" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Skinbase Enhance</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Image Upscaler</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Improve older wallpapers, digital art, and photos with a clean upscaled version. Your original file is never replaced automatically.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={props.createUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20">
<i className="fa-solid fa-sparkles text-[10px]" />
Start enhance
</Link>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Daily limit</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{formatEnhanceInteger(props.dailyLimit || 0)}</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Total jobs</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{formatEnhanceInteger(props.jobs?.total || jobs.length)}</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Completed outputs</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{formatEnhanceInteger(latestCompleted.length)}</div>
</div>
</div>
</section>
<EnhanceStubWarning config={enhanceConfig} className="mt-6" />
{flash.success ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{flash.success}</div> : null}
{flash.error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
{latestCompleted.length > 0 ? (
<section className="mt-8">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Latest completed</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Recent enhanced outputs</h2>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{latestCompleted.map((job) => (
<Link key={job.id} href={job.show_url} className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] transition hover:border-sky-300/30 hover:bg-[#0b1524]">
<div className="aspect-square bg-black/20">
{job.output_url ? <img src={job.output_url} alt={`Enhance job ${job.id}`} className="h-full w-full object-cover" /> : null}
</div>
<div className="p-4">
<EnhanceStatusBadge status={job.status} />
<div className="mt-3 text-sm font-semibold text-white">Job #{job.id}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{job.scale}x {job.mode}</div>
</div>
</Link>
))}
</div>
</section>
) : null}
<section className="mt-8">
<div className="mb-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">History</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Your enhance jobs</h2>
</div>
{jobs.length === 0 ? (
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">No enhance jobs yet. Upload an image to start your first upscale.</div>
) : (
<div className="space-y-4">
{jobs.map((job) => <JobCard key={job.id} job={job} />)}
</div>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,144 @@
import React from 'react'
import { Head, Link, router, usePage } from '@inertiajs/react'
import BeforeAfterSlider from '../../components/enhance/BeforeAfterSlider'
import EnhanceStatusBadge from '../../components/enhance/EnhanceStatusBadge'
import EnhanceStubWarning from '../../components/enhance/EnhanceStubWarning'
import { formatEnhanceDate } from '../../utils/enhanceFormatting'
function formatDate(value) {
return formatEnhanceDate(value)
}
function DetailRow({ label, value }) {
return (
<div className="flex items-start justify-between gap-4 border-b border-white/[0.06] py-3 last:border-b-0 last:pb-0">
<dt className="text-sm text-slate-400">{label}</dt>
<dd className="text-right text-sm text-white">{value}</dd>
</div>
)
}
export default function EnhanceShow() {
const { props } = usePage()
const job = props.job || {}
const flash = props.flash || {}
const errors = props.errors || {}
const statusKey = String(job.status || '').toLowerCase()
const statusCopy = {
pending: 'Waiting to be queued.',
queued: 'Waiting for processor.',
processing: 'Enhancing image.',
completed: 'Enhanced image ready.',
failed: 'Enhancement failed.',
cancelled: 'Cancelled.',
expired: 'Enhanced output expired and cleaned files were removed.',
}[statusKey] || 'Unknown status.'
React.useEffect(() => {
if (!['pending', 'queued', 'processing'].includes(statusKey)) {
return undefined
}
const timer = window.setTimeout(() => {
router.reload({ only: ['job', 'flash'], preserveScroll: true })
}, 8000)
return () => window.clearTimeout(timer)
}, [statusKey])
const canCompare = Boolean(job.source_url && job.output_url && job.status === 'completed')
return (
<div className="w-full pb-16 pt-8">
<Head title={`Enhance Job #${job.id || ''}`} />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<EnhanceStatusBadge status={job.status} />
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.scale}x</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.mode}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.engine}</span>
</div>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Enhance job #{job.id}</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">{statusCopy}</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={props.indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
<i className="fa-solid fa-arrow-left text-[10px]" />
Back to jobs
</Link>
<Link href={props.createUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20">
<i className="fa-solid fa-plus text-[10px]" />
New enhance
</Link>
{job.download_url ? <a href={job.download_url} className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-50 transition hover:bg-emerald-400/20">Download enhanced</a> : null}
{job.can_retry ? <button type="button" onClick={() => router.post(job.retry_url, {}, { preserveScroll: true })} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-amber-50 transition hover:bg-amber-400/20">Retry</button> : null}
{job.can_delete ? <button type="button" onClick={() => {
if (!window.confirm('Delete this enhance job and its generated files?')) return
router.delete(job.delete_url)
}} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/20">Delete</button> : null}
</div>
</div>
</section>
<EnhanceStubWarning config={props.enhanceConfig} className="mt-6" />
{flash.success ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{flash.success}</div> : null}
{flash.error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
{errors.job ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{errors.job}</div> : null}
{job.error_message ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{job.error_message}</div> : null}
{canCompare ? <div className="mt-8"><BeforeAfterSlider beforeUrl={job.source_url} afterUrl={job.output_url} /></div> : null}
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
<div className="space-y-6">
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="grid gap-6 lg:grid-cols-2">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Original source</div>
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
{job.source_url ? <img src={job.source_url} alt="Original source" className="w-full object-cover" /> : <div className="flex min-h-[280px] items-center justify-center text-white/20"><i className="fa-solid fa-image text-4xl" /></div>}
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Enhanced result</div>
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
{job.output_url ? <img src={job.output_url} alt="Enhanced output" className="w-full object-cover" /> : <div className="flex min-h-[280px] items-center justify-center text-white/20"><i className="fa-solid fa-hourglass-half text-4xl" /></div>}
</div>
</div>
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Metadata</div>
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
<pre className="overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(job.metadata || {}, null, 2)}</pre>
</div>
</section>
</div>
<aside className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Job details</div>
<dl className="mt-4">
<DetailRow label="Created" value={formatDate(job.created_at)} />
<DetailRow label="Queued" value={formatDate(job.queued_at)} />
<DetailRow label="Started" value={formatDate(job.started_at)} />
<DetailRow label="Finished" value={formatDate(job.finished_at)} />
<DetailRow label="Expires" value={formatDate(job.expires_at)} />
<DetailRow label="Input size" value={job.input_filesize ? `${(job.input_filesize / 1024 / 1024).toFixed(2)} MB` : '—'} />
<DetailRow label="Input mime" value={job.input_mime || '—'} />
<DetailRow label="Input dimensions" value={job.input_width && job.input_height ? `${job.input_width} × ${job.input_height}` : '—'} />
<DetailRow label="Output size" value={job.output_filesize ? `${(job.output_filesize / 1024 / 1024).toFixed(2)} MB` : '—'} />
<DetailRow label="Output mime" value={job.output_mime || '—'} />
<DetailRow label="Output dimensions" value={job.output_width && job.output_height ? `${job.output_width} × ${job.output_height}` : '—'} />
<DetailRow label="Processing seconds" value={job.processing_seconds ?? '—'} />
<DetailRow label="Artwork" value={job.artwork?.title ? <a href={job.artwork.url} className="text-sky-300 hover:text-sky-200">{job.artwork.title}</a> : 'Standalone upload'} />
</dl>
</aside>
</div>
</div>
)
}