1691 lines
91 KiB
JavaScript
1691 lines
91 KiB
JavaScript
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 & 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 & 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 & 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 & 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>
|
||
)
|
||
}
|