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), quote_width: project?.typography?.quote_width != null ? Number(project.typography.quote_width) : null, 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 mergeServerCard(currentCard, serverCard) { if (!currentCard) return serverCard if (!serverCard) return currentCard // Keep the latest local editing state, especially project_json drag positions, // while still accepting server-authored metadata like preview URLs and status. return { ...currentCard, ...serverCard, title: currentCard.title, quote_text: currentCard.quote_text, quote_author: currentCard.quote_author, quote_source: currentCard.quote_source, description: currentCard.description, format: currentCard.format, visibility: currentCard.visibility, template_id: currentCard.template_id, category_id: currentCard.category_id, background_type: currentCard.background_type, background_image_id: currentCard.background_image_id, allow_download: currentCard.allow_download, allow_remix: currentCard.allow_remix, allow_background_reuse: currentCard.allow_background_reuse, allow_export: currentCard.allow_export, style_family: currentCard.style_family, palette_family: currentCard.palette_family, editor_mode_last_used: currentCard.editor_mode_last_used, tags: currentCard.tags, project_json: currentCard.project_json, } } 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') const [activeTab, setActiveTab] = React.useState('background') // 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))) const autosaveAbortRef = React.useRef(null) 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(() => { if (autosaveAbortRef.current) { autosaveAbortRef.current.abort() } const controller = new AbortController() autosaveAbortRef.current = controller window.axios.post(apiUrl(endpoints.draftAutosavePattern, cardId), payload, { signal: controller.signal }) .then(() => { autosaveAbortRef.current = null // Use the serialized form we sent — not the server response — so we // never overwrite state the user changed while the request was in-flight. lastSerialized.current = serialized setAutosaveStatus('saved') setAutosaveMessage('All changes saved') }) .catch((error) => { if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return 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 handleElementMove(elementId, x, y, widthPct) { if (elementId.startsWith('block:')) { const key = elementId.slice(6) setCard((current) => { const blocks = Array.isArray(current.project_json?.text_blocks) ? [...current.project_json.text_blocks] : [] const idx = blocks.findIndex((b) => b.key === key || b.type === key) if (idx === -1) return current const updated = [...blocks] updated[idx] = { ...updated[idx], pos_x: x, pos_y: y, ...(widthPct != null ? { pos_width: widthPct } : {}) } return { ...current, project_json: { ...current.project_json, text_blocks: updated } } }) } else if (elementId.startsWith('decoration:')) { const idx = parseInt(elementId.slice(11), 10) setCard((current) => { const decorations = Array.isArray(current.project_json?.decorations) ? [...current.project_json.decorations] : [] if (idx < 0 || idx >= decorations.length) return current const updated = [...decorations] updated[idx] = { ...updated[idx], pos_x: x, pos_y: y } return { ...current, project_json: { ...current.project_json, decorations: updated } } }) } } 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 updateDecoration(index, patch) { const current = Array.isArray(card.project_json?.decorations) ? [...card.project_json.decorations] : [] current[index] = { ...current[index], ...patch } updateCard({}, { decorations: current }) } 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 const payload = buildPayload(card, tagInput) const serialized = JSON.stringify(payload) if (autosaveAbortRef.current) { autosaveAbortRef.current.abort() autosaveAbortRef.current = null } setBusy(true) setAutosaveStatus('saving') window.axios.patch(apiUrl(endpoints.draftUpdatePattern, cardId), payload) .then((response) => { const nextCard = normalizeCard(response.data.data, editorOptions) setCard((current) => mergeServerCard(current, nextCard)) lastSerialized.current = serialized setAutosaveStatus('saved') setAutosaveMessage('Draft saved') loadVersions(nextCard.id) }) .catch(() => { setAutosaveStatus('error') setAutosaveMessage('Save failed') }) .finally(() => setBusy(false)) } function renderPreview() { if (!cardId) return const payload = buildPayload(card, tagInput) const serialized = JSON.stringify(payload) if (autosaveAbortRef.current) { autosaveAbortRef.current.abort() autosaveAbortRef.current = null } setBusy(true) setAutosaveStatus('saving') setAutosaveMessage('Saving before render…') // Always flush the current editor state to the server before triggering the // render job, so the generated image matches exactly what the user sees. window.axios.post(apiUrl(endpoints.draftAutosavePattern, cardId), payload) .then(() => window.axios.post(apiUrl(endpoints.draftRenderPattern, cardId))) .then((response) => { const nextCard = normalizeCard(response.data.data, editorOptions) setCard((current) => mergeServerCard(current, nextCard)) lastSerialized.current = serialized setAutosaveStatus('saved') setAutosaveMessage('Preview image rendered ✓') // Switch to the Publish tab so the rendered image is immediately visible. setActiveTab('publish') }) .catch(() => { setAutosaveStatus('error') setAutosaveMessage('Render failed') }) .finally(() => setBusy(false)) } function publishCard() { if (!cardId) return const payload = buildPayload(card, tagInput) const serialized = JSON.stringify(payload) if (autosaveAbortRef.current) { autosaveAbortRef.current.abort() autosaveAbortRef.current = null } setBusy(true) window.axios.post(apiUrl(endpoints.draftPublishPattern, cardId), payload) .then((response) => { const nextCard = normalizeCard(response.data.data, editorOptions) setCard((current) => mergeServerCard(current, nextCard)) lastSerialized.current = serialized 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) setActiveTab('background') window.axios.post(apiUrl(endpoints.draftBackgroundPattern, cardId), formData).then((response) => { const nextCard = normalizeCard(response.data.data, editorOptions) const bgInfo = response.data.background if (bgInfo?.processed_url) { nextCard.background_image = { processed_url: bgInfo.processed_url } nextCard.background_type = 'upload' if (nextCard.project_json) { nextCard.project_json = deepMerge(nextCard.project_json, { background: { type: 'upload' } }) } } 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 || {}) const editorTabs = [ { key: 'background', label: 'Background' }, { key: 'content', label: 'Content' }, { key: 'typography', label: 'Typography' }, { key: 'layout', label: 'Layout' }, { key: 'publish', label: 'Publish' }, ] const tabIndex = editorTabs.findIndex((t) => t.key === activeTab) function goToNextTab(direction) { const next = editorTabs[tabIndex + direction] if (next) { setActiveTab(next.key) window.scrollTo({ top: 0, behavior: 'smooth' }) } } function tabBtnClasses(active) { return `flex-none whitespace-nowrap rounded-2xl border px-4 py-2 text-sm font-semibold transition ${ active ? 'border-sky-400/30 bg-sky-500/15 text-sky-200' : 'border-transparent text-slate-400 hover:bg-white/[0.05] hover:text-white' }` } return ( {/* Top action bar */}

Nova Cards editor

{card.title || 'Untitled card'}

{card.public_url ? Public page : null}
{/* Main grid: tabbed editor (left) + sticky preview (right) */}
{/* Editor panel */}
{/* Tab navigation */}
{editorTabs.map((tab) => ( ))}
{/* Tab panels */}
{/* BACKGROUND TAB */} {activeTab === 'background' && (
Template
Background type
{(editorOptions.background_modes || []).map((mode) => ( ))}
{(backgroundType === 'gradient' || backgroundType === 'template') && (
Gradient
)} {backgroundType === 'solid' && (
Solid color
updateCard({ background_type: 'solid' }, { background: { type: 'solid', solid_color: event.target.value } })} className="h-12 w-16 cursor-pointer rounded-xl border border-white/10 bg-[#0d1726] p-1.5" /> {card.project_json?.background?.solid_color || '#111827'}
)} {backgroundType === 'upload' && (
Custom image
{card.background_image?.processed_url ? (
Uploaded background
✓ Uploaded
) : ( )}
)}
Overlay & depth
{advancedMode && ( <> )}
{advancedMode && (
Decorations
{(editorOptions.decor_presets || []).map((decor) => ( ))}
{decorations.length > 0 && (
{decorations.map((decor, index) => (
{decor.glyph} {decor.key}
Opacity updateDecoration(index, { opacity: Number(event.target.value) })} className="w-full" /> {decor.opacity ?? 85}%
))}
)}
)}
)} {/* CONTENT TAB */} {activeTab === 'content' && (