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 editor

Structured card creation with live preview and autosave.

Quick mode keeps the core card flow visible. Advanced mode unlocks layered text, fine-tuning, and deeper effects.
{card.public_url ? Public page : null}
{mobileSteps.map((step, index) => ( ))}
{currentMobileStepMeta ? (
Current step
{currentMobileStepMeta.label}
{currentMobileStepMeta.description}
) : null}
Templates
Backgrounds
{(editorOptions.background_modes || []).map((mode) => ( ))}
{(backgroundType === 'gradient' || backgroundType === 'template') ? (
) : null}
{advancedMode ? : null} {advancedMode ? : null} {advancedMode ? : null} {advancedMode ? : null}
{advancedMode ?
Decorations
{(editorOptions.decor_presets || []).map((decor) => ( ))}
{decorations.length ? (
{decorations.map((decor, index) => (
{decor.glyph || '✦'} {decor.key}
))}
) : null}
: null} {advancedMode ?
Official packs
Asset packs
{(editorOptions.asset_packs || []).map((pack) => ( ))}
Template packs
{(editorOptions.template_packs || []).map((pack) => ( ))}
{(editorOptions.asset_packs || []).map((pack) => { const items = pack?.manifest_json?.items || [] if (!items.length) return null return (
{pack.name}
{items.map((item) => ( ))}
) })}
{assetItems.length ? (
{assetItems.map((item, index) => (
{item.glyph ? `${item.glyph} ` : ''}{item.label || item.asset_key}
))}
) : null}
: null}
{card.preview_url ? (
Rendered preview
Rendered preview
) : null}
Content