optimizations
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
const styles = {
|
||||
idle: 'border-white/10 bg-white/[0.04] text-slate-300',
|
||||
saving: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
saved: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
error: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
||||
}
|
||||
|
||||
export default function NovaCardAutosaveIndicator({ status = 'idle', message = '' }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] ${styles[status] || styles.idle}`}>
|
||||
<i className={`fa-solid ${status === 'saving' ? 'fa-rotate fa-spin' : status === 'saved' ? 'fa-check' : status === 'error' ? 'fa-triangle-exclamation' : 'fa-cloud'}`} />
|
||||
{message || (status === 'saving' ? 'Saving' : status === 'saved' ? 'Saved' : status === 'error' ? 'Failed' : 'Draft')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
187
resources/js/components/nova-cards/NovaCardCanvasPreview.jsx
Normal file
187
resources/js/components/nova-cards/NovaCardCanvasPreview.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React from 'react'
|
||||
|
||||
const aspectRatios = {
|
||||
square: '1 / 1',
|
||||
portrait: '4 / 5',
|
||||
story: '9 / 16',
|
||||
landscape: '16 / 9',
|
||||
}
|
||||
|
||||
const placementStyles = {
|
||||
'top-left': { top: '12%', left: '12%' },
|
||||
'top-right': { top: '12%', right: '12%' },
|
||||
'bottom-left': { bottom: '12%', left: '12%' },
|
||||
'bottom-right': { bottom: '12%', right: '12%' },
|
||||
}
|
||||
|
||||
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))'
|
||||
if (style === 'dark-soft') return 'linear-gradient(180deg, rgba(2,6,23,0.18), rgba(2,6,23,0.48))'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function positionStyle(position) {
|
||||
if (position === 'top') return { alignItems: 'flex-start', paddingTop: '10%' }
|
||||
if (position === 'upper-middle') return { alignItems: 'flex-start', paddingTop: '22%' }
|
||||
if (position === 'lower-middle') return { alignItems: 'flex-end', paddingBottom: '18%' }
|
||||
if (position === 'bottom') return { alignItems: 'flex-end', paddingBottom: '10%' }
|
||||
return { alignItems: 'center' }
|
||||
}
|
||||
|
||||
function alignmentClass(alignment) {
|
||||
if (alignment === 'left') return 'items-start text-left'
|
||||
if (alignment === 'right') return 'items-end text-right'
|
||||
return 'items-center text-center'
|
||||
}
|
||||
|
||||
function focalPositionStyle(position) {
|
||||
if (position === 'top') return 'center top'
|
||||
if (position === 'bottom') return 'center bottom'
|
||||
if (position === 'left') return 'left center'
|
||||
if (position === 'right') return 'right center'
|
||||
if (position === 'top-left') return 'left top'
|
||||
if (position === 'top-right') return 'right top'
|
||||
if (position === 'bottom-left') return 'left bottom'
|
||||
if (position === 'bottom-right') return 'right bottom'
|
||||
return 'center center'
|
||||
}
|
||||
|
||||
function shadowValue(preset) {
|
||||
if (preset === 'none') return 'none'
|
||||
if (preset === 'strong') return '0 12px 36px rgba(2, 6, 23, 0.72)'
|
||||
return '0 8px 24px rgba(2, 6, 23, 0.5)'
|
||||
}
|
||||
|
||||
function resolveTextBlocks(card, project) {
|
||||
const blocks = Array.isArray(project.text_blocks) ? project.text_blocks.filter((block) => block?.enabled !== false && String(block?.text || '').trim() !== '') : []
|
||||
if (blocks.length) return blocks
|
||||
|
||||
const content = project.content || {}
|
||||
|
||||
return [
|
||||
{ key: 'title', type: 'title', text: card?.title || content.title || 'Untitled card', enabled: true },
|
||||
{ key: 'quote', type: 'quote', text: card?.quote_text || content.quote_text || 'Your next quote starts here.', enabled: true },
|
||||
{ key: 'author', type: 'author', text: card?.quote_author || content.quote_author || '', enabled: Boolean(card?.quote_author || content.quote_author) },
|
||||
{ key: 'source', type: 'source', text: card?.quote_source || content.quote_source || '', enabled: Boolean(card?.quote_source || content.quote_source) },
|
||||
].filter((block) => String(block.text || '').trim() !== '')
|
||||
}
|
||||
|
||||
function blockClass(type) {
|
||||
if (type === 'title') return 'text-[11px] font-semibold uppercase tracking-[0.28em] text-white/55'
|
||||
if (type === 'author') return 'font-medium uppercase tracking-[0.18em] text-white/80 sm:text-sm lg:text-base'
|
||||
if (type === 'source') return 'text-[11px] uppercase tracking-[0.18em] text-white/50 sm:text-xs'
|
||||
if (type === 'caption') return 'text-[10px] uppercase tracking-[0.2em] text-white/45'
|
||||
if (type === 'body') return 'text-sm leading-6 text-white/90 sm:text-base'
|
||||
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))
|
||||
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'
|
||||
|
||||
if (type === 'title') {
|
||||
return { color: accentColor, 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) }
|
||||
}
|
||||
|
||||
if (type === 'body' || type === 'caption') {
|
||||
return { color: textColor, lineHeight, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
return { color: textColor, fontSize: `${quoteSize / 4}px`, lineHeight, letterSpacing: `${letterSpacing / 12}px`, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
const project = card?.project_json || {}
|
||||
const layout = project.layout || {}
|
||||
const typography = project.typography || {}
|
||||
const background = project.background || {}
|
||||
const backgroundImage = card?.background_image?.processed_url
|
||||
const colors = Array.isArray(background.gradient_colors) && background.gradient_colors.length >= 2
|
||||
? background.gradient_colors
|
||||
: ['#0f172a', '#1d4ed8']
|
||||
const backgroundStyle = background.type === 'upload' && backgroundImage
|
||||
? `url(${backgroundImage}) ${focalPositionStyle(background.focal_position)}/cover no-repeat`
|
||||
: background.type === 'solid'
|
||||
? background.solid_color || '#111827'
|
||||
: `linear-gradient(180deg, ${colors[0]}, ${colors[1]})`
|
||||
|
||||
const textBlocks = resolveTextBlocks(card, project)
|
||||
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 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 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>
|
||||
)
|
||||
})}
|
||||
|
||||
{assetItems.slice(0, 6).map((item, index) => {
|
||||
if (item?.type === 'frame') {
|
||||
const top = index % 2 === 0 ? '10%' : '88%'
|
||||
return <div key={`${item.asset_key || item.label || 'frame'}-${index}`} className="absolute left-[12%] right-[12%] h-px bg-white/35" style={{ top }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.asset_key || item.label || 'asset'}-${index}`}
|
||||
className="absolute text-white/85 drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
style={{
|
||||
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`,
|
||||
}}
|
||||
>
|
||||
{item.glyph || item.label || '✦'}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<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) => {
|
||||
const type = block?.type || 'body'
|
||||
const text = type === 'author' ? `— ${block.text}` : block.text
|
||||
|
||||
return (
|
||||
<div key={`${block.key || type}-${index}`} style={blockStyle(type, typography, textColor, accentColor)} className={blockClass(type)}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
resources/js/components/nova-cards/NovaCardFontPicker.jsx
Normal file
22
resources/js/components/nova-cards/NovaCardFontPicker.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function NovaCardFontPicker({ fonts = [], selectedKey = null, onSelect }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{fonts.map((font) => {
|
||||
const active = selectedKey === font.key
|
||||
return (
|
||||
<button
|
||||
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]'}`}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function NovaCardGradientPicker({ gradients = [], selectedKey = null, onSelect }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{gradients.map((gradient) => {
|
||||
const active = selectedKey === gradient.key
|
||||
return (
|
||||
<button
|
||||
key={gradient.key}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(gradient)}
|
||||
className={`overflow-hidden rounded-[22px] border text-left transition ${active ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}
|
||||
>
|
||||
<div className="h-20 w-full" style={{ background: `linear-gradient(135deg, ${gradient.colors?.[0] || '#0f172a'}, ${gradient.colors?.[1] || '#1d4ed8'})` }} />
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-semibold text-white">{gradient.label}</div>
|
||||
<div className="mt-1 text-[11px] uppercase tracking-[0.18em] text-slate-400">{gradient.key}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function NovaCardTemplatePicker({ templates = [], selectedId = null, onSelect }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{templates.map((template) => {
|
||||
const active = Number(selectedId) === Number(template.id)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(template)}
|
||||
className={`rounded-[24px] 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]'}`}
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">Template</div>
|
||||
<div className="mt-2 text-base font-semibold tracking-[-0.03em]">{template.name}</div>
|
||||
{template.description ? <div className="mt-2 text-sm text-slate-400">{template.description}</div> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{(template.supported_formats || []).map((format) => (
|
||||
<span key={`${template.id}-${format}`} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">
|
||||
{format}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user