optimizations
This commit is contained in:
255
resources/js/components/nova-cards/NovaCardPresetPicker.jsx
Normal file
255
resources/js/components/nova-cards/NovaCardPresetPicker.jsx
Normal file
@@ -0,0 +1,255 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user