1501 lines
83 KiB
JavaScript
1501 lines
83 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),
|
|
author_size: Number(project?.typography?.author_size || 28),
|
|
letter_spacing: Number(project?.typography?.letter_spacing || 0),
|
|
line_height: Number(project?.typography?.line_height || 1.2),
|
|
shadow_preset: project?.typography?.shadow_preset || 'soft',
|
|
},
|
|
background: {
|
|
type: project?.background?.type || card?.background_type || 'gradient',
|
|
gradient_preset: project?.background?.gradient_preset || defaultGradient?.key || 'midnight-nova',
|
|
gradient_colors: project?.background?.gradient_colors || defaultGradient?.colors || ['#0f172a', '#1d4ed8'],
|
|
solid_color: project?.background?.solid_color || '#111827',
|
|
background_image_id: project?.background?.background_image_id || card?.background_image_id || null,
|
|
overlay_style: project?.background?.overlay_style || 'dark-soft',
|
|
focal_position: project?.background?.focal_position || 'center',
|
|
blur_level: Number(project?.background?.blur_level || 0),
|
|
opacity: Number(project?.background?.opacity || 50),
|
|
},
|
|
canvas: {
|
|
density: project?.canvas?.density || 'standard',
|
|
safe_zone: project?.canvas?.safe_zone !== false,
|
|
},
|
|
frame: {
|
|
preset: project?.frame?.preset || 'none',
|
|
color: project?.frame?.color || null,
|
|
width: Number(project?.frame?.width || 1),
|
|
},
|
|
effects: {
|
|
color_grade: project?.effects?.color_grade || 'none',
|
|
effect_preset: project?.effects?.effect_preset || 'none',
|
|
intensity: Number(project?.effects?.intensity || 50),
|
|
},
|
|
export_preferences: {
|
|
allow_export: project?.export_preferences?.allow_export !== false,
|
|
default_format: project?.export_preferences?.default_format || 'preview',
|
|
},
|
|
source_context: {
|
|
style_family: project?.source_context?.style_family || null,
|
|
palette_family: project?.source_context?.palette_family || null,
|
|
editor_mode: project?.source_context?.editor_mode || card?.editor_mode_last_used || 'full',
|
|
},
|
|
decorations: Array.isArray(project?.decorations) ? project.decorations : [],
|
|
assets: {
|
|
pack_ids: Array.isArray(project?.assets?.pack_ids) ? project.assets.pack_ids : [],
|
|
template_pack_ids: Array.isArray(project?.assets?.template_pack_ids) ? project.assets.template_pack_ids : [],
|
|
items: Array.isArray(project?.assets?.items) ? project.assets.items : [],
|
|
},
|
|
}
|
|
}
|
|
|
|
function syncTextBlocks(blocks, type, text) {
|
|
const list = Array.isArray(blocks) ? [...blocks] : defaultTextBlocks()
|
|
const index = list.findIndex((block) => block.type === type)
|
|
const next = {
|
|
key: type,
|
|
type,
|
|
text,
|
|
enabled: type === 'title' || type === 'quote' ? true : Boolean(String(text || '').trim()),
|
|
style: list[index]?.style || {},
|
|
}
|
|
|
|
if (index === -1) {
|
|
list.push(next)
|
|
return list
|
|
}
|
|
|
|
list[index] = { ...list[index], ...next }
|
|
return list
|
|
}
|
|
|
|
function normalizeCard(card, options) {
|
|
if (!card) {
|
|
const defaultTemplate = options.templates?.[0] || null
|
|
const defaultCategory = options.categories?.[0] || null
|
|
const project = normalizeProject(null, options)
|
|
|
|
return {
|
|
id: null,
|
|
title: 'Untitled card',
|
|
quote_text: 'Your next quote starts here.',
|
|
quote_author: '',
|
|
quote_source: '',
|
|
description: '',
|
|
format: options.formats?.[0]?.key || 'square',
|
|
visibility: 'private',
|
|
status: 'draft',
|
|
moderation_status: 'pending',
|
|
allow_download: true,
|
|
background_type: 'gradient',
|
|
template_id: defaultTemplate?.id || null,
|
|
category_id: defaultCategory?.id || null,
|
|
background_image_id: null,
|
|
tags: [],
|
|
preview_url: null,
|
|
public_url: null,
|
|
schema_version: 2,
|
|
allow_remix: true,
|
|
likes_count: 0,
|
|
favorites_count: 0,
|
|
saves_count: 0,
|
|
remixes_count: 0,
|
|
challenge_entries_count: 0,
|
|
lineage: { original_card: null, root_card: null },
|
|
editor_mode_last_used: 'full',
|
|
project_json: project,
|
|
}
|
|
}
|
|
|
|
return {
|
|
...card,
|
|
tags: Array.isArray(card.tags) ? card.tags : [],
|
|
allow_remix: card.allow_remix !== false,
|
|
project_json: normalizeProject(card.project_json || {}, options, card),
|
|
}
|
|
}
|
|
|
|
function buildPayload(card, tagInput) {
|
|
return {
|
|
title: card.title,
|
|
quote_text: card.quote_text,
|
|
quote_author: card.quote_author,
|
|
quote_source: card.quote_source,
|
|
description: card.description,
|
|
format: card.format,
|
|
visibility: card.visibility,
|
|
allow_download: Boolean(card.allow_download),
|
|
allow_remix: Boolean(card.allow_remix),
|
|
allow_background_reuse: Boolean(card.allow_background_reuse),
|
|
allow_export: Boolean(card.allow_export !== false),
|
|
style_family: card.style_family || null,
|
|
palette_family: card.palette_family || null,
|
|
editor_mode_last_used: card.editor_mode_last_used || card.project_json?.source_context?.editor_mode || 'full',
|
|
background_type: card.background_type,
|
|
background_image_id: card.background_image_id,
|
|
template_id: card.template_id,
|
|
category_id: card.category_id,
|
|
tags: String(tagInput || '')
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean),
|
|
project_json: card.project_json,
|
|
}
|
|
}
|
|
|
|
function fillPattern(pattern, replacements) {
|
|
let resolved = String(pattern || '')
|
|
Object.entries(replacements).forEach(([key, value]) => {
|
|
resolved = resolved.replace(`__${key}__`, String(value))
|
|
})
|
|
return resolved
|
|
}
|
|
|
|
function apiUrl(pattern, id) {
|
|
return fillPattern(pattern, { CARD: id })
|
|
}
|
|
|
|
export default function StudioCardEditor() {
|
|
const { props } = usePage()
|
|
const editorOptions = props.editorOptions || {}
|
|
const endpoints = props.endpoints || {}
|
|
const previewMode = Boolean(props.previewMode)
|
|
const mobileSteps = Array.isArray(props.mobileSteps) && props.mobileSteps.length ? props.mobileSteps : defaultMobileSteps
|
|
|
|
const [card, setCard] = React.useState(() => normalizeCard(props.card, editorOptions))
|
|
const [cardId, setCardId] = React.useState(props.card?.id || null)
|
|
const [tagInput, setTagInput] = React.useState(() => (props.card?.tags || []).map((tag) => tag.name).join(', '))
|
|
const [versions, setVersions] = React.useState(() => Array.isArray(props.versions) ? props.versions : [])
|
|
const [collections, setCollections] = React.useState([])
|
|
const [selectedCollectionId, setSelectedCollectionId] = React.useState('')
|
|
const [autosaveStatus, setAutosaveStatus] = React.useState(props.card ? 'saved' : 'idle')
|
|
const [autosaveMessage, setAutosaveMessage] = React.useState(props.card ? 'Loaded' : 'Preparing draft')
|
|
const [busy, setBusy] = React.useState(false)
|
|
const [uploading, setUploading] = React.useState(false)
|
|
const [currentMobileStep, setCurrentMobileStep] = React.useState(previewMode ? 'preview' : mobileSteps[0]?.key || 'format')
|
|
// v3 state
|
|
const [creatorPresets, setCreatorPresets] = React.useState(() => editorOptions.creator_presets || {})
|
|
const [aiSuggestions, setAiSuggestions] = React.useState(null)
|
|
const [loadingAi, setLoadingAi] = React.useState(false)
|
|
const [exportStatus, setExportStatus] = React.useState(null)
|
|
const [requestingExport, setRequestingExport] = React.useState(false)
|
|
const [activeExportType, setActiveExportType] = React.useState('preview')
|
|
const createStarted = React.useRef(false)
|
|
const lastSerialized = React.useRef(JSON.stringify(buildPayload(normalizeCard(props.card, editorOptions), tagInput)))
|
|
|
|
React.useEffect(() => {
|
|
setCurrentMobileStep(previewMode ? 'preview' : mobileSteps[0]?.key || 'format')
|
|
}, [mobileSteps, previewMode])
|
|
|
|
function replaceTextBlocks(nextBlocks) {
|
|
setCard((current) => {
|
|
const blocks = Array.isArray(nextBlocks) ? nextBlocks : []
|
|
const next = { ...current }
|
|
next.project_json = deepMerge(current.project_json || {}, { text_blocks: blocks })
|
|
|
|
const quoteBlock = blocks.find((block) => block?.type === 'quote')
|
|
const titleBlock = blocks.find((block) => block?.type === 'title')
|
|
const authorBlock = blocks.find((block) => block?.type === 'author')
|
|
const sourceBlock = blocks.find((block) => block?.type === 'source')
|
|
next.title = titleBlock?.text || next.title
|
|
next.quote_text = quoteBlock?.text || next.quote_text
|
|
next.quote_author = authorBlock?.text || ''
|
|
next.quote_source = sourceBlock?.text || ''
|
|
next.project_json.content = {
|
|
...(next.project_json.content || {}),
|
|
title: next.title,
|
|
quote_text: next.quote_text,
|
|
quote_author: next.quote_author,
|
|
quote_source: next.quote_source,
|
|
}
|
|
|
|
return next
|
|
})
|
|
}
|
|
|
|
function loadVersions(targetCardId) {
|
|
if (!targetCardId || !endpoints.draftVersionsPattern) return
|
|
window.axios.get(apiUrl(endpoints.draftVersionsPattern, targetCardId))
|
|
.then((response) => {
|
|
setVersions(Array.isArray(response.data?.data) ? response.data.data : [])
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
function loadCollections() {
|
|
if (!endpoints.collectionsIndex) return
|
|
window.axios.get(endpoints.collectionsIndex)
|
|
.then((response) => {
|
|
const items = Array.isArray(response.data?.data) ? response.data.data : []
|
|
setCollections(items)
|
|
if (!selectedCollectionId && items[0]?.id) {
|
|
setSelectedCollectionId(String(items[0].id))
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
if (!cardId) return
|
|
loadVersions(cardId)
|
|
loadCollections()
|
|
}, [cardId])
|
|
|
|
React.useEffect(() => {
|
|
if (cardId || createStarted.current) return
|
|
createStarted.current = true
|
|
setBusy(true)
|
|
|
|
window.axios.post(endpoints.draftStore, {
|
|
format: card.format,
|
|
template_id: card.template_id,
|
|
category_id: card.category_id,
|
|
}).then((response) => {
|
|
const nextCard = normalizeCard(response.data.data, editorOptions)
|
|
setCard(nextCard)
|
|
setCardId(nextCard.id)
|
|
setAutosaveStatus('saved')
|
|
setAutosaveMessage('Draft created')
|
|
lastSerialized.current = JSON.stringify(buildPayload(nextCard, tagInput))
|
|
}).catch(() => {
|
|
setAutosaveStatus('error')
|
|
setAutosaveMessage('Could not create draft')
|
|
}).finally(() => {
|
|
setBusy(false)
|
|
})
|
|
}, [card, cardId, editorOptions, endpoints.draftStore, tagInput])
|
|
|
|
React.useEffect(() => {
|
|
if (!cardId || busy || uploading) return
|
|
|
|
const payload = buildPayload(card, tagInput)
|
|
const serialized = JSON.stringify(payload)
|
|
if (serialized === lastSerialized.current) return
|
|
|
|
setAutosaveStatus('saving')
|
|
setAutosaveMessage('Saving draft')
|
|
|
|
const timer = window.setTimeout(() => {
|
|
window.axios.post(apiUrl(endpoints.draftAutosavePattern, cardId), payload)
|
|
.then((response) => {
|
|
const nextCard = normalizeCard(response.data.data, editorOptions)
|
|
setCard(nextCard)
|
|
lastSerialized.current = JSON.stringify(buildPayload(nextCard, tagInput))
|
|
setAutosaveStatus('saved')
|
|
setAutosaveMessage('All changes saved')
|
|
})
|
|
.catch(() => {
|
|
setAutosaveStatus('error')
|
|
setAutosaveMessage('Autosave failed')
|
|
})
|
|
}, 900)
|
|
|
|
return () => window.clearTimeout(timer)
|
|
}, [busy, card, cardId, editorOptions, endpoints.draftAutosavePattern, tagInput, uploading])
|
|
|
|
function updateCard(partial, projectPatch = null) {
|
|
setCard((current) => {
|
|
const next = { ...current, ...partial }
|
|
if (projectPatch) {
|
|
next.project_json = deepMerge(current.project_json || {}, projectPatch)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
function updateTextField(key, value) {
|
|
const typeMap = {
|
|
title: 'title',
|
|
quote_text: 'quote',
|
|
quote_author: 'author',
|
|
quote_source: 'source',
|
|
}
|
|
|
|
setCard((current) => {
|
|
const next = { ...current, [key]: value }
|
|
next.project_json = deepMerge(current.project_json || {}, {
|
|
content: { [key]: value },
|
|
text_blocks: syncTextBlocks(current.project_json?.text_blocks, typeMap[key], value),
|
|
})
|
|
return next
|
|
})
|
|
}
|
|
|
|
function updateTextBlock(index, patch) {
|
|
const blocks = Array.isArray(card.project_json?.text_blocks) ? [...card.project_json.text_blocks] : []
|
|
blocks[index] = { ...blocks[index], ...patch }
|
|
replaceTextBlocks(blocks)
|
|
}
|
|
|
|
function addTextBlock(type = 'body') {
|
|
const nextBlock = {
|
|
key: `${type}-${Date.now()}`,
|
|
type,
|
|
text: '',
|
|
enabled: true,
|
|
style: {},
|
|
}
|
|
replaceTextBlocks([...(Array.isArray(card.project_json?.text_blocks) ? card.project_json.text_blocks : []), nextBlock])
|
|
}
|
|
|
|
function removeTextBlock(index) {
|
|
const blocks = Array.isArray(card.project_json?.text_blocks) ? card.project_json.text_blocks : []
|
|
replaceTextBlocks(blocks.filter((_, itemIndex) => itemIndex !== index))
|
|
}
|
|
|
|
function moveTextBlock(index, direction) {
|
|
const blocks = Array.isArray(card.project_json?.text_blocks) ? [...card.project_json.text_blocks] : []
|
|
const nextIndex = index + direction
|
|
|
|
if (nextIndex < 0 || nextIndex >= blocks.length) {
|
|
return
|
|
}
|
|
|
|
const [moved] = blocks.splice(index, 1)
|
|
blocks.splice(nextIndex, 0, moved)
|
|
replaceTextBlocks(blocks)
|
|
}
|
|
|
|
function setEditorMode(mode) {
|
|
updateCard({ editor_mode_last_used: mode }, { source_context: { editor_mode: mode } })
|
|
}
|
|
|
|
function handleTemplateSelect(template) {
|
|
const layoutPreset = template.config_json?.layout || 'quote_heavy'
|
|
|
|
updateCard(
|
|
{ template_id: template.id },
|
|
{
|
|
template: { id: template.id, slug: template.slug },
|
|
layout: {
|
|
layout: layoutPreset,
|
|
...(layoutPresetMap[layoutPreset] || {}),
|
|
alignment: template.config_json?.text_align || layoutPresetMap[layoutPreset]?.alignment || 'center',
|
|
},
|
|
typography: {
|
|
font_preset: template.config_json?.font_preset || card.project_json?.typography?.font_preset,
|
|
text_color: template.config_json?.text_color || card.project_json?.typography?.text_color,
|
|
},
|
|
background: {
|
|
gradient_preset: template.config_json?.gradient_preset || card.project_json?.background?.gradient_preset,
|
|
overlay_style: template.config_json?.overlay_style || card.project_json?.background?.overlay_style,
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
function handleGradientSelect(gradient) {
|
|
updateCard({ background_type: 'gradient' }, {
|
|
background: {
|
|
type: 'gradient',
|
|
gradient_preset: gradient.key,
|
|
gradient_colors: gradient.colors,
|
|
},
|
|
})
|
|
}
|
|
|
|
function handleFontSelect(font) {
|
|
updateCard({}, {
|
|
typography: {
|
|
font_preset: font.key,
|
|
},
|
|
})
|
|
}
|
|
|
|
function reloadPresets() {
|
|
if (!endpoints.presetsIndex) return
|
|
window.axios.get(endpoints.presetsIndex)
|
|
.then((response) => {
|
|
const data = response.data?.data || response.data || {}
|
|
setCreatorPresets(data)
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
function handleApplyPresetPatch(patch) {
|
|
setCard((current) => ({
|
|
...current,
|
|
project_json: deepMerge(current.project_json || {}, patch),
|
|
}))
|
|
}
|
|
|
|
function fetchAiSuggestions() {
|
|
if (!cardId || !endpoints.aiSuggestPattern) return
|
|
const url = endpoints.aiSuggestPattern.replace('__CARD__', cardId)
|
|
setLoadingAi(true)
|
|
window.axios.get(url)
|
|
.then((response) => setAiSuggestions(response.data?.suggestions || response.data || null))
|
|
.catch(() => {})
|
|
.finally(() => setLoadingAi(false))
|
|
}
|
|
|
|
function applyAiTagSuggestions(tags) {
|
|
if (!Array.isArray(tags) || tags.length === 0) return
|
|
const existing = tagInput ? tagInput.split(',').map((t) => t.trim()).filter(Boolean) : []
|
|
const merged = [...new Set([...existing, ...tags])]
|
|
setTagInput(merged.join(', '))
|
|
}
|
|
|
|
function requestExport(exportType) {
|
|
if (!cardId || !endpoints.exportPattern) return
|
|
const url = endpoints.exportPattern.replace('__CARD__', cardId)
|
|
setRequestingExport(true)
|
|
setExportStatus(null)
|
|
setActiveExportType(exportType)
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
window.axios.post(url, { export_type: exportType }, { headers: { 'X-CSRF-TOKEN': csrfToken } })
|
|
.then((response) => {
|
|
const exportData = response.data?.data || response.data
|
|
setExportStatus(exportData)
|
|
// Poll until ready
|
|
if (exportData?.status === 'pending' || exportData?.status === 'processing') {
|
|
pollExportStatus(exportData.id)
|
|
}
|
|
})
|
|
.catch(() => setExportStatus({ status: 'failed' }))
|
|
.finally(() => setRequestingExport(false))
|
|
}
|
|
|
|
function pollExportStatus(exportId, attempts = 0) {
|
|
if (!endpoints.exportStatusPattern || attempts > 20) return
|
|
const url = endpoints.exportStatusPattern.replace('__EXPORT__', exportId)
|
|
window.setTimeout(() => {
|
|
window.axios.get(url)
|
|
.then((response) => {
|
|
const data = response.data?.data || response.data
|
|
setExportStatus(data)
|
|
if (data?.status === 'pending' || data?.status === 'processing') {
|
|
pollExportStatus(exportId, attempts + 1)
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}, 2500)
|
|
}
|
|
|
|
function applyLayoutPreset(presetKey) {
|
|
updateCard({}, {
|
|
layout: {
|
|
layout: presetKey,
|
|
...(layoutPresetMap[presetKey] || {}),
|
|
},
|
|
})
|
|
}
|
|
|
|
function addDecoration(decoration) {
|
|
const placements = ['top-left', 'top-right', 'bottom-left', 'bottom-right']
|
|
const current = Array.isArray(card.project_json?.decorations) ? card.project_json.decorations : []
|
|
updateCard({}, {
|
|
decorations: [
|
|
...current,
|
|
{
|
|
key: decoration.key,
|
|
glyph: decoration.glyph,
|
|
placement: placements[current.length % placements.length],
|
|
size: 28,
|
|
},
|
|
].slice(0, editorOptions.validation?.max_decorations || 6),
|
|
})
|
|
}
|
|
|
|
function removeDecoration(index) {
|
|
const current = Array.isArray(card.project_json?.decorations) ? card.project_json.decorations : []
|
|
updateCard({}, { decorations: current.filter((_, itemIndex) => itemIndex !== index) })
|
|
}
|
|
|
|
function togglePack(packId, bucket = 'pack_ids') {
|
|
const current = Array.isArray(card.project_json?.assets?.[bucket]) ? card.project_json.assets[bucket] : []
|
|
const numericId = Number(packId)
|
|
const next = current.includes(numericId) ? current.filter((item) => item !== numericId) : [...current, numericId]
|
|
updateCard({}, { assets: { [bucket]: next } })
|
|
}
|
|
|
|
function addAssetItem(item) {
|
|
const current = Array.isArray(card.project_json?.assets?.items) ? card.project_json.assets.items : []
|
|
updateCard({}, {
|
|
assets: {
|
|
items: [...current, {
|
|
asset_key: item.key,
|
|
label: item.label,
|
|
glyph: item.glyph,
|
|
type: item.type || 'glyph',
|
|
}].slice(0, editorOptions.validation?.max_asset_items || 12),
|
|
},
|
|
})
|
|
}
|
|
|
|
function removeAssetItem(index) {
|
|
const current = Array.isArray(card.project_json?.assets?.items) ? card.project_json.assets.items : []
|
|
updateCard({}, { assets: { items: current.filter((_, itemIndex) => itemIndex !== index) } })
|
|
}
|
|
|
|
function manualSave() {
|
|
if (!cardId) return
|
|
setBusy(true)
|
|
setAutosaveStatus('saving')
|
|
window.axios.patch(apiUrl(endpoints.draftUpdatePattern, cardId), buildPayload(card, tagInput))
|
|
.then((response) => {
|
|
const nextCard = normalizeCard(response.data.data, editorOptions)
|
|
setCard(nextCard)
|
|
lastSerialized.current = JSON.stringify(buildPayload(nextCard, tagInput))
|
|
setAutosaveStatus('saved')
|
|
setAutosaveMessage('Draft saved')
|
|
loadVersions(nextCard.id)
|
|
})
|
|
.catch(() => {
|
|
setAutosaveStatus('error')
|
|
setAutosaveMessage('Save failed')
|
|
})
|
|
.finally(() => setBusy(false))
|
|
}
|
|
|
|
function renderPreview() {
|
|
if (!cardId) return
|
|
setBusy(true)
|
|
window.axios.post(apiUrl(endpoints.draftRenderPattern, cardId))
|
|
.then((response) => {
|
|
const nextCard = normalizeCard(response.data.data, editorOptions)
|
|
setCard(nextCard)
|
|
setAutosaveStatus('saved')
|
|
setAutosaveMessage('Preview rendered')
|
|
})
|
|
.catch(() => {
|
|
setAutosaveStatus('error')
|
|
setAutosaveMessage('Render failed')
|
|
})
|
|
.finally(() => setBusy(false))
|
|
}
|
|
|
|
function publishCard() {
|
|
if (!cardId) return
|
|
setBusy(true)
|
|
window.axios.post(apiUrl(endpoints.draftPublishPattern, cardId), buildPayload(card, tagInput))
|
|
.then((response) => {
|
|
const nextCard = normalizeCard(response.data.data, editorOptions)
|
|
setCard(nextCard)
|
|
setAutosaveStatus('saved')
|
|
setAutosaveMessage('Card published')
|
|
loadVersions(nextCard.id)
|
|
})
|
|
.catch((error) => {
|
|
setAutosaveStatus('error')
|
|
setAutosaveMessage(error?.response?.data?.message || 'Publish failed')
|
|
})
|
|
.finally(() => setBusy(false))
|
|
}
|
|
|
|
function deleteDraft() {
|
|
if (!cardId || !window.confirm('Delete this draft?')) return
|
|
setBusy(true)
|
|
window.axios.delete(apiUrl(endpoints.draftDeletePattern, cardId))
|
|
.then(() => {
|
|
window.location.assign(endpoints.studioCards || '/studio/cards')
|
|
})
|
|
.finally(() => setBusy(false))
|
|
}
|
|
|
|
function uploadBackground(event) {
|
|
const file = event.target.files?.[0]
|
|
if (!file || !cardId) return
|
|
|
|
const formData = new FormData()
|
|
formData.append('background', file)
|
|
|
|
setUploading(true)
|
|
window.axios.post(apiUrl(endpoints.draftBackgroundPattern, cardId), formData).then((response) => {
|
|
const nextCard = normalizeCard(response.data.data, editorOptions)
|
|
setCard(nextCard)
|
|
setAutosaveStatus('saved')
|
|
setAutosaveMessage('Background uploaded')
|
|
}).catch(() => {
|
|
setAutosaveStatus('error')
|
|
setAutosaveMessage('Upload failed')
|
|
}).finally(() => setUploading(false))
|
|
}
|
|
|
|
function createCollection() {
|
|
const name = window.prompt('Collection name')
|
|
if (!name || !endpoints.collectionsStore) return
|
|
window.axios.post(endpoints.collectionsStore, { name })
|
|
.then(() => loadCollections())
|
|
.catch(() => {
|
|
setAutosaveStatus('error')
|
|
setAutosaveMessage('Collection could not be created')
|
|
})
|
|
}
|
|
|
|
function saveToCollection() {
|
|
if (!cardId || !endpoints.savePattern) return
|
|
setBusy(true)
|
|
window.axios.post(apiUrl(endpoints.savePattern, cardId), {
|
|
collection_id: selectedCollectionId ? Number(selectedCollectionId) : undefined,
|
|
}).then((response) => {
|
|
setCard((current) => ({ ...current, saves_count: Number(response.data?.saves_count || current.saves_count || 0) }))
|
|
setAutosaveStatus('saved')
|
|
setAutosaveMessage('Saved to collection')
|
|
loadCollections()
|
|
}).catch(() => {
|
|
setAutosaveStatus('error')
|
|
setAutosaveMessage('Save to collection failed')
|
|
}).finally(() => setBusy(false))
|
|
}
|
|
|
|
function restoreVersion(versionId) {
|
|
if (!cardId || !endpoints.draftRestorePattern) return
|
|
setBusy(true)
|
|
window.axios.post(fillPattern(endpoints.draftRestorePattern, { CARD: cardId, VERSION: versionId }))
|
|
.then((response) => {
|
|
const nextCard = normalizeCard(response.data.data, editorOptions)
|
|
setCard(nextCard)
|
|
lastSerialized.current = JSON.stringify(buildPayload(nextCard, tagInput))
|
|
setAutosaveStatus('saved')
|
|
setAutosaveMessage('Version restored')
|
|
loadVersions(nextCard.id)
|
|
})
|
|
.catch(() => {
|
|
setAutosaveStatus('error')
|
|
setAutosaveMessage('Restore failed')
|
|
})
|
|
.finally(() => setBusy(false))
|
|
}
|
|
|
|
function submitChallenge(challengeId) {
|
|
if (!cardId || !endpoints.challengeSubmitPattern) return
|
|
setBusy(true)
|
|
window.axios.post(fillPattern(endpoints.challengeSubmitPattern, { CHALLENGE: challengeId, CARD: cardId }))
|
|
.then((response) => {
|
|
setCard((current) => ({ ...current, challenge_entries_count: Number(response.data?.challenge_entries_count || current.challenge_entries_count || 0) }))
|
|
setAutosaveStatus('saved')
|
|
setAutosaveMessage('Submitted to challenge')
|
|
})
|
|
.catch((error) => {
|
|
setAutosaveStatus('error')
|
|
setAutosaveMessage(error?.response?.data?.message || 'Challenge submission failed')
|
|
})
|
|
.finally(() => setBusy(false))
|
|
}
|
|
|
|
const decorations = Array.isArray(card.project_json?.decorations) ? card.project_json.decorations : []
|
|
const textBlocks = Array.isArray(card.project_json?.text_blocks) ? card.project_json.text_blocks : []
|
|
const backgroundType = card.project_json?.background?.type || card.background_type || 'gradient'
|
|
const assetItems = Array.isArray(card.project_json?.assets?.items) ? card.project_json.assets.items : []
|
|
const selectedAssetPackIds = Array.isArray(card.project_json?.assets?.pack_ids) ? card.project_json.assets.pack_ids : []
|
|
const selectedTemplatePackIds = Array.isArray(card.project_json?.assets?.template_pack_ids) ? card.project_json.assets.template_pack_ids : []
|
|
const currentMobileStepIndex = Math.max(0, mobileSteps.findIndex((step) => step.key === currentMobileStep))
|
|
const currentMobileStepMeta = mobileSteps[currentMobileStepIndex] || mobileSteps[0]
|
|
const editorMode = card.editor_mode_last_used || card.project_json?.source_context?.editor_mode || 'full'
|
|
const advancedMode = editorMode !== 'quick'
|
|
const currentProjectSummary = summarizeProjectSnapshot(card.project_json || {})
|
|
|
|
function sectionVisibility(stepKey) {
|
|
return `${currentMobileStep === stepKey ? 'block' : 'hidden'} xl:block`
|
|
}
|
|
|
|
function goToStep(index) {
|
|
const step = mobileSteps[index]
|
|
if (!step) return
|
|
setCurrentMobileStep(step.key)
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
}
|
|
|
|
return (
|
|
<StudioLayout title={previewMode ? 'Card Preview' : 'Card Editor'}>
|
|
<Head title={previewMode ? 'Nova Card Preview' : 'Nova Card Editor'} />
|
|
|
|
<section className="mb-6 rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_40%),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="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Nova Cards editor</p>
|
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Structured card creation with live preview and autosave.</h2>
|
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
<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>
|
|
<span className="text-sm text-slate-300">Quick mode keeps the core card flow visible. Advanced mode unlocks layered text, fine-tuning, and deeper effects.</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<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.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="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="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>
|
|
{card.public_url ? <a href={card.public_url} 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]">Public page</a> : null}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mb-6 xl:hidden">
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_40px_rgba(2,6,23,0.18)]">
|
|
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
{mobileSteps.map((step, index) => (
|
|
<button key={step.key} type="button" onClick={() => goToStep(index)} className={pillClasses(currentMobileStep === step.key)}>
|
|
{index + 1}. {step.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{currentMobileStepMeta ? (
|
|
<div className="mt-3 rounded-[18px] border border-white/10 bg-[#0d1726] px-4 py-3">
|
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-200/70">Current step</div>
|
|
<div className="mt-1 text-base font-semibold text-white">{currentMobileStepMeta.label}</div>
|
|
<div className="mt-1 text-sm text-slate-300">{currentMobileStepMeta.description}</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,320px)_minmax(0,1fr)_minmax(0,340px)]">
|
|
<div className="space-y-6">
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('background')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Templates</div>
|
|
<NovaCardTemplatePicker templates={editorOptions.templates || []} selectedId={card.template_id} onSelect={handleTemplateSelect} />
|
|
</section>
|
|
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('background')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Backgrounds</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>
|
|
|
|
{(backgroundType === 'gradient' || backgroundType === 'template') ? (
|
|
<div className="mt-4">
|
|
<NovaCardGradientPicker gradients={editorOptions.gradient_presets || []} selectedKey={card.project_json?.background?.gradient_preset} onSelect={handleGradientSelect} />
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
<label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Solid color</span>
|
|
<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-full rounded-2xl border border-white/10 bg-[#0d1726] p-2" />
|
|
</label>
|
|
{advancedMode ? <label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Overlay</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> : null}
|
|
{advancedMode ? <label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Background blur</span>
|
|
<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> : null}
|
|
{advancedMode ? <label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Overlay opacity</span>
|
|
<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> : null}
|
|
{advancedMode ? <label className="block text-sm text-slate-300 sm:col-span-2">
|
|
<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> : null}
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-[22px] border border-dashed border-white/12 bg-white/[0.03] p-4">
|
|
<label className="flex cursor-pointer items-center justify-between gap-4 text-sm text-slate-300">
|
|
<span>Upload background image</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-white">{uploading ? 'Uploading' : 'Choose'}</span>
|
|
<input type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={uploadBackground} />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
{advancedMode ? <section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('background')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Decorations</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{(editorOptions.decor_presets || []).map((decor) => (
|
|
<button key={decor.key} type="button" onClick={() => addDecoration(decor)} className="flex items-center justify-between rounded-[18px] border border-white/10 bg-white/[0.03] px-4 py-3 text-left text-sm text-white transition hover:bg-white/[0.05]">
|
|
<span>{decor.label}</span>
|
|
<span className="text-lg text-sky-100">{decor.glyph}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{decorations.length ? (
|
|
<div className="mt-4 space-y-2">
|
|
{decorations.map((decor, index) => (
|
|
<div key={`${decor.key}-${index}`} 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>{decor.glyph || '✦'} {decor.key}</span>
|
|
<button type="button" onClick={() => removeDecoration(index)} className="text-rose-200 transition hover:text-rose-100">Remove</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</section> : null}
|
|
|
|
{advancedMode ? <section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('background')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Official packs</div>
|
|
<div>
|
|
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Asset packs</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{(editorOptions.asset_packs || []).map((pack) => (
|
|
<button key={`${pack.id || pack.slug}-asset`} type="button" onClick={() => pack.id && togglePack(pack.id, 'pack_ids')} className={pillClasses(pack.id ? selectedAssetPackIds.includes(Number(pack.id)) : false)}>
|
|
{pack.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="mt-4">
|
|
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Template packs</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{(editorOptions.template_packs || []).map((pack) => (
|
|
<button key={`${pack.id || pack.slug}-template`} type="button" onClick={() => pack.id && togglePack(pack.id, 'template_pack_ids')} className={pillClasses(pack.id ? selectedTemplatePackIds.includes(Number(pack.id)) : false)}>
|
|
{pack.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-3">
|
|
{(editorOptions.asset_packs || []).map((pack) => {
|
|
const items = pack?.manifest_json?.items || []
|
|
if (!items.length) return null
|
|
|
|
return (
|
|
<div key={`${pack.id || pack.slug}-items`}>
|
|
<div className="mb-2 text-sm font-semibold text-white">{pack.name}</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{items.map((item) => (
|
|
<button key={`${pack.id || pack.slug}-${item.key}`} type="button" onClick={() => addAssetItem(item)} className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.05]">
|
|
{item.glyph ? `${item.glyph} ` : ''}{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{assetItems.length ? (
|
|
<div className="mt-4 space-y-2">
|
|
{assetItems.map((item, index) => (
|
|
<div key={`${item.asset_key || item.label || 'asset'}-${index}`} 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>{item.glyph ? `${item.glyph} ` : ''}{item.label || item.asset_key}</span>
|
|
<button type="button" onClick={() => removeAssetItem(index)} className="text-rose-200 transition hover:text-rose-100">Remove</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</section> : null}
|
|
</div>
|
|
|
|
<div className={`space-y-5 ${sectionVisibility('preview')}`}>
|
|
<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)]">
|
|
<NovaCardCanvasPreview card={card} className="mx-auto max-w-[760px]" />
|
|
{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 preview</div>
|
|
<img src={card.preview_url} alt="Rendered preview" className="w-full rounded-[18px] border border-white/10 object-cover" />
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('content')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Content</div>
|
|
<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>
|
|
</div>
|
|
|
|
<div className="mt-5 border-t border-white/10 pt-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Text blocks</div>
|
|
<div className="mt-1 text-sm text-slate-300">{advancedMode ? 'Layer extra body or caption blocks without breaking legacy quote fields.' : 'Switch to advanced mode to reorder layered text blocks and add extra copy regions.'}</div>
|
|
</div>
|
|
{advancedMode ? <div className="flex flex-wrap gap-2">
|
|
<button type="button" onClick={() => addTextBlock('body')} className="rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white">Add body</button>
|
|
<button type="button" onClick={() => addTextBlock('caption')} className="rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white">Add caption</button>
|
|
</div> : null}
|
|
</div>
|
|
|
|
<div className="mt-4 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-4">
|
|
<div className={`grid gap-3 ${advancedMode ? 'sm:grid-cols-[42px_160px_minmax(0,1fr)_auto]' : 'sm:grid-cols-[160px_minmax(0,1fr)_auto]'} sm:items-center`}>
|
|
{advancedMode ? <div className="flex flex-col gap-2">
|
|
<button type="button" onClick={() => moveTextBlock(index, -1)} disabled={index === 0} className="rounded-xl border border-white/10 bg-white/[0.05] px-2 py-1 text-xs font-semibold text-white transition hover:bg-white/[0.08] disabled:opacity-40">Up</button>
|
|
<button type="button" onClick={() => moveTextBlock(index, 1)} disabled={index === textBlocks.length - 1} className="rounded-xl border border-white/10 bg-white/[0.05] px-2 py-1 text-xs font-semibold text-white transition hover:bg-white/[0.08] disabled:opacity-40">Dn</button>
|
|
</div> : null}
|
|
<select value={block.type || 'body'} onChange={(event) => updateTextBlock(index, { type: event.target.value })} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
|
<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="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" />
|
|
<div className="flex items-center gap-3">
|
|
{advancedMode ? <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{index + 1}</span> : null}
|
|
<label className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-slate-400">
|
|
<input type="checkbox" checked={block.enabled !== false} onChange={(event) => updateTextBlock(index, { enabled: event.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
|
|
On
|
|
</label>
|
|
{advancedMode ? <button type="button" onClick={() => removeTextBlock(index)} disabled={block.type === 'title' || block.type === 'quote'} className="text-sm text-rose-200 transition hover:text-rose-100 disabled:opacity-40">Remove</button> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('style')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Typography</div>
|
|
<NovaCardFontPicker fonts={editorOptions.font_presets || []} selectedKey={card.project_json?.typography?.font_preset} onSelect={handleFontSelect} />
|
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
<label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Quote size</span>
|
|
<input type="range" min="32" max="128" value={card.project_json?.typography?.quote_size || 72} onChange={(event) => updateCard({}, { typography: { quote_size: Number(event.target.value) } })} className="w-full" />
|
|
</label>
|
|
<label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Author size</span>
|
|
<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" />
|
|
</label>
|
|
<label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Text color</span>
|
|
<input type="color" value={card.project_json?.typography?.text_color || '#ffffff'} onChange={(event) => updateCard({}, { typography: { text_color: event.target.value } })} className="h-12 w-full rounded-2xl border border-white/10 bg-[#0d1726] p-2" />
|
|
</label>
|
|
<label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Accent color</span>
|
|
<input type="color" value={card.project_json?.typography?.accent_color || '#e0f2fe'} onChange={(event) => updateCard({}, { typography: { accent_color: event.target.value } })} className="h-12 w-full rounded-2xl border border-white/10 bg-[#0d1726] p-2" />
|
|
</label>
|
|
{advancedMode ? <label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Letter spacing</span>
|
|
<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" />
|
|
</label> : null}
|
|
{advancedMode ? <label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Line height</span>
|
|
<select value={String(card.project_json?.typography?.line_height || 1.2)} onChange={(event) => updateCard({}, { typography: { line_height: Number(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.line_height_presets || []).map((preset) => <option key={preset.key} value={preset.value}>{preset.label}</option>)}
|
|
</select>
|
|
</label> : null}
|
|
</div>
|
|
{advancedMode ? <div className="mt-4 flex flex-wrap gap-2">
|
|
{(editorOptions.shadow_presets || []).map((preset) => (
|
|
<button key={preset.key} type="button" onClick={() => updateCard({}, { typography: { shadow_preset: preset.key } })} className={pillClasses((card.project_json?.typography?.shadow_preset || 'soft') === preset.key)}>
|
|
{preset.label}
|
|
</button>
|
|
))}
|
|
</div> : null}
|
|
</section>
|
|
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('format')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Format</div>
|
|
<div className="space-y-4">
|
|
<label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Format</span>
|
|
<select value={card.format || 'square'} onChange={(event) => updateCard({ format: 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.formats || []).map((format) => <option key={format.key} value={format.key}>{format.label}</option>)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('style')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Layout</div>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<span className="mb-2 block text-sm text-slate-300">Layout preset</span>
|
|
<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>
|
|
|
|
{advancedMode ? <div className="grid gap-4 sm:grid-cols-2">
|
|
<label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Alignment</span>
|
|
<select value={card.project_json?.layout?.alignment || 'center'} onChange={(event) => updateCard({}, { layout: { alignment: 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.alignment_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-2 block">Vertical position</span>
|
|
<select value={card.project_json?.layout?.position || 'center'} onChange={(event) => updateCard({}, { layout: { 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.position_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-2 block">Padding</span>
|
|
<select value={card.project_json?.layout?.padding || 'comfortable'} onChange={(event) => updateCard({}, { layout: { padding: 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.padding_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-2 block">Text width</span>
|
|
<select value={card.project_json?.layout?.max_width || 'balanced'} onChange={(event) => updateCard({}, { layout: { max_width: 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.max_width_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
|
|
</select>
|
|
</label>
|
|
</div> : <p className="text-sm text-slate-300">Advanced mode exposes alignment, spacing, and width controls when you need layout precision.</p>}
|
|
</div>
|
|
</section>
|
|
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('publish')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Publish settings</div>
|
|
<div className="space-y-4">
|
|
<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((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">Visibility</span>
|
|
<select value={card.visibility || 'private'} onChange={(event) => updateCard({ visibility: 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="private">Private</option>
|
|
<option value="unlisted">Unlisted</option>
|
|
<option value="public">Public</option>
|
|
</select>
|
|
</label>
|
|
<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>
|
|
<label 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>Allow download</span>
|
|
<input type="checkbox" checked={Boolean(card.allow_download)} onChange={(event) => updateCard({ allow_download: event.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
|
|
</label>
|
|
<label 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>Allow remix</span>
|
|
<input type="checkbox" checked={Boolean(card.allow_remix)} onChange={(event) => updateCard({ allow_remix: event.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
|
|
</label>
|
|
<label 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>Allow export</span>
|
|
<input type="checkbox" checked={Boolean(card.allow_export !== false)} onChange={(event) => updateCard({ allow_export: event.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
|
|
</label>
|
|
<label 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>Allow background reuse</span>
|
|
<input type="checkbox" checked={Boolean(card.allow_background_reuse)} onChange={(event) => updateCard({ allow_background_reuse: event.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
|
|
</label>
|
|
<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>
|
|
<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={4} 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>
|
|
</section>
|
|
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('publish')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Collections & challenges</div>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<span className="mb-2 block text-sm text-slate-300">Save to collection</span>
|
|
<div className="flex gap-3">
|
|
<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-300 transition hover:text-white">Create collection</button>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="mb-2 block text-sm text-slate-300">Challenge entry</span>
|
|
<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>
|
|
</section>
|
|
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('publish')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Version history</div>
|
|
<div className="space-y-2">
|
|
{versions.length ? versions.map((version) => (
|
|
<div key={version.id} 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">
|
|
<div className="min-w-0 flex-1 pr-4">
|
|
<div className="font-semibold text-white">Version {version.version_number}</div>
|
|
<div className="text-xs text-slate-400">{version.label || 'Autosaved snapshot'}{version.created_at ? ` • ${new Date(version.created_at).toLocaleString()}` : ''}</div>
|
|
<div className="mt-3 rounded-[18px] border border-white/10 bg-[#0d1726] px-3 py-3">
|
|
<div className="text-sm font-semibold text-white">{summarizeProjectSnapshot(version.snapshot_json || {}).title || currentProjectSummary.title}</div>
|
|
<div className="mt-1 text-sm text-slate-300">{summarizeProjectSnapshot(version.snapshot_json || {}).quote || 'Snapshot available for compare.'}</div>
|
|
<div className="mt-2 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1">Blocks {summarizeProjectSnapshot(version.snapshot_json || {}).enabledBlocks}/{summarizeProjectSnapshot(version.snapshot_json || {}).blockCount}</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1">{summarizeProjectSnapshot(version.snapshot_json || {}).layout.replace(/_/g, ' ')}</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1">{summarizeProjectSnapshot(version.snapshot_json || {}).font.replace(/-/g, ' ')}</span>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100/80">
|
|
{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-1">{item}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={() => restoreVersion(version.id)} disabled={busy || !cardId} className="rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.08] disabled:opacity-60">Restore</button>
|
|
</div>
|
|
)) : <div className="rounded-2xl border border-dashed border-white/12 bg-white/[0.03] px-4 py-5 text-sm text-slate-400">Versions appear here after the first saved snapshot.</div>}
|
|
</div>
|
|
</section>
|
|
|
|
{advancedMode ? <section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('style')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Frame & Effects</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<label className="block text-sm text-slate-300 sm:col-span-2">
|
|
<span className="mb-2 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>
|
|
<label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">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-2 block">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>
|
|
<label className="block text-sm text-slate-300">
|
|
<span className="mb-2 block">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-2 block">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>
|
|
</section> : null}
|
|
|
|
{advancedMode && (Object.keys(creatorPresets).length > 0 || endpoints.presetsIndex) ? (
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('style')}`}>
|
|
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Creator presets</div>
|
|
<NovaCardPresetPicker
|
|
presets={creatorPresets}
|
|
endpoints={endpoints}
|
|
cardId={cardId}
|
|
onApplyPatch={handleApplyPresetPatch}
|
|
onPresetsChange={reloadPresets}
|
|
/>
|
|
</section>
|
|
) : null}
|
|
|
|
{advancedMode && endpoints.aiSuggestPattern ? (
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('style')}`}>
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="text-sm 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-4">
|
|
{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-2 text-xs text-slate-400 underline transition hover:text-white">
|
|
Add all
|
|
</button>
|
|
</div>
|
|
)}
|
|
{aiSuggestions.mood && (
|
|
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2.5">
|
|
<i className="fa-solid fa-wand-magic-sparkles text-sky-300 text-xs" />
|
|
<div>
|
|
<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>
|
|
</div>
|
|
)}
|
|
{aiSuggestions.layout_suggestion && (
|
|
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2.5">
|
|
<i className="fa-solid fa-table-columns text-sky-300 text-xs" />
|
|
<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="ml-auto 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="flex items-start gap-3 rounded-xl border border-amber-400/15 bg-amber-400/[0.06] px-3 py-2.5">
|
|
<i className="fa-solid fa-triangle-exclamation text-amber-300 text-xs mt-0.5" />
|
|
<div className="text-sm text-amber-100">{fix}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-slate-500">Run a suggestion pass to see AI-powered tips for this card.</p>
|
|
)}
|
|
</section>
|
|
) : null}
|
|
|
|
{endpoints.exportPattern ? (
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('preview')}`}>
|
|
<div className="mb-4 text-sm 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 rounded-[18px] border border-white/10 bg-white/[0.03] p-4">
|
|
<div className="flex items-center gap-3">
|
|
<i className={`fa-solid text-sm ${exportStatus.status === 'ready' ? 'fa-check text-emerald-300' : exportStatus.status === 'failed' ? 'fa-triangle-exclamation text-rose-300' : 'fa-rotate fa-spin text-sky-300'}`} />
|
|
<div>
|
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Export status</div>
|
|
<div className="text-sm font-semibold text-white capitalize">{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>
|
|
</div>
|
|
)}
|
|
</section>
|
|
) : null}
|
|
|
|
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 ${sectionVisibility('publish')}`}>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Draft actions</div>
|
|
<div className="mt-2 text-sm text-slate-300">Preview route and delete controls for draft cleanup.</div>
|
|
{card.lineage?.original_card ? <div className="mt-2 text-xs uppercase tracking-[0.16em] text-sky-200/70">Remix lineage: {card.lineage.original_card.title}</div> : null}
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
{cardId ? <Link href={(endpoints.studioCards || '/studio/cards')}>Back</Link> : 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.5 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60">Delete draft</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<section 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={() => goToStep(currentMobileStepIndex - 1)} disabled={currentMobileStepIndex === 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 {currentMobileStepIndex + 1} / {mobileSteps.length}</div>
|
|
<div className="mt-1 text-sm font-semibold text-white">{currentMobileStepMeta?.label}</div>
|
|
</div>
|
|
<button type="button" onClick={() => goToStep(currentMobileStepIndex + 1)} disabled={currentMobileStepIndex >= mobileSteps.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>
|
|
</section>
|
|
</StudioLayout>
|
|
)
|
|
}
|