Fix module issues, fix styling, add conditions to when the background removal and edit controls are shown

This commit is contained in:
khalid@traclabs.com
2026-04-23 08:48:11 -05:00
parent 4d19363d58
commit 628a6765f4
32 changed files with 11663 additions and 287 deletions

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import '../styles/PWAInstall.css';
export function PWAInstall() {
const [deferredPrompt, setDeferredPrompt] = useState(null);
@@ -30,17 +31,17 @@ export function PWAInstall() {
{showInstall && (
<div className="pwa-install-banner">
<p>Install Apparel Designer for offline access!</p>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div className="pwa-install-actions">
<button onClick={handleInstall} className="install-btn">Install</button>
<button onClick={() => setShowInstall(false)} className="dismiss-btn">Later</button>
</div>
</div>
)}
{updateAvailable && (
<div style={{ position: 'fixed', bottom: '1rem', left: '50%', transform: 'translateX(-50%)', background: 'var(--accent)', color: '#fff', padding: '0.75rem 1.5rem', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-lg)', zIndex: 9999, display: 'flex', alignItems: 'center', gap: '1rem', fontSize: '13px' }}>
<div className="pwa-update-banner">
<span>🔄 New version available!</span>
<button onClick={handleUpdate} style={{ padding: '0.375rem 0.75rem', background: '#fff', color: 'var(--accent)', border: 'none', borderRadius: 'var(--radius-sm)', fontWeight: '600', fontSize: '12px', cursor: 'pointer' }}>Refresh</button>
<button onClick={() => { setUpdateAvailable(false); setNewWorker(null); }} style={{ padding: '0.375rem 0.5rem', background: 'transparent', color: '#fff', border: 'none', cursor: 'pointer', fontSize: '16px', opacity: 0.8 }}></button>
<button onClick={handleUpdate} className="refresh-btn">Refresh</button>
<button onClick={() => { setUpdateAvailable(false); setNewWorker(null); }} className="close-btn"></button>
</div>
)}
</>

View File

@@ -4,42 +4,117 @@ import { ImageElement } from './ImageElement';
import { TextElement } from './TextElement';
import { TemplateLayer } from './TemplateLayer';
import { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
import { memo } from 'react';
import { memo, useCallback } from 'react';
import '../../styles/DesignCanvas.css';
const CANVAS_SIZE = 300;
const HANDLE_PADDING = 40;
export const DesignCanvas = memo(function DesignCanvas({
elements, selectedId, onSelect, onDeselect, onUpdate, onCommit,
currentTemplate, assignedSlots, getDragBoundFunc,
}) {
const slots = currentTemplate?.slots || [];
const stageSize = CANVAS_SIZE + HANDLE_PADDING * 2;
const constrainTransform = useCallback((oldBox, newBox) => {
// During rotation the drag-bound func handles containment
if (Math.abs(oldBox.rotation - newBox.rotation) > 0.001) return newBox;
const cos = Math.cos(newBox.rotation);
const sin = Math.sin(newBox.rotation);
function getCorners({ x, y, width: w, height: h }) {
return [
[x, y],
[x + w * cos, y + w * sin],
[x + w * cos - h * sin, y + w * sin + h * cos],
[x - h * sin, y + h * cos],
];
}
const nc = getCorners(newBox);
const inBounds = nc.every(
([cx, cy]) => cx >= 0 && cx <= CANVAS_SIZE && cy >= 0 && cy <= CANVAS_SIZE
);
if (inBounds) return newBox;
// With constant rotation, every corner coordinate is linear in t (0→old, 1→new).
// Find the largest t where all corners stay within [0, CANVAS_SIZE].
const oc = getCorners(oldBox);
let maxT = 1;
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 2; j++) {
const a = oc[i][j];
const delta = nc[i][j] - a;
if (Math.abs(delta) < 0.001) continue;
const limit = delta > 0 ? CANVAS_SIZE : 0;
const t = (limit - a) / delta;
if (t > 0 && t < maxT) maxT = t;
}
}
if (maxT < 0.001) return oldBox;
return {
x: oldBox.x + (newBox.x - oldBox.x) * maxT,
y: oldBox.y + (newBox.y - oldBox.y) * maxT,
width: oldBox.width + (newBox.width - oldBox.width) * maxT,
height: oldBox.height + (newBox.height - oldBox.height) * maxT,
rotation: newBox.rotation,
};
}, []);
// Regular function so Konva can bind `this` to the dragged node,
// letting us read width/height/rotation to compute the rotated bounding box.
const canvasDragBound = useCallback(function (pos) {
const w = this.width();
const h = this.height();
const rad = (this.rotation() * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const cornersX = [0, w * cos, w * cos - h * sin, -h * sin];
const cornersY = [0, w * sin, w * sin + h * cos, h * cos];
const minX = Math.min(...cornersX);
const maxX = Math.max(...cornersX);
const minY = Math.min(...cornersY);
const maxY = Math.max(...cornersY);
return {
x: Math.max(HANDLE_PADDING - minX, Math.min(pos.x, HANDLE_PADDING + CANVAS_SIZE - maxX)),
y: Math.max(HANDLE_PADDING - minY, Math.min(pos.y, HANDLE_PADDING + CANVAS_SIZE - maxY)),
};
}, []);
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<div className="design-canvas-wrapper">
<TShirtSVG size={CANVAS_SIZE} />
<div className={`design-canvas-border${selectedId ? ' selected' : ''}`} />
<Stage
width={CANVAS_SIZE} height={CANVAS_SIZE}
width={stageSize} height={stageSize}
onClick={onDeselect} onTap={onDeselect}
style={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
border: selectedId ? '2px solid #38bdf8' : '1px dashed #cbd5e1',
borderRadius: '8px', background: 'rgba(255, 255, 255, 0.5)',
}}
className="design-canvas-stage"
>
<Layer>
<Layer x={HANDLE_PADDING} y={HANDLE_PADDING}>
{currentTemplate && <TemplateLayer template={currentTemplate} canvasSize={CANVAS_SIZE} />}
</Layer>
<Layer listening={false}>
<Layer x={HANDLE_PADDING} y={HANDLE_PADDING} listening={false}>
{slots.map((slot) => <SlotBoundsGuide key={slot.id} slot={slot} />)}
</Layer>
<Layer>
<Layer x={HANDLE_PADDING} y={HANDLE_PADDING}>
{elements.map((el) => {
if (el.type === 'image') {
return (
<ImageElement key={el.id} id={el.id} x={el.x} y={el.y} width={el.width} height={el.height}
rotation={el.rotation} src={el.src} crop={el.crop} isSelected={el.id === selectedId}
onSelect={() => onSelect(el.id)} onUpdate={(attrs) => onUpdate(el.id, attrs)} onCommit={onCommit}
dragBoundFunc={el.slotId ? getDragBoundFunc?.(el.slotId, { width: el.width, height: el.height }) : null}
onSelect={() => (el.id === selectedId ? onDeselect?.() : onSelect(el.id))}
onUpdate={(attrs) => onUpdate(el.id, attrs)}
onCommit={onCommit}
dragBoundFunc={canvasDragBound}
transformBoundFunc={constrainTransform}
/>
);
}
@@ -47,18 +122,22 @@ export const DesignCanvas = memo(function DesignCanvas({
return (
<TextElement key={el.id} id={el.id} x={el.x} y={el.y} text={el.text} fontSize={el.fontSize}
fontFamily={el.fontFamily} fill={el.fill} rotation={el.rotation} isSelected={el.id === selectedId}
onSelect={() => onSelect(el.id)} onUpdate={(attrs) => onUpdate(el.id, attrs)} onCommit={onCommit}
onSelect={() => (el.id === selectedId ? onDeselect?.() : onSelect(el.id))}
onUpdate={(attrs) => onUpdate(el.id, attrs)}
onCommit={onCommit}
dragBoundFunc={canvasDragBound}
transformBoundFunc={constrainTransform}
/>
);
}
return null;
})}
</Layer>
<Layer listening={false}>
<Layer x={HANDLE_PADDING} y={HANDLE_PADDING} listening={false}>
{slots.map((slot) => <SlotPlaceholder key={slot.id} slot={slot} isEmpty={!assignedSlots?.[slot.id]} />)}
</Layer>
</Stage>
<div style={{ position: 'absolute', bottom: '-40px', left: '50%', transform: 'translateX(-50%)', fontSize: '12px', color: 'var(--text-secondary)', textAlign: 'center', whiteSpace: 'nowrap' }}>
<div className="design-canvas-info">
Design Area: 15" × 15" Export: 4500 × 4500px @ 300 DPI
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, memo } from 'react';
import { useEffect, useRef, memo, useCallback } from 'react';
import { Image, Transformer } from 'react-konva';
import useImage from 'use-image';
@@ -8,28 +8,46 @@ function URLImage({ src, innerRef, ...props }) {
}
export const ImageElement = memo(function ImageElement({
id,
id: _id,
x = 0,
y = 0,
width = 100,
height = 100,
rotation = 0,
src,
crop,
isSelected,
onSelect,
onUpdate,
onCommit,
dragBoundFunc,
transformBoundFunc,
}) {
const shapeRef = useRef(null);
const trRef = useRef(null);
useEffect(() => {
if (isSelected && trRef.current && shapeRef.current) {
trRef.current.nodes([shapeRef.current]);
trRef.current.getLayer().batchDraw();
}
const attachTransformer = useCallback(() => {
if (!isSelected) return;
const transformer = trRef.current;
const shape = shapeRef.current;
if (!transformer || !shape) return;
transformer.nodes([shape]);
transformer.getLayer()?.batchDraw();
}, [isSelected]);
const setTransformerRef = useCallback(
(node) => {
trRef.current = node;
attachTransformer();
},
[attachTransformer]
);
useEffect(() => {
attachTransformer();
}, [attachTransformer]);
return (
<>
<URLImage
@@ -40,9 +58,21 @@ export const ImageElement = memo(function ImageElement({
height={height}
rotation={rotation}
src={src}
crop={
crop
? { x: crop.sx, y: crop.sy, width: crop.sWidth, height: crop.sHeight }
: undefined
}
draggable
onClick={onSelect}
onTap={onSelect}
dragBoundFunc={dragBoundFunc}
onClick={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onTap={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onDragEnd={(e) => {
onUpdate({ x: e.target.x(), y: e.target.y() });
onCommit?.();
@@ -66,10 +96,11 @@ export const ImageElement = memo(function ImageElement({
/>
{isSelected && (
<Transformer
ref={trRef}
ref={setTransformerRef}
keepRatio
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 20 || newBox.height < 20) return oldBox;
return newBox;
if (Math.abs(newBox.width) < 20 || Math.abs(newBox.height) < 20) return oldBox;
return transformBoundFunc ? transformBoundFunc(oldBox, newBox) : newBox;
}}
anchorSize={8}
anchorCornerRadius={4}

View File

@@ -1,10 +1,12 @@
import '../../styles/TShirtSVG.css';
export function TShirtSVG({ size = 300 }) {
const padding = size * 0.1;
const innerSize = size - padding * 2;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', pointerEvents: 'none', zIndex: 0 }}>
className="tshirt-svg">
<path d={`M ${padding} ${padding + innerSize * 0.15} L ${padding + innerSize * 0.15} ${padding} L ${size - padding - innerSize * 0.15} ${padding} L ${size - padding} ${padding + innerSize * 0.15} L ${size - padding} ${size - padding} L ${padding} ${size - padding} Z`}
fill="none" stroke="var(--border)" strokeWidth="2" strokeDasharray="4,4" />
<rect x={size * 0.3} y={size * 0.25} width={size * 0.4} height={size * 0.35} fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.5" />

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef, memo } from 'react';
import { useEffect, useRef, memo, useCallback } from 'react';
import { Text, Transformer } from 'react-konva';
export const TextElement = memo(function TextElement({
id,
id: _id,
x = 0,
y = 0,
text = '',
@@ -14,17 +14,34 @@ export const TextElement = memo(function TextElement({
onSelect,
onUpdate,
onCommit,
dragBoundFunc,
transformBoundFunc,
}) {
const textRef = useRef(null);
const trRef = useRef(null);
useEffect(() => {
if (isSelected && trRef.current && textRef.current) {
trRef.current.nodes([textRef.current]);
trRef.current.getLayer().batchDraw();
}
const attachTransformer = useCallback(() => {
if (!isSelected) return;
const transformer = trRef.current;
const node = textRef.current;
if (!transformer || !node) return;
transformer.nodes([node]);
transformer.getLayer()?.batchDraw();
}, [isSelected]);
const setTransformerRef = useCallback(
(node) => {
trRef.current = node;
attachTransformer();
},
[attachTransformer]
);
useEffect(() => {
attachTransformer();
}, [attachTransformer]);
return (
<>
<Text
@@ -37,8 +54,15 @@ export const TextElement = memo(function TextElement({
fill={fill}
rotation={rotation}
draggable
onClick={onSelect}
onTap={onSelect}
dragBoundFunc={dragBoundFunc}
onClick={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onTap={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onDragEnd={(e) => {
onUpdate({ x: e.target.x(), y: e.target.y() });
onCommit?.();
@@ -60,8 +84,9 @@ export const TextElement = memo(function TextElement({
/>
{isSelected && (
<Transformer
ref={trRef}
ref={setTransformerRef}
enabledAnchors={['top-left', 'top-right', 'bottom-left', 'bottom-right']}
boundBoxFunc={transformBoundFunc}
anchorSize={8}
anchorCornerRadius={4}
borderStroke="#38bdf8"

View File

@@ -1,8 +1,10 @@
import { useState, useEffect, useRef } from 'react';
import FilerobotImageEditor from 'react-filerobot-image-editor';
import { useEffect, useRef } from 'react';
import FilerobotImageEditor, { TABS } from 'react-filerobot-image-editor';
import { StyleSheetManager } from 'styled-components';
import isPropValid from '@emotion/is-prop-valid';
import '../../styles/PhotoPreEditor.css';
export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
const [saving, setSaving] = useState(false);
const modalContentRef = useRef(null);
const previousFocusRef = useRef(null);
@@ -16,28 +18,78 @@ export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
};
}, [onClose]);
const handleComplete = (editedImageObject) => {
setSaving(true);
editedImageObject.exportAsync({ quality: 1, mimeType: 'image/png' })
.then((blob) => { setSaving(false); onComplete(URL.createObjectURL(blob)); })
.catch((error) => { console.error('Export failed:', error); setSaving(false); onClose(); });
const base64ToBlob = (base64DataUrl) => {
const [header, data] = base64DataUrl.split(',');
const mimeMatch = header?.match(/data:(.*?);base64/);
const mime = mimeMatch?.[1] || 'image/png';
const binary = atob(data || '');
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: mime });
};
const handleSave = async (savedImageData) => {
try {
// Prefer base64 when available (works without CORS/network).
if (savedImageData?.imageBase64) {
const blob = base64ToBlob(savedImageData.imageBase64);
onComplete(URL.createObjectURL(blob));
return;
}
// Fallback to canvas when provided by the library.
if (savedImageData?.imageCanvas instanceof HTMLCanvasElement) {
const blob = await new Promise((resolve) =>
savedImageData.imageCanvas.toBlob(resolve, savedImageData.mimeType || 'image/png', savedImageData.quality),
);
if (blob) onComplete(URL.createObjectURL(blob));
else throw new Error('Canvas export failed');
return;
}
// Final fallback: cloudimageUrl (fetch then blob).
if (savedImageData?.cloudimageUrl) {
const res = await fetch(savedImageData.cloudimageUrl);
const blob = await res.blob();
onComplete(URL.createObjectURL(blob));
return;
}
throw new Error('No export data returned from image editor');
} catch (error) {
console.error('Export failed:', error);
onClose();
}
};
return (
<div className="filerobot-overlay" role="dialog" aria-modal="true" aria-labelledby="photo-editor-title">
<div className="filerobot-container" ref={modalContentRef} role="document">
<FilerobotImageEditor
source={imageSrc} onSave={handleComplete} onClose={onClose}
annotationsCommon={{ fill: '#ff0000', stroke: '#000000', strokeWidth: 0 }}
annotations={['Text', 'Rectangle', 'Ellipse', 'Line', 'Pen', 'Eraser']}
tabs={['adjust', 'filters', 'finetune', 'annotate', 'watermark']}
defaultTabId="adjust"
theme={{ accentColor: '#38bdf8', palettePrimary: '#38bdf8' }}
saveButtonProps={{ label: saving ? 'Exporting...' : 'Use Edited Image' }}
closeOnSave
/>
<StyleSheetManager
// Filerobot/@scaleflex styled-components pass a bunch of styling props to DOM nodes (e.g. isPhoneScreen).
// Filtering them here prevents noisy React console warnings.
shouldForwardProp={(prop, element) => (typeof element === 'string' ? isPropValid(prop) : true)}
>
<FilerobotImageEditor
source={imageSrc}
onBeforeSave={() => false}
onSave={handleSave}
onClose={() => onClose()}
tabsIds={[TABS.ADJUST, TABS.FILTERS, TABS.FINETUNE]}
defaultTabId={TABS.ADJUST}
Crop={{ autoResize: true, defaultSizePercentage: 1, ratio: 'custom' }}
theme={{ accentColor: '#38bdf8', palettePrimary: '#38bdf8' }}
forceToPngInEllipticalCrop
closeAfterSave
defaultSavedImageName="edited-image"
defaultSavedImageType="png"
defaultSavedImageQuality={1}
savingPixelRatio={4}
previewPixelRatio={4}
/>
</StyleSheetManager>
</div>
<h2 id="photo-editor-title" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)' }}>Photo Editor</h2>
<h2 id="photo-editor-title" className="sr-only">Photo Editor</h2>
</div>
);
}

View File

@@ -1,31 +1,27 @@
import { memo } from 'react';
import '../../styles/LayersPanel.css';
export const LayersPanel = memo(function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
const getIcon = (el) => el.type === 'image' ? (el.bgRemoved ? '🖼️' : '📷') : el.type === 'text' ? '📝' : '🎨';
const getName = (el) => el.type === 'image' ? (el.bgRemoved ? 'Image (BG ✓)' : 'Image') : el.type === 'text' ? (el.text?.substring(0, 20) || 'Text') : 'Sticker';
if (elements.length === 0) {
return <div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '12px' }}>No elements yet. Add images, text, or stickers to your design.</div>;
return <div className="layers-empty">No elements yet. Add images, text, or stickers to your design.</div>;
}
return (
<div>
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '12px', fontWeight: '600', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Layers ({elements.length})</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<h3 className="layers-title">Layers ({elements.length})</h3>
<div className="layers-list">
{elements.map((element) => (
<div key={element.id} onClick={() => onSelect(element.id)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem',
background: selectedId === element.id ? 'var(--accent-bg)' : 'transparent',
border: `1px solid ${selectedId === element.id ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
}}>
<span style={{ fontSize: '14px' }}>{getIcon(element)}</span>
<span style={{ flex: 1, fontSize: '12px', color: selectedId === element.id ? 'var(--accent)' : 'var(--text-primary)', fontWeight: selectedId === element.id ? '600' : '400', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
className={`layers-item${selectedId === element.id ? ' selected' : ''}`}>
<span className="layers-item-icon">{getIcon(element)}</span>
<span className={`layers-item-name${selectedId === element.id ? ' selected' : ''}`}>
{getName(element)}
</span>
<button onClick={(e) => { e.stopPropagation(); onDelete(element.id); }}
style={{ width: '24px', height: '24px', border: 'none', borderRadius: 'var(--radius-sm)', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px', color: 'var(--text-muted)' }}>
className="layers-item-delete">
×
</button>
</div>

View File

@@ -1,14 +1,15 @@
import { memo } from 'react';
import { BackgroundRemovalButton } from '../sidebar/BackgroundRemovalButton';
import '../../styles/PropertiesPanel.css';
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete, onEditPhoto }) {
if (!element) {
return (
<div className="properties-panel">
<div style={{ padding: '1rem', borderBottom: '1px solid var(--border)' }}>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: 'var(--text-primary)' }}>Properties</h3>
<div className="properties-panel__header">
<h3 className="properties-panel__title">Properties</h3>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem', color: 'var(--text-muted)', fontSize: '12px', textAlign: 'center' }}>
<div className="properties-panel__empty">
Select an element to edit its properties
</div>
</div>
@@ -19,57 +20,53 @@ export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate
const handleSizeChange = (axis, value) => onUpdate({ [axis]: Math.max(20, parseFloat(value) || 20) });
const handleRotationChange = (value) => onUpdate({ rotation: Math.max(-180, Math.min(180, parseFloat(value) || 0)) });
const inputStyle = { width: '100%', padding: '0.5rem', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', fontSize: '13px' };
const labelStyle = { display: 'block', fontSize: '11px', fontWeight: '600', color: 'var(--text-secondary)', marginBottom: '0.5rem', textTransform: 'uppercase' };
return (
<div className="properties-panel">
<div style={{ padding: '1rem', borderBottom: '1px solid var(--border)' }}>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: 'var(--text-primary)' }}>Properties</h3>
<div className="properties-panel__header">
<h3 className="properties-panel__title">Properties</h3>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '1rem' }}>
{/* Element type badge */}
<div style={{ display: 'inline-block', padding: '4px 8px', background: 'var(--accent-bg)', borderRadius: 'var(--radius-sm)', fontSize: '11px', fontWeight: '600', color: 'var(--accent)', textTransform: 'uppercase', marginBottom: '1rem' }}>
<div className="properties-panel__body">
<div className="properties-panel__type-badge">
{element.type}
</div>
{/* Position */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Position</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>X</label>
<input type="number" value={Math.round(element.x)} onChange={(e) => handlePositionChange('x', e.target.value)} style={inputStyle} />
<div className="properties-panel__section">
<label className="properties-panel__label">Position</label>
<div className="properties-panel__row">
<div className="properties-panel__field">
<label className="properties-panel__axis-label">X</label>
<input type="number" value={Math.round(element.x)} onChange={(e) => handlePositionChange('x', e.target.value)} className="properties-panel__input" />
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Y</label>
<input type="number" value={Math.round(element.y)} onChange={(e) => handlePositionChange('y', e.target.value)} style={inputStyle} />
<div className="properties-panel__field">
<label className="properties-panel__axis-label">Y</label>
<input type="number" value={Math.round(element.y)} onChange={(e) => handlePositionChange('y', e.target.value)} className="properties-panel__input" />
</div>
</div>
</div>
{/* Size (for images and stickers) */}
{(element.type === 'image' || element.type === 'sticker') && (
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Size</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>W</label>
<input type="number" value={Math.round(element.width)} onChange={(e) => handleSizeChange('width', e.target.value)} style={inputStyle} />
<div className="properties-panel__section">
<label className="properties-panel__label">Size</label>
<div className="properties-panel__row">
<div className="properties-panel__field">
<label className="properties-panel__axis-label">W</label>
<input type="number" value={Math.round(element.width)} onChange={(e) => handleSizeChange('width', e.target.value)} className="properties-panel__input" />
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>H</label>
<input type="number" value={Math.round(element.height)} onChange={(e) => handleSizeChange('height', e.target.value)} style={inputStyle} />
<div className="properties-panel__field">
<label className="properties-panel__axis-label">H</label>
<input type="number" value={Math.round(element.height)} onChange={(e) => handleSizeChange('height', e.target.value)} className="properties-panel__input" />
</div>
</div>
</div>
)}
{/* Edit Photo button */}
{element.type === 'image' && onEditPhoto && (
<div style={{ marginBottom: '1rem' }}>
<button onClick={() => onEditPhoto(element)} style={{ width: '100%', padding: '0.75rem', border: '1px solid var(--accent)', borderRadius: 'var(--radius-md)', background: 'var(--accent-bg)', color: 'var(--accent)', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
{/* Edit Photo button (user uploads only, not stickers) */}
{element.type === 'image' && !element.emoji && onEditPhoto && (
<div className="properties-panel__section">
<button onClick={() => onEditPhoto(element)} className="properties-panel__edit-btn">
Edit Photo
</button>
</div>
@@ -78,25 +75,25 @@ export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate
{/* Text-specific controls */}
{element.type === 'text' && (
<>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Font Size: {Math.round(element.fontSize)}px</label>
<input type="range" min="12" max="120" value={element.fontSize} onChange={(e) => onUpdate({ fontSize: parseInt(e.target.value, 10) })} style={{ width: '100%' }} />
<div className="properties-panel__section">
<label className="properties-panel__label">Font Size: {Math.round(element.fontSize)}px</label>
<input type="range" min="12" max="120" value={element.fontSize} onChange={(e) => onUpdate({ fontSize: parseInt(e.target.value, 10) })} className="properties-panel__range" />
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Color</label>
<input type="color" value={element.fill} onChange={(e) => onUpdate({ fill: e.target.value })} style={{ width: '100%', height: '36px', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer', padding: '2px' }} />
<div className="properties-panel__section">
<label className="properties-panel__label">Color</label>
<input type="color" value={element.fill} onChange={(e) => onUpdate({ fill: e.target.value })} className="properties-panel__color-input" />
</div>
</>
)}
{/* Rotation */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Rotation: {Math.round(element.rotation)}°</label>
<input type="range" min="-180" max="180" value={element.rotation} onChange={(e) => handleRotationChange(e.target.value)} style={{ width: '100%' }} />
<div className="properties-panel__section">
<label className="properties-panel__label">Rotation: {Math.round(element.rotation)}°</label>
<input type="range" min="-180" max="180" value={element.rotation} onChange={(e) => handleRotationChange(e.target.value)} className="properties-panel__range" />
</div>
{/* Background Removal (for images) */}
{element.type === 'image' && (
{/* Background Removal (user uploads only, not stickers) */}
{element.type === 'image' && !element.emoji && (
<BackgroundRemovalButton
selectedElement={element}
onUpdate={(_id, attrs) => onUpdate(attrs)}
@@ -104,7 +101,7 @@ export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate
)}
{/* Delete */}
<button onClick={() => onDelete(element.id)} style={{ width: '100%', padding: '0.75rem', border: 'none', borderRadius: 'var(--radius-md)', background: 'var(--error)', color: '#fff', fontSize: '13px', fontWeight: '600', cursor: 'pointer', marginTop: '1rem' }}>
<button onClick={() => onDelete(element.id)} className="properties-panel__delete-btn">
Delete Element
</button>
</div>

View File

@@ -3,6 +3,7 @@ import { UploadTab } from './UploadTab';
import { StickersTab } from './StickersTab';
import { TextTab } from './TextTab';
import { TemplatesTab } from './TemplatesTab';
import '../../styles/Sidebar.css';
const TABS = [
{ id: 'upload', label: 'Upload', icon: '📁' },
@@ -26,21 +27,16 @@ export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, on
return (
<div className="sidebar">
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', background: 'var(--bg-primary)' }}>
<div className="sidebar-tabs">
{TABS.map((tab) => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
style={{
flex: 1, padding: '12px 8px', border: 'none', background: 'transparent', cursor: 'pointer',
fontSize: '11px', fontWeight: activeTab === tab.id ? '600' : '400',
color: activeTab === tab.id ? 'var(--accent)' : 'var(--text-secondary)',
borderBottom: activeTab === tab.id ? '2px solid var(--accent)' : '2px solid transparent',
}}>
<div style={{ fontSize: '16px', marginBottom: '2px' }}>{tab.icon}</div>
className={`sidebar-tab-btn${activeTab === tab.id ? ' active' : ''}`}>
<div className="sidebar-tab-icon">{tab.icon}</div>
{tab.label}
</button>
))}
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '1rem' }}>{renderTabContent()}</div>
<div className="sidebar-content">{renderTabContent()}</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { STICKERS, STICKER_CATEGORIES } from '../../constants/stickers';
import '../../styles/StickersTab.css';
export function StickersTab({ onAddSticker }) {
const [activeCategory, setActiveCategory] = useState('all');
@@ -29,24 +30,20 @@ export function StickersTab({ onAddSticker }) {
return (
<div>
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>Stickers</h3>
<div style={{ display: 'flex', gap: '6px', marginBottom: '1rem', flexWrap: 'wrap' }}>
<h3 className="stickers-title">Stickers</h3>
<div className="stickers-categories">
{STICKER_CATEGORIES.map((cat) => (
<button key={cat} onClick={() => setActiveCategory(cat)}
style={{
padding: '6px 12px', border: `1px solid ${activeCategory === cat ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-xl)', background: activeCategory === cat ? 'var(--accent)' : 'var(--bg-primary)',
color: activeCategory === cat ? '#fff' : 'var(--text-secondary)', fontSize: '11px', cursor: 'pointer', textTransform: 'capitalize',
}}
className={`stickers-category-btn${activeCategory === cat ? ' active' : ''}`}
>
{cat}
</button>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '8px' }}>
<div className="stickers-grid">
{filteredStickers.map((sticker, index) => (
<button key={index} onClick={() => handleAddSticker(sticker.emoji)}
style={{ aspectRatio: '1', border: 'none', borderRadius: 'var(--radius-md)', background: 'var(--bg-primary)', fontSize: '28px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
className="sticker-btn"
>
{sticker.emoji}
</button>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
import '../../styles/TemplatesTab.css';
function getCategoryEmoji(category) {
const emojis = {
@@ -52,60 +53,48 @@ export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
return (
<div>
<input id="slot-file-input" type="file" accept="image/*" onChange={handleFileChange} style={{ display: 'none' }} />
<input id="slot-file-input" type="file" accept="image/*" onChange={handleFileChange} className="templates-hidden-input" />
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>Templates</h3>
<h3 className="templates-title">Templates</h3>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '1rem', lineHeight: '1.4' }}>
<div className="templates-description">
Choose a template to get started or design freely.
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div className="templates-list">
{templates.map((template) => (
<button
key={template.id}
onClick={() => handleSelectTemplate(template)}
style={{
display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem',
border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
background: template.id === selectedTemplateId ? 'var(--bg-secondary)' : 'var(--bg-primary)',
cursor: 'pointer', textAlign: 'left', transition: 'all 0.15s ease',
}}
className={`template-btn${template.id === selectedTemplateId ? ' selected' : ''}`}
>
<div style={{
width: '48px', height: '48px', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)', fontSize: '24px',
}}>
<div className="template-thumbnail">
{template.thumbnail}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>{template.name}</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{template.description}</div>
<div className="template-info">
<div className="template-name">{template.name}</div>
<div className="template-desc">{template.description}</div>
</div>
{template.hasSlots && (
<span style={{ fontSize: '10px', padding: '2px 6px', background: 'var(--accent)', color: '#fff', borderRadius: '4px', fontWeight: '600' }}>SLOTS</span>
<span className="template-slots-badge">SLOTS</span>
)}
</button>
))}
</div>
{selectedTemplateId && selectedTemplateId !== 'freeform' && slots.length > 0 && (
<div style={{ marginTop: '1rem', padding: '1rem', background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
<h4 style={{ margin: '0 0 0.75rem 0', fontSize: '12px', fontWeight: '600', color: 'var(--text-primary)' }}>Template Slots</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div className="template-slots-section">
<h4 className="template-slots-title">Template Slots</h4>
<div className="template-slots-list">
{slots.map((slot) => (
<button
key={slot.id}
onClick={() => handleSlotClick(slot.id)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem',
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
background: 'var(--bg-primary)', cursor: 'pointer', fontSize: '12px', color: 'var(--text-primary)',
}}
className="template-slot-btn"
>
<span style={{ fontSize: '16px' }}>📷</span>
<span className="template-slot-icon">📷</span>
<span>{slot.label}</span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', marginLeft: 'auto' }}>
<span className="template-slot-dimensions">
{slot.bounds.width}×{slot.bounds.height}
</span>
</button>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { FONTS } from '../../constants/fonts';
import '../../styles/TextTab.css';
export function TextTab({ onAddText }) {
const [text, setText] = useState('Your text here');
@@ -11,43 +12,40 @@ export function TextTab({ onAddText }) {
onAddText({ type: 'text', x: 150, y: 150, text, fontFamily, fontSize, fill, rotation: 0 });
};
const labelStyle = { display: 'block', fontSize: '11px', fontWeight: '600', color: 'var(--text-secondary)', marginBottom: '0.5rem', textTransform: 'uppercase' };
const inputStyle = { width: '100%', padding: '0.75rem', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', fontSize: '14px', fontFamily: 'var(--font-body)' };
return (
<div>
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>Add Text</h3>
<h3 className="text-tab-title">Add Text</h3>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Text Content</label>
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
<div className="text-tab-field">
<label className="text-tab-label">Text Content</label>
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3} className="text-tab-textarea" />
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Font</label>
<select value={fontFamily} onChange={(e) => setFontFamily(e.target.value)} style={{ ...inputStyle, fontSize: '13px', fontFamily, cursor: 'pointer', background: 'var(--bg-primary)' }}>
<div className="text-tab-field">
<label className="text-tab-label">Font</label>
<select value={fontFamily} onChange={(e) => setFontFamily(e.target.value)} className="text-tab-select" style={{ fontFamily }}>
{FONTS.map((font) => <option key={font.family} value={font.family}>{font.name}</option>)}
</select>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Font Size: {fontSize}px</label>
<input type="range" min="12" max="120" value={fontSize} onChange={(e) => setFontSize(parseInt(e.target.value, 10))} style={{ width: '100%' }} />
<div className="text-tab-field">
<label className="text-tab-label">Font Size: {fontSize}px</label>
<input type="range" min="12" max="120" value={fontSize} onChange={(e) => setFontSize(parseInt(e.target.value, 10))} className="text-tab-range" />
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Color</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="color" value={fill} onChange={(e) => setFill(e.target.value)} style={{ width: '40px', height: '40px', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer', padding: '2px' }} />
<input type="text" value={fill} onChange={(e) => setFill(e.target.value)} style={{ flex: 1, padding: '0.75rem', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', fontSize: '13px', fontFamily: 'var(--font-mono)' }} />
<div className="text-tab-field">
<label className="text-tab-label">Color</label>
<div className="text-tab-color-group">
<input type="color" value={fill} onChange={(e) => setFill(e.target.value)} className="text-tab-color-input" />
<input type="text" value={fill} onChange={(e) => setFill(e.target.value)} className="text-tab-color-text" />
</div>
</div>
<div style={{ padding: '1rem', background: 'var(--bg-primary)', borderRadius: 'var(--radius-md)', marginBottom: '1rem', textAlign: 'center' }}>
<div style={{ fontFamily, fontSize: `${fontSize * 0.5}px`, color: fill, wordBreak: 'break-word' }}>{text}</div>
<div className="text-tab-preview">
<div className="text-tab-preview-text" style={{ fontFamily, fontSize: `${fontSize * 0.5}px`, color: fill }}>{text}</div>
</div>
<button onClick={handleAddText} style={{ width: '100%', padding: '0.875rem', border: 'none', borderRadius: 'var(--radius-md)', background: 'var(--accent)', color: '#fff', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
<button onClick={handleAddText} className="text-tab-submit">
Add Text to Canvas
</button>
</div>

View File

@@ -1,10 +1,19 @@
import { useRef, useState } from 'react';
import '../../styles/UploadTab.css';
export function UploadTab({ onAddImage }) {
const fileInputRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const getImageSize = (src) =>
new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => resolve({ width: img.naturalWidth || img.width, height: img.naturalHeight || img.height });
img.onerror = () => reject(new Error('Failed to load image'));
img.src = src;
});
const handleFiles = async (files) => {
const file = files[0];
if (!file) return;
@@ -19,7 +28,28 @@ export function UploadTab({ onAddImage }) {
const response = await fetch('/api/upload', { method: 'POST', body: formData });
if (!response.ok) throw new Error('Upload failed');
const data = await response.json();
onAddImage({ type: 'image', x: 75, y: 75, width: 150, height: 150, rotation: 0, src: data.preview.url, originalUrl: data.original.url });
// Preserve aspect ratio by fitting the image into a 150×150 box.
const { width: naturalW, height: naturalH } = await getImageSize(data.preview.url);
const maxSide = 150;
const scale = Math.min(maxSide / naturalW, maxSide / naturalH, 1);
const width = Math.max(20, Math.round(naturalW * scale));
const height = Math.max(20, Math.round(naturalH * scale));
// Canvas is 300×300; start roughly centered.
const x = Math.round((300 - width) / 2);
const y = Math.round((300 - height) / 2);
onAddImage({
type: 'image',
x,
y,
width,
height,
rotation: 0,
src: data.preview.url,
originalUrl: data.original.url,
});
} catch (error) {
console.error('Upload error:', error);
alert('Failed to upload image. Please try again.');
@@ -30,16 +60,16 @@ export function UploadTab({ onAddImage }) {
return (
<div>
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>Upload Image</h3>
<h3 className="upload-tab-title">Upload Image</h3>
<div onClick={() => fileInputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }}
style={{ border: `2px dashed ${isDragging ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 'var(--radius-md)', padding: '2rem 1rem', textAlign: 'center', cursor: 'pointer', background: isDragging ? 'var(--accent-bg)' : 'var(--bg-primary)', marginBottom: '1rem' }}>
<div style={{ fontSize: '32px', marginBottom: '0.5rem' }}>📁</div>
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>Click to upload or drag and drop</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>JPEG, PNG, WebP (max 20MB)</div>
className={`upload-dropzone${isDragging ? ' dragging' : ''}`}>
<div className="upload-dropzone-icon">📁</div>
<div className="upload-dropzone-text">Click to upload or drag and drop</div>
<div className="upload-dropzone-hint">JPEG, PNG, WebP (max 20MB)</div>
</div>
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => handleFiles(e.target.files)} style={{ display: 'none' }} />
{isUploading && <div style={{ padding: '0.75rem', background: 'var(--accent-bg)', borderRadius: 'var(--radius-sm)', fontSize: '12px', color: 'var(--accent)', textAlign: 'center' }}>Uploading...</div>}
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', fontSize: '11px', color: 'var(--text-muted)', lineHeight: '1.4' }}>
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => handleFiles(e.target.files)} className="upload-hidden-input" />
{isUploading && <div className="upload-status">Uploading...</div>}
<div className="upload-tip">
<strong>Tip:</strong> After uploading, you can remove the background using the background removal tool in the properties panel.
</div>
</div>