Flatted and issues fixed with Claude Desktop.
This commit is contained in:
1
src/App.css
Normal file
1
src/App.css
Normal file
@@ -0,0 +1 @@
|
||||
/* App-level styles — project styles go here */
|
||||
138
src/App.jsx
Normal file
138
src/App.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DesignCanvas } from './components/canvas/DesignCanvas';
|
||||
import { Sidebar } from './components/sidebar/Sidebar';
|
||||
import { LayersPanel } from './components/panels/LayersPanel';
|
||||
import { PropertiesPanel } from './components/panels/PropertiesPanel';
|
||||
import { PWAInstall } from './components/PWAInstall';
|
||||
import { OfflineIndicator } from './components/OfflineIndicator';
|
||||
import { PhotoPreEditor } from './components/editor/PhotoPreEditor';
|
||||
import { useDesignEditor } from './hooks/useDesignEditor';
|
||||
import { useExport } from './hooks/useExport';
|
||||
import { useTemplate } from './hooks/useTemplate';
|
||||
import { TEMPLATES } from './constants/templates';
|
||||
|
||||
function App() {
|
||||
const [editingElement, setEditingElement] = useState(null);
|
||||
|
||||
const {
|
||||
elements, selectedId, addElement, updateElement, deleteElement,
|
||||
selectElement, deselectAll, commitHistory, undo, redo, canUndo, canRedo, initializeHistory,
|
||||
} = useDesignEditor();
|
||||
|
||||
const { exporting, progress, exportDesign, error, clearExport } = useExport();
|
||||
|
||||
const {
|
||||
currentTemplate, currentTemplateId, assignedSlots,
|
||||
loadTemplate, clearTemplate, getSlots, assignImageToSlot, getDragBoundFunc, isSlotFilled,
|
||||
} = useTemplate(TEMPLATES);
|
||||
|
||||
const selectedElement = elements.find((el) => el.id === selectedId);
|
||||
|
||||
useEffect(() => { initializeHistory(); }, [initializeHistory]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canUndo) undo();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) {
|
||||
e.preventDefault();
|
||||
if (canRedo) redo();
|
||||
return;
|
||||
}
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId) {
|
||||
deleteElement(selectedId);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, deleteElement, undo, redo, canUndo, canRedo]);
|
||||
|
||||
const handleAddTemplate = (templateId) => {
|
||||
if (templateId === 'freeform') { clearTemplate(); return; }
|
||||
const success = loadTemplate(templateId);
|
||||
if (success) {
|
||||
const template = TEMPLATES.find(t => t.id === templateId);
|
||||
if (template?.elements) {
|
||||
template.elements.forEach((el, index) => {
|
||||
setTimeout(() => addElement({ ...el }), index * 50);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlotImageUpload = (slotId, imageData) => {
|
||||
const elementData = assignImageToSlot(slotId, imageData);
|
||||
if (elementData) addElement(elementData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="editor-layout">
|
||||
<OfflineIndicator />
|
||||
<PWAInstall />
|
||||
|
||||
<Sidebar
|
||||
onAddImage={(data) => addElement(data)}
|
||||
onAddSticker={(data) => addElement(data)}
|
||||
onAddText={(data) => addElement(data)}
|
||||
onAddTemplate={handleAddTemplate}
|
||||
onSlotImageUpload={handleSlotImageUpload}
|
||||
/>
|
||||
|
||||
<div className="canvas-area">
|
||||
<div style={{ marginBottom: '1rem', textAlign: 'center' }}>
|
||||
<h1 style={{ margin: '0 0 0.25rem 0', fontSize: '20px', color: 'var(--text-primary)' }}>Apparel Designer</h1>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text-secondary)' }}>T-shirt customization editor</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', justifyContent: 'center' }}>
|
||||
<button onClick={() => canUndo && undo()} disabled={!canUndo} className="icon-btn" style={{ opacity: canUndo ? 1 : 0.5 }}>↶ Undo</button>
|
||||
<button onClick={() => canRedo && redo()} disabled={!canRedo} className="icon-btn" style={{ opacity: canRedo ? 1 : 0.5 }}>↷ Redo</button>
|
||||
<button onClick={() => exportDesign(elements, 'tshirt-design', currentTemplate)} disabled={exporting || elements.length === 0} className="export-btn" style={{ opacity: elements.length === 0 ? 0.5 : 1 }}>
|
||||
{exporting ? `Exporting... ${progress}%` : '⬇ Export HD'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="export-error">
|
||||
<p>⚠️ Export failed: {error}</p>
|
||||
<button onClick={clearExport} className="close-error">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DesignCanvas
|
||||
elements={elements} selectedId={selectedId}
|
||||
onSelect={selectElement} onDeselect={deselectAll}
|
||||
onUpdate={(id, attrs) => updateElement(id, attrs)} onCommit={commitHistory}
|
||||
currentTemplate={currentTemplate} assignedSlots={assignedSlots} getDragBoundFunc={getDragBoundFunc}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '1.5rem', width: '100%', maxWidth: '400px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-md)', padding: '1rem', boxShadow: 'var(--shadow-md)' }}>
|
||||
<LayersPanel elements={elements} selectedId={selectedId} onSelect={selectElement} onDelete={deleteElement} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PropertiesPanel
|
||||
element={selectedElement}
|
||||
onUpdate={(attrs) => updateElement(selectedId, attrs)}
|
||||
onDelete={deleteElement}
|
||||
onEditPhoto={(el) => setEditingElement(el)}
|
||||
/>
|
||||
|
||||
{editingElement && (
|
||||
<PhotoPreEditor
|
||||
imageSrc={editingElement.src}
|
||||
onComplete={(url) => { updateElement(editingElement.id, { src: url }); setEditingElement(null); }}
|
||||
onClose={() => setEditingElement(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
16
src/components/OfflineIndicator.jsx
Normal file
16
src/components/OfflineIndicator.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function OfflineIndicator() {
|
||||
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); };
|
||||
}, []);
|
||||
|
||||
if (!isOffline) return null;
|
||||
return <div className="offline-indicator"><span>⚠️</span><span>You're offline — changes are saved locally</span></div>;
|
||||
}
|
||||
48
src/components/PWAInstall.jsx
Normal file
48
src/components/PWAInstall.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function PWAInstall() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
||||
const [showInstall, setShowInstall] = useState(false);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [newWorker, setNewWorker] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeInstallPrompt = (e) => { e.preventDefault(); setDeferredPrompt(e); setShowInstall(true); };
|
||||
const handleSWUpdated = (event) => { setNewWorker(event.detail); setUpdateAvailable(true); };
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.addEventListener('swUpdated', handleSWUpdated);
|
||||
return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('swUpdated', handleSWUpdated); };
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') { setShowInstall(false); setDeferredPrompt(null); }
|
||||
};
|
||||
|
||||
const handleUpdate = () => { if (newWorker) { newWorker.postMessage({ type: 'SKIP_WAITING' }); window.location.reload(); } };
|
||||
|
||||
if (!showInstall && !updateAvailable) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showInstall && (
|
||||
<div className="pwa-install-banner">
|
||||
<p>Install Apparel Designer for offline access!</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<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' }}>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
src/components/canvas/DesignCanvas.jsx
Normal file
66
src/components/canvas/DesignCanvas.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Stage, Layer } from 'react-konva';
|
||||
import { TShirtSVG } from './TShirtSVG';
|
||||
import { ImageElement } from './ImageElement';
|
||||
import { TextElement } from './TextElement';
|
||||
import { TemplateLayer } from './TemplateLayer';
|
||||
import { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
|
||||
import { memo } from 'react';
|
||||
|
||||
const CANVAS_SIZE = 300;
|
||||
|
||||
export const DesignCanvas = memo(function DesignCanvas({
|
||||
elements, selectedId, onSelect, onDeselect, onUpdate, onCommit,
|
||||
currentTemplate, assignedSlots, getDragBoundFunc,
|
||||
}) {
|
||||
const slots = currentTemplate?.slots || [];
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<TShirtSVG size={CANVAS_SIZE} />
|
||||
<Stage
|
||||
width={CANVAS_SIZE} height={CANVAS_SIZE}
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<Layer>
|
||||
{currentTemplate && <TemplateLayer template={currentTemplate} canvasSize={CANVAS_SIZE} />}
|
||||
</Layer>
|
||||
<Layer listening={false}>
|
||||
{slots.map((slot) => <SlotBoundsGuide key={slot.id} slot={slot} />)}
|
||||
</Layer>
|
||||
<Layer>
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (el.type === 'text') {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Layer>
|
||||
<Layer 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' }}>
|
||||
Design Area: 15" × 15" • Export: 4500 × 4500px @ 300 DPI
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
83
src/components/canvas/ImageElement.jsx
Normal file
83
src/components/canvas/ImageElement.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef, memo } from 'react';
|
||||
import { Image, Transformer } from 'react-konva';
|
||||
import useImage from 'use-image';
|
||||
|
||||
function URLImage({ src, innerRef, ...props }) {
|
||||
const [img] = useImage(src, 'anonymous');
|
||||
return <Image image={img} ref={innerRef} {...props} />;
|
||||
}
|
||||
|
||||
export const ImageElement = memo(function ImageElement({
|
||||
id,
|
||||
x = 0,
|
||||
y = 0,
|
||||
width = 100,
|
||||
height = 100,
|
||||
rotation = 0,
|
||||
src,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onCommit,
|
||||
}) {
|
||||
const shapeRef = useRef(null);
|
||||
const trRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && trRef.current && shapeRef.current) {
|
||||
trRef.current.nodes([shapeRef.current]);
|
||||
trRef.current.getLayer().batchDraw();
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<URLImage
|
||||
innerRef={shapeRef}
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
rotation={rotation}
|
||||
src={src}
|
||||
draggable
|
||||
onClick={onSelect}
|
||||
onTap={onSelect}
|
||||
onDragEnd={(e) => {
|
||||
onUpdate({ x: e.target.x(), y: e.target.y() });
|
||||
onCommit?.();
|
||||
}}
|
||||
onTransformEnd={() => {
|
||||
const node = shapeRef.current;
|
||||
if (!node) return;
|
||||
const scaleX = node.scaleX();
|
||||
const scaleY = node.scaleY();
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
onUpdate({
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
width: Math.max(20, node.width() * scaleX),
|
||||
height: Math.max(20, node.height() * scaleY),
|
||||
rotation: node.rotation(),
|
||||
});
|
||||
onCommit?.();
|
||||
}}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Transformer
|
||||
ref={trRef}
|
||||
boundBoxFunc={(oldBox, newBox) => {
|
||||
if (newBox.width < 20 || newBox.height < 20) return oldBox;
|
||||
return newBox;
|
||||
}}
|
||||
anchorSize={8}
|
||||
anchorCornerRadius={4}
|
||||
borderStroke="#38bdf8"
|
||||
anchorStroke="#38bdf8"
|
||||
anchorFill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
33
src/components/canvas/SlotPlaceholder.jsx
Normal file
33
src/components/canvas/SlotPlaceholder.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Group, Rect, Text, Line } from 'react-konva';
|
||||
|
||||
export function SlotPlaceholder({ slot, isEmpty = true }) {
|
||||
const { bounds, label } = slot;
|
||||
const { x, y, width, height } = bounds;
|
||||
if (!isEmpty) return null;
|
||||
|
||||
return (
|
||||
<Group name={`slot-placeholder-${slot.id}`}>
|
||||
<Rect x={x} y={y} width={width} height={height} stroke="#94a3b8" strokeWidth={2} dash={[8, 4]} cornerRadius={4} listening={false} />
|
||||
<Rect x={x} y={y} width={width} height={height} fill="rgba(148, 163, 184, 0.1)" listening={false} />
|
||||
<Text text="📷" x={x + width / 2} y={y + height / 2 - 20} fontSize={24} align="center" offsetX={12} listening={false} />
|
||||
<Text text={label || 'Drop image here'} x={x + width / 2} y={y + height / 2 + 10} fontSize={11} fontFamily="DM Sans" fill="#64748b" align="center" offsetX={width / 2} listening={false} />
|
||||
<Line points={[x, y, x + 20, y]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x, y, x, y + 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x + width, y, x + width - 20, y]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x + width, y, x + width, y + 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x, y + height, x + 20, y + height]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x, y + height, x, y + height - 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x + width, y + height, x + width - 20, y + height]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x + width, y + height, x + width, y + height - 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlotBoundsGuide({ slot }) {
|
||||
const { bounds, id } = slot;
|
||||
return (
|
||||
<Group name={`slot-bounds-${id}`} listening={false}>
|
||||
<Rect x={bounds.x} y={bounds.y} width={bounds.width} height={bounds.height} stroke="rgba(56, 189, 248, 0.3)" strokeWidth={1} dash={[4, 4]} cornerRadius={2} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
14
src/components/canvas/TShirtSVG.jsx
Normal file
14
src/components/canvas/TShirtSVG.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
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 }}>
|
||||
<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" />
|
||||
<text x={size / 2} y={size * 0.45} textAnchor="middle" fill="var(--text-muted)" fontSize="10" fontFamily="var(--font-mono)">Print Zone</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
37
src/components/canvas/TemplateLayer.jsx
Normal file
37
src/components/canvas/TemplateLayer.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Group, Image as KonvaImage, Rect, Text as KonvaText } from 'react-konva';
|
||||
import useImage from 'use-image';
|
||||
|
||||
function TemplateImage({ src, x, y, width, height, opacity = 1, listening = false }) {
|
||||
const [img] = useImage(src, 'anonymous');
|
||||
return <KonvaImage image={img} x={x} y={y} width={width} height={height} opacity={opacity} listening={listening} />;
|
||||
}
|
||||
|
||||
function TemplateText({ text, x, y, fontSize, fontFamily, fill, rotation = 0 }) {
|
||||
return <KonvaText text={text} x={x} y={y} fontSize={fontSize} fontFamily={fontFamily} fill={fill} rotation={rotation} listening={false} />;
|
||||
}
|
||||
|
||||
export function TemplateLayer({ template, canvasSize = 300 }) {
|
||||
if (!template) return null;
|
||||
const { background, overlay } = template;
|
||||
|
||||
return (
|
||||
<Group name="template-layer">
|
||||
{background && (
|
||||
<Group name="template-background">
|
||||
{background.type === 'color' ? (
|
||||
<Rect x={0} y={0} width={canvasSize} height={canvasSize} fill={background.color} listening={false} />
|
||||
) : background.type === 'image' ? (
|
||||
<TemplateImage src={background.src} x={0} y={0} width={canvasSize} height={canvasSize} listening={false} />
|
||||
) : null}
|
||||
</Group>
|
||||
)}
|
||||
{overlay && overlay.map((el, index) => {
|
||||
if (el.nonPrintable) return null;
|
||||
const key = `overlay-${index}`;
|
||||
if (el.type === 'image') return <TemplateImage key={key} src={el.src} x={el.x || 0} y={el.y || 0} width={el.width || 100} height={el.height || 100} opacity={el.opacity} listening={false} />;
|
||||
if (el.type === 'text') return <TemplateText key={key} text={el.text} x={el.x || 0} y={el.y || 0} fontSize={el.fontSize} fontFamily={el.fontFamily} fill={el.fill} rotation={el.rotation} />;
|
||||
return null;
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
74
src/components/canvas/TextElement.jsx
Normal file
74
src/components/canvas/TextElement.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, memo } from 'react';
|
||||
import { Text, Transformer } from 'react-konva';
|
||||
|
||||
export const TextElement = memo(function TextElement({
|
||||
id,
|
||||
x = 0,
|
||||
y = 0,
|
||||
text = '',
|
||||
fontSize = 24,
|
||||
fontFamily = 'DM Sans',
|
||||
fill = '#0f172a',
|
||||
rotation = 0,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onCommit,
|
||||
}) {
|
||||
const textRef = useRef(null);
|
||||
const trRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && trRef.current && textRef.current) {
|
||||
trRef.current.nodes([textRef.current]);
|
||||
trRef.current.getLayer().batchDraw();
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
ref={textRef}
|
||||
x={x}
|
||||
y={y}
|
||||
text={text}
|
||||
fontSize={fontSize}
|
||||
fontFamily={fontFamily}
|
||||
fill={fill}
|
||||
rotation={rotation}
|
||||
draggable
|
||||
onClick={onSelect}
|
||||
onTap={onSelect}
|
||||
onDragEnd={(e) => {
|
||||
onUpdate({ x: e.target.x(), y: e.target.y() });
|
||||
onCommit?.();
|
||||
}}
|
||||
onTransformEnd={() => {
|
||||
const node = textRef.current;
|
||||
if (!node) return;
|
||||
const scaleX = node.scaleX();
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
onUpdate({
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
fontSize: Math.max(12, node.fontSize() * scaleX),
|
||||
rotation: node.rotation(),
|
||||
});
|
||||
onCommit?.();
|
||||
}}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Transformer
|
||||
ref={trRef}
|
||||
enabledAnchors={['top-left', 'top-right', 'bottom-left', 'bottom-right']}
|
||||
anchorSize={8}
|
||||
anchorCornerRadius={4}
|
||||
borderStroke="#38bdf8"
|
||||
anchorStroke="#38bdf8"
|
||||
anchorFill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
6
src/components/canvas/index.js
Normal file
6
src/components/canvas/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export { DesignCanvas } from './DesignCanvas';
|
||||
export { TShirtSVG } from './TShirtSVG';
|
||||
export { ImageElement } from './ImageElement';
|
||||
export { TextElement } from './TextElement';
|
||||
export { TemplateLayer } from './TemplateLayer';
|
||||
export { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
|
||||
43
src/components/editor/PhotoPreEditor.jsx
Normal file
43
src/components/editor/PhotoPreEditor.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import FilerobotImageEditor from 'react-filerobot-image-editor';
|
||||
|
||||
export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const modalContentRef = useRef(null);
|
||||
const previousFocusRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
previousFocusRef.current = document.activeElement;
|
||||
const handleKeyDown = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocusRef.current?.focus();
|
||||
};
|
||||
}, [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(); });
|
||||
};
|
||||
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
<h2 id="photo-editor-title" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)' }}>Photo Editor</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/components/editor/index.js
Normal file
1
src/components/editor/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { PhotoPreEditor } from './PhotoPreEditor';
|
||||
36
src/components/panels/LayersPanel.jsx
Normal file
36
src/components/panels/LayersPanel.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
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>
|
||||
<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' }}>
|
||||
{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' }}>
|
||||
{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)' }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
113
src/components/panels/PropertiesPanel.jsx
Normal file
113
src/components/panels/PropertiesPanel.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { memo } from 'react';
|
||||
import { BackgroundRemovalButton } from '../sidebar/BackgroundRemovalButton';
|
||||
|
||||
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>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem', color: 'var(--text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
Select an element to edit its properties
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePositionChange = (axis, value) => onUpdate({ [axis]: parseFloat(value) || 0 });
|
||||
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>
|
||||
|
||||
<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' }}>
|
||||
{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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Background Removal (for images) */}
|
||||
{element.type === 'image' && (
|
||||
<BackgroundRemovalButton
|
||||
selectedElement={element}
|
||||
onUpdate={(_id, attrs) => onUpdate(attrs)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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' }}>
|
||||
Delete Element
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
2
src/components/panels/index.js
Normal file
2
src/components/panels/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LayersPanel } from './LayersPanel';
|
||||
export { PropertiesPanel } from './PropertiesPanel';
|
||||
23
src/components/sidebar/BackgroundRemovalButton.jsx
Normal file
23
src/components/sidebar/BackgroundRemovalButton.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useBackgroundRemoval } from '../../hooks/useBackgroundRemoval';
|
||||
|
||||
export function BackgroundRemovalButton({ selectedElement, onUpdate }) {
|
||||
const { loading, progress, hasModel, loadModel, removeBackground } = useBackgroundRemoval();
|
||||
|
||||
const handleRemoveBackground = async () => {
|
||||
if (!selectedElement || selectedElement.type !== 'image') return;
|
||||
if (!hasModel) { const loaded = await loadModel(); if (!loaded) return; }
|
||||
const resultUrl = await removeBackground(selectedElement.src);
|
||||
if (resultUrl) onUpdate(selectedElement.id, { src: resultUrl, bgRemoved: true });
|
||||
};
|
||||
|
||||
if (!selectedElement || selectedElement.type !== 'image') return null;
|
||||
|
||||
return (
|
||||
<div className="bg-removal-container">
|
||||
<button className="bg-removal-btn" onClick={handleRemoveBackground} disabled={loading}>
|
||||
{loading ? (<><div className="spinner-small" />{progress > 0 ? `Loading: ${progress}%` : 'Removing Background...'}</>) : (<>✨ Remove Background</>)}
|
||||
</button>
|
||||
{!hasModel && <p className="bg-removal-hint">First use requires downloading ~86MB model. Subsequent uses are cached.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/sidebar/Sidebar.jsx
Normal file
46
src/components/sidebar/Sidebar.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { UploadTab } from './UploadTab';
|
||||
import { StickersTab } from './StickersTab';
|
||||
import { TextTab } from './TextTab';
|
||||
import { TemplatesTab } from './TemplatesTab';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'upload', label: 'Upload', icon: '📁' },
|
||||
{ id: 'stickers', label: 'Stickers', icon: '🎨' },
|
||||
{ id: 'text', label: 'Text', icon: '📝' },
|
||||
{ id: 'templates', label: 'Templates', icon: '📋' },
|
||||
];
|
||||
|
||||
export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, onSlotImageUpload }) {
|
||||
const [activeTab, setActiveTab] = useState('upload');
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'upload': return <UploadTab onAddImage={onAddImage} />;
|
||||
case 'stickers': return <StickersTab onAddSticker={onAddSticker} />;
|
||||
case 'text': return <TextTab onAddText={onAddText} />;
|
||||
case 'templates': return <TemplatesTab onAddTemplate={onAddTemplate} onSlotImageUpload={onSlotImageUpload} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', background: 'var(--bg-primary)' }}>
|
||||
{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>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '1rem' }}>{renderTabContent()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/components/sidebar/StickersTab.jsx
Normal file
57
src/components/sidebar/StickersTab.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import { STICKERS, STICKER_CATEGORIES } from '../../constants/stickers';
|
||||
|
||||
export function StickersTab({ onAddSticker }) {
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
|
||||
const filteredStickers = activeCategory === 'all'
|
||||
? STICKERS
|
||||
: STICKERS.filter(s => s.category === activeCategory);
|
||||
|
||||
const handleAddSticker = (emoji) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const size = 100;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.font = `${size * 0.8}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(emoji, size / 2, size / 2);
|
||||
|
||||
onAddSticker({
|
||||
type: 'image',
|
||||
x: 125, y: 125, width: 80, height: 80, rotation: 0,
|
||||
src: canvas.toDataURL('image/png'),
|
||||
emoji,
|
||||
});
|
||||
};
|
||||
|
||||
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' }}>
|
||||
{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',
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '8px' }}>
|
||||
{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' }}
|
||||
>
|
||||
{sticker.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/components/sidebar/TemplatesTab.jsx
Normal file
118
src/components/sidebar/TemplatesTab.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
|
||||
|
||||
function getCategoryEmoji(category) {
|
||||
const emojis = {
|
||||
Sports: '⚽', Music: '🎸', Quotes: '💬', Animals: '🐱',
|
||||
Abstract: '🌈', Vintage: '🏅', Nature: '🏔️', Tech: '💻',
|
||||
};
|
||||
return emojis[category] || '🎨';
|
||||
}
|
||||
|
||||
export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
|
||||
const [uploadSlotId, setUploadSlotId] = useState(null);
|
||||
|
||||
const templates = [
|
||||
{ id: 'freeform', name: 'Freeform', description: 'No template - design freely', thumbnail: '🎨' },
|
||||
...TEMPLATES.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
thumbnail: getCategoryEmoji(t.category),
|
||||
hasSlots: !!t.slots,
|
||||
})),
|
||||
];
|
||||
|
||||
const handleSelectTemplate = (template) => {
|
||||
setSelectedTemplateId(template.id);
|
||||
onAddTemplate(template.id);
|
||||
};
|
||||
|
||||
const handleSlotClick = (slotId) => {
|
||||
setUploadSlotId(slotId);
|
||||
document.getElementById('slot-file-input')?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && uploadSlotId) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
onSlotImageUpload?.(uploadSlotId, event.target.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
e.target.value = '';
|
||||
setUploadSlotId(null);
|
||||
};
|
||||
|
||||
const selectedTemplate = TEMPLATES.find(t => t.id === selectedTemplateId);
|
||||
const slots = selectedTemplate?.slots || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input id="slot-file-input" type="file" accept="image/*" onChange={handleFileChange} style={{ display: 'none' }} />
|
||||
|
||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>Templates</h3>
|
||||
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '1rem', lineHeight: '1.4' }}>
|
||||
Choose a template to get started or design freely.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{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',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '48px', height: '48px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)', fontSize: '24px',
|
||||
}}>
|
||||
{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>
|
||||
{template.hasSlots && (
|
||||
<span style={{ fontSize: '10px', padding: '2px 6px', background: 'var(--accent)', color: '#fff', borderRadius: '4px', fontWeight: '600' }}>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' }}>
|
||||
{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)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>📷</span>
|
||||
<span>{slot.label}</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--text-muted)', marginLeft: 'auto' }}>
|
||||
{slot.bounds.width}×{slot.bounds.height}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/components/sidebar/TextTab.jsx
Normal file
55
src/components/sidebar/TextTab.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { FONTS } from '../../constants/fonts';
|
||||
|
||||
export function TextTab({ onAddText }) {
|
||||
const [text, setText] = useState('Your text here');
|
||||
const [fontFamily, setFontFamily] = useState('Roboto');
|
||||
const [fontSize, setFontSize] = useState(48);
|
||||
const [fill, setFill] = useState('#0f172a');
|
||||
|
||||
const handleAddText = () => {
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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)' }}>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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' }}>
|
||||
Add Text to Canvas
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/sidebar/UploadTab.jsx
Normal file
47
src/components/sidebar/UploadTab.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export function UploadTab({ onAddImage }) {
|
||||
const fileInputRef = useRef(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFiles = async (files) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) { alert('Please upload a JPEG, PNG, or WebP image'); return; }
|
||||
if (file.size > 20 * 1024 * 1024) { alert('File size must be under 20MB'); return; }
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
alert('Failed to upload image. Please try again.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>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>
|
||||
</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' }}>
|
||||
<strong>Tip:</strong> After uploading, you can remove the background using the background removal tool in the properties panel.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/components/sidebar/index.js
Normal file
5
src/components/sidebar/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { UploadTab } from './UploadTab';
|
||||
export { StickersTab } from './StickersTab';
|
||||
export { TextTab } from './TextTab';
|
||||
export { TemplatesTab } from './TemplatesTab';
|
||||
22
src/constants/fonts.js
Normal file
22
src/constants/fonts.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export const FONTS = [
|
||||
{ name: 'Roboto', family: 'Roboto' },
|
||||
{ name: 'Open Sans', family: 'Open Sans' },
|
||||
{ name: 'Lato', family: 'Lato' },
|
||||
{ name: 'Montserrat', family: 'Montserrat' },
|
||||
{ name: 'Oswald', family: 'Oswald' },
|
||||
{ name: 'Raleway', family: 'Raleway' },
|
||||
{ name: 'Poppins', family: 'Poppins' },
|
||||
{ name: 'Roboto Condensed', family: 'Roboto Condensed' },
|
||||
{ name: 'Source Sans 3', family: 'Source Sans 3' },
|
||||
{ name: 'Roboto Slab', family: 'Roboto Slab' },
|
||||
{ name: 'Merriweather', family: 'Merriweather' },
|
||||
{ name: 'Ubuntu', family: 'Ubuntu' },
|
||||
{ name: 'Playfair Display', family: 'Playfair Display' },
|
||||
{ name: 'Nunito', family: 'Nunito' },
|
||||
{ name: 'Rubik', family: 'Rubik' },
|
||||
{ name: 'Work Sans', family: 'Work Sans' },
|
||||
{ name: 'Lora', family: 'Lora' },
|
||||
{ name: 'Fira Sans', family: 'Fira Sans' },
|
||||
{ name: 'Barlow', family: 'Barlow' },
|
||||
{ name: 'Bebas Neue', family: 'Bebas Neue' },
|
||||
];
|
||||
159
src/constants/stickers.js
Normal file
159
src/constants/stickers.js
Normal file
@@ -0,0 +1,159 @@
|
||||
export const STICKER_CATEGORIES = ['all', 'faces', 'animals', 'food', 'sports', 'nature', 'objects'];
|
||||
|
||||
export const STICKERS = [
|
||||
// Faces
|
||||
{ emoji: '😀', category: 'faces' },
|
||||
{ emoji: '😁', category: 'faces' },
|
||||
{ emoji: '😂', category: 'faces' },
|
||||
{ emoji: '🤣', category: 'faces' },
|
||||
{ emoji: '😃', category: 'faces' },
|
||||
{ emoji: '😄', category: 'faces' },
|
||||
{ emoji: '😅', category: 'faces' },
|
||||
{ emoji: '😆', category: 'faces' },
|
||||
{ emoji: '😉', category: 'faces' },
|
||||
{ emoji: '😊', category: 'faces' },
|
||||
{ emoji: '😋', category: 'faces' },
|
||||
{ emoji: '😎', category: 'faces' },
|
||||
{ emoji: '😍', category: 'faces' },
|
||||
{ emoji: '😘', category: 'faces' },
|
||||
{ emoji: '🥰', category: 'faces' },
|
||||
{ emoji: '😗', category: 'faces' },
|
||||
{ emoji: '🤔', category: 'faces' },
|
||||
{ emoji: '🤨', category: 'faces' },
|
||||
{ emoji: '🧐', category: 'faces' },
|
||||
{ emoji: '🤓', category: 'faces' },
|
||||
{ emoji: '😈', category: 'faces' },
|
||||
{ emoji: '🤠', category: 'faces' },
|
||||
{ emoji: '🥳', category: 'faces' },
|
||||
{ emoji: '🤩', category: 'faces' },
|
||||
|
||||
// Animals
|
||||
{ emoji: '🐶', category: 'animals' },
|
||||
{ emoji: '🐱', category: 'animals' },
|
||||
{ emoji: '🐭', category: 'animals' },
|
||||
{ emoji: '🐹', category: 'animals' },
|
||||
{ emoji: '🐰', category: 'animals' },
|
||||
{ emoji: '🦊', category: 'animals' },
|
||||
{ emoji: '🐻', category: 'animals' },
|
||||
{ emoji: '🐼', category: 'animals' },
|
||||
{ emoji: '🐨', category: 'animals' },
|
||||
{ emoji: '🐯', category: 'animals' },
|
||||
{ emoji: '🦁', category: 'animals' },
|
||||
{ emoji: '🐮', category: 'animals' },
|
||||
{ emoji: '🐷', category: 'animals' },
|
||||
{ emoji: '🐸', category: 'animals' },
|
||||
{ emoji: '🐵', category: 'animals' },
|
||||
{ emoji: '🐔', category: 'animals' },
|
||||
{ emoji: '🐧', category: 'animals' },
|
||||
{ emoji: '🐦', category: 'animals' },
|
||||
{ emoji: '🦄', category: 'animals' },
|
||||
{ emoji: '🐝', category: 'animals' },
|
||||
{ emoji: '🦋', category: 'animals' },
|
||||
{ emoji: '🐌', category: 'animals' },
|
||||
{ emoji: '🐞', category: 'animals' },
|
||||
{ emoji: '🐢', category: 'animals' },
|
||||
|
||||
// Food
|
||||
{ emoji: '🍎', category: 'food' },
|
||||
{ emoji: '🍐', category: 'food' },
|
||||
{ emoji: '🍊', category: 'food' },
|
||||
{ emoji: '🍋', category: 'food' },
|
||||
{ emoji: '🍌', category: 'food' },
|
||||
{ emoji: '🍉', category: 'food' },
|
||||
{ emoji: '🍇', category: 'food' },
|
||||
{ emoji: '🍓', category: 'food' },
|
||||
{ emoji: '🍈', category: 'food' },
|
||||
{ emoji: '🍒', category: 'food' },
|
||||
{ emoji: '🍑', category: 'food' },
|
||||
{ emoji: '🍍', category: 'food' },
|
||||
{ emoji: '🥥', category: 'food' },
|
||||
{ emoji: '🥝', category: 'food' },
|
||||
{ emoji: '🍅', category: 'food' },
|
||||
{ emoji: '🥑', category: 'food' },
|
||||
{ emoji: '🍆', category: 'food' },
|
||||
{ emoji: '🥔', category: 'food' },
|
||||
{ emoji: '🥕', category: 'food' },
|
||||
{ emoji: '🌽', category: 'food' },
|
||||
{ emoji: '🍕', category: 'food' },
|
||||
{ emoji: '🍔', category: 'food' },
|
||||
{ emoji: '🍟', category: 'food' },
|
||||
{ emoji: '🌭', category: 'food' },
|
||||
|
||||
// Sports
|
||||
{ emoji: '⚽', category: 'sports' },
|
||||
{ emoji: '🏀', category: 'sports' },
|
||||
{ emoji: '🏈', category: 'sports' },
|
||||
{ emoji: '⚾', category: 'sports' },
|
||||
{ emoji: '🥎', category: 'sports' },
|
||||
{ emoji: '🎾', category: 'sports' },
|
||||
{ emoji: '🏐', category: 'sports' },
|
||||
{ emoji: '🏉', category: 'sports' },
|
||||
{ emoji: '🎱', category: 'sports' },
|
||||
{ emoji: '🏓', category: 'sports' },
|
||||
{ emoji: '🏸', category: 'sports' },
|
||||
{ emoji: '🥅', category: 'sports' },
|
||||
{ emoji: '⛳', category: 'sports' },
|
||||
{ emoji: '🥊', category: 'sports' },
|
||||
{ emoji: '🥋', category: 'sports' },
|
||||
{ emoji: '🎯', category: 'sports' },
|
||||
{ emoji: '⛹️', category: 'sports' },
|
||||
{ emoji: '🚴', category: 'sports' },
|
||||
{ emoji: '🏆', category: 'sports' },
|
||||
{ emoji: '🥇', category: 'sports' },
|
||||
{ emoji: '🥈', category: 'sports' },
|
||||
{ emoji: '🥉', category: 'sports' },
|
||||
{ emoji: '🏅', category: 'sports' },
|
||||
{ emoji: '🎖️', category: 'sports' },
|
||||
|
||||
// Nature
|
||||
{ emoji: '🌸', category: 'nature' },
|
||||
{ emoji: '💐', category: 'nature' },
|
||||
{ emoji: '🌹', category: 'nature' },
|
||||
{ emoji: '🌺', category: 'nature' },
|
||||
{ emoji: '🌻', category: 'nature' },
|
||||
{ emoji: '🌼', category: 'nature' },
|
||||
{ emoji: '🌷', category: 'nature' },
|
||||
{ emoji: '🌱', category: 'nature' },
|
||||
{ emoji: '🌲', category: 'nature' },
|
||||
{ emoji: '🌳', category: 'nature' },
|
||||
{ emoji: '🌴', category: 'nature' },
|
||||
{ emoji: '🌵', category: 'nature' },
|
||||
{ emoji: '🌾', category: 'nature' },
|
||||
{ emoji: '🌿', category: 'nature' },
|
||||
{ emoji: '☘️', category: 'nature' },
|
||||
{ emoji: '🍀', category: 'nature' },
|
||||
{ emoji: '🍁', category: 'nature' },
|
||||
{ emoji: '🍂', category: 'nature' },
|
||||
{ emoji: '🍃', category: 'nature' },
|
||||
{ emoji: '🌈', category: 'nature' },
|
||||
{ emoji: '☀️', category: 'nature' },
|
||||
{ emoji: '🌙', category: 'nature' },
|
||||
{ emoji: '⭐', category: 'nature' },
|
||||
{ emoji: '🔥', category: 'nature' },
|
||||
|
||||
// Objects
|
||||
{ emoji: '❤️', category: 'objects' },
|
||||
{ emoji: '💛', category: 'objects' },
|
||||
{ emoji: '💚', category: 'objects' },
|
||||
{ emoji: '💙', category: 'objects' },
|
||||
{ emoji: '💜', category: 'objects' },
|
||||
{ emoji: '🧡', category: 'objects' },
|
||||
{ emoji: '💔', category: 'objects' },
|
||||
{ emoji: '💯', category: 'objects' },
|
||||
{ emoji: '✨', category: 'objects' },
|
||||
{ emoji: '🌟', category: 'objects' },
|
||||
{ emoji: '💫', category: 'objects' },
|
||||
{ emoji: '🎵', category: 'objects' },
|
||||
{ emoji: '🎶', category: 'objects' },
|
||||
{ emoji: '🎸', category: 'objects' },
|
||||
{ emoji: '🎺', category: 'objects' },
|
||||
{ emoji: '🎷', category: 'objects' },
|
||||
{ emoji: '🎹', category: 'objects' },
|
||||
{ emoji: '👑', category: 'objects' },
|
||||
{ emoji: '💎', category: 'objects' },
|
||||
{ emoji: '🎁', category: 'objects' },
|
||||
{ emoji: '🎈', category: 'objects' },
|
||||
{ emoji: '🎉', category: 'objects' },
|
||||
{ emoji: '🎊', category: 'objects' },
|
||||
{ emoji: '🔮', category: 'objects' },
|
||||
];
|
||||
323
src/constants/templates.js
Normal file
323
src/constants/templates.js
Normal file
@@ -0,0 +1,323 @@
|
||||
// Pre-designed templates for t-shirt customization
|
||||
export const TEMPLATES = [
|
||||
{
|
||||
id: 'team-sport',
|
||||
name: 'Team Sport',
|
||||
category: 'Sports',
|
||||
description: 'Classic team jersey with number and text',
|
||||
slots: [
|
||||
{
|
||||
id: 'chest-text',
|
||||
label: 'Team Name',
|
||||
bounds: { x: 75, y: 70, width: 150, height: 40 },
|
||||
aspectRatio: 3.75,
|
||||
},
|
||||
{
|
||||
id: 'chest-number',
|
||||
label: 'Number',
|
||||
bounds: { x: 100, y: 120, width: 100, height: 100 },
|
||||
aspectRatio: 1,
|
||||
},
|
||||
],
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'TEAM NAME',
|
||||
x: 75,
|
||||
y: 80,
|
||||
fontSize: 28,
|
||||
fontFamily: 'Impact',
|
||||
fill: '#ffffff',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '23',
|
||||
x: 150,
|
||||
y: 150,
|
||||
fontSize: 72,
|
||||
fontFamily: 'Impact',
|
||||
fill: '#ffffff',
|
||||
rotation: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'band-merch',
|
||||
name: 'Band Merch',
|
||||
category: 'Music',
|
||||
description: 'Classic band t-shirt design',
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'BAND NAME',
|
||||
x: 150,
|
||||
y: 70,
|
||||
fontSize: 32,
|
||||
fontFamily: 'Georgia',
|
||||
fill: '#fbbf24',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'WORLD TOUR 2026',
|
||||
x: 150,
|
||||
y: 110,
|
||||
fontSize: 16,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#ffffff',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '🎸',
|
||||
x: 150,
|
||||
y: 180,
|
||||
fontSize: 64,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#ffffff',
|
||||
rotation: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'minimal-quote',
|
||||
name: 'Minimal Quote',
|
||||
category: 'Quotes',
|
||||
description: 'Simple centered quote design',
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '"Be the change"',
|
||||
x: 150,
|
||||
y: 130,
|
||||
fontSize: 24,
|
||||
fontFamily: 'Georgia',
|
||||
fill: '#1e293b',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'you wish to see',
|
||||
x: 150,
|
||||
y: 160,
|
||||
fontSize: 18,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#64748b',
|
||||
rotation: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'funny-cat',
|
||||
name: 'Funny Cat',
|
||||
category: 'Animals',
|
||||
description: 'Cute cat with funny text',
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '😼',
|
||||
x: 150,
|
||||
y: 100,
|
||||
fontSize: 80,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#000000',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'I do what I want',
|
||||
x: 150,
|
||||
y: 200,
|
||||
fontSize: 20,
|
||||
fontFamily: 'Comic Sans MS',
|
||||
fill: '#475569',
|
||||
rotation: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gradient-vibes',
|
||||
name: 'Gradient Vibes',
|
||||
category: 'Abstract',
|
||||
description: 'Modern gradient text design',
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'GOOD',
|
||||
x: 150,
|
||||
y: 110,
|
||||
fontSize: 48,
|
||||
fontFamily: 'Impact',
|
||||
fill: '#ec4899',
|
||||
rotation: -5,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'VIBES',
|
||||
x: 150,
|
||||
y: 160,
|
||||
fontSize: 48,
|
||||
fontFamily: 'Impact',
|
||||
fill: '#8b5cf6',
|
||||
rotation: 5,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '✨',
|
||||
x: 80,
|
||||
y: 90,
|
||||
fontSize: 32,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#fbbf24',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '🌙',
|
||||
x: 220,
|
||||
y: 190,
|
||||
fontSize: 32,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#38bdf8',
|
||||
rotation: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vintage-badge',
|
||||
name: 'Vintage Badge',
|
||||
category: 'Vintage',
|
||||
description: 'Retro badge style design',
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'EST.',
|
||||
x: 150,
|
||||
y: 80,
|
||||
fontSize: 18,
|
||||
fontFamily: 'Times New Roman',
|
||||
fill: '#78716c',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '2026',
|
||||
x: 150,
|
||||
y: 105,
|
||||
fontSize: 36,
|
||||
fontFamily: 'Times New Roman',
|
||||
fill: '#78716c',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'AUTHENTIC',
|
||||
x: 150,
|
||||
y: 150,
|
||||
fontSize: 24,
|
||||
fontFamily: 'Times New Roman',
|
||||
fill: '#78716c',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'QUALITY',
|
||||
x: 150,
|
||||
y: 180,
|
||||
fontSize: 24,
|
||||
fontFamily: 'Times New Roman',
|
||||
fill: '#78716c',
|
||||
rotation: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nature-lover',
|
||||
name: 'Nature Lover',
|
||||
category: 'Nature',
|
||||
description: 'Mountain and nature themed',
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '🏔️',
|
||||
x: 150,
|
||||
y: 90,
|
||||
fontSize: 56,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#000000',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'ADVENTURE',
|
||||
x: 150,
|
||||
y: 160,
|
||||
fontSize: 28,
|
||||
fontFamily: 'Impact',
|
||||
fill: '#059669',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'AWAITS',
|
||||
x: 150,
|
||||
y: 190,
|
||||
fontSize: 20,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#6b7280',
|
||||
rotation: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tech-geek',
|
||||
name: 'Tech Geek',
|
||||
category: 'Tech',
|
||||
description: 'Programming themed design',
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '</>',
|
||||
x: 150,
|
||||
y: 100,
|
||||
fontSize: 64,
|
||||
fontFamily: 'Courier New',
|
||||
fill: '#3b82f6',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello, World!',
|
||||
x: 150,
|
||||
y: 170,
|
||||
fontSize: 20,
|
||||
fontFamily: 'Courier New',
|
||||
fill: '#1e293b',
|
||||
rotation: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '// Code is life',
|
||||
x: 150,
|
||||
y: 195,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Courier New',
|
||||
fill: '#94a3b8',
|
||||
rotation: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const TEMPLATE_CATEGORIES = [
|
||||
'All',
|
||||
'Sports',
|
||||
'Music',
|
||||
'Quotes',
|
||||
'Animals',
|
||||
'Abstract',
|
||||
'Vintage',
|
||||
'Nature',
|
||||
'Tech',
|
||||
];
|
||||
4
src/hooks/index.js
Normal file
4
src/hooks/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useDesignEditor } from './useDesignEditor';
|
||||
export { useBackgroundRemoval } from './useBackgroundRemoval';
|
||||
export { useExport } from './useExport';
|
||||
export { useTemplate } from './useTemplate';
|
||||
90
src/hooks/useBackgroundRemoval.js
Normal file
90
src/hooks/useBackgroundRemoval.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { AutoModel, AutoProcessor, RawImage } from '@huggingface/transformers';
|
||||
|
||||
export function useBackgroundRemoval() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const modelRef = useRef(null);
|
||||
const processorRef = useRef(null);
|
||||
|
||||
const loadModel = useCallback(async () => {
|
||||
if (modelRef.current && processorRef.current) return true;
|
||||
|
||||
setLoading(true);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
modelRef.current = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
|
||||
dtype: 'q8',
|
||||
device: navigator.gpu ? 'webgpu' : 'wasm',
|
||||
progress_callback: (p) => {
|
||||
if (p.progress != null) setProgress(Math.round(p.progress * 50));
|
||||
},
|
||||
});
|
||||
|
||||
processorRef.current = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
|
||||
|
||||
setProgress(50);
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load background removal model:', error);
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeBackground = useCallback(async (imageSrc) => {
|
||||
if (!modelRef.current || !processorRef.current) {
|
||||
const loaded = await loadModel();
|
||||
if (!loaded) return null;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setProgress(50);
|
||||
|
||||
try {
|
||||
const image = await RawImage.fromURL(imageSrc);
|
||||
const { pixel_values } = await processorRef.current(image);
|
||||
|
||||
setProgress(70);
|
||||
|
||||
const { output } = await modelRef.current({ input: pixel_values });
|
||||
|
||||
setProgress(90);
|
||||
|
||||
const mask = await RawImage.fromTensor(
|
||||
output[0].mul(255).to('uint8')
|
||||
).resize(image.width, image.height);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.drawImage(await image.toCanvas(), 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
for (let i = 0; i < mask.data.length; i++) {
|
||||
imageData.data[i * 4 + 3] = mask.data[i];
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
setProgress(100);
|
||||
setLoading(false);
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch (error) {
|
||||
console.error('Background removal failed:', error);
|
||||
setLoading(false);
|
||||
return null;
|
||||
}
|
||||
}, [loadModel]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
progress,
|
||||
hasModel: !!(modelRef.current),
|
||||
loadModel,
|
||||
removeBackground,
|
||||
};
|
||||
}
|
||||
72
src/hooks/useDesignEditor.js
Normal file
72
src/hooks/useDesignEditor.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
const DEBOUNCE_DELAY_MS = 300;
|
||||
|
||||
export function useDesignEditor() {
|
||||
const [elements, setElements] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const historyRef = useRef([]);
|
||||
const historyIndexRef = useRef(-1);
|
||||
const historyTimerRef = useRef(null);
|
||||
const pendingChangesRef = useRef(null);
|
||||
|
||||
const saveToHistory = useCallback((newElements) => {
|
||||
if (historyIndexRef.current < historyRef.current.length - 1) {
|
||||
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
|
||||
}
|
||||
historyRef.current.push(JSON.stringify(newElements));
|
||||
if (historyRef.current.length > MAX_HISTORY) { historyRef.current.shift(); }
|
||||
else { historyIndexRef.current++; }
|
||||
}, []);
|
||||
|
||||
const flushPendingChanges = useCallback(() => {
|
||||
if (pendingChangesRef.current) { saveToHistory(pendingChangesRef.current); pendingChangesRef.current = null; }
|
||||
if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; }
|
||||
}, [saveToHistory]);
|
||||
|
||||
useEffect(() => { return () => { if (historyTimerRef.current) clearTimeout(historyTimerRef.current); }; }, []);
|
||||
|
||||
const canUndo = historyIndexRef.current > 0;
|
||||
const canRedo = historyIndexRef.current < historyRef.current.length - 1;
|
||||
|
||||
const addElement = useCallback((element) => {
|
||||
flushPendingChanges();
|
||||
const newElement = { ...element, id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` };
|
||||
setElements((prev) => { const newElements = [...prev, newElement]; saveToHistory(newElements); return newElements; });
|
||||
setSelectedId(newElement.id);
|
||||
return newElement.id;
|
||||
}, [flushPendingChanges, saveToHistory]);
|
||||
|
||||
const updateElement = useCallback((id, attrs) => {
|
||||
setElements((prev) => {
|
||||
const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
|
||||
pendingChangesRef.current = newElements;
|
||||
if (historyTimerRef.current) clearTimeout(historyTimerRef.current);
|
||||
historyTimerRef.current = setTimeout(() => { flushPendingChanges(); }, DEBOUNCE_DELAY_MS);
|
||||
return newElements;
|
||||
});
|
||||
}, [flushPendingChanges]);
|
||||
|
||||
const deleteElement = useCallback((id) => {
|
||||
flushPendingChanges();
|
||||
setElements((prev) => { const newElements = prev.filter((el) => el.id !== id); saveToHistory(newElements); return newElements; });
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
}, [selectedId, flushPendingChanges, saveToHistory]);
|
||||
|
||||
const selectElement = useCallback((id) => setSelectedId(id), []);
|
||||
const deselectAll = useCallback(() => setSelectedId(null), []);
|
||||
const commitHistory = useCallback(() => flushPendingChanges(), [flushPendingChanges]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndexRef.current > 0) { historyIndexRef.current--; setElements(JSON.parse(historyRef.current[historyIndexRef.current])); setSelectedId(null); }
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndexRef.current < historyRef.current.length - 1) { historyIndexRef.current++; setElements(JSON.parse(historyRef.current[historyIndexRef.current])); setSelectedId(null); }
|
||||
}, []);
|
||||
|
||||
const initializeHistory = useCallback(() => { historyRef.current = [JSON.stringify([])]; historyIndexRef.current = 0; }, []);
|
||||
|
||||
return { elements, selectedId, addElement, updateElement, deleteElement, selectElement, deselectAll, commitHistory, undo, redo, canUndo, canRedo, initializeHistory };
|
||||
}
|
||||
47
src/hooks/useExport.js
Normal file
47
src/hooks/useExport.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useExport() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [exportUrl, setExportUrl] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const exportDesign = useCallback(async (elements, designName = 'design', template = null) => {
|
||||
setExporting(true); setProgress(0); setError(null); setExportUrl(null);
|
||||
|
||||
try {
|
||||
const progressInterval = setInterval(() => { setProgress((prev) => Math.min(prev + 10, 90)); }, 200);
|
||||
|
||||
const response = await fetch('/api/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ elements, designName, template }),
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setProgress(100);
|
||||
|
||||
if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Export failed'); }
|
||||
|
||||
const data = await response.json();
|
||||
setExportUrl(data.export.url);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = data.export.url;
|
||||
link.download = data.export.filename;
|
||||
link.click();
|
||||
|
||||
setExporting(false);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
setError(err.message);
|
||||
setExporting(false);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearExport = useCallback(() => { setExportUrl(null); setError(null); }, []);
|
||||
|
||||
return { exporting, progress, exportUrl, error, exportDesign, clearExport };
|
||||
}
|
||||
57
src/hooks/useTemplate.js
Normal file
57
src/hooks/useTemplate.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
export function calculateAutoCrop(imageSize, slotSize) {
|
||||
const imageRatio = imageSize.width / imageSize.height;
|
||||
const slotRatio = slotSize.width / slotSize.height;
|
||||
let sx, sy, sWidth, sHeight;
|
||||
if (imageRatio > slotRatio) { sHeight = imageSize.height; sWidth = imageSize.height * slotRatio; sx = (imageSize.width - sWidth) / 2; sy = 0; }
|
||||
else { sWidth = imageSize.width; sHeight = imageSize.width / slotRatio; sx = 0; sy = (imageSize.height - sHeight) / 2; }
|
||||
return { sx, sy, sWidth, sHeight };
|
||||
}
|
||||
|
||||
export function createDragBoundFunc(slot, elementSize) {
|
||||
const { bounds } = slot;
|
||||
const minX = bounds.x, minY = bounds.y;
|
||||
const maxX = bounds.x + bounds.width - elementSize.width;
|
||||
const maxY = bounds.y + bounds.height - elementSize.height;
|
||||
return (_oldBox, newBox) => ({ x: Math.max(minX, Math.min(newBox.x, maxX)), y: Math.max(minY, Math.min(newBox.y, maxY)), width: newBox.width, height: newBox.height });
|
||||
}
|
||||
|
||||
export function useTemplate(templates = []) {
|
||||
const [currentTemplateId, setCurrentTemplateId] = useState(null);
|
||||
const [assignedSlots, setAssignedSlots] = useState({});
|
||||
const templateRef = useRef(null);
|
||||
|
||||
const currentTemplate = templates.find(t => t.id === currentTemplateId) || null;
|
||||
const getSlots = useCallback(() => currentTemplate?.slots || [], [currentTemplate]);
|
||||
|
||||
const loadTemplate = useCallback((templateId) => {
|
||||
const template = templates.find(t => t.id === templateId);
|
||||
if (template) { setCurrentTemplateId(templateId); setAssignedSlots({}); templateRef.current = template; return true; }
|
||||
return false;
|
||||
}, [templates]);
|
||||
|
||||
const clearTemplate = useCallback(() => { setCurrentTemplateId(null); setAssignedSlots({}); templateRef.current = null; }, []);
|
||||
|
||||
const assignImageToSlot = useCallback((slotId, imageData) => {
|
||||
const slots = getSlots();
|
||||
const slot = slots.find(s => s.id === slotId);
|
||||
if (!slot) return null;
|
||||
const elementData = { type: 'image', src: imageData, x: slot.bounds.x, y: slot.bounds.y, width: slot.bounds.width, height: slot.bounds.height, slotId, crop: null };
|
||||
const img = new Image();
|
||||
img.src = imageData;
|
||||
img.onload = () => { elementData.crop = calculateAutoCrop({ width: img.width, height: img.height }, { width: slot.bounds.width, height: slot.bounds.height }); };
|
||||
setAssignedSlots(prev => ({ ...prev, [slotId]: elementData }));
|
||||
return elementData;
|
||||
}, [getSlots]);
|
||||
|
||||
const getDragBoundFunc = useCallback((slotId, elementSize) => {
|
||||
const slot = getSlots().find(s => s.id === slotId);
|
||||
if (!slot) return null;
|
||||
return createDragBoundFunc(slot, elementSize);
|
||||
}, [getSlots]);
|
||||
|
||||
const isSlotFilled = useCallback((slotId) => !!assignedSlots[slotId], [assignedSlots]);
|
||||
|
||||
return { currentTemplateId, currentTemplate, assignedSlots, loadTemplate, clearTemplate, getSlots, assignImageToSlot, getDragBoundFunc, isSlotFilled };
|
||||
}
|
||||
144
src/index.css
Normal file
144
src/index.css
Normal file
@@ -0,0 +1,144 @@
|
||||
:root {
|
||||
--accent: #38bdf8;
|
||||
--accent-hover: #0ea5e9;
|
||||
--accent-bg: rgba(56, 189, 248, 0.1);
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--border: #e2e8f0;
|
||||
--border-focus: #38bdf8;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94a3b8;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'Space Mono', ui-monospace, Consolas, monospace;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; }
|
||||
#root { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
button { font-family: inherit; cursor: pointer; outline: none; }
|
||||
button:focus-visible { box-shadow: 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--accent); }
|
||||
input, textarea, select { font-family: inherit; }
|
||||
|
||||
.editor-layout { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
.sidebar {
|
||||
width: 320px; background: var(--bg-secondary); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
background: var(--bg-tertiary); overflow: auto; padding: 2rem;
|
||||
}
|
||||
|
||||
.properties-panel {
|
||||
width: 280px; background: var(--bg-secondary); border-left: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: 0.5rem 0.75rem; background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); font-size: 0.75rem; cursor: pointer; transition: all 0.2s; color: var(--text-secondary);
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.export-btn {
|
||||
padding: 0.5rem 1rem; background: linear-gradient(135deg, #22c55e, #16a34a); color: white;
|
||||
border: none; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: 500;
|
||||
cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.export-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||
.export-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.export-error {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.75rem 1rem; background: #fef2f2; border: 1px solid #fecaca;
|
||||
border-radius: var(--radius-md); color: #dc2626; font-size: 0.875rem;
|
||||
margin-bottom: 1rem; width: 100%; max-width: 400px;
|
||||
}
|
||||
.export-error p { margin: 0; }
|
||||
.close-error { background: transparent; border: none; color: #dc2626; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
|
||||
|
||||
.bg-removal-container { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||
.bg-removal-btn {
|
||||
width: 100%; padding: 0.75rem; background: linear-gradient(135deg, #8b5cf6, #ec4899); color: white;
|
||||
border: none; border-radius: var(--radius-md); font-weight: 500; cursor: pointer; transition: all 0.2s;
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
}
|
||||
.bg-removal-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||
.bg-removal-btn:disabled { opacity: 0.7; cursor: not-allowed; }
|
||||
.bg-removal-hint { font-size: 0.7rem; color: var(--text-muted); margin: 0.5rem 0 0 0; line-height: 1.4; }
|
||||
|
||||
.spinner-small {
|
||||
width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white; border-radius: 50%; animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.filerobot-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8); display: flex; align-items: center;
|
||||
justify-content: center; z-index: 1000;
|
||||
}
|
||||
.filerobot-container { width: 90%; height: 90%; background: #1e1e1e; border-radius: var(--radius-lg); overflow: hidden; }
|
||||
|
||||
.pwa-install-banner {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--bg-primary); border: 1px solid var(--accent); border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.5rem; box-shadow: var(--shadow-lg); z-index: 1000;
|
||||
display: flex; align-items: center; gap: 1rem; animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateX(-50%) translateY(20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
|
||||
.pwa-install-banner p { margin: 0; font-size: 0.875rem; color: var(--text-primary); }
|
||||
.install-btn { padding: 0.5rem 1rem; background: var(--accent); color: white; border: none; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: 500; cursor: pointer; }
|
||||
.dismiss-btn { padding: 0.5rem 1rem; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.75rem; cursor: pointer; }
|
||||
|
||||
.offline-indicator {
|
||||
position: fixed; top: 0; left: 0; right: 0;
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706); color: white;
|
||||
padding: 0.5rem 1rem; text-align: center; font-size: 12px; font-weight: 500;
|
||||
z-index: 9999; display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) and (min-width: 768px) {
|
||||
.sidebar { width: 280px; }
|
||||
.properties-panel { width: 240px; }
|
||||
.canvas-area { padding: 1.5rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.editor-layout { flex-direction: column; }
|
||||
.sidebar { width: 100%; height: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); }
|
||||
.canvas-area { padding: 1rem; width: 100%; }
|
||||
.properties-panel { width: 100%; border-left: none; border-top: 1px solid var(--border); max-height: 40vh; }
|
||||
.pwa-install-banner { left: 10px; right: 10px; transform: none; width: auto; flex-direction: column; text-align: center; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.sidebar { max-height: 40vh; }
|
||||
.canvas-area { padding: 0.5rem; }
|
||||
button { min-height: 44px; }
|
||||
}
|
||||
25
src/main.jsx
Normal file
25
src/main.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
window.dispatchEvent(new CustomEvent('swUpdated', { detail: newWorker }));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user