import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaCardCanvasPreview from '../../components/nova-cards/NovaCardCanvasPreview'
import NovaCardTemplatePicker from '../../components/nova-cards/NovaCardTemplatePicker'
import NovaCardGradientPicker from '../../components/nova-cards/NovaCardGradientPicker'
import NovaCardFontPicker from '../../components/nova-cards/NovaCardFontPicker'
import NovaCardAutosaveIndicator from '../../components/nova-cards/NovaCardAutosaveIndicator'
import NovaCardPresetPicker from '../../components/nova-cards/NovaCardPresetPicker'
const defaultMobileSteps = [
{ key: 'format', label: 'Format', description: 'Choose the canvas shape and basic direction.' },
{ key: 'background', label: 'Template & Background', description: 'Pick the visual foundation for the card.' },
{ key: 'content', label: 'Text', description: 'Write the quote, author, and source.' },
{ key: 'style', label: 'Style', description: 'Fine-tune typography and layout.' },
{ key: 'preview', label: 'Preview', description: 'Check the live composition before publish.' },
{ key: 'publish', label: 'Publish', description: 'Review metadata and release settings.' },
]
const overlayOptions = [
{ value: 'none', label: 'None' },
{ value: 'dark-soft', label: 'Dark Soft' },
{ value: 'dark-strong', label: 'Dark Strong' },
{ value: 'light-soft', label: 'Light Soft' },
]
const layoutPresetMap = {
quote_heavy: { alignment: 'center', position: 'center', padding: 'comfortable', max_width: 'balanced' },
author_emphasis: { alignment: 'left', position: 'lower-middle', padding: 'comfortable', max_width: 'compact' },
centered: { alignment: 'center', position: 'center', padding: 'airy', max_width: 'compact' },
minimal: { alignment: 'left', position: 'upper-middle', padding: 'tight', max_width: 'wide' },
}
function deepMerge(target, source) {
if (!source || typeof source !== 'object') return target
const next = Array.isArray(target) ? [...target] : { ...(target || {}) }
Object.entries(source).forEach(([key, value]) => {
if (Array.isArray(value)) {
next[key] = value
return
}
if (value && typeof value === 'object') {
next[key] = deepMerge(next[key], value)
return
}
next[key] = value
})
return next
}
function pillClasses(active) {
return `rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] transition ${active ? '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]'}`
}
function defaultTextBlocks() {
return [
{ key: 'title', type: 'title', text: 'Untitled card', enabled: true, style: { role: 'eyebrow' } },
{ key: 'quote', type: 'quote', text: 'Your next quote starts here.', enabled: true, style: { role: 'headline' } },
{ key: 'author', type: 'author', text: '', enabled: false, style: { role: 'byline' } },
{ key: 'source', type: 'source', text: '', enabled: false, style: { role: 'caption' } },
]
}
function truncateText(value, limit = 96) {
const normalized = String(value || '').trim()
if (normalized.length <= limit) return normalized
return `${normalized.slice(0, limit - 1).trimEnd()}...`
}
function projectTextValue(project, type, fallbackKey = null) {
const blocks = Array.isArray(project?.text_blocks) ? project.text_blocks : []
const block = blocks.find((item) => item?.type === type && String(item?.text || '').trim() !== '')
if (block) return String(block.text || '')
if (!fallbackKey) return ''
return String(project?.content?.[fallbackKey] || '')
}
function summarizeProjectSnapshot(project) {
const blocks = Array.isArray(project?.text_blocks) ? project.text_blocks : []
const enabledBlocks = blocks.filter((block) => block?.enabled !== false && String(block?.text || '').trim() !== '').length
return {
title: truncateText(projectTextValue(project, 'title', 'title') || 'Untitled card', 64),
quote: truncateText(projectTextValue(project, 'quote', 'quote_text'), 88),
blockCount: blocks.length,
enabledBlocks,
layout: String(project?.layout?.layout || 'quote_heavy'),
font: String(project?.typography?.font_preset || 'modern-sans'),
background: String(project?.background?.gradient_preset || project?.background?.solid_color || project?.background?.type || 'gradient'),
}
}
function compareProjectSnapshots(currentProject, versionProject) {
if (!versionProject || typeof versionProject !== 'object') {
return ['Snapshot unavailable']
}
const changes = []
const currentSummary = summarizeProjectSnapshot(currentProject)
const versionSummary = summarizeProjectSnapshot(versionProject)
if (currentSummary.title !== versionSummary.title) changes.push('Title copy changed')
if (currentSummary.quote !== versionSummary.quote) changes.push('Quote copy changed')
if (currentSummary.blockCount !== versionSummary.blockCount) changes.push(`Block count ${versionSummary.blockCount}`)
if (currentSummary.layout !== versionSummary.layout) changes.push(`Layout ${versionSummary.layout.replace(/_/g, ' ')}`)
if (currentSummary.font !== versionSummary.font) changes.push(`Font ${versionSummary.font.replace(/-/g, ' ')}`)
if (currentSummary.background !== versionSummary.background) changes.push('Background treatment changed')
return changes.length ? changes.slice(0, 4) : ['Matches current draft']
}
function normalizeProject(project, options, card = null) {
const defaultGradient = options.gradient_presets?.[0] || null
const defaultFont = options.font_presets?.[0] || null
const content = project?.content || {}
const blocks = Array.isArray(project?.text_blocks) && project.text_blocks.length ? project.text_blocks : defaultTextBlocks().map((block) => ({
...block,
text: block.type === 'title'
? (card?.title || content.title || block.text)
: block.type === 'quote'
? (card?.quote_text || content.quote_text || block.text)
: block.type === 'author'
? (card?.quote_author || content.quote_author || '')
: (card?.quote_source || content.quote_source || ''),
enabled: block.type === 'title' || block.type === 'quote' ? true : Boolean(block.type === 'author' ? (card?.quote_author || content.quote_author) : (card?.quote_source || content.quote_source)),
}))
return {
schema_version: Number(project?.schema_version || 3),
meta: project?.meta || { editor: 'nova-cards-v3' },
template: project?.template || { id: card?.template_id || null, slug: card?.template?.slug || null },
content: {
title: card?.title || content.title || 'Untitled card',
quote_text: card?.quote_text || content.quote_text || 'Your next quote starts here.',
quote_author: card?.quote_author || content.quote_author || '',
quote_source: card?.quote_source || content.quote_source || '',
},
text_blocks: blocks,
layout: {
layout: project?.layout?.layout || 'quote_heavy',
position: project?.layout?.position || 'center',
alignment: project?.layout?.alignment || 'center',
padding: project?.layout?.padding || 'comfortable',
max_width: project?.layout?.max_width || 'balanced',
},
typography: {
font_preset: project?.typography?.font_preset || defaultFont?.key || 'modern-sans',
text_color: project?.typography?.text_color || '#ffffff',
accent_color: project?.typography?.accent_color || '#e0f2fe',
quote_size: Number(project?.typography?.quote_size || 72),
author_size: Number(project?.typography?.author_size || 28),
letter_spacing: Number(project?.typography?.letter_spacing || 0),
line_height: Number(project?.typography?.line_height || 1.2),
shadow_preset: project?.typography?.shadow_preset || 'soft',
},
background: {
type: project?.background?.type || card?.background_type || 'gradient',
gradient_preset: project?.background?.gradient_preset || defaultGradient?.key || 'midnight-nova',
gradient_colors: project?.background?.gradient_colors || defaultGradient?.colors || ['#0f172a', '#1d4ed8'],
solid_color: project?.background?.solid_color || '#111827',
background_image_id: project?.background?.background_image_id || card?.background_image_id || null,
overlay_style: project?.background?.overlay_style || 'dark-soft',
focal_position: project?.background?.focal_position || 'center',
blur_level: Number(project?.background?.blur_level || 0),
opacity: Number(project?.background?.opacity || 50),
},
canvas: {
density: project?.canvas?.density || 'standard',
safe_zone: project?.canvas?.safe_zone !== false,
},
frame: {
preset: project?.frame?.preset || 'none',
color: project?.frame?.color || null,
width: Number(project?.frame?.width || 1),
},
effects: {
color_grade: project?.effects?.color_grade || 'none',
effect_preset: project?.effects?.effect_preset || 'none',
intensity: Number(project?.effects?.intensity || 50),
},
export_preferences: {
allow_export: project?.export_preferences?.allow_export !== false,
default_format: project?.export_preferences?.default_format || 'preview',
},
source_context: {
style_family: project?.source_context?.style_family || null,
palette_family: project?.source_context?.palette_family || null,
editor_mode: project?.source_context?.editor_mode || card?.editor_mode_last_used || 'full',
},
decorations: Array.isArray(project?.decorations) ? project.decorations : [],
assets: {
pack_ids: Array.isArray(project?.assets?.pack_ids) ? project.assets.pack_ids : [],
template_pack_ids: Array.isArray(project?.assets?.template_pack_ids) ? project.assets.template_pack_ids : [],
items: Array.isArray(project?.assets?.items) ? project.assets.items : [],
},
}
}
function syncTextBlocks(blocks, type, text) {
const list = Array.isArray(blocks) ? [...blocks] : defaultTextBlocks()
const index = list.findIndex((block) => block.type === type)
const next = {
key: type,
type,
text,
enabled: type === 'title' || type === 'quote' ? true : Boolean(String(text || '').trim()),
style: list[index]?.style || {},
}
if (index === -1) {
list.push(next)
return list
}
list[index] = { ...list[index], ...next }
return list
}
function normalizeCard(card, options) {
if (!card) {
const defaultTemplate = options.templates?.[0] || null
const defaultCategory = options.categories?.[0] || null
const project = normalizeProject(null, options)
return {
id: null,
title: 'Untitled card',
quote_text: 'Your next quote starts here.',
quote_author: '',
quote_source: '',
description: '',
format: options.formats?.[0]?.key || 'square',
visibility: 'private',
status: 'draft',
moderation_status: 'pending',
allow_download: true,
background_type: 'gradient',
template_id: defaultTemplate?.id || null,
category_id: defaultCategory?.id || null,
background_image_id: null,
tags: [],
preview_url: null,
public_url: null,
schema_version: 2,
allow_remix: true,
likes_count: 0,
favorites_count: 0,
saves_count: 0,
remixes_count: 0,
challenge_entries_count: 0,
lineage: { original_card: null, root_card: null },
editor_mode_last_used: 'full',
project_json: project,
}
}
return {
...card,
tags: Array.isArray(card.tags) ? card.tags : [],
allow_remix: card.allow_remix !== false,
project_json: normalizeProject(card.project_json || {}, options, card),
}
}
function buildPayload(card, tagInput) {
return {
title: card.title,
quote_text: card.quote_text,
quote_author: card.quote_author,
quote_source: card.quote_source,
description: card.description,
format: card.format,
visibility: card.visibility,
allow_download: Boolean(card.allow_download),
allow_remix: Boolean(card.allow_remix),
allow_background_reuse: Boolean(card.allow_background_reuse),
allow_export: Boolean(card.allow_export !== false),
style_family: card.style_family || null,
palette_family: card.palette_family || null,
editor_mode_last_used: card.editor_mode_last_used || card.project_json?.source_context?.editor_mode || 'full',
background_type: card.background_type,
background_image_id: card.background_image_id,
template_id: card.template_id,
category_id: card.category_id,
tags: String(tagInput || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
project_json: card.project_json,
}
}
function fillPattern(pattern, replacements) {
let resolved = String(pattern || '')
Object.entries(replacements).forEach(([key, value]) => {
resolved = resolved.replace(`__${key}__`, String(value))
})
return resolved
}
function apiUrl(pattern, id) {
return fillPattern(pattern, { CARD: id })
}
export default function StudioCardEditor() {
const { props } = usePage()
const editorOptions = props.editorOptions || {}
const endpoints = props.endpoints || {}
const previewMode = Boolean(props.previewMode)
const mobileSteps = Array.isArray(props.mobileSteps) && props.mobileSteps.length ? props.mobileSteps : defaultMobileSteps
const [card, setCard] = React.useState(() => normalizeCard(props.card, editorOptions))
const [cardId, setCardId] = React.useState(props.card?.id || null)
const [tagInput, setTagInput] = React.useState(() => (props.card?.tags || []).map((tag) => tag.name).join(', '))
const [versions, setVersions] = React.useState(() => Array.isArray(props.versions) ? props.versions : [])
const [collections, setCollections] = React.useState([])
const [selectedCollectionId, setSelectedCollectionId] = React.useState('')
const [autosaveStatus, setAutosaveStatus] = React.useState(props.card ? 'saved' : 'idle')
const [autosaveMessage, setAutosaveMessage] = React.useState(props.card ? 'Loaded' : 'Preparing draft')
const [busy, setBusy] = React.useState(false)
const [uploading, setUploading] = React.useState(false)
const [currentMobileStep, setCurrentMobileStep] = React.useState(previewMode ? 'preview' : mobileSteps[0]?.key || 'format')
// v3 state
const [creatorPresets, setCreatorPresets] = React.useState(() => editorOptions.creator_presets || {})
const [aiSuggestions, setAiSuggestions] = React.useState(null)
const [loadingAi, setLoadingAi] = React.useState(false)
const [exportStatus, setExportStatus] = React.useState(null)
const [requestingExport, setRequestingExport] = React.useState(false)
const [activeExportType, setActiveExportType] = React.useState('preview')
const createStarted = React.useRef(false)
const lastSerialized = React.useRef(JSON.stringify(buildPayload(normalizeCard(props.card, editorOptions), tagInput)))
React.useEffect(() => {
setCurrentMobileStep(previewMode ? 'preview' : mobileSteps[0]?.key || 'format')
}, [mobileSteps, previewMode])
function replaceTextBlocks(nextBlocks) {
setCard((current) => {
const blocks = Array.isArray(nextBlocks) ? nextBlocks : []
const next = { ...current }
next.project_json = deepMerge(current.project_json || {}, { text_blocks: blocks })
const quoteBlock = blocks.find((block) => block?.type === 'quote')
const titleBlock = blocks.find((block) => block?.type === 'title')
const authorBlock = blocks.find((block) => block?.type === 'author')
const sourceBlock = blocks.find((block) => block?.type === 'source')
next.title = titleBlock?.text || next.title
next.quote_text = quoteBlock?.text || next.quote_text
next.quote_author = authorBlock?.text || ''
next.quote_source = sourceBlock?.text || ''
next.project_json.content = {
...(next.project_json.content || {}),
title: next.title,
quote_text: next.quote_text,
quote_author: next.quote_author,
quote_source: next.quote_source,
}
return next
})
}
function loadVersions(targetCardId) {
if (!targetCardId || !endpoints.draftVersionsPattern) return
window.axios.get(apiUrl(endpoints.draftVersionsPattern, targetCardId))
.then((response) => {
setVersions(Array.isArray(response.data?.data) ? response.data.data : [])
})
.catch(() => {})
}
function loadCollections() {
if (!endpoints.collectionsIndex) return
window.axios.get(endpoints.collectionsIndex)
.then((response) => {
const items = Array.isArray(response.data?.data) ? response.data.data : []
setCollections(items)
if (!selectedCollectionId && items[0]?.id) {
setSelectedCollectionId(String(items[0].id))
}
})
.catch(() => {})
}
React.useEffect(() => {
if (!cardId) return
loadVersions(cardId)
loadCollections()
}, [cardId])
React.useEffect(() => {
if (cardId || createStarted.current) return
createStarted.current = true
setBusy(true)
window.axios.post(endpoints.draftStore, {
format: card.format,
template_id: card.template_id,
category_id: card.category_id,
}).then((response) => {
const nextCard = normalizeCard(response.data.data, editorOptions)
setCard(nextCard)
setCardId(nextCard.id)
setAutosaveStatus('saved')
setAutosaveMessage('Draft created')
lastSerialized.current = JSON.stringify(buildPayload(nextCard, tagInput))
}).catch(() => {
setAutosaveStatus('error')
setAutosaveMessage('Could not create draft')
}).finally(() => {
setBusy(false)
})
}, [card, cardId, editorOptions, endpoints.draftStore, tagInput])
React.useEffect(() => {
if (!cardId || busy || uploading) return
const payload = buildPayload(card, tagInput)
const serialized = JSON.stringify(payload)
if (serialized === lastSerialized.current) return
setAutosaveStatus('saving')
setAutosaveMessage('Saving draft')
const timer = window.setTimeout(() => {
window.axios.post(apiUrl(endpoints.draftAutosavePattern, cardId), payload)
.then((response) => {
const nextCard = normalizeCard(response.data.data, editorOptions)
setCard(nextCard)
lastSerialized.current = JSON.stringify(buildPayload(nextCard, tagInput))
setAutosaveStatus('saved')
setAutosaveMessage('All changes saved')
})
.catch(() => {
setAutosaveStatus('error')
setAutosaveMessage('Autosave failed')
})
}, 900)
return () => window.clearTimeout(timer)
}, [busy, card, cardId, editorOptions, endpoints.draftAutosavePattern, tagInput, uploading])
function updateCard(partial, projectPatch = null) {
setCard((current) => {
const next = { ...current, ...partial }
if (projectPatch) {
next.project_json = deepMerge(current.project_json || {}, projectPatch)
}
return next
})
}
function updateTextField(key, value) {
const typeMap = {
title: 'title',
quote_text: 'quote',
quote_author: 'author',
quote_source: 'source',
}
setCard((current) => {
const next = { ...current, [key]: value }
next.project_json = deepMerge(current.project_json || {}, {
content: { [key]: value },
text_blocks: syncTextBlocks(current.project_json?.text_blocks, typeMap[key], value),
})
return next
})
}
function updateTextBlock(index, patch) {
const blocks = Array.isArray(card.project_json?.text_blocks) ? [...card.project_json.text_blocks] : []
blocks[index] = { ...blocks[index], ...patch }
replaceTextBlocks(blocks)
}
function addTextBlock(type = 'body') {
const nextBlock = {
key: `${type}-${Date.now()}`,
type,
text: '',
enabled: true,
style: {},
}
replaceTextBlocks([...(Array.isArray(card.project_json?.text_blocks) ? card.project_json.text_blocks : []), nextBlock])
}
function removeTextBlock(index) {
const blocks = Array.isArray(card.project_json?.text_blocks) ? card.project_json.text_blocks : []
replaceTextBlocks(blocks.filter((_, itemIndex) => itemIndex !== index))
}
function moveTextBlock(index, direction) {
const blocks = Array.isArray(card.project_json?.text_blocks) ? [...card.project_json.text_blocks] : []
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= blocks.length) {
return
}
const [moved] = blocks.splice(index, 1)
blocks.splice(nextIndex, 0, moved)
replaceTextBlocks(blocks)
}
function setEditorMode(mode) {
updateCard({ editor_mode_last_used: mode }, { source_context: { editor_mode: mode } })
}
function handleTemplateSelect(template) {
const layoutPreset = template.config_json?.layout || 'quote_heavy'
updateCard(
{ template_id: template.id },
{
template: { id: template.id, slug: template.slug },
layout: {
layout: layoutPreset,
...(layoutPresetMap[layoutPreset] || {}),
alignment: template.config_json?.text_align || layoutPresetMap[layoutPreset]?.alignment || 'center',
},
typography: {
font_preset: template.config_json?.font_preset || card.project_json?.typography?.font_preset,
text_color: template.config_json?.text_color || card.project_json?.typography?.text_color,
},
background: {
gradient_preset: template.config_json?.gradient_preset || card.project_json?.background?.gradient_preset,
overlay_style: template.config_json?.overlay_style || card.project_json?.background?.overlay_style,
},
},
)
}
function handleGradientSelect(gradient) {
updateCard({ background_type: 'gradient' }, {
background: {
type: 'gradient',
gradient_preset: gradient.key,
gradient_colors: gradient.colors,
},
})
}
function handleFontSelect(font) {
updateCard({}, {
typography: {
font_preset: font.key,
},
})
}
function reloadPresets() {
if (!endpoints.presetsIndex) return
window.axios.get(endpoints.presetsIndex)
.then((response) => {
const data = response.data?.data || response.data || {}
setCreatorPresets(data)
})
.catch(() => {})
}
function handleApplyPresetPatch(patch) {
setCard((current) => ({
...current,
project_json: deepMerge(current.project_json || {}, patch),
}))
}
function fetchAiSuggestions() {
if (!cardId || !endpoints.aiSuggestPattern) return
const url = endpoints.aiSuggestPattern.replace('__CARD__', cardId)
setLoadingAi(true)
window.axios.get(url)
.then((response) => setAiSuggestions(response.data?.suggestions || response.data || null))
.catch(() => {})
.finally(() => setLoadingAi(false))
}
function applyAiTagSuggestions(tags) {
if (!Array.isArray(tags) || tags.length === 0) return
const existing = tagInput ? tagInput.split(',').map((t) => t.trim()).filter(Boolean) : []
const merged = [...new Set([...existing, ...tags])]
setTagInput(merged.join(', '))
}
function requestExport(exportType) {
if (!cardId || !endpoints.exportPattern) return
const url = endpoints.exportPattern.replace('__CARD__', cardId)
setRequestingExport(true)
setExportStatus(null)
setActiveExportType(exportType)
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
window.axios.post(url, { export_type: exportType }, { headers: { 'X-CSRF-TOKEN': csrfToken } })
.then((response) => {
const exportData = response.data?.data || response.data
setExportStatus(exportData)
// Poll until ready
if (exportData?.status === 'pending' || exportData?.status === 'processing') {
pollExportStatus(exportData.id)
}
})
.catch(() => setExportStatus({ status: 'failed' }))
.finally(() => setRequestingExport(false))
}
function pollExportStatus(exportId, attempts = 0) {
if (!endpoints.exportStatusPattern || attempts > 20) return
const url = endpoints.exportStatusPattern.replace('__EXPORT__', exportId)
window.setTimeout(() => {
window.axios.get(url)
.then((response) => {
const data = response.data?.data || response.data
setExportStatus(data)
if (data?.status === 'pending' || data?.status === 'processing') {
pollExportStatus(exportId, attempts + 1)
}
})
.catch(() => {})
}, 2500)
}
function applyLayoutPreset(presetKey) {
updateCard({}, {
layout: {
layout: presetKey,
...(layoutPresetMap[presetKey] || {}),
},
})
}
function addDecoration(decoration) {
const placements = ['top-left', 'top-right', 'bottom-left', 'bottom-right']
const current = Array.isArray(card.project_json?.decorations) ? card.project_json.decorations : []
updateCard({}, {
decorations: [
...current,
{
key: decoration.key,
glyph: decoration.glyph,
placement: placements[current.length % placements.length],
size: 28,
},
].slice(0, editorOptions.validation?.max_decorations || 6),
})
}
function removeDecoration(index) {
const current = Array.isArray(card.project_json?.decorations) ? card.project_json.decorations : []
updateCard({}, { decorations: current.filter((_, itemIndex) => itemIndex !== index) })
}
function togglePack(packId, bucket = 'pack_ids') {
const current = Array.isArray(card.project_json?.assets?.[bucket]) ? card.project_json.assets[bucket] : []
const numericId = Number(packId)
const next = current.includes(numericId) ? current.filter((item) => item !== numericId) : [...current, numericId]
updateCard({}, { assets: { [bucket]: next } })
}
function addAssetItem(item) {
const current = Array.isArray(card.project_json?.assets?.items) ? card.project_json.assets.items : []
updateCard({}, {
assets: {
items: [...current, {
asset_key: item.key,
label: item.label,
glyph: item.glyph,
type: item.type || 'glyph',
}].slice(0, editorOptions.validation?.max_asset_items || 12),
},
})
}
function removeAssetItem(index) {
const current = Array.isArray(card.project_json?.assets?.items) ? card.project_json.assets.items : []
updateCard({}, { assets: { items: current.filter((_, itemIndex) => itemIndex !== index) } })
}
function manualSave() {
if (!cardId) return
setBusy(true)
setAutosaveStatus('saving')
window.axios.patch(apiUrl(endpoints.draftUpdatePattern, cardId), buildPayload(card, tagInput))
.then((response) => {
const nextCard = normalizeCard(response.data.data, editorOptions)
setCard(nextCard)
lastSerialized.current = JSON.stringify(buildPayload(nextCard, tagInput))
setAutosaveStatus('saved')
setAutosaveMessage('Draft saved')
loadVersions(nextCard.id)
})
.catch(() => {
setAutosaveStatus('error')
setAutosaveMessage('Save failed')
})
.finally(() => setBusy(false))
}
function renderPreview() {
if (!cardId) return
setBusy(true)
window.axios.post(apiUrl(endpoints.draftRenderPattern, cardId))
.then((response) => {
const nextCard = normalizeCard(response.data.data, editorOptions)
setCard(nextCard)
setAutosaveStatus('saved')
setAutosaveMessage('Preview rendered')
})
.catch(() => {
setAutosaveStatus('error')
setAutosaveMessage('Render failed')
})
.finally(() => setBusy(false))
}
function publishCard() {
if (!cardId) return
setBusy(true)
window.axios.post(apiUrl(endpoints.draftPublishPattern, cardId), buildPayload(card, tagInput))
.then((response) => {
const nextCard = normalizeCard(response.data.data, editorOptions)
setCard(nextCard)
setAutosaveStatus('saved')
setAutosaveMessage('Card published')
loadVersions(nextCard.id)
})
.catch((error) => {
setAutosaveStatus('error')
setAutosaveMessage(error?.response?.data?.message || 'Publish failed')
})
.finally(() => setBusy(false))
}
function deleteDraft() {
if (!cardId || !window.confirm('Delete this draft?')) return
setBusy(true)
window.axios.delete(apiUrl(endpoints.draftDeletePattern, cardId))
.then(() => {
window.location.assign(endpoints.studioCards || '/studio/cards')
})
.finally(() => setBusy(false))
}
function uploadBackground(event) {
const file = event.target.files?.[0]
if (!file || !cardId) return
const formData = new FormData()
formData.append('background', file)
setUploading(true)
window.axios.post(apiUrl(endpoints.draftBackgroundPattern, cardId), formData).then((response) => {
const nextCard = normalizeCard(response.data.data, editorOptions)
setCard(nextCard)
setAutosaveStatus('saved')
setAutosaveMessage('Background uploaded')
}).catch(() => {
setAutosaveStatus('error')
setAutosaveMessage('Upload failed')
}).finally(() => setUploading(false))
}
function createCollection() {
const name = window.prompt('Collection name')
if (!name || !endpoints.collectionsStore) return
window.axios.post(endpoints.collectionsStore, { name })
.then(() => loadCollections())
.catch(() => {
setAutosaveStatus('error')
setAutosaveMessage('Collection could not be created')
})
}
function saveToCollection() {
if (!cardId || !endpoints.savePattern) return
setBusy(true)
window.axios.post(apiUrl(endpoints.savePattern, cardId), {
collection_id: selectedCollectionId ? Number(selectedCollectionId) : undefined,
}).then((response) => {
setCard((current) => ({ ...current, saves_count: Number(response.data?.saves_count || current.saves_count || 0) }))
setAutosaveStatus('saved')
setAutosaveMessage('Saved to collection')
loadCollections()
}).catch(() => {
setAutosaveStatus('error')
setAutosaveMessage('Save to collection failed')
}).finally(() => setBusy(false))
}
function restoreVersion(versionId) {
if (!cardId || !endpoints.draftRestorePattern) return
setBusy(true)
window.axios.post(fillPattern(endpoints.draftRestorePattern, { CARD: cardId, VERSION: versionId }))
.then((response) => {
const nextCard = normalizeCard(response.data.data, editorOptions)
setCard(nextCard)
lastSerialized.current = JSON.stringify(buildPayload(nextCard, tagInput))
setAutosaveStatus('saved')
setAutosaveMessage('Version restored')
loadVersions(nextCard.id)
})
.catch(() => {
setAutosaveStatus('error')
setAutosaveMessage('Restore failed')
})
.finally(() => setBusy(false))
}
function submitChallenge(challengeId) {
if (!cardId || !endpoints.challengeSubmitPattern) return
setBusy(true)
window.axios.post(fillPattern(endpoints.challengeSubmitPattern, { CHALLENGE: challengeId, CARD: cardId }))
.then((response) => {
setCard((current) => ({ ...current, challenge_entries_count: Number(response.data?.challenge_entries_count || current.challenge_entries_count || 0) }))
setAutosaveStatus('saved')
setAutosaveMessage('Submitted to challenge')
})
.catch((error) => {
setAutosaveStatus('error')
setAutosaveMessage(error?.response?.data?.message || 'Challenge submission failed')
})
.finally(() => setBusy(false))
}
const decorations = Array.isArray(card.project_json?.decorations) ? card.project_json.decorations : []
const textBlocks = Array.isArray(card.project_json?.text_blocks) ? card.project_json.text_blocks : []
const backgroundType = card.project_json?.background?.type || card.background_type || 'gradient'
const assetItems = Array.isArray(card.project_json?.assets?.items) ? card.project_json.assets.items : []
const selectedAssetPackIds = Array.isArray(card.project_json?.assets?.pack_ids) ? card.project_json.assets.pack_ids : []
const selectedTemplatePackIds = Array.isArray(card.project_json?.assets?.template_pack_ids) ? card.project_json.assets.template_pack_ids : []
const currentMobileStepIndex = Math.max(0, mobileSteps.findIndex((step) => step.key === currentMobileStep))
const currentMobileStepMeta = mobileSteps[currentMobileStepIndex] || mobileSteps[0]
const editorMode = card.editor_mode_last_used || card.project_json?.source_context?.editor_mode || 'full'
const advancedMode = editorMode !== 'quick'
const currentProjectSummary = summarizeProjectSnapshot(card.project_json || {})
function sectionVisibility(stepKey) {
return `${currentMobileStep === stepKey ? 'block' : 'hidden'} xl:block`
}
function goToStep(index) {
const step = mobileSteps[index]
if (!step) return
setCurrentMobileStep(step.key)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
Nova Cards editorStructured card creation with live preview and autosave.
Advanced mode exposes alignment, spacing, and width controls when you need layout precision.
}Run a suggestion pass to see AI-powered tips for this card.
)}