Files
SkinbaseNova/resources/js/Pages/Studio/StudioCardEditor.jsx

1691 lines
91 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<StudioLayout title={previewMode ? 'Card Preview' : 'Card Editor'}>
<Head title={previewMode ? 'Nova Card Preview' : 'Nova Card Editor'} />
{/* Top action bar */}
<section className="mb-5 flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.10),transparent_40%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] px-5 py-4 shadow-[0_18px_48px_rgba(2,6,23,0.28)]">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Nova Cards editor</p>
<h2 className="mt-1 truncate text-xl font-semibold tracking-[-0.03em] text-white">{card.title || 'Untitled card'}</h2>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<button type="button" onClick={() => setEditorMode('quick')} className={pillClasses(editorMode === 'quick')}>Quick mode</button>
<button type="button" onClick={() => setEditorMode('full')} className={pillClasses(editorMode === 'full')}>Advanced mode</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<NovaCardAutosaveIndicator status={autosaveStatus} message={autosaveMessage} />
<button type="button" onClick={manualSave} disabled={busy || !cardId} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08] disabled:opacity-60">Save</button>
<button type="button" onClick={publishCard} disabled={busy || !cardId} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-400/15 disabled:opacity-60">Publish</button>
{card.public_url ? <a href={card.public_url} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Public page</a> : null}
</div>
</section>
{/* Main grid: tabbed editor (left) + sticky preview (right) */}
<div className="xl:grid xl:grid-cols-[minmax(0,480px)_minmax(0,1fr)] xl:items-start xl:gap-6">
{/* Editor panel */}
<div className="mb-6 xl:mb-0">
{/* Tab navigation */}
<div className="no-scrollbar -mx-1 flex gap-1 overflow-x-auto px-1 pb-2">
{editorTabs.map((tab) => (
<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={tabBtnClasses(activeTab === tab.key)}>
{tab.label}
</button>
))}
</div>
{/* Tab panels */}
<div className="mt-2 rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
{/* BACKGROUND TAB */}
{activeTab === 'background' && (
<div className="space-y-6">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Template</div>
<NovaCardTemplatePicker templates={editorOptions.templates || []} selectedId={card.template_id} onSelect={handleTemplateSelect} />
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Background type</div>
<div className="flex flex-wrap gap-2">
{(editorOptions.background_modes || []).map((mode) => (
<button key={mode.key} type="button" onClick={() => updateCard({ background_type: mode.key }, { background: { type: mode.key } })} className={pillClasses(backgroundType === mode.key)}>
{mode.label}
</button>
))}
</div>
</div>
{(backgroundType === 'gradient' || backgroundType === 'template') && (
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Gradient</div>
<NovaCardGradientPicker gradients={editorOptions.gradient_presets || []} selectedKey={card.project_json?.background?.gradient_preset} onSelect={handleGradientSelect} />
</div>
)}
{backgroundType === 'solid' && (
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Solid color</div>
<div className="flex items-center gap-3">
<input type="color" value={card.project_json?.background?.solid_color || '#111827'} onChange={(event) => 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" />
<span className="font-mono text-sm text-white/60">{card.project_json?.background?.solid_color || '#111827'}</span>
</div>
</div>
)}
{backgroundType === 'upload' && (
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Custom image</div>
{card.background_image?.processed_url ? (
<div className="overflow-hidden rounded-[18px] border border-white/10">
<img src={card.background_image.processed_url} alt="Uploaded background" className="h-44 w-full object-cover" />
<div className="flex items-center justify-between bg-white/[0.03] px-4 py-2.5">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-emerald-300"> Uploaded</span>
<label className="cursor-pointer text-xs text-slate-400 transition hover:text-white">
Replace image
<input type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={uploadBackground} disabled={uploading} />
</label>
</div>
</div>
) : (
<label className={`flex cursor-pointer flex-col items-center gap-3 rounded-[22px] border-2 border-dashed px-6 py-10 text-center transition ${uploading ? 'border-sky-400/40 bg-sky-400/[0.04]' : 'border-white/12 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.04]'}`}>
<div className="text-3xl">{uploading ? '⏳' : '🖼️'}</div>
<div className="text-sm font-semibold text-slate-300">{uploading ? 'Uploading…' : 'Choose background image'}</div>
<div className="text-xs text-slate-500">JPG, PNG or WebP · Max 8 MB · Min 480×480 px</div>
<input type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={uploadBackground} disabled={uploading} />
</label>
)}
</div>
)}
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Overlay &amp; depth</div>
<div className="space-y-4">
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Overlay style</span>
<select value={card.project_json?.background?.overlay_style || 'dark-soft'} onChange={(event) => updateCard({}, { background: { overlay_style: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{overlayOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="block text-sm text-slate-300">
<div className="mb-2 flex justify-between">
<span>Overlay opacity</span>
<span className="font-semibold text-white">{card.project_json?.background?.opacity || 50}%</span>
</div>
<input type="range" min="0" max="90" step="10" value={card.project_json?.background?.opacity || 50} onChange={(event) => updateCard({}, { background: { opacity: Number(event.target.value) } })} className="w-full" />
</label>
{advancedMode && (
<>
<label className="block text-sm text-slate-300">
<div className="mb-2 flex justify-between">
<span>Background blur</span>
<span className="font-semibold text-white">{card.project_json?.background?.blur_level || 0}px</span>
</div>
<input type="range" min="0" max="32" step="4" value={card.project_json?.background?.blur_level || 0} onChange={(event) => updateCard({}, { background: { blur_level: Number(event.target.value) } })} className="w-full" />
</label>
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Focal position</span>
<select value={card.project_json?.background?.focal_position || 'center'} onChange={(event) => updateCard({}, { background: { focal_position: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.focal_positions || []).map((position) => <option key={position.key} value={position.key}>{position.label}</option>)}
</select>
</label>
</>
)}
</div>
</div>
{advancedMode && (
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Decorations</div>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{(editorOptions.decor_presets || []).map((decor) => (
<button key={decor.key} type="button" onClick={() => addDecoration(decor)} className="flex flex-col items-center gap-1 rounded-[18px] border border-white/10 bg-white/[0.03] px-2 py-3 text-center transition hover:bg-white/[0.05]">
<span className="text-2xl">{decor.glyph}</span>
<span className="text-[10px] text-slate-400">{decor.label}</span>
</button>
))}
</div>
{decorations.length > 0 && (
<div className="mt-3 space-y-2">
{decorations.map((decor, index) => (
<div key={`${decor.key}-${index}`} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<div className="flex items-center justify-between">
<span className="text-base">{decor.glyph}</span>
<span className="flex-1 px-3 text-slate-300">{decor.key}</span>
<button type="button" onClick={() => removeDecoration(index)} className="text-rose-300 transition hover:text-rose-200">Remove</button>
</div>
<div className="mt-2 flex items-center gap-3">
<span className="text-[10px] uppercase tracking-[0.16em] text-slate-500 w-14 shrink-0">Opacity</span>
<input type="range" min="10" max="100" step="5"
value={decor.opacity ?? 85}
onChange={(event) => updateDecoration(index, { opacity: Number(event.target.value) })}
className="w-full" />
<span className="w-10 shrink-0 text-right font-mono text-xs text-white/70">{decor.opacity ?? 85}%</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* CONTENT TAB */}
{activeTab === 'content' && (
<div className="space-y-4">
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Title</span>
<input value={card.title || ''} onChange={(event) => updateTextField('title', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" />
</label>
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Quote text</span>
<textarea value={card.quote_text || ''} onChange={(event) => updateTextField('quote_text', event.target.value)} rows={5} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" />
</label>
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Author</span>
<input value={card.quote_author || ''} onChange={(event) => updateTextField('quote_author', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" />
</label>
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Source</span>
<input value={card.quote_source || ''} onChange={(event) => updateTextField('quote_source', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" />
</label>
{advancedMode && (
<div className="border-t border-white/10 pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Text blocks</div>
<div className="flex gap-2">
<button type="button" onClick={() => addTextBlock('body')} className="rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-white">+ Body</button>
<button type="button" onClick={() => addTextBlock('caption')} className="rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-white">+ Caption</button>
</div>
</div>
<div className="space-y-3">
{textBlocks.map((block, index) => (
<div key={`${block.key || block.type}-${index}`} className="rounded-[20px] border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center gap-2">
<div className="flex flex-col gap-1">
<button type="button" onClick={() => moveTextBlock(index, -1)} disabled={index === 0} className="rounded border border-white/10 bg-white/[0.05] px-1.5 py-0.5 text-[10px] text-white disabled:opacity-30"></button>
<button type="button" onClick={() => moveTextBlock(index, 1)} disabled={index === textBlocks.length - 1} className="rounded border border-white/10 bg-white/[0.05] px-1.5 py-0.5 text-[10px] text-white disabled:opacity-30"></button>
</div>
<select value={block.type || 'body'} onChange={(event) => updateTextBlock(index, { type: event.target.value })} className="rounded-xl border border-white/10 bg-[#0d1726] px-2 py-2 text-xs text-white outline-none">
<option value="title">Title</option>
<option value="quote">Quote</option>
<option value="author">Author</option>
<option value="source">Source</option>
<option value="body">Body</option>
<option value="caption">Caption</option>
</select>
<input value={block.text || ''} onChange={(event) => updateTextBlock(index, { text: event.target.value, enabled: block.type === 'title' || block.type === 'quote' ? true : Boolean(event.target.value.trim()) })} className="min-w-0 flex-1 rounded-xl border border-white/10 bg-[#0d1726] px-3 py-2 text-sm text-white outline-none" />
<label className="flex items-center gap-1 text-xs text-slate-400">
<input type="checkbox" checked={block.enabled !== false} onChange={(e) => updateTextBlock(index, { enabled: e.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
On
</label>
<button type="button" onClick={() => removeTextBlock(index)} disabled={block.type === 'title' || block.type === 'quote'} className="text-rose-300 transition hover:text-rose-200 disabled:opacity-30">×</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* TYPOGRAPHY TAB */}
{activeTab === 'typography' && (
<div className="space-y-6">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Font family</div>
<NovaCardFontPicker fonts={editorOptions.font_presets || []} selectedKey={card.project_json?.typography?.font_preset} onSelect={handleFontSelect} />
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quote size</div>
<div className="flex items-center gap-3">
<input type="range" min="10" max="128" value={card.project_json?.typography?.quote_size || 72} onChange={(event) => updateCard({}, { typography: { quote_size: Number(event.target.value) } })} className="w-full" />
<span className="w-16 shrink-0 text-right font-mono text-sm font-semibold text-white">{card.project_json?.typography?.quote_size || 72}px</span>
</div>
<div className="mt-2 overflow-hidden rounded-xl border border-white/10 bg-[#0d1726] px-3 py-2">
<span className="font-semibold text-white/90 leading-none" style={{
fontFamily: (editorOptions.font_presets || []).find((f) => f.key === card.project_json?.typography?.font_preset)?.family,
fontSize: `${Math.min(Math.max(Number(card.project_json?.typography?.quote_size || 72) / 4, 6), 36)}px`,
color: card.project_json?.typography?.text_color || '#ffffff',
}}>Preview text</span>
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quote width</div>
<div className="flex items-center gap-3">
<input type="range" min="30" max="100" step="1"
value={card.project_json?.typography?.quote_width ?? 76}
onChange={(event) => updateCard({}, { typography: { quote_width: Number(event.target.value) } })}
className="w-full" />
<span className="w-16 shrink-0 text-right font-mono text-sm font-semibold text-white">{card.project_json?.typography?.quote_width ?? 76}%</span>
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Author &amp; caption size</div>
<div className="flex items-center gap-3">
<input type="range" min="14" max="42" value={card.project_json?.typography?.author_size || 28} onChange={(event) => updateCard({}, { typography: { author_size: Number(event.target.value) } })} className="w-full" />
<span className="w-16 shrink-0 text-right font-mono text-sm font-semibold text-white">{card.project_json?.typography?.author_size || 28}px</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Text color</div>
<div className="flex items-center gap-2">
<input type="color" value={card.project_json?.typography?.text_color || '#ffffff'} onChange={(event) => updateCard({}, { typography: { text_color: event.target.value } })} className="h-10 w-12 shrink-0 cursor-pointer rounded-xl border border-white/10 bg-[#0d1726] p-1" />
<span className="font-mono text-xs text-white/60">{card.project_json?.typography?.text_color || '#ffffff'}</span>
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Accent color</div>
<div className="flex items-center gap-2">
<input type="color" value={card.project_json?.typography?.accent_color || '#e0f2fe'} onChange={(event) => updateCard({}, { typography: { accent_color: event.target.value } })} className="h-10 w-12 shrink-0 cursor-pointer rounded-xl border border-white/10 bg-[#0d1726] p-1" />
<span className="font-mono text-xs text-white/60">{card.project_json?.typography?.accent_color || '#e0f2fe'}</span>
</div>
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Text opacity</div>
<div className="flex items-center gap-3">
<input type="range" min="10" max="100" step="5"
value={card.project_json?.typography?.text_opacity ?? 100}
onChange={(event) => updateCard({}, { typography: { text_opacity: Number(event.target.value) } })}
className="w-full" />
<span className="w-16 shrink-0 text-right font-mono text-sm font-semibold text-white">{card.project_json?.typography?.text_opacity ?? 100}%</span>
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Text shadow</div>
<div className="flex gap-2">
{(editorOptions.shadow_presets || [{ key: 'none', label: 'None' }, { key: 'soft', label: 'Soft' }, { key: 'strong', label: 'Strong' }]).map((preset) => {
const active = (card.project_json?.typography?.shadow_preset || 'soft') === preset.key
const shadowStyle = preset.key === 'soft' ? '0 6px 16px rgba(2,6,23,0.6)' : preset.key === 'strong' ? '0 10px 28px rgba(2,6,23,0.85)' : 'none'
return (
<button key={preset.key} type="button" onClick={() => updateCard({}, { typography: { shadow_preset: preset.key } })}
className={`flex-1 rounded-2xl border py-3 text-sm font-bold transition ${active ? 'border-sky-400/30 bg-sky-500/15 text-white' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.05]'}`}
style={{ textShadow: shadowStyle }}
>
{preset.label}
</button>
)
})}
</div>
</div>
{advancedMode && (
<>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Letter spacing</div>
<div className="flex items-center gap-3">
<input type="range" min="-2" max="10" value={card.project_json?.typography?.letter_spacing || 0} onChange={(event) => updateCard({}, { typography: { letter_spacing: Number(event.target.value) } })} className="w-full" />
<span className="w-16 shrink-0 text-right font-mono text-sm font-semibold text-white">{card.project_json?.typography?.letter_spacing || 0}</span>
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Line height</div>
<div className="flex gap-2">
{(editorOptions.line_height_presets || []).map((preset) => (
<button key={preset.key} type="button" onClick={() => updateCard({}, { typography: { line_height: Number(preset.value) } })}
className={`flex-1 rounded-2xl border py-2.5 text-sm font-semibold transition ${String(card.project_json?.typography?.line_height || 1.2) === String(preset.value) ? 'border-sky-400/30 bg-sky-500/15 text-sky-200' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.05]'}`}
>
{preset.label}
</button>
))}
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quote mark &amp; panel</div>
<div className="grid grid-cols-2 gap-3">
<label className="block text-sm text-slate-300">
<span className="mb-1.5 block text-xs">Quote mark style</span>
<select value={card.project_json?.typography?.quote_mark_preset || 'none'} onChange={(event) => updateCard({}, { typography: { quote_mark_preset: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.quote_mark_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
</select>
</label>
<label className="block text-sm text-slate-300">
<span className="mb-1.5 block text-xs">Text panel style</span>
<select value={card.project_json?.typography?.text_panel_style || 'none'} onChange={(event) => updateCard({}, { typography: { text_panel_style: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.text_panel_styles || []).map((style) => <option key={style.key} value={style.key}>{style.label}</option>)}
</select>
</label>
</div>
</div>
</>
)}
</div>
)}
{/* LAYOUT TAB */}
{activeTab === 'layout' && (
<div className="space-y-6">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Format</div>
<div className="flex flex-wrap gap-2">
{(editorOptions.formats || []).map((format) => (
<button key={format.key} type="button" onClick={() => updateCard({ format: format.key })} className={pillClasses((card.format || 'square') === format.key)}>
{format.label}
</button>
))}
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Layout preset</div>
<div className="flex flex-wrap gap-2">
{(editorOptions.layout_presets || []).map((preset) => (
<button key={preset.key} type="button" onClick={() => applyLayoutPreset(preset.key)} className={pillClasses((card.project_json?.layout?.layout || 'quote_heavy') === preset.key)}>
{preset.label}
</button>
))}
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Text alignment</div>
<div className="flex gap-2">
{(editorOptions.alignment_presets || [{ key: 'left', label: 'Left' }, { key: 'center', label: 'Center' }, { key: 'right', label: 'Right' }]).map((preset) => (
<button key={preset.key} type="button" onClick={() => updateCard({}, { layout: { alignment: preset.key } })}
className={`flex-1 rounded-2xl border py-2.5 text-sm font-semibold transition ${(card.project_json?.layout?.alignment || 'center') === preset.key ? 'border-sky-400/30 bg-sky-500/15 text-sky-200' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.05]'}`}
>
{preset.label}
</button>
))}
</div>
</div>
{advancedMode && (
<>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Vertical position</div>
<div className="flex flex-wrap gap-2">
{(editorOptions.position_presets || []).map((preset) => (
<button key={preset.key} type="button" onClick={() => updateCard({}, { layout: { position: preset.key } })} className={pillClasses((card.project_json?.layout?.position || 'center') === preset.key)}>
{preset.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Padding</div>
<div className="flex flex-col gap-2">
{(editorOptions.padding_presets || []).map((preset) => (
<button key={preset.key} type="button" onClick={() => updateCard({}, { layout: { padding: preset.key } })} className={pillClasses((card.project_json?.layout?.padding || 'comfortable') === preset.key)}>
{preset.label}
</button>
))}
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Text width</div>
<div className="flex flex-col gap-2">
{(editorOptions.max_width_presets || []).map((preset) => (
<button key={preset.key} type="button" onClick={() => updateCard({}, { layout: { max_width: preset.key } })} className={pillClasses((card.project_json?.layout?.max_width || 'balanced') === preset.key)}>
{preset.label}
</button>
))}
</div>
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Frame &amp; effects</div>
<div className="space-y-3">
<label className="block text-sm text-slate-300">
<span className="mb-1.5 block">Frame</span>
<select value={card.project_json?.frame?.preset || 'none'} onChange={(event) => updateCard({}, { frame: { preset: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.frame_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
</select>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block text-sm text-slate-300">
<span className="mb-1.5 block text-xs">Color grade</span>
<select value={card.project_json?.effects?.color_grade || 'none'} onChange={(event) => updateCard({}, { effects: { color_grade: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.color_grade_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
</select>
</label>
<label className="block text-sm text-slate-300">
<span className="mb-1.5 block text-xs">Effect</span>
<select value={card.project_json?.effects?.effect_preset || 'none'} onChange={(event) => updateCard({}, { effects: { effect_preset: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.effect_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
</select>
</label>
</div>
</div>
</div>
</>
)}
{advancedMode && (Object.keys(creatorPresets).length > 0 || endpoints.presetsIndex) && (
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Creator presets</div>
<NovaCardPresetPicker presets={creatorPresets} endpoints={endpoints} cardId={cardId} onApplyPatch={handleApplyPresetPatch} onPresetsChange={reloadPresets} />
</div>
)}
{advancedMode && endpoints.aiSuggestPattern && (
<div>
<div className="mb-3 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">AI assist</div>
<button type="button" disabled={loadingAi || !cardId} onClick={fetchAiSuggestions} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1.5 text-xs font-semibold text-sky-200 transition hover:bg-sky-400/15 disabled:opacity-50">
{loadingAi ? 'Analysing…' : 'Suggest'}
</button>
</div>
{aiSuggestions ? (
<div className="space-y-3">
{aiSuggestions.tags?.length > 0 && (
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Suggested tags</div>
<div className="flex flex-wrap gap-2">
{aiSuggestions.tags.map((tag) => (
<button key={tag} type="button" onClick={() => applyAiTagSuggestions([tag])} className="rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-xs font-semibold text-sky-200 transition hover:bg-sky-400/15">+ {tag}</button>
))}
</div>
<button type="button" onClick={() => applyAiTagSuggestions(aiSuggestions.tags)} className="mt-1.5 text-xs text-slate-400 underline transition hover:text-white">Add all</button>
</div>
)}
{aiSuggestions.mood && (
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2.5">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Mood</div>
<div className="text-sm text-white">{aiSuggestions.mood}</div>
</div>
)}
{aiSuggestions.layout_suggestion && (
<div className="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2.5">
<div>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Layout suggestion</div>
<div className="text-sm text-white">{aiSuggestions.layout_suggestion}</div>
</div>
<button type="button" onClick={() => updateCard({}, { layout: { layout: aiSuggestions.layout_suggestion } })} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Apply</button>
</div>
)}
{(aiSuggestions.readability_fixes || []).map((fix, fi) => (
<div key={fi} className="rounded-xl border border-amber-400/15 bg-amber-400/[0.06] px-3 py-2.5 text-sm text-amber-100">{fix}</div>
))}
</div>
) : (
<p className="text-sm text-slate-500">Run a suggestion pass to see AI-powered tips for this card.</p>
)}
</div>
)}
</div>
)}
{/* PUBLISH TAB */}
{activeTab === 'publish' && (
<div className="space-y-5">
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Category</span>
<select value={card.category_id || ''} onChange={(event) => updateCard({ category_id: event.target.value ? Number(event.target.value) : null })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="">Select category</option>
{(editorOptions.categories || []).map((cat) => <option key={cat.id} value={cat.id}>{cat.name}</option>)}
</select>
</label>
<div>
<div className="mb-2 text-sm text-slate-300">Visibility</div>
<div className="flex gap-2">
{['private', 'unlisted', 'public'].map((v) => (
<button key={v} type="button" onClick={() => updateCard({ visibility: v })}
className={`flex-1 rounded-2xl border py-2.5 text-sm font-semibold capitalize transition ${(card.visibility || 'private') === v ? 'border-sky-400/30 bg-sky-500/15 text-sky-200' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.05]'}`}
>
{v}
</button>
))}
</div>
</div>
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Tags</span>
<input value={tagInput} onChange={(event) => setTagInput(event.target.value)} placeholder="motivation, calm, poetry" className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" />
</label>
<div className="space-y-2">
{[
{ key: 'allow_download', label: 'Allow download' },
{ key: 'allow_remix', label: 'Allow remix' },
{ key: 'allow_export', label: 'Allow export' },
{ key: 'allow_background_reuse', label: 'Allow background reuse' },
].map(({ key, label }) => (
<label key={key} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<span>{label}</span>
<input type="checkbox" checked={key === 'allow_export' ? Boolean(card.allow_export !== false) : Boolean(card[key])} onChange={(event) => updateCard({ [key]: event.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
</label>
))}
</div>
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Description</span>
<textarea value={card.description || ''} onChange={(event) => updateCard({ description: event.target.value })} rows={3} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" />
</label>
{advancedMode && (
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Style family</span>
<select value={card.style_family || ''} onChange={(event) => updateCard({ style_family: event.target.value || null })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="">None</option>
{(editorOptions.style_families || []).map((sf) => <option key={sf.key} value={sf.key}>{sf.label}</option>)}
</select>
</label>
)}
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Save to collection</div>
<div className="flex gap-2">
<select value={selectedCollectionId} onChange={(event) => setSelectedCollectionId(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="">Default saved cards</option>
{collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}
</select>
<button type="button" onClick={saveToCollection} disabled={!cardId || busy} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60">Save</button>
</div>
<button type="button" onClick={createCollection} className="mt-2 text-sm text-slate-400 transition hover:text-white">+ Create collection</button>
</div>
{(editorOptions.challenge_feed || []).length > 0 && (
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Challenge entry</div>
<div className="space-y-2">
{(editorOptions.challenge_feed || []).slice(0, 4).map((challenge) => (
<button key={challenge.id} type="button" onClick={() => submitChallenge(challenge.id)} disabled={!cardId || busy} className="flex w-full items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-left text-sm text-slate-200 transition hover:bg-white/[0.05] disabled:opacity-60">
<span>{challenge.title}</span>
<span className="text-xs uppercase tracking-[0.16em] text-slate-400">{challenge.status}</span>
</button>
))}
</div>
</div>
)}
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Version history</div>
<div className="space-y-2">
{versions.length > 0 ? versions.map((version) => (
<div key={version.id} className="rounded-2xl border border-white/10 bg-white/[0.03] p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-white">Version {version.version_number}</div>
<div className="text-xs text-slate-400">{version.label || 'Autosaved'}{version.created_at ? ` · ${new Date(version.created_at).toLocaleString()}` : ''}</div>
</div>
<button type="button" onClick={() => restoreVersion(version.id)} disabled={busy || !cardId} className="shrink-0 rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.08] disabled:opacity-60">Restore</button>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em]">
{compareProjectSnapshots(card.project_json || {}, version.snapshot_json || {}).map((item) => (
<span key={`${version.id}-${item}`} className="rounded-full border border-sky-300/15 bg-sky-400/10 px-2 py-0.5 text-sky-200/80">{item}</span>
))}
</div>
</div>
)) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">Versions appear here after the first saved snapshot.</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-white/10 pt-4">
{cardId ? <Link href={endpoints.studioCards || '/studio/cards'} className="text-sm text-slate-300 transition hover:text-white"> Back to cards</Link> : null}
{card.lineage?.original_card ? <div className="text-xs uppercase tracking-[0.16em] text-sky-200/70">Remix of: {card.lineage.original_card.title}</div> : null}
<button type="button" onClick={deleteDraft} disabled={busy || !cardId} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60">Delete draft</button>
</div>
</div>
)}
</div>
</div>
{/* Sticky preview panel */}
<div className="xl:sticky xl:top-5 xl:self-start xl:max-h-[calc(100vh-2.5rem)] xl:overflow-y-auto xl:pb-5">
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="mb-3 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Live preview</div>
<NovaCardAutosaveIndicator status={autosaveStatus} message={autosaveMessage} />
</div>
<NovaCardCanvasPreview card={card} fonts={editorOptions.font_presets || []} editable={true} onElementMove={handleElementMove} className="mx-auto w-full max-w-full" />
{card.preview_url && (
<div className="mt-5 rounded-[22px] border border-white/10 bg-white/[0.03] p-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Rendered image</div>
<img src={`${card.preview_url}?version=${card.render_version || 0}`} alt="Rendered preview" className="w-full rounded-[18px] border border-white/10 object-cover" />
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
<button type="button" onClick={manualSave} disabled={busy || !cardId} className="flex-1 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08] disabled:opacity-60">Save</button>
<button type="button" onClick={renderPreview} disabled={busy || !cardId} className="flex-1 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60">Render preview</button>
<button type="button" onClick={publishCard} disabled={busy || !cardId} className="flex-1 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2.5 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-400/15 disabled:opacity-60">Publish</button>
</div>
{card.public_url && (
<a href={card.public_url} className="mt-2 flex w-full items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.05]">
View public page
</a>
)}
</section>
{endpoints.exportPattern && (
<section className="mt-4 rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Export</div>
<div className="flex flex-wrap gap-2">
{(editorOptions.export_formats || []).map((fmt) => (
<button key={fmt.key} type="button" disabled={requestingExport || !cardId} onClick={() => requestExport(fmt.key)}
className={`rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] transition disabled:opacity-50 ${activeExportType === fmt.key && exportStatus ? '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]'}`}
>
{fmt.label}
</button>
))}
</div>
{exportStatus && (
<div className="mt-3 flex items-center gap-3 rounded-[18px] border border-white/10 bg-white/[0.03] p-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Export status</div>
<div className="text-sm font-semibold capitalize text-white">{exportStatus.status}</div>
</div>
{exportStatus.status === 'ready' && exportStatus.output_url && (
<a href={exportStatus.output_url} download className="ml-auto rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1.5 text-xs font-semibold text-emerald-200 transition hover:bg-emerald-400/15">Download</a>
)}
</div>
)}
</section>
)}
</div>
</div>
{/* Mobile bottom tab navigation */}
<nav className="sticky bottom-0 z-20 mt-6 border-t border-white/10 bg-[rgba(2,6,23,0.92)] px-4 py-3 backdrop-blur xl:hidden">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3">
<button type="button" onClick={() => goToNextTab(-1)} disabled={tabIndex === 0} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08] disabled:opacity-50">
Back
</button>
<div className="text-center">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Step {tabIndex + 1} / {editorTabs.length}</div>
<div className="mt-0.5 text-sm font-semibold text-white">{editorTabs[tabIndex]?.label}</div>
</div>
<button type="button" onClick={() => goToNextTab(1)} disabled={tabIndex >= editorTabs.length - 1} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-50">
Next
</button>
</div>
</nav>
</StudioLayout>
)
}