Fix module issues, fix styling, add conditions to when the background removal and edit controls are shown
This commit is contained in:
10173
package-lock.json
generated
Normal file
10173
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,5 +43,11 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"serialize-javascript": "^7.0.3",
|
||||||
|
"vite-plugin-pwa": {
|
||||||
|
"vite": "$vite"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
server.js
10
server.js
@@ -4,7 +4,7 @@ import multer from 'multer';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { createCanvas, loadImage } from 'canvas';
|
import { createCanvas, loadImage } from 'canvas';
|
||||||
import { fileURLToPath } from 'module';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { mkdirSync, existsSync, writeFileSync } from 'fs';
|
import { mkdirSync, existsSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
@@ -38,6 +38,14 @@ app.use('/exports', express.static(exportsDir));
|
|||||||
if (IS_PRODUCTION) {
|
if (IS_PRODUCTION) {
|
||||||
const clientDist = join(__dirname, 'dist');
|
const clientDist = join(__dirname, 'dist');
|
||||||
app.use(express.static(clientDist));
|
app.use(express.static(clientDist));
|
||||||
|
} else {
|
||||||
|
// Dev UX: backend doesn't serve the SPA; Vite does.
|
||||||
|
app.get('/', (_req, res) => {
|
||||||
|
res
|
||||||
|
.status(302)
|
||||||
|
.set('Location', 'http://localhost:5173/')
|
||||||
|
.send('Redirecting to Vite dev server...');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map MIME types to file extensions
|
// Map MIME types to file extensions
|
||||||
|
|||||||
38
src/App.css
38
src/App.css
@@ -1 +1,37 @@
|
|||||||
/* App-level styles — project styles go here */
|
.canvas-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-header h1 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-header p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 100px;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|||||||
77
src/App.jsx
77
src/App.jsx
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { DesignCanvas } from './components/canvas/DesignCanvas';
|
import { DesignCanvas } from './components/canvas/DesignCanvas';
|
||||||
import { Sidebar } from './components/sidebar/Sidebar';
|
import { Sidebar } from './components/sidebar/Sidebar';
|
||||||
import { LayersPanel } from './components/panels/LayersPanel';
|
import { LayersPanel } from './components/panels/LayersPanel';
|
||||||
@@ -10,9 +10,12 @@ import { useDesignEditor } from './hooks/useDesignEditor';
|
|||||||
import { useExport } from './hooks/useExport';
|
import { useExport } from './hooks/useExport';
|
||||||
import { useTemplate } from './hooks/useTemplate';
|
import { useTemplate } from './hooks/useTemplate';
|
||||||
import { TEMPLATES } from './constants/templates';
|
import { TEMPLATES } from './constants/templates';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [editingElement, setEditingElement] = useState(null);
|
const [editingElement, setEditingElement] = useState(null);
|
||||||
|
const canvasContainerRef = useRef(null);
|
||||||
|
const propertiesPanelRef = useRef(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
elements, selectedId, addElement, updateElement, deleteElement,
|
elements, selectedId, addElement, updateElement, deleteElement,
|
||||||
@@ -22,8 +25,8 @@ function App() {
|
|||||||
const { exporting, progress, exportDesign, error, clearExport } = useExport();
|
const { exporting, progress, exportDesign, error, clearExport } = useExport();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentTemplate, currentTemplateId, assignedSlots,
|
currentTemplate, assignedSlots,
|
||||||
loadTemplate, clearTemplate, getSlots, assignImageToSlot, getDragBoundFunc, isSlotFilled,
|
loadTemplate, clearTemplate, assignImageToSlot, getDragBoundFunc,
|
||||||
} = useTemplate(TEMPLATES);
|
} = useTemplate(TEMPLATES);
|
||||||
|
|
||||||
const selectedElement = elements.find((el) => el.id === selectedId);
|
const selectedElement = elements.find((el) => el.id === selectedId);
|
||||||
@@ -53,6 +56,21 @@ function App() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [selectedId, deleteElement, undo, redo, canUndo, canRedo]);
|
}, [selectedId, deleteElement, undo, redo, canUndo, canRedo]);
|
||||||
|
|
||||||
|
// Deselect when clicking outside the canvas
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
if (
|
||||||
|
selectedId
|
||||||
|
&& canvasContainerRef.current && !canvasContainerRef.current.contains(e.target)
|
||||||
|
&& propertiesPanelRef.current && !propertiesPanelRef.current.contains(e.target)
|
||||||
|
) {
|
||||||
|
deselectAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleMouseDown);
|
||||||
|
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
}, [selectedId, deselectAll]);
|
||||||
|
|
||||||
const handleAddTemplate = (templateId) => {
|
const handleAddTemplate = (templateId) => {
|
||||||
if (templateId === 'freeform') { clearTemplate(); return; }
|
if (templateId === 'freeform') { clearTemplate(); return; }
|
||||||
const success = loadTemplate(templateId);
|
const success = loadTemplate(templateId);
|
||||||
@@ -85,15 +103,15 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="canvas-area">
|
<div className="canvas-area">
|
||||||
<div style={{ marginBottom: '1rem', textAlign: 'center' }}>
|
<div className="canvas-header">
|
||||||
<h1 style={{ margin: '0 0 0.25rem 0', fontSize: '20px', color: 'var(--text-primary)' }}>Apparel Designer</h1>
|
<h1>Apparel Designer</h1>
|
||||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text-secondary)' }}>T-shirt customization editor</p>
|
<p>T-shirt customization editor</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', justifyContent: 'center' }}>
|
<div className="canvas-toolbar">
|
||||||
<button onClick={() => canUndo && undo()} disabled={!canUndo} className="icon-btn" style={{ opacity: canUndo ? 1 : 0.5 }}>↶ Undo</button>
|
<button onClick={() => canUndo && undo()} disabled={!canUndo} className="icon-btn">↶ Undo</button>
|
||||||
<button onClick={() => canRedo && redo()} disabled={!canRedo} className="icon-btn" style={{ opacity: canRedo ? 1 : 0.5 }}>↷ Redo</button>
|
<button onClick={() => canRedo && redo()} disabled={!canRedo} className="icon-btn">↷ 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 }}>
|
<button onClick={() => exportDesign(elements, 'tshirt-design', currentTemplate)} disabled={exporting || elements.length === 0} className="export-btn">
|
||||||
{exporting ? `Exporting... ${progress}%` : '⬇ Export HD'}
|
{exporting ? `Exporting... ${progress}%` : '⬇ Export HD'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,29 +123,64 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div ref={canvasContainerRef}>
|
||||||
<DesignCanvas
|
<DesignCanvas
|
||||||
elements={elements} selectedId={selectedId}
|
elements={elements} selectedId={selectedId}
|
||||||
onSelect={selectElement} onDeselect={deselectAll}
|
onSelect={selectElement} onDeselect={deselectAll}
|
||||||
onUpdate={(id, attrs) => updateElement(id, attrs)} onCommit={commitHistory}
|
onUpdate={(id, attrs) => updateElement(id, attrs)} onCommit={commitHistory}
|
||||||
currentTemplate={currentTemplate} assignedSlots={assignedSlots} getDragBoundFunc={getDragBoundFunc}
|
currentTemplate={currentTemplate} assignedSlots={assignedSlots} getDragBoundFunc={getDragBoundFunc}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '1.5rem', width: '100%', maxWidth: '400px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-md)', padding: '1rem', boxShadow: 'var(--shadow-md)' }}>
|
<div className="layers-panel-wrapper">
|
||||||
<LayersPanel elements={elements} selectedId={selectedId} onSelect={selectElement} onDelete={deleteElement} />
|
<LayersPanel elements={elements} selectedId={selectedId} onSelect={selectElement} onDelete={deleteElement} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ref={propertiesPanelRef}>
|
||||||
<PropertiesPanel
|
<PropertiesPanel
|
||||||
element={selectedElement}
|
element={selectedElement}
|
||||||
onUpdate={(attrs) => updateElement(selectedId, attrs)}
|
onUpdate={(attrs) => updateElement(selectedId, attrs)}
|
||||||
onDelete={deleteElement}
|
onDelete={deleteElement}
|
||||||
onEditPhoto={(el) => setEditingElement(el)}
|
onEditPhoto={(el) => setEditingElement(el)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{editingElement && (
|
{editingElement && (
|
||||||
<PhotoPreEditor
|
<PhotoPreEditor
|
||||||
imageSrc={editingElement.src}
|
imageSrc={editingElement.src}
|
||||||
onComplete={(url) => { updateElement(editingElement.id, { src: url }); setEditingElement(null); }}
|
onComplete={(url) => {
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const oldWidth = editingElement.width || 100;
|
||||||
|
const oldHeight = editingElement.height || 100;
|
||||||
|
const newAspect = img.naturalWidth / img.naturalHeight;
|
||||||
|
const oldAspect = oldWidth / oldHeight;
|
||||||
|
|
||||||
|
let newWidth, newHeight;
|
||||||
|
if (Math.abs(newAspect - oldAspect) < 0.01) {
|
||||||
|
newWidth = oldWidth;
|
||||||
|
newHeight = oldHeight;
|
||||||
|
} else {
|
||||||
|
const area = oldWidth * oldHeight;
|
||||||
|
newWidth = Math.sqrt(area * newAspect);
|
||||||
|
newHeight = newWidth / newAspect;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateElement(editingElement.id, {
|
||||||
|
src: url,
|
||||||
|
width: Math.round(newWidth),
|
||||||
|
height: Math.round(newHeight),
|
||||||
|
crop: undefined,
|
||||||
|
});
|
||||||
|
setEditingElement(null);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
updateElement(editingElement.id, { src: url });
|
||||||
|
setEditingElement(null);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
}}
|
||||||
onClose={() => setEditingElement(null)}
|
onClose={() => setEditingElement(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import '../styles/PWAInstall.css';
|
||||||
|
|
||||||
export function PWAInstall() {
|
export function PWAInstall() {
|
||||||
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
||||||
@@ -30,17 +31,17 @@ export function PWAInstall() {
|
|||||||
{showInstall && (
|
{showInstall && (
|
||||||
<div className="pwa-install-banner">
|
<div className="pwa-install-banner">
|
||||||
<p>Install Apparel Designer for offline access!</p>
|
<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={handleInstall} className="install-btn">Install</button>
|
||||||
<button onClick={() => setShowInstall(false)} className="dismiss-btn">Later</button>
|
<button onClick={() => setShowInstall(false)} className="dismiss-btn">Later</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{updateAvailable && (
|
{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>
|
<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={handleUpdate} className="refresh-btn">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={() => { setUpdateAvailable(false); setNewWorker(null); }} className="close-btn">✕</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,42 +4,117 @@ import { ImageElement } from './ImageElement';
|
|||||||
import { TextElement } from './TextElement';
|
import { TextElement } from './TextElement';
|
||||||
import { TemplateLayer } from './TemplateLayer';
|
import { TemplateLayer } from './TemplateLayer';
|
||||||
import { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
|
import { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
|
||||||
import { memo } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
|
import '../../styles/DesignCanvas.css';
|
||||||
|
|
||||||
const CANVAS_SIZE = 300;
|
const CANVAS_SIZE = 300;
|
||||||
|
const HANDLE_PADDING = 40;
|
||||||
|
|
||||||
export const DesignCanvas = memo(function DesignCanvas({
|
export const DesignCanvas = memo(function DesignCanvas({
|
||||||
elements, selectedId, onSelect, onDeselect, onUpdate, onCommit,
|
elements, selectedId, onSelect, onDeselect, onUpdate, onCommit,
|
||||||
currentTemplate, assignedSlots, getDragBoundFunc,
|
currentTemplate, assignedSlots, getDragBoundFunc,
|
||||||
}) {
|
}) {
|
||||||
const slots = currentTemplate?.slots || [];
|
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 (
|
return (
|
||||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
<div className="design-canvas-wrapper">
|
||||||
<TShirtSVG size={CANVAS_SIZE} />
|
<TShirtSVG size={CANVAS_SIZE} />
|
||||||
|
<div className={`design-canvas-border${selectedId ? ' selected' : ''}`} />
|
||||||
<Stage
|
<Stage
|
||||||
width={CANVAS_SIZE} height={CANVAS_SIZE}
|
width={stageSize} height={stageSize}
|
||||||
onClick={onDeselect} onTap={onDeselect}
|
onClick={onDeselect} onTap={onDeselect}
|
||||||
style={{
|
className="design-canvas-stage"
|
||||||
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>
|
<Layer x={HANDLE_PADDING} y={HANDLE_PADDING}>
|
||||||
{currentTemplate && <TemplateLayer template={currentTemplate} canvasSize={CANVAS_SIZE} />}
|
{currentTemplate && <TemplateLayer template={currentTemplate} canvasSize={CANVAS_SIZE} />}
|
||||||
</Layer>
|
</Layer>
|
||||||
<Layer listening={false}>
|
<Layer x={HANDLE_PADDING} y={HANDLE_PADDING} listening={false}>
|
||||||
{slots.map((slot) => <SlotBoundsGuide key={slot.id} slot={slot} />)}
|
{slots.map((slot) => <SlotBoundsGuide key={slot.id} slot={slot} />)}
|
||||||
</Layer>
|
</Layer>
|
||||||
<Layer>
|
<Layer x={HANDLE_PADDING} y={HANDLE_PADDING}>
|
||||||
{elements.map((el) => {
|
{elements.map((el) => {
|
||||||
if (el.type === 'image') {
|
if (el.type === 'image') {
|
||||||
return (
|
return (
|
||||||
<ImageElement key={el.id} id={el.id} x={el.x} y={el.y} width={el.width} height={el.height}
|
<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}
|
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}
|
onSelect={() => (el.id === selectedId ? onDeselect?.() : onSelect(el.id))}
|
||||||
dragBoundFunc={el.slotId ? getDragBoundFunc?.(el.slotId, { width: el.width, height: el.height }) : null}
|
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
||||||
|
onCommit={onCommit}
|
||||||
|
dragBoundFunc={canvasDragBound}
|
||||||
|
transformBoundFunc={constrainTransform}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,18 +122,22 @@ export const DesignCanvas = memo(function DesignCanvas({
|
|||||||
return (
|
return (
|
||||||
<TextElement key={el.id} id={el.id} x={el.x} y={el.y} text={el.text} fontSize={el.fontSize}
|
<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}
|
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;
|
return null;
|
||||||
})}
|
})}
|
||||||
</Layer>
|
</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]} />)}
|
{slots.map((slot) => <SlotPlaceholder key={slot.id} slot={slot} isEmpty={!assignedSlots?.[slot.id]} />)}
|
||||||
</Layer>
|
</Layer>
|
||||||
</Stage>
|
</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
|
Design Area: 15" × 15" • Export: 4500 × 4500px @ 300 DPI
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, memo } from 'react';
|
import { useEffect, useRef, memo, useCallback } from 'react';
|
||||||
import { Image, Transformer } from 'react-konva';
|
import { Image, Transformer } from 'react-konva';
|
||||||
import useImage from 'use-image';
|
import useImage from 'use-image';
|
||||||
|
|
||||||
@@ -8,28 +8,46 @@ function URLImage({ src, innerRef, ...props }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ImageElement = memo(function ImageElement({
|
export const ImageElement = memo(function ImageElement({
|
||||||
id,
|
id: _id,
|
||||||
x = 0,
|
x = 0,
|
||||||
y = 0,
|
y = 0,
|
||||||
width = 100,
|
width = 100,
|
||||||
height = 100,
|
height = 100,
|
||||||
rotation = 0,
|
rotation = 0,
|
||||||
src,
|
src,
|
||||||
|
crop,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onCommit,
|
onCommit,
|
||||||
|
dragBoundFunc,
|
||||||
|
transformBoundFunc,
|
||||||
}) {
|
}) {
|
||||||
const shapeRef = useRef(null);
|
const shapeRef = useRef(null);
|
||||||
const trRef = useRef(null);
|
const trRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const attachTransformer = useCallback(() => {
|
||||||
if (isSelected && trRef.current && shapeRef.current) {
|
if (!isSelected) return;
|
||||||
trRef.current.nodes([shapeRef.current]);
|
const transformer = trRef.current;
|
||||||
trRef.current.getLayer().batchDraw();
|
const shape = shapeRef.current;
|
||||||
}
|
if (!transformer || !shape) return;
|
||||||
|
|
||||||
|
transformer.nodes([shape]);
|
||||||
|
transformer.getLayer()?.batchDraw();
|
||||||
}, [isSelected]);
|
}, [isSelected]);
|
||||||
|
|
||||||
|
const setTransformerRef = useCallback(
|
||||||
|
(node) => {
|
||||||
|
trRef.current = node;
|
||||||
|
attachTransformer();
|
||||||
|
},
|
||||||
|
[attachTransformer]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
attachTransformer();
|
||||||
|
}, [attachTransformer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<URLImage
|
<URLImage
|
||||||
@@ -40,9 +58,21 @@ export const ImageElement = memo(function ImageElement({
|
|||||||
height={height}
|
height={height}
|
||||||
rotation={rotation}
|
rotation={rotation}
|
||||||
src={src}
|
src={src}
|
||||||
|
crop={
|
||||||
|
crop
|
||||||
|
? { x: crop.sx, y: crop.sy, width: crop.sWidth, height: crop.sHeight }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
draggable
|
draggable
|
||||||
onClick={onSelect}
|
dragBoundFunc={dragBoundFunc}
|
||||||
onTap={onSelect}
|
onClick={(e) => {
|
||||||
|
e.cancelBubble = true;
|
||||||
|
onSelect?.();
|
||||||
|
}}
|
||||||
|
onTap={(e) => {
|
||||||
|
e.cancelBubble = true;
|
||||||
|
onSelect?.();
|
||||||
|
}}
|
||||||
onDragEnd={(e) => {
|
onDragEnd={(e) => {
|
||||||
onUpdate({ x: e.target.x(), y: e.target.y() });
|
onUpdate({ x: e.target.x(), y: e.target.y() });
|
||||||
onCommit?.();
|
onCommit?.();
|
||||||
@@ -66,10 +96,11 @@ export const ImageElement = memo(function ImageElement({
|
|||||||
/>
|
/>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<Transformer
|
<Transformer
|
||||||
ref={trRef}
|
ref={setTransformerRef}
|
||||||
|
keepRatio
|
||||||
boundBoxFunc={(oldBox, newBox) => {
|
boundBoxFunc={(oldBox, newBox) => {
|
||||||
if (newBox.width < 20 || newBox.height < 20) return oldBox;
|
if (Math.abs(newBox.width) < 20 || Math.abs(newBox.height) < 20) return oldBox;
|
||||||
return newBox;
|
return transformBoundFunc ? transformBoundFunc(oldBox, newBox) : newBox;
|
||||||
}}
|
}}
|
||||||
anchorSize={8}
|
anchorSize={8}
|
||||||
anchorCornerRadius={4}
|
anchorCornerRadius={4}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import '../../styles/TShirtSVG.css';
|
||||||
|
|
||||||
export function TShirtSVG({ size = 300 }) {
|
export function TShirtSVG({ size = 300 }) {
|
||||||
const padding = size * 0.1;
|
const padding = size * 0.1;
|
||||||
const innerSize = size - padding * 2;
|
const innerSize = size - padding * 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}
|
<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`}
|
<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" />
|
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" />
|
<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" />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, memo } from 'react';
|
import { useEffect, useRef, memo, useCallback } from 'react';
|
||||||
import { Text, Transformer } from 'react-konva';
|
import { Text, Transformer } from 'react-konva';
|
||||||
|
|
||||||
export const TextElement = memo(function TextElement({
|
export const TextElement = memo(function TextElement({
|
||||||
id,
|
id: _id,
|
||||||
x = 0,
|
x = 0,
|
||||||
y = 0,
|
y = 0,
|
||||||
text = '',
|
text = '',
|
||||||
@@ -14,17 +14,34 @@ export const TextElement = memo(function TextElement({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onCommit,
|
onCommit,
|
||||||
|
dragBoundFunc,
|
||||||
|
transformBoundFunc,
|
||||||
}) {
|
}) {
|
||||||
const textRef = useRef(null);
|
const textRef = useRef(null);
|
||||||
const trRef = useRef(null);
|
const trRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const attachTransformer = useCallback(() => {
|
||||||
if (isSelected && trRef.current && textRef.current) {
|
if (!isSelected) return;
|
||||||
trRef.current.nodes([textRef.current]);
|
const transformer = trRef.current;
|
||||||
trRef.current.getLayer().batchDraw();
|
const node = textRef.current;
|
||||||
}
|
if (!transformer || !node) return;
|
||||||
|
|
||||||
|
transformer.nodes([node]);
|
||||||
|
transformer.getLayer()?.batchDraw();
|
||||||
}, [isSelected]);
|
}, [isSelected]);
|
||||||
|
|
||||||
|
const setTransformerRef = useCallback(
|
||||||
|
(node) => {
|
||||||
|
trRef.current = node;
|
||||||
|
attachTransformer();
|
||||||
|
},
|
||||||
|
[attachTransformer]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
attachTransformer();
|
||||||
|
}, [attachTransformer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
@@ -37,8 +54,15 @@ export const TextElement = memo(function TextElement({
|
|||||||
fill={fill}
|
fill={fill}
|
||||||
rotation={rotation}
|
rotation={rotation}
|
||||||
draggable
|
draggable
|
||||||
onClick={onSelect}
|
dragBoundFunc={dragBoundFunc}
|
||||||
onTap={onSelect}
|
onClick={(e) => {
|
||||||
|
e.cancelBubble = true;
|
||||||
|
onSelect?.();
|
||||||
|
}}
|
||||||
|
onTap={(e) => {
|
||||||
|
e.cancelBubble = true;
|
||||||
|
onSelect?.();
|
||||||
|
}}
|
||||||
onDragEnd={(e) => {
|
onDragEnd={(e) => {
|
||||||
onUpdate({ x: e.target.x(), y: e.target.y() });
|
onUpdate({ x: e.target.x(), y: e.target.y() });
|
||||||
onCommit?.();
|
onCommit?.();
|
||||||
@@ -60,8 +84,9 @@ export const TextElement = memo(function TextElement({
|
|||||||
/>
|
/>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<Transformer
|
<Transformer
|
||||||
ref={trRef}
|
ref={setTransformerRef}
|
||||||
enabledAnchors={['top-left', 'top-right', 'bottom-left', 'bottom-right']}
|
enabledAnchors={['top-left', 'top-right', 'bottom-left', 'bottom-right']}
|
||||||
|
boundBoxFunc={transformBoundFunc}
|
||||||
anchorSize={8}
|
anchorSize={8}
|
||||||
anchorCornerRadius={4}
|
anchorCornerRadius={4}
|
||||||
borderStroke="#38bdf8"
|
borderStroke="#38bdf8"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import FilerobotImageEditor from 'react-filerobot-image-editor';
|
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 }) {
|
export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const modalContentRef = useRef(null);
|
const modalContentRef = useRef(null);
|
||||||
const previousFocusRef = useRef(null);
|
const previousFocusRef = useRef(null);
|
||||||
|
|
||||||
@@ -16,28 +18,78 @@ export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const handleComplete = (editedImageObject) => {
|
const base64ToBlob = (base64DataUrl) => {
|
||||||
setSaving(true);
|
const [header, data] = base64DataUrl.split(',');
|
||||||
editedImageObject.exportAsync({ quality: 1, mimeType: 'image/png' })
|
const mimeMatch = header?.match(/data:(.*?);base64/);
|
||||||
.then((blob) => { setSaving(false); onComplete(URL.createObjectURL(blob)); })
|
const mime = mimeMatch?.[1] || 'image/png';
|
||||||
.catch((error) => { console.error('Export failed:', error); setSaving(false); onClose(); });
|
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 (
|
return (
|
||||||
<div className="filerobot-overlay" role="dialog" aria-modal="true" aria-labelledby="photo-editor-title">
|
<div className="filerobot-overlay" role="dialog" aria-modal="true" aria-labelledby="photo-editor-title">
|
||||||
<div className="filerobot-container" ref={modalContentRef} role="document">
|
<div className="filerobot-container" ref={modalContentRef} role="document">
|
||||||
<FilerobotImageEditor
|
<StyleSheetManager
|
||||||
source={imageSrc} onSave={handleComplete} onClose={onClose}
|
// Filerobot/@scaleflex styled-components pass a bunch of styling props to DOM nodes (e.g. isPhoneScreen).
|
||||||
annotationsCommon={{ fill: '#ff0000', stroke: '#000000', strokeWidth: 0 }}
|
// Filtering them here prevents noisy React console warnings.
|
||||||
annotations={['Text', 'Rectangle', 'Ellipse', 'Line', 'Pen', 'Eraser']}
|
shouldForwardProp={(prop, element) => (typeof element === 'string' ? isPropValid(prop) : true)}
|
||||||
tabs={['adjust', 'filters', 'finetune', 'annotate', 'watermark']}
|
>
|
||||||
defaultTabId="adjust"
|
<FilerobotImageEditor
|
||||||
theme={{ accentColor: '#38bdf8', palettePrimary: '#38bdf8' }}
|
source={imageSrc}
|
||||||
saveButtonProps={{ label: saving ? 'Exporting...' : 'Use Edited Image' }}
|
onBeforeSave={() => false}
|
||||||
closeOnSave
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,27 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
import '../../styles/LayersPanel.css';
|
||||||
|
|
||||||
export const LayersPanel = memo(function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
|
export const LayersPanel = memo(function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
|
||||||
const getIcon = (el) => el.type === 'image' ? (el.bgRemoved ? '🖼️' : '📷') : el.type === 'text' ? '📝' : '🎨';
|
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';
|
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) {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '12px', fontWeight: '600', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Layers ({elements.length})</h3>
|
<h3 className="layers-title">Layers ({elements.length})</h3>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
<div className="layers-list">
|
||||||
{elements.map((element) => (
|
{elements.map((element) => (
|
||||||
<div key={element.id} onClick={() => onSelect(element.id)}
|
<div key={element.id} onClick={() => onSelect(element.id)}
|
||||||
style={{
|
className={`layers-item${selectedId === element.id ? ' selected' : ''}`}>
|
||||||
display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem',
|
<span className="layers-item-icon">{getIcon(element)}</span>
|
||||||
background: selectedId === element.id ? 'var(--accent-bg)' : 'transparent',
|
<span className={`layers-item-name${selectedId === element.id ? ' selected' : ''}`}>
|
||||||
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)}
|
{getName(element)}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={(e) => { e.stopPropagation(); onDelete(element.id); }}
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { BackgroundRemovalButton } from '../sidebar/BackgroundRemovalButton';
|
import { BackgroundRemovalButton } from '../sidebar/BackgroundRemovalButton';
|
||||||
|
import '../../styles/PropertiesPanel.css';
|
||||||
|
|
||||||
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete, onEditPhoto }) {
|
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete, onEditPhoto }) {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return (
|
return (
|
||||||
<div className="properties-panel">
|
<div className="properties-panel">
|
||||||
<div style={{ padding: '1rem', borderBottom: '1px solid var(--border)' }}>
|
<div className="properties-panel__header">
|
||||||
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: 'var(--text-primary)' }}>Properties</h3>
|
<h3 className="properties-panel__title">Properties</h3>
|
||||||
</div>
|
</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
|
Select an element to edit its properties
|
||||||
</div>
|
</div>
|
||||||
</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 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 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 (
|
return (
|
||||||
<div className="properties-panel">
|
<div className="properties-panel">
|
||||||
<div style={{ padding: '1rem', borderBottom: '1px solid var(--border)' }}>
|
<div className="properties-panel__header">
|
||||||
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: 'var(--text-primary)' }}>Properties</h3>
|
<h3 className="properties-panel__title">Properties</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '1rem' }}>
|
<div className="properties-panel__body">
|
||||||
{/* Element type badge */}
|
<div className="properties-panel__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}
|
{element.type}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Position */}
|
{/* Position */}
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="properties-panel__section">
|
||||||
<label style={labelStyle}>Position</label>
|
<label className="properties-panel__label">Position</label>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div className="properties-panel__row">
|
||||||
<div style={{ flex: 1 }}>
|
<div className="properties-panel__field">
|
||||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>X</label>
|
<label className="properties-panel__axis-label">X</label>
|
||||||
<input type="number" value={Math.round(element.x)} onChange={(e) => handlePositionChange('x', e.target.value)} style={inputStyle} />
|
<input type="number" value={Math.round(element.x)} onChange={(e) => handlePositionChange('x', e.target.value)} className="properties-panel__input" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div className="properties-panel__field">
|
||||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Y</label>
|
<label className="properties-panel__axis-label">Y</label>
|
||||||
<input type="number" value={Math.round(element.y)} onChange={(e) => handlePositionChange('y', e.target.value)} style={inputStyle} />
|
<input type="number" value={Math.round(element.y)} onChange={(e) => handlePositionChange('y', e.target.value)} className="properties-panel__input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Size (for images and stickers) */}
|
{/* Size (for images and stickers) */}
|
||||||
{(element.type === 'image' || element.type === 'sticker') && (
|
{(element.type === 'image' || element.type === 'sticker') && (
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="properties-panel__section">
|
||||||
<label style={labelStyle}>Size</label>
|
<label className="properties-panel__label">Size</label>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div className="properties-panel__row">
|
||||||
<div style={{ flex: 1 }}>
|
<div className="properties-panel__field">
|
||||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>W</label>
|
<label className="properties-panel__axis-label">W</label>
|
||||||
<input type="number" value={Math.round(element.width)} onChange={(e) => handleSizeChange('width', e.target.value)} style={inputStyle} />
|
<input type="number" value={Math.round(element.width)} onChange={(e) => handleSizeChange('width', e.target.value)} className="properties-panel__input" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div className="properties-panel__field">
|
||||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>H</label>
|
<label className="properties-panel__axis-label">H</label>
|
||||||
<input type="number" value={Math.round(element.height)} onChange={(e) => handleSizeChange('height', e.target.value)} style={inputStyle} />
|
<input type="number" value={Math.round(element.height)} onChange={(e) => handleSizeChange('height', e.target.value)} className="properties-panel__input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit Photo button */}
|
{/* Edit Photo button (user uploads only, not stickers) */}
|
||||||
{element.type === 'image' && onEditPhoto && (
|
{element.type === 'image' && !element.emoji && onEditPhoto && (
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="properties-panel__section">
|
||||||
<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' }}>
|
<button onClick={() => onEditPhoto(element)} className="properties-panel__edit-btn">
|
||||||
✏️ Edit Photo
|
✏️ Edit Photo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,25 +75,25 @@ export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate
|
|||||||
{/* Text-specific controls */}
|
{/* Text-specific controls */}
|
||||||
{element.type === 'text' && (
|
{element.type === 'text' && (
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="properties-panel__section">
|
||||||
<label style={labelStyle}>Font Size: {Math.round(element.fontSize)}px</label>
|
<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) })} style={{ width: '100%' }} />
|
<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>
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="properties-panel__section">
|
||||||
<label style={labelStyle}>Color</label>
|
<label className="properties-panel__label">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' }} />
|
<input type="color" value={element.fill} onChange={(e) => onUpdate({ fill: e.target.value })} className="properties-panel__color-input" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rotation */}
|
{/* Rotation */}
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="properties-panel__section">
|
||||||
<label style={labelStyle}>Rotation: {Math.round(element.rotation)}°</label>
|
<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)} style={{ width: '100%' }} />
|
<input type="range" min="-180" max="180" value={element.rotation} onChange={(e) => handleRotationChange(e.target.value)} className="properties-panel__range" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Background Removal (for images) */}
|
{/* Background Removal (user uploads only, not stickers) */}
|
||||||
{element.type === 'image' && (
|
{element.type === 'image' && !element.emoji && (
|
||||||
<BackgroundRemovalButton
|
<BackgroundRemovalButton
|
||||||
selectedElement={element}
|
selectedElement={element}
|
||||||
onUpdate={(_id, attrs) => onUpdate(attrs)}
|
onUpdate={(_id, attrs) => onUpdate(attrs)}
|
||||||
@@ -104,7 +101,7 @@ export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete */}
|
{/* 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
|
Delete Element
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { UploadTab } from './UploadTab';
|
|||||||
import { StickersTab } from './StickersTab';
|
import { StickersTab } from './StickersTab';
|
||||||
import { TextTab } from './TextTab';
|
import { TextTab } from './TextTab';
|
||||||
import { TemplatesTab } from './TemplatesTab';
|
import { TemplatesTab } from './TemplatesTab';
|
||||||
|
import '../../styles/Sidebar.css';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'upload', label: 'Upload', icon: '📁' },
|
{ id: 'upload', label: 'Upload', icon: '📁' },
|
||||||
@@ -26,21 +27,16 @@ export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, on
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', background: 'var(--bg-primary)' }}>
|
<div className="sidebar-tabs">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||||
style={{
|
className={`sidebar-tab-btn${activeTab === tab.id ? ' active' : ''}`}>
|
||||||
flex: 1, padding: '12px 8px', border: 'none', background: 'transparent', cursor: 'pointer',
|
<div className="sidebar-tab-icon">{tab.icon}</div>
|
||||||
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}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '1rem' }}>{renderTabContent()}</div>
|
<div className="sidebar-content">{renderTabContent()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { STICKERS, STICKER_CATEGORIES } from '../../constants/stickers';
|
import { STICKERS, STICKER_CATEGORIES } from '../../constants/stickers';
|
||||||
|
import '../../styles/StickersTab.css';
|
||||||
|
|
||||||
export function StickersTab({ onAddSticker }) {
|
export function StickersTab({ onAddSticker }) {
|
||||||
const [activeCategory, setActiveCategory] = useState('all');
|
const [activeCategory, setActiveCategory] = useState('all');
|
||||||
@@ -29,24 +30,20 @@ export function StickersTab({ onAddSticker }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>Stickers</h3>
|
<h3 className="stickers-title">Stickers</h3>
|
||||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
<div className="stickers-categories">
|
||||||
{STICKER_CATEGORIES.map((cat) => (
|
{STICKER_CATEGORIES.map((cat) => (
|
||||||
<button key={cat} onClick={() => setActiveCategory(cat)}
|
<button key={cat} onClick={() => setActiveCategory(cat)}
|
||||||
style={{
|
className={`stickers-category-btn${activeCategory === cat ? ' active' : ''}`}
|
||||||
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}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '8px' }}>
|
<div className="stickers-grid">
|
||||||
{filteredStickers.map((sticker, index) => (
|
{filteredStickers.map((sticker, index) => (
|
||||||
<button key={index} onClick={() => handleAddSticker(sticker.emoji)}
|
<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}
|
{sticker.emoji}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
|
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
|
||||||
|
import '../../styles/TemplatesTab.css';
|
||||||
|
|
||||||
function getCategoryEmoji(category) {
|
function getCategoryEmoji(category) {
|
||||||
const emojis = {
|
const emojis = {
|
||||||
@@ -52,60 +53,48 @@ export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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.
|
Choose a template to get started or design freely.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div className="templates-list">
|
||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<button
|
<button
|
||||||
key={template.id}
|
key={template.id}
|
||||||
onClick={() => handleSelectTemplate(template)}
|
onClick={() => handleSelectTemplate(template)}
|
||||||
style={{
|
className={`template-btn${template.id === selectedTemplateId ? ' selected' : ''}`}
|
||||||
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={{
|
<div className="template-thumbnail">
|
||||||
width: '48px', height: '48px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)', fontSize: '24px',
|
|
||||||
}}>
|
|
||||||
{template.thumbnail}
|
{template.thumbnail}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div className="template-info">
|
||||||
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>{template.name}</div>
|
<div className="template-name">{template.name}</div>
|
||||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{template.description}</div>
|
<div className="template-desc">{template.description}</div>
|
||||||
</div>
|
</div>
|
||||||
{template.hasSlots && (
|
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTemplateId && selectedTemplateId !== 'freeform' && slots.length > 0 && (
|
{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)' }}>
|
<div className="template-slots-section">
|
||||||
<h4 style={{ margin: '0 0 0.75rem 0', fontSize: '12px', fontWeight: '600', color: 'var(--text-primary)' }}>Template Slots</h4>
|
<h4 className="template-slots-title">Template Slots</h4>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div className="template-slots-list">
|
||||||
{slots.map((slot) => (
|
{slots.map((slot) => (
|
||||||
<button
|
<button
|
||||||
key={slot.id}
|
key={slot.id}
|
||||||
onClick={() => handleSlotClick(slot.id)}
|
onClick={() => handleSlotClick(slot.id)}
|
||||||
style={{
|
className="template-slot-btn"
|
||||||
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 className="template-slot-icon">📷</span>
|
||||||
<span>{slot.label}</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}
|
{slot.bounds.width}×{slot.bounds.height}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FONTS } from '../../constants/fonts';
|
import { FONTS } from '../../constants/fonts';
|
||||||
|
import '../../styles/TextTab.css';
|
||||||
|
|
||||||
export function TextTab({ onAddText }) {
|
export function TextTab({ onAddText }) {
|
||||||
const [text, setText] = useState('Your text here');
|
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 });
|
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 (
|
return (
|
||||||
<div>
|
<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' }}>
|
<div className="text-tab-field">
|
||||||
<label style={labelStyle}>Text Content</label>
|
<label className="text-tab-label">Text Content</label>
|
||||||
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3} className="text-tab-textarea" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="text-tab-field">
|
||||||
<label style={labelStyle}>Font</label>
|
<label className="text-tab-label">Font</label>
|
||||||
<select value={fontFamily} onChange={(e) => setFontFamily(e.target.value)} style={{ ...inputStyle, fontSize: '13px', fontFamily, cursor: 'pointer', background: 'var(--bg-primary)' }}>
|
<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>)}
|
{FONTS.map((font) => <option key={font.family} value={font.family}>{font.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="text-tab-field">
|
||||||
<label style={labelStyle}>Font Size: {fontSize}px</label>
|
<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))} style={{ width: '100%' }} />
|
<input type="range" min="12" max="120" value={fontSize} onChange={(e) => setFontSize(parseInt(e.target.value, 10))} className="text-tab-range" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="text-tab-field">
|
||||||
<label style={labelStyle}>Color</label>
|
<label className="text-tab-label">Color</label>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
<div className="text-tab-color-group">
|
||||||
<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="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)} style={{ flex: 1, padding: '0.75rem', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', fontSize: '13px', fontFamily: 'var(--font-mono)' }} />
|
<input type="text" value={fill} onChange={(e) => setFill(e.target.value)} className="text-tab-color-text" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '1rem', background: 'var(--bg-primary)', borderRadius: 'var(--radius-md)', marginBottom: '1rem', textAlign: 'center' }}>
|
<div className="text-tab-preview">
|
||||||
<div style={{ fontFamily, fontSize: `${fontSize * 0.5}px`, color: fill, wordBreak: 'break-word' }}>{text}</div>
|
<div className="text-tab-preview-text" style={{ fontFamily, fontSize: `${fontSize * 0.5}px`, color: fill }}>{text}</div>
|
||||||
</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
|
Add Text to Canvas
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
import '../../styles/UploadTab.css';
|
||||||
|
|
||||||
export function UploadTab({ onAddImage }) {
|
export function UploadTab({ onAddImage }) {
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isUploading, setIsUploading] = 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 handleFiles = async (files) => {
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -19,7 +28,28 @@ export function UploadTab({ onAddImage }) {
|
|||||||
const response = await fetch('/api/upload', { method: 'POST', body: formData });
|
const response = await fetch('/api/upload', { method: 'POST', body: formData });
|
||||||
if (!response.ok) throw new Error('Upload failed');
|
if (!response.ok) throw new Error('Upload failed');
|
||||||
const data = await response.json();
|
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) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
alert('Failed to upload image. Please try again.');
|
alert('Failed to upload image. Please try again.');
|
||||||
@@ -30,16 +60,16 @@ export function UploadTab({ onAddImage }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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); }}
|
<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' }}>
|
className={`upload-dropzone${isDragging ? ' dragging' : ''}`}>
|
||||||
<div style={{ fontSize: '32px', marginBottom: '0.5rem' }}>📁</div>
|
<div className="upload-dropzone-icon">📁</div>
|
||||||
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>Click to upload or drag and drop</div>
|
<div className="upload-dropzone-text">Click to upload or drag and drop</div>
|
||||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>JPEG, PNG, WebP (max 20MB)</div>
|
<div className="upload-dropzone-hint">JPEG, PNG, WebP (max 20MB)</div>
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => handleFiles(e.target.files)} style={{ display: 'none' }} />
|
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => handleFiles(e.target.files)} className="upload-hidden-input" />
|
||||||
{isUploading && <div style={{ padding: '0.75rem', background: 'var(--accent-bg)', borderRadius: 'var(--radius-sm)', fontSize: '12px', color: 'var(--accent)', textAlign: 'center' }}>Uploading...</div>}
|
{isUploading && <div className="upload-status">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' }}>
|
<div className="upload-tip">
|
||||||
<strong>Tip:</strong> After uploading, you can remove the background using the background removal tool in the properties panel.
|
<strong>Tip:</strong> After uploading, you can remove the background using the background removal tool in the properties panel.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { AutoModel, AutoProcessor, RawImage } from '@huggingface/transformers';
|
import { AutoModel, AutoProcessor, RawImage, env } from '@huggingface/transformers';
|
||||||
|
|
||||||
export function useBackgroundRemoval() {
|
export function useBackgroundRemoval() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [hasModel, setHasModel] = useState(false);
|
||||||
const modelRef = useRef(null);
|
const modelRef = useRef(null);
|
||||||
const processorRef = useRef(null);
|
const processorRef = useRef(null);
|
||||||
|
|
||||||
@@ -14,6 +15,10 @@ export function useBackgroundRemoval() {
|
|||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Reduce ONNX Runtime Web console noise (node assignment warnings, etc.).
|
||||||
|
// Note: depending on ONNX Runtime build/version, some warnings may still appear.
|
||||||
|
env.backends.onnx.logLevel = 'error';
|
||||||
|
|
||||||
modelRef.current = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
|
modelRef.current = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
|
||||||
dtype: 'q8',
|
dtype: 'q8',
|
||||||
device: navigator.gpu ? 'webgpu' : 'wasm',
|
device: navigator.gpu ? 'webgpu' : 'wasm',
|
||||||
@@ -26,6 +31,7 @@ export function useBackgroundRemoval() {
|
|||||||
|
|
||||||
setProgress(50);
|
setProgress(50);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setHasModel(true);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load background removal model:', error);
|
console.error('Failed to load background removal model:', error);
|
||||||
@@ -83,7 +89,7 @@ export function useBackgroundRemoval() {
|
|||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
progress,
|
progress,
|
||||||
hasModel: !!(modelRef.current),
|
hasModel,
|
||||||
loadModel,
|
loadModel,
|
||||||
removeBackground,
|
removeBackground,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,18 @@ const DEBOUNCE_DELAY_MS = 300;
|
|||||||
export function useDesignEditor() {
|
export function useDesignEditor() {
|
||||||
const [elements, setElements] = useState([]);
|
const [elements, setElements] = useState([]);
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
|
const [canUndo, setCanUndo] = useState(false);
|
||||||
|
const [canRedo, setCanRedo] = useState(false);
|
||||||
const historyRef = useRef([]);
|
const historyRef = useRef([]);
|
||||||
const historyIndexRef = useRef(-1);
|
const historyIndexRef = useRef(-1);
|
||||||
const historyTimerRef = useRef(null);
|
const historyTimerRef = useRef(null);
|
||||||
const pendingChangesRef = useRef(null);
|
const pendingChangesRef = useRef(null);
|
||||||
|
|
||||||
|
const syncUndoRedo = useCallback(() => {
|
||||||
|
setCanUndo(historyIndexRef.current > 0);
|
||||||
|
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const saveToHistory = useCallback((newElements) => {
|
const saveToHistory = useCallback((newElements) => {
|
||||||
if (historyIndexRef.current < historyRef.current.length - 1) {
|
if (historyIndexRef.current < historyRef.current.length - 1) {
|
||||||
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
|
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
|
||||||
@@ -18,7 +25,8 @@ export function useDesignEditor() {
|
|||||||
historyRef.current.push(JSON.stringify(newElements));
|
historyRef.current.push(JSON.stringify(newElements));
|
||||||
if (historyRef.current.length > MAX_HISTORY) { historyRef.current.shift(); }
|
if (historyRef.current.length > MAX_HISTORY) { historyRef.current.shift(); }
|
||||||
else { historyIndexRef.current++; }
|
else { historyIndexRef.current++; }
|
||||||
}, []);
|
syncUndoRedo();
|
||||||
|
}, [syncUndoRedo]);
|
||||||
|
|
||||||
const flushPendingChanges = useCallback(() => {
|
const flushPendingChanges = useCallback(() => {
|
||||||
if (pendingChangesRef.current) { saveToHistory(pendingChangesRef.current); pendingChangesRef.current = null; }
|
if (pendingChangesRef.current) { saveToHistory(pendingChangesRef.current); pendingChangesRef.current = null; }
|
||||||
@@ -27,9 +35,6 @@ export function useDesignEditor() {
|
|||||||
|
|
||||||
useEffect(() => { return () => { if (historyTimerRef.current) clearTimeout(historyTimerRef.current); }; }, []);
|
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) => {
|
const addElement = useCallback((element) => {
|
||||||
flushPendingChanges();
|
flushPendingChanges();
|
||||||
const newElement = { ...element, id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` };
|
const newElement = { ...element, id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` };
|
||||||
@@ -59,14 +64,28 @@ export function useDesignEditor() {
|
|||||||
const commitHistory = useCallback(() => flushPendingChanges(), [flushPendingChanges]);
|
const commitHistory = useCallback(() => flushPendingChanges(), [flushPendingChanges]);
|
||||||
|
|
||||||
const undo = useCallback(() => {
|
const undo = useCallback(() => {
|
||||||
if (historyIndexRef.current > 0) { historyIndexRef.current--; setElements(JSON.parse(historyRef.current[historyIndexRef.current])); setSelectedId(null); }
|
if (historyIndexRef.current > 0) {
|
||||||
}, []);
|
historyIndexRef.current--;
|
||||||
|
setElements(JSON.parse(historyRef.current[historyIndexRef.current]));
|
||||||
|
setSelectedId(null);
|
||||||
|
syncUndoRedo();
|
||||||
|
}
|
||||||
|
}, [syncUndoRedo]);
|
||||||
|
|
||||||
const redo = useCallback(() => {
|
const redo = useCallback(() => {
|
||||||
if (historyIndexRef.current < historyRef.current.length - 1) { historyIndexRef.current++; setElements(JSON.parse(historyRef.current[historyIndexRef.current])); setSelectedId(null); }
|
if (historyIndexRef.current < historyRef.current.length - 1) {
|
||||||
}, []);
|
historyIndexRef.current++;
|
||||||
|
setElements(JSON.parse(historyRef.current[historyIndexRef.current]));
|
||||||
|
setSelectedId(null);
|
||||||
|
syncUndoRedo();
|
||||||
|
}
|
||||||
|
}, [syncUndoRedo]);
|
||||||
|
|
||||||
const initializeHistory = useCallback(() => { historyRef.current = [JSON.stringify([])]; historyIndexRef.current = 0; }, []);
|
const initializeHistory = useCallback(() => {
|
||||||
|
historyRef.current = [JSON.stringify([])];
|
||||||
|
historyIndexRef.current = 0;
|
||||||
|
syncUndoRedo();
|
||||||
|
}, [syncUndoRedo]);
|
||||||
|
|
||||||
return { elements, selectedId, addElement, updateElement, deleteElement, selectElement, deselectAll, commitHistory, undo, redo, canUndo, canRedo, initializeHistory };
|
return { elements, selectedId, addElement, updateElement, deleteElement, selectElement, deselectAll, commitHistory, undo, redo, canUndo, canRedo, initializeHistory };
|
||||||
}
|
}
|
||||||
|
|||||||
377
src/index.css
377
src/index.css
@@ -33,112 +33,357 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* {
|
||||||
body { margin: 0; min-height: 100vh; }
|
box-sizing: border-box;
|
||||||
#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; }
|
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 {
|
.sidebar {
|
||||||
width: 320px; background: var(--bg-secondary); border-right: 1px solid var(--border);
|
width: 320px;
|
||||||
display: flex; flex-direction: column; overflow: hidden;
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-area {
|
.canvas-area {
|
||||||
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
flex: 1;
|
||||||
background: var(--bg-tertiary); overflow: auto; padding: 2rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.properties-panel {
|
.properties-panel {
|
||||||
width: 280px; background: var(--bg-secondary); border-left: 1px solid var(--border);
|
width: 280px;
|
||||||
display: flex; flex-direction: column; overflow: hidden;
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
padding: 0.5rem 0.75rem; background: var(--bg-primary); border: 1px solid var(--border);
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: var(--radius-sm); font-size: 0.75rem; cursor: pointer; transition: all 0.2s; color: var(--text-secondary);
|
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;
|
||||||
}
|
}
|
||||||
.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 {
|
.export-btn {
|
||||||
padding: 0.5rem 1rem; background: linear-gradient(135deg, #22c55e, #16a34a); color: white;
|
padding: 0.5rem 1rem;
|
||||||
border: none; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: 500;
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem;
|
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-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
|
||||||
.export-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.export-error {
|
.export-error {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex;
|
||||||
padding: 0.75rem 1rem; background: #fef2f2; border: 1px solid #fecaca;
|
align-items: center;
|
||||||
border-radius: var(--radius-md); color: #dc2626; font-size: 0.875rem;
|
justify-content: space-between;
|
||||||
margin-bottom: 1rem; width: 100%; max-width: 400px;
|
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); }
|
.export-error p {
|
||||||
.bg-removal-btn {
|
margin: 0;
|
||||||
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;
|
.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;
|
||||||
}
|
}
|
||||||
.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 {
|
.spinner-small {
|
||||||
width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.3);
|
width: 16px;
|
||||||
border-top-color: white; border-radius: 50%; animation: spin 1s linear infinite;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.filerobot-overlay {
|
.filerobot-overlay {
|
||||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
position: fixed;
|
||||||
background: rgba(0, 0, 0, 0.8); display: flex; align-items: center;
|
top: 0;
|
||||||
justify-content: center; z-index: 1000;
|
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;
|
||||||
}
|
}
|
||||||
.filerobot-container { width: 90%; height: 90%; background: #1e1e1e; border-radius: var(--radius-lg); overflow: hidden; }
|
|
||||||
|
|
||||||
.pwa-install-banner {
|
.pwa-install-banner {
|
||||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
position: fixed;
|
||||||
background: var(--bg-primary); border: 1px solid var(--accent); border-radius: var(--radius-lg);
|
bottom: 20px;
|
||||||
padding: 1rem 1.5rem; box-shadow: var(--shadow-lg); z-index: 1000;
|
left: 50%;
|
||||||
display: flex; align-items: center; gap: 1rem; animation: slideUp 0.3s ease-out;
|
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;
|
||||||
}
|
}
|
||||||
@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 {
|
.offline-indicator {
|
||||||
position: fixed; top: 0; left: 0; right: 0;
|
position: fixed;
|
||||||
background: linear-gradient(135deg, #f59e0b, #d97706); color: white;
|
top: 0;
|
||||||
padding: 0.5rem 1rem; text-align: center; font-size: 12px; font-weight: 500;
|
left: 0;
|
||||||
z-index: 9999; display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
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);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) and (min-width: 768px) {
|
@media (max-width: 1200px) and (min-width: 768px) {
|
||||||
.sidebar { width: 280px; }
|
.sidebar {
|
||||||
.properties-panel { width: 240px; }
|
width: 280px;
|
||||||
.canvas-area { padding: 1.5rem; }
|
}
|
||||||
|
|
||||||
|
.properties-panel {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.editor-layout { flex-direction: column; }
|
.editor-layout {
|
||||||
.sidebar { width: 100%; height: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); }
|
flex-direction: column;
|
||||||
.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; }
|
.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) {
|
@media (max-width: 480px) {
|
||||||
.sidebar { max-height: 40vh; }
|
.sidebar {
|
||||||
.canvas-area { padding: 0.5rem; }
|
max-height: 40vh;
|
||||||
button { min-height: 44px; }
|
}
|
||||||
|
|
||||||
|
.canvas-area {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
41
src/styles/DesignCanvas.css
Normal file
41
src/styles/DesignCanvas.css
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
.design-canvas-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-canvas-border {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-canvas-border.selected {
|
||||||
|
border: 2px solid #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-canvas-stage {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-canvas-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -200px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
69
src/styles/LayersPanel.css
Normal file
69
src/styles/LayersPanel.css
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
.layers-empty {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-title {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-item.selected {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-item-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 400;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-item-name.selected {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-item-delete {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
42
src/styles/PWAInstall.css
Normal file
42
src/styles/PWAInstall.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
.pwa-install-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-banner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-banner .refresh-btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-banner .close-btn {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
7
src/styles/PhotoPreEditor.css
Normal file
7
src/styles/PhotoPreEditor.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
}
|
||||||
113
src/styles/PropertiesPanel.css
Normal file
113
src/styles/PropertiesPanel.css
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
.properties-panel__header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__axis-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__range {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__color-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__edit-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel__delete-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--error);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
34
src/styles/Sidebar.css
Normal file
34
src/styles/Sidebar.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
.sidebar-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab-btn.active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
47
src/styles/StickersTab.css
Normal file
47
src/styles/StickersTab.css
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
.stickers-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stickers-categories {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stickers-category-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stickers-category-btn.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stickers-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticker-btn {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
font-size: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
8
src/styles/TShirtSVG.css
Normal file
8
src/styles/TShirtSVG.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.tshirt-svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
118
src/styles/TemplatesTab.css
Normal file
118
src/styles/TemplatesTab.css
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
.templates-hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-description {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-btn.selected {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-thumbnail {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-slots-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-slots-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-slots-title {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-slots-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-slot-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-slot-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-slot-dimensions {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
99
src/styles/TextTab.css
Normal file
99
src/styles/TextTab.css
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
.text-tab-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-range {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-color-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-color-input {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-color-text {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-preview {
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-preview-text {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tab-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
59
src/styles/UploadTab.css
Normal file
59
src/styles/UploadTab.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
.upload-tab-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone.dragging {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-tip {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user