Files
SkinbaseNova/resources/js/components/nova-cards/NovaCardPresetPicker.jsx
2026-03-28 19:15:39 +01:00

256 lines
9.1 KiB
JavaScript

import React from 'react'
const TYPE_LABELS = {
style: 'Style',
layout: 'Layout',
background: 'Background',
typography: 'Typography',
starter: 'Starter',
}
const TYPE_ICONS = {
style: 'fa-palette',
layout: 'fa-table-columns',
background: 'fa-image',
typography: 'fa-font',
starter: 'fa-star',
}
function PresetCard({ preset, onApply, onDelete, applying }) {
return (
<div className="group relative flex items-center gap-3 rounded-[18px] border border-white/10 bg-white/[0.03] px-3.5 py-3 transition hover:border-white/20 hover:bg-white/[0.05]">
<button
type="button"
disabled={applying}
onClick={() => onApply(preset)}
className="flex flex-1 items-center gap-3 text-left"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.06] text-sky-300 text-xs">
<i className={`fa-solid ${TYPE_ICONS[preset.preset_type] || 'fa-sparkles'}`} />
</span>
<span className="flex-1 min-w-0">
<span className="block truncate text-sm font-semibold text-white">{preset.name}</span>
<span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
{TYPE_LABELS[preset.preset_type] || preset.preset_type}
{preset.is_default ? ' · Default' : ''}
</span>
</span>
{applying ? (
<i className="fa-solid fa-rotate fa-spin text-sky-300 text-xs" />
) : (
<i className="fa-solid fa-chevron-right text-slate-500 text-xs opacity-0 transition group-hover:opacity-100" />
)}
</button>
<button
type="button"
onClick={() => onDelete(preset)}
className="ml-1 rounded-full p-1.5 text-slate-500 opacity-0 transition hover:text-rose-400 group-hover:opacity-100"
title="Delete preset"
>
<i className="fa-solid fa-trash-can text-xs" />
</button>
</div>
)
}
export default function NovaCardPresetPicker({
presets = {},
endpoints = {},
cardId = null,
onApplyPatch,
onPresetsChange,
activeType = null,
}) {
const [selectedType, setSelectedType] = React.useState(activeType || 'style')
const [applyingId, setApplyingId] = React.useState(null)
const [capturing, setCapturing] = React.useState(false)
const [captureName, setCaptureName] = React.useState('')
const [captureType, setCaptureType] = React.useState('style')
const [showCaptureForm, setShowCaptureForm] = React.useState(false)
const [error, setError] = React.useState(null)
const typeKeys = Object.keys(TYPE_LABELS)
const listedPresets = Array.isArray(presets[selectedType]) ? presets[selectedType] : []
async function handleApply(preset) {
if (!cardId || !endpoints.presetApplyPattern) return
const url = endpoints.presetApplyPattern
.replace('__PRESET__', preset.id)
.replace('__CARD__', cardId)
setApplyingId(preset.id)
setError(null)
try {
const res = await fetch(url, {
method: 'GET',
headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' },
})
const data = await res.json()
if (!res.ok) throw new Error(data?.message || 'Failed to apply preset')
if (data?.project_patch && onApplyPatch) {
onApplyPatch(data.project_patch)
}
} catch (err) {
setError(err.message || 'Failed to apply preset')
} finally {
setApplyingId(null)
}
}
async function handleDelete(preset) {
if (!endpoints.presetDestroyPattern) return
const url = endpoints.presetDestroyPattern.replace('__PRESET__', preset.id)
if (!window.confirm(`Delete preset "${preset.name}"?`)) return
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const res = await fetch(url, {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
})
if (!res.ok) throw new Error('Failed to delete preset')
onPresetsChange?.()
} catch (err) {
setError(err.message || 'Failed to delete preset')
}
}
async function handleCapture(e) {
e.preventDefault()
if (!cardId || !endpoints.capturePresetPattern || !captureName.trim()) return
const url = endpoints.capturePresetPattern.replace('__CARD__', cardId)
setCapturing(true)
setError(null)
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({ name: captureName.trim(), preset_type: captureType }),
})
const data = await res.json()
if (!res.ok) throw new Error(data?.message || 'Failed to capture preset')
setCaptureName('')
setShowCaptureForm(false)
onPresetsChange?.()
} catch (err) {
setError(err.message || 'Failed to capture preset')
} finally {
setCapturing(false)
}
}
return (
<div className="flex flex-col gap-4">
{/* Type tabs */}
<div className="flex flex-wrap gap-2">
{typeKeys.map((type) => (
<button
key={type}
type="button"
onClick={() => setSelectedType(type)}
className={`rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] transition ${
selectedType === type
? 'border-sky-300/30 bg-sky-400/15 text-sky-100'
: 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.05]'
}`}
>
<i className={`fa-solid ${TYPE_ICONS[type]} mr-1.5`} />
{TYPE_LABELS[type]}
{Array.isArray(presets[type]) && presets[type].length > 0 && (
<span className="ml-1.5 rounded-full bg-white/10 px-1.5 text-[10px]">{presets[type].length}</span>
)}
</button>
))}
</div>
{/* Preset list */}
{listedPresets.length > 0 ? (
<div className="flex flex-col gap-2">
{listedPresets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
applying={applyingId === preset.id}
onApply={handleApply}
onDelete={handleDelete}
/>
))}
</div>
) : (
<p className="text-sm text-slate-500">No {TYPE_LABELS[selectedType]?.toLowerCase()} presets saved yet.</p>
)}
{/* Capture from current card */}
{cardId && endpoints.capturePresetPattern && (
<div className="mt-1 border-t border-white/[0.06] pt-3">
{showCaptureForm ? (
<form onSubmit={handleCapture} className="flex flex-col gap-2">
<input
type="text"
value={captureName}
onChange={(e) => setCaptureName(e.target.value)}
placeholder="Preset name…"
maxLength={64}
required
className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-slate-500 outline-none focus:border-sky-400/40"
/>
<select
value={captureType}
onChange={(e) => setCaptureType(e.target.value)}
className="w-full rounded-xl border border-white/10 bg-slate-900 px-3 py-2 text-sm text-white outline-none focus:border-sky-400/40"
>
{typeKeys.map((type) => (
<option key={type} value={type}>{TYPE_LABELS[type]}</option>
))}
</select>
<div className="flex gap-2">
<button
type="submit"
disabled={capturing || !captureName.trim()}
className="flex-1 rounded-xl bg-sky-500/20 py-2 text-sm font-semibold text-sky-200 transition hover:bg-sky-500/30 disabled:opacity-50"
>
{capturing ? 'Saving…' : 'Save preset'}
</button>
<button
type="button"
onClick={() => setShowCaptureForm(false)}
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-slate-400 transition hover:bg-white/[0.05]"
>
Cancel
</button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setShowCaptureForm(true)}
className="flex w-full items-center gap-2 rounded-xl border border-dashed border-white/15 px-3 py-2.5 text-sm text-slate-400 transition hover:border-white/25 hover:text-slate-200"
>
<i className="fa-solid fa-plus text-xs" />
Capture current as preset
</button>
)}
</div>
)}
{error && (
<p className="rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs text-rose-300">{error}</p>
)}
</div>
)
}