Implement creator studio and upload updates
This commit is contained in:
@@ -14,6 +14,82 @@ const placementStyles = {
|
||||
'bottom-right': { bottom: '12%', right: '12%' },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DraggableElement — wraps any canvas element with pointer-drag positioning.
|
||||
// Position is tracked locally during drag (avoids parent re-renders), then
|
||||
// committed to the parent via onMove(elementId, x%, y%) on pointer-up.
|
||||
// ---------------------------------------------------------------------------
|
||||
function DraggableElement({ elementId, canvasRef, freePos, savedWidth, onMove, style, className, children }) {
|
||||
const dragState = React.useRef(null)
|
||||
const [localPos, setLocalPos] = React.useState(null)
|
||||
const [localWidth, setLocalWidth] = React.useState(null)
|
||||
|
||||
function handlePointerDown(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const canvasRect = canvas.getBoundingClientRect()
|
||||
const elemRect = event.currentTarget.getBoundingClientRect()
|
||||
const offsetX = event.clientX - elemRect.left
|
||||
const offsetY = event.clientY - elemRect.top
|
||||
const startX = ((elemRect.left - canvasRect.left) / canvasRect.width) * 100
|
||||
const startY = ((elemRect.top - canvasRect.top) / canvasRect.height) * 100
|
||||
// Capture element width as % of canvas so it stays the same after going absolute.
|
||||
const widthPct = Math.round((elemRect.width / canvasRect.width) * 1000) / 10
|
||||
dragState.current = { canvasRect, offsetX, offsetY, widthPct }
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
setLocalPos({ x: startX, y: startY })
|
||||
setLocalWidth(widthPct)
|
||||
}
|
||||
|
||||
function calcPos(event) {
|
||||
const { canvasRect, offsetX, offsetY } = dragState.current
|
||||
return {
|
||||
x: Math.max(0, Math.min(94, ((event.clientX - canvasRect.left - offsetX) / canvasRect.width) * 100)),
|
||||
y: Math.max(0, Math.min(92, ((event.clientY - canvasRect.top - offsetY) / canvasRect.height) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
if (!dragState.current) return
|
||||
setLocalPos(calcPos(event))
|
||||
}
|
||||
|
||||
function handlePointerUp(event) {
|
||||
if (!dragState.current) return
|
||||
const pos = calcPos(event)
|
||||
const widthPct = dragState.current.widthPct
|
||||
dragState.current = null
|
||||
setLocalPos(null)
|
||||
setLocalWidth(null)
|
||||
onMove?.(elementId, Math.round(pos.x * 10) / 10, Math.round(pos.y * 10) / 10, widthPct)
|
||||
}
|
||||
|
||||
const isDragging = localPos !== null
|
||||
const absPos = localPos ?? freePos
|
||||
|
||||
// Width to use when the element is absolutely positioned.
|
||||
const resolvedWidth = localWidth ?? savedWidth
|
||||
|
||||
const posStyle = absPos
|
||||
? { position: 'absolute', left: `${absPos.x}%`, top: `${absPos.y}%`, zIndex: isDragging ? 50 : 10, width: resolvedWidth != null ? `${resolvedWidth}%` : undefined }
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ ...style, ...posStyle, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
|
||||
className={`${className} rounded ${isDragging ? 'ring-2 ring-sky-400/70 opacity-90' : 'ring-1 ring-transparent hover:ring-white/30'} transition-[box-shadow]`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function overlayStyle(style) {
|
||||
if (style === 'dark-strong') return 'linear-gradient(180deg, rgba(2,6,23,0.38), rgba(2,6,23,0.68))'
|
||||
if (style === 'light-soft') return 'linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.22))'
|
||||
@@ -30,9 +106,9 @@ function positionStyle(position) {
|
||||
}
|
||||
|
||||
function alignmentClass(alignment) {
|
||||
if (alignment === 'left') return 'items-start text-left'
|
||||
if (alignment === 'right') return 'items-end text-right'
|
||||
return 'items-center text-center'
|
||||
if (alignment === 'left') return 'justify-start items-start text-left'
|
||||
if (alignment === 'right') return 'justify-end items-end text-right'
|
||||
return 'justify-center items-center text-center'
|
||||
}
|
||||
|
||||
function focalPositionStyle(position) {
|
||||
@@ -76,32 +152,55 @@ function blockClass(type) {
|
||||
return 'font-semibold tracking-[-0.03em] sm:text-[1.65rem] lg:text-[2.1rem]'
|
||||
}
|
||||
|
||||
function blockStyle(type, typography, textColor, accentColor) {
|
||||
const quoteSize = Math.max(26, Math.min(typography.quote_size || 72, 120))
|
||||
function blockStyle(type, typography, textColor, accentColor, fontFamily) {
|
||||
const quoteSize = Math.max(10, Math.min(typography.quote_size || 72, 120))
|
||||
const authorSize = Math.max(14, Math.min(typography.author_size || 28, 42))
|
||||
const letterSpacing = Math.max(-1, Math.min(typography.letter_spacing || 0, 10))
|
||||
const lineHeight = Math.max(0.9, Math.min(typography.line_height || 1.2, 1.8))
|
||||
const shadowPreset = typography.shadow_preset || 'soft'
|
||||
// text_opacity: 10–100 (stored as integer percent), default 100
|
||||
const opacity = typography.text_opacity != null ? Math.max(10, Math.min(100, Number(typography.text_opacity))) / 100 : 1
|
||||
|
||||
// Convert canvas pixels (1080-normalised) to container-query width units so
|
||||
// the preview proportions always match the rendered image regardless of how
|
||||
// wide the preview panel is.
|
||||
const toCqw = (px) => `${(px / 1080 * 100).toFixed(2)}cqw`
|
||||
|
||||
const sizeMap = {
|
||||
title: toCqw(Math.max(16, quoteSize * 0.48)),
|
||||
quote: toCqw(quoteSize),
|
||||
author: toCqw(authorSize),
|
||||
source: toCqw(Math.max(12, authorSize * 0.82)),
|
||||
body: toCqw(Math.max(16, quoteSize * 0.54)),
|
||||
caption: toCqw(Math.max(12, authorSize * 0.74)),
|
||||
}
|
||||
|
||||
const fontSize = sizeMap[type] ?? sizeMap.quote
|
||||
|
||||
const font = fontFamily ? { fontFamily } : {}
|
||||
|
||||
if (type === 'title') {
|
||||
return { color: accentColor, letterSpacing: `${Math.max(letterSpacing, 0) / 10}em`, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: accentColor, fontSize, letterSpacing: `${Math.max(letterSpacing, 0) / 10}em`, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
if (type === 'author' || type === 'source') {
|
||||
return { color: accentColor, fontSize: `${authorSize / 4}px`, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: accentColor, fontSize, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
if (type === 'body' || type === 'caption') {
|
||||
return { color: textColor, lineHeight, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: textColor, fontSize, lineHeight, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
return { color: textColor, fontSize: `${quoteSize / 4}px`, lineHeight, letterSpacing: `${letterSpacing / 12}px`, textShadow: shadowValue(shadowPreset) }
|
||||
return { ...font, opacity, color: textColor, fontSize, lineHeight, letterSpacing: `${letterSpacing / 12}px`, textShadow: shadowValue(shadowPreset) }
|
||||
}
|
||||
|
||||
export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
export default function NovaCardCanvasPreview({ card, fonts = [], className = '', editable = false, onElementMove = null, renderMode = false }) {
|
||||
const canvasRef = React.useRef(null)
|
||||
const project = card?.project_json || {}
|
||||
const layout = project.layout || {}
|
||||
const typography = project.typography || {}
|
||||
const resolvedFont = fonts.find((f) => f.key === (typography.font_preset || 'modern-sans'))
|
||||
const fontFamily = resolvedFont?.family || null
|
||||
const background = project.background || {}
|
||||
const backgroundImage = card?.background_image?.processed_url
|
||||
const colors = Array.isArray(background.gradient_colors) && background.gradient_colors.length >= 2
|
||||
@@ -113,39 +212,30 @@ export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
? background.solid_color || '#111827'
|
||||
: `linear-gradient(180deg, ${colors[0]}, ${colors[1]})`
|
||||
|
||||
const textBlocks = resolveTextBlocks(card, project)
|
||||
const allTextBlocks = resolveTextBlocks(card, project)
|
||||
// Blocks with a saved free position are rendered absolutely; others stay in normal flow.
|
||||
const flowTextBlocks = allTextBlocks.filter((b) => b.pos_x == null || b.pos_y == null)
|
||||
const freeTextBlocks = allTextBlocks.filter((b) => b.pos_x != null && b.pos_y != null)
|
||||
|
||||
const decorations = Array.isArray(project.decorations) ? project.decorations : []
|
||||
const assetItems = Array.isArray(project.assets?.items) ? project.assets.items : []
|
||||
const textColor = typography.text_color || '#ffffff'
|
||||
const accentColor = typography.accent_color || textColor
|
||||
const maxWidth = layout.max_width === 'compact' ? '62%' : layout.max_width === 'wide' ? '88%' : '76%'
|
||||
const layoutMaxWidth = layout.max_width === 'compact' ? '62%' : layout.max_width === 'wide' ? '88%' : '76%'
|
||||
const maxWidth = typography.quote_width != null ? `${typography.quote_width}%` : layoutMaxWidth
|
||||
const padding = layout.padding === 'tight' ? '8%' : layout.padding === 'airy' ? '14%' : '11%'
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-[28px] border border-white/10 bg-slate-950 shadow-[0_30px_80px_rgba(2,6,23,0.35)] ${className}`} style={{ aspectRatio: aspectRatios[card?.format || 'square'] || aspectRatios.square }}>
|
||||
<div ref={canvasRef} data-card-canvas className={`relative overflow-hidden bg-slate-950 [container-type:inline-size] ${renderMode ? '' : 'rounded-[28px] border border-white/10 shadow-[0_30px_80px_rgba(2,6,23,0.35)]'} ${className}`} style={{ aspectRatio: aspectRatios[card?.format || 'square'] || aspectRatios.square }}>
|
||||
<div className="absolute inset-0" style={{ background: backgroundStyle, filter: background.type === 'upload' && Number(background.blur_level || 0) > 0 ? `blur(${Math.max(Number(background.blur_level) / 8, 0)}px)` : undefined }} />
|
||||
<div className="absolute inset-0" style={{ background: overlayStyle(background.overlay_style), opacity: Math.max(0, Math.min(Number(background.opacity || 50), 100)) / 100 }} />
|
||||
<div className="absolute left-4 top-4 rounded-full border border-white/10 bg-black/25 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80 backdrop-blur">
|
||||
{(card?.format || 'square').replace('-', ' ')}
|
||||
</div>
|
||||
|
||||
{decorations.slice(0, 6).map((decoration, index) => {
|
||||
const placement = placementStyles[decoration.placement] || placementStyles['top-right']
|
||||
return (
|
||||
<div
|
||||
key={`${decoration.key || decoration.glyph || 'dec'}-${index}`}
|
||||
className="absolute text-white/85 drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
style={{
|
||||
...placement,
|
||||
color: accentColor,
|
||||
fontSize: `${Math.max(18, Math.min(decoration.size || 28, 64))}px`,
|
||||
}}
|
||||
>
|
||||
{decoration.glyph || '✦'}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!renderMode && (
|
||||
<div className="absolute left-4 top-4 rounded-full border border-white/10 bg-black/25 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80 backdrop-blur">
|
||||
{(card?.format || 'square').replace('-', ' ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset items (non-draggable) */}
|
||||
{assetItems.slice(0, 6).map((item, index) => {
|
||||
if (item?.type === 'frame') {
|
||||
const top = index % 2 === 0 ? '10%' : '88%'
|
||||
@@ -160,7 +250,7 @@ export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
top: `${12 + ((index % 3) * 18)}%`,
|
||||
left: `${10 + (Math.floor(index / 3) * 72)}%`,
|
||||
color: accentColor,
|
||||
fontSize: `${Math.max(18, Math.min(item.size || 26, 56))}px`,
|
||||
fontSize: `${(Math.max(18, Math.min(item.size || 26, 56)) / 1080 * 100).toFixed(2)}cqw`,
|
||||
}}
|
||||
>
|
||||
{item.glyph || item.label || '✦'}
|
||||
@@ -168,20 +258,122 @@ export default function NovaCardCanvasPreview({ card, className = '' }) {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Flow-positioned text blocks */}
|
||||
<div className={`relative flex h-full w-full ${alignmentClass(layout.alignment)}`} style={{ padding, ...positionStyle(layout.position) }}>
|
||||
<div className="flex w-full flex-col gap-4" style={{ maxWidth }}>
|
||||
{textBlocks.map((block, index) => {
|
||||
{flowTextBlocks.map((block, index) => {
|
||||
const type = block?.type || 'body'
|
||||
const text = type === 'author' ? `— ${block.text}` : block.text
|
||||
const defStyle = blockStyle(type, typography, textColor, accentColor, fontFamily)
|
||||
const blockCls = blockClass(type)
|
||||
const blockKey = `${block.key || type}-${index}`
|
||||
|
||||
if (editable) {
|
||||
return (
|
||||
<DraggableElement
|
||||
key={blockKey}
|
||||
elementId={`block:${block.key || type}`}
|
||||
canvasRef={canvasRef}
|
||||
freePos={null}
|
||||
onMove={onElementMove}
|
||||
style={defStyle}
|
||||
className={blockCls}
|
||||
>
|
||||
{text}
|
||||
</DraggableElement>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${block.key || type}-${index}`} style={blockStyle(type, typography, textColor, accentColor)} className={blockClass(type)}>
|
||||
<div key={blockKey} style={defStyle} className={blockCls}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Absolutely-positioned text blocks (dragged out of flow) */}
|
||||
{freeTextBlocks.map((block, index) => {
|
||||
const type = block?.type || 'body'
|
||||
const text = type === 'author' ? `— ${block.text}` : block.text
|
||||
const defStyle = blockStyle(type, typography, textColor, accentColor, fontFamily)
|
||||
const blockCls = blockClass(type)
|
||||
const blockKey = `free-${block.key || type}-${index}`
|
||||
|
||||
if (editable) {
|
||||
return (
|
||||
<DraggableElement
|
||||
key={blockKey}
|
||||
elementId={`block:${block.key || type}`}
|
||||
canvasRef={canvasRef}
|
||||
freePos={{ x: block.pos_x, y: block.pos_y }}
|
||||
savedWidth={block.pos_width ?? null}
|
||||
onMove={onElementMove}
|
||||
style={defStyle}
|
||||
className={blockCls}
|
||||
>
|
||||
{text}
|
||||
</DraggableElement>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={blockKey}
|
||||
style={{ ...defStyle, position: 'absolute', left: `${block.pos_x}%`, top: `${block.pos_y}%`, ...(block.pos_width != null ? { width: `${block.pos_width}%` } : {}) }}
|
||||
className={blockCls}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Decorations — rendered last so they sit on top of all text content */}
|
||||
{decorations.slice(0, 6).map((decoration, index) => {
|
||||
const hasFreePos = decoration.pos_x != null && decoration.pos_y != null
|
||||
const placementPos = placementStyles[decoration.placement] || placementStyles['top-right']
|
||||
const decOpacity = decoration.opacity != null ? Math.max(10, Math.min(100, Number(decoration.opacity))) / 100 : 0.85
|
||||
const decStyle = {
|
||||
color: accentColor,
|
||||
fontSize: `${(Math.max(18, Math.min(decoration.size || 28, 64)) / 1080 * 100).toFixed(2)}cqw`,
|
||||
opacity: decOpacity,
|
||||
zIndex: 20,
|
||||
}
|
||||
const decKey = `${decoration.key || decoration.glyph || 'dec'}-${index}`
|
||||
const decContent = decoration.glyph || '✦'
|
||||
|
||||
if (editable) {
|
||||
const freePos = hasFreePos ? { x: decoration.pos_x, y: decoration.pos_y } : null
|
||||
const absStyle = hasFreePos
|
||||
? { position: 'absolute', left: `${decoration.pos_x}%`, top: `${decoration.pos_y}%` }
|
||||
: { position: 'absolute', ...placementPos }
|
||||
return (
|
||||
<DraggableElement
|
||||
key={decKey}
|
||||
elementId={`decoration:${index}`}
|
||||
canvasRef={canvasRef}
|
||||
freePos={freePos}
|
||||
savedWidth={null}
|
||||
onMove={onElementMove}
|
||||
style={{ ...absStyle, ...decStyle }}
|
||||
className="drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
>
|
||||
{decContent}
|
||||
</DraggableElement>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={decKey}
|
||||
className="absolute drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
|
||||
style={{ ...placementPos, ...decStyle }}
|
||||
>
|
||||
{decContent}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
|
||||
export default function NovaCardFontPicker({ fonts = [], selectedKey = null, onSelect }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3">
|
||||
{fonts.map((font) => {
|
||||
const active = selectedKey === font.key
|
||||
return (
|
||||
@@ -10,10 +10,20 @@ export default function NovaCardFontPicker({ fonts = [], selectedKey = null, onS
|
||||
key={font.key}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(font)}
|
||||
className={`rounded-[22px] border p-4 text-left transition ${active ? 'border-sky-300/35 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.05]'}`}
|
||||
className={`rounded-[22px] border p-4 text-left transition ${active ? 'border-sky-300/35 bg-sky-400/12 ring-1 ring-sky-400/20 text-white' : 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.05]'}`}
|
||||
>
|
||||
<div className="text-lg font-semibold tracking-[-0.03em]" style={{ fontFamily: font.family }}>{font.label}</div>
|
||||
<div className="mt-2 text-sm text-slate-400">{font.recommended_use}</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500 mb-2">{font.label}</div>
|
||||
<div className="text-[1.65rem] font-semibold leading-tight text-white" style={{ fontFamily: font.family, fontWeight: font.weight || 600 }}>
|
||||
The quick brown fox
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-400 leading-relaxed" style={{ fontFamily: font.family }}>
|
||||
{font.recommended_use}
|
||||
</div>
|
||||
{active && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 rounded-full border border-sky-400/25 bg-sky-500/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-sky-300">
|
||||
Active
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user