diff --git a/client/package.json b/client/package.json
index 30b0b2b..fbd370a 100644
--- a/client/package.json
+++ b/client/package.json
@@ -14,7 +14,9 @@
"react-dom": "^19.2.5",
"react-konva": "^18.2.10",
"konva": "^9.3.18",
- "use-image": "^1.1.1"
+ "use-image": "^1.1.1",
+ "@xenova/transformers": "^2.17.2",
+ "react-filerobot-image-editor": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
diff --git a/client/src/components/editor/PhotoPreEditor.jsx b/client/src/components/editor/PhotoPreEditor.jsx
new file mode 100644
index 0000000..7167c41
--- /dev/null
+++ b/client/src/components/editor/PhotoPreEditor.jsx
@@ -0,0 +1,58 @@
+import { useState } from 'react';
+import FilerobotImageEditor from 'react-filerobot-image-editor';
+
+export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
+ const [saving, setSaving] = useState(false);
+
+ const handleComplete = (editedImageObject, designState) => {
+ setSaving(true);
+
+ // Export the edited image
+ editedImageObject.exportAsync({
+ quality: 1,
+ mimeType: 'image/png',
+ }).then((blob) => {
+ const url = URL.createObjectURL(blob);
+ setSaving(false);
+ onComplete(url);
+ }).catch((error) => {
+ console.error('Export failed:', error);
+ setSaving(false);
+ onClose();
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/client/src/components/editor/index.js b/client/src/components/editor/index.js
new file mode 100644
index 0000000..d18f55a
--- /dev/null
+++ b/client/src/components/editor/index.js
@@ -0,0 +1 @@
+export { PhotoPreEditor } from './PhotoPreEditor';
diff --git a/client/src/components/properties/PropertiesPanel.jsx b/client/src/components/properties/PropertiesPanel.jsx
new file mode 100644
index 0000000..c9b46fb
--- /dev/null
+++ b/client/src/components/properties/PropertiesPanel.jsx
@@ -0,0 +1,118 @@
+import { BackgroundRemovalButton } from '../sidebar/BackgroundRemovalButton';
+
+export function PropertiesPanel({ selectedElement, onUpdate, onDelete }) {
+ if (!selectedElement) {
+ return (
+
+
Properties
+
+
Select an element to edit its properties
+
+
+ );
+ }
+
+ const handlePositionChange = (axis, value) => {
+ onUpdate(selectedElement.id, { [axis]: Number(value) });
+ };
+
+ const handleSizeChange = (dimension, value) => {
+ onUpdate(selectedElement.id, { [dimension]: Number(value) });
+ };
+
+ const handleRotationChange = (value) => {
+ onUpdate(selectedElement.id, { rotation: Number(value) });
+ };
+
+ const getIcon = () => {
+ if (selectedElement.type === 'image') return '🖼️';
+ if (selectedElement.type === 'text') return 'T';
+ if (selectedElement.type === 'sticker') return '😊';
+ return '📦';
+ };
+
+ return (
+
+
Properties
+
+ {getIcon()}
+
+ {selectedElement.type === 'text'
+ ? selectedElement.text?.substring(0, 20) || 'Text'
+ : `${selectedElement.type}`}
+
+
+
+
+
+
+
+
+
+ handleRotationChange(e.target.value)}
+ className="rotation-slider"
+ />
+
+
+ {selectedElement.type === 'image' && (
+
+ )}
+
+
+
+ );
+}
diff --git a/client/src/components/properties/index.js b/client/src/components/properties/index.js
new file mode 100644
index 0000000..43c7b2c
--- /dev/null
+++ b/client/src/components/properties/index.js
@@ -0,0 +1 @@
+export { PropertiesPanel } from './PropertiesPanel';
diff --git a/client/src/components/sidebar/BackgroundRemovalButton.jsx b/client/src/components/sidebar/BackgroundRemovalButton.jsx
new file mode 100644
index 0000000..0f93cf7
--- /dev/null
+++ b/client/src/components/sidebar/BackgroundRemovalButton.jsx
@@ -0,0 +1,47 @@
+import { useBackgroundRemoval } from '../../hooks/useBackgroundRemoval';
+
+export function BackgroundRemovalButton({ selectedElement, onUpdate }) {
+ const { loading, progress, hasModel, loadModel, removeBackground } = useBackgroundRemoval();
+
+ const handleRemoveBackground = async () => {
+ if (!selectedElement || selectedElement.type !== 'image') return;
+
+ if (!hasModel) {
+ const loaded = await loadModel();
+ if (!loaded) return;
+ }
+
+ const resultUrl = await removeBackground(selectedElement.src);
+ if (resultUrl) {
+ onUpdate(selectedElement.id, { src: resultUrl });
+ }
+ };
+
+ if (!selectedElement || selectedElement.type !== 'image') {
+ return null;
+ }
+
+ return (
+
+
+ {!hasModel && (
+
+ First use requires downloading ~170MB model. Subsequent uses are cached.
+
+ )}
+
+ );
+}
diff --git a/client/src/constants/templates.js b/client/src/constants/templates.js
new file mode 100644
index 0000000..f2d3969
--- /dev/null
+++ b/client/src/constants/templates.js
@@ -0,0 +1,309 @@
+// Pre-designed templates for t-shirt customization
+export const TEMPLATES = [
+ {
+ id: 'team-sport',
+ name: 'Team Sport',
+ category: 'Sports',
+ description: 'Classic team jersey with number and text',
+ elements: [
+ {
+ type: 'text',
+ text: 'TEAM NAME',
+ x: 75,
+ y: 80,
+ fontSize: 28,
+ fontFamily: 'Impact',
+ fill: '#ffffff',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: '23',
+ x: 150,
+ y: 150,
+ fontSize: 72,
+ fontFamily: 'Impact',
+ fill: '#ffffff',
+ rotation: 0,
+ },
+ ],
+ },
+ {
+ id: 'band-merch',
+ name: 'Band Merch',
+ category: 'Music',
+ description: 'Classic band t-shirt design',
+ elements: [
+ {
+ type: 'text',
+ text: 'BAND NAME',
+ x: 150,
+ y: 70,
+ fontSize: 32,
+ fontFamily: 'Georgia',
+ fill: '#fbbf24',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: 'WORLD TOUR 2026',
+ x: 150,
+ y: 110,
+ fontSize: 16,
+ fontFamily: 'Arial',
+ fill: '#ffffff',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: '🎸',
+ x: 150,
+ y: 180,
+ fontSize: 64,
+ fontFamily: 'Arial',
+ fill: '#ffffff',
+ rotation: 0,
+ },
+ ],
+ },
+ {
+ id: 'minimal-quote',
+ name: 'Minimal Quote',
+ category: 'Quotes',
+ description: 'Simple centered quote design',
+ elements: [
+ {
+ type: 'text',
+ text: '"Be the change"',
+ x: 150,
+ y: 130,
+ fontSize: 24,
+ fontFamily: 'Georgia',
+ fill: '#1e293b',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: 'you wish to see',
+ x: 150,
+ y: 160,
+ fontSize: 18,
+ fontFamily: 'Arial',
+ fill: '#64748b',
+ rotation: 0,
+ },
+ ],
+ },
+ {
+ id: 'funny-cat',
+ name: 'Funny Cat',
+ category: 'Animals',
+ description: 'Cute cat with funny text',
+ elements: [
+ {
+ type: 'text',
+ text: '😼',
+ x: 150,
+ y: 100,
+ fontSize: 80,
+ fontFamily: 'Arial',
+ fill: '#000000',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: 'I do what I want',
+ x: 150,
+ y: 200,
+ fontSize: 20,
+ fontFamily: 'Comic Sans MS',
+ fill: '#475569',
+ rotation: 0,
+ },
+ ],
+ },
+ {
+ id: 'gradient-vibes',
+ name: 'Gradient Vibes',
+ category: 'Abstract',
+ description: 'Modern gradient text design',
+ elements: [
+ {
+ type: 'text',
+ text: 'GOOD',
+ x: 150,
+ y: 110,
+ fontSize: 48,
+ fontFamily: 'Impact',
+ fill: '#ec4899',
+ rotation: -5,
+ },
+ {
+ type: 'text',
+ text: 'VIBES',
+ x: 150,
+ y: 160,
+ fontSize: 48,
+ fontFamily: 'Impact',
+ fill: '#8b5cf6',
+ rotation: 5,
+ },
+ {
+ type: 'text',
+ text: '✨',
+ x: 80,
+ y: 90,
+ fontSize: 32,
+ fontFamily: 'Arial',
+ fill: '#fbbf24',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: '🌙',
+ x: 220,
+ y: 190,
+ fontSize: 32,
+ fontFamily: 'Arial',
+ fill: '#38bdf8',
+ rotation: 0,
+ },
+ ],
+ },
+ {
+ id: 'vintage-badge',
+ name: 'Vintage Badge',
+ category: 'Vintage',
+ description: 'Retro badge style design',
+ elements: [
+ {
+ type: 'text',
+ text: 'EST.',
+ x: 150,
+ y: 80,
+ fontSize: 18,
+ fontFamily: 'Times New Roman',
+ fill: '#78716c',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: '2026',
+ x: 150,
+ y: 105,
+ fontSize: 36,
+ fontFamily: 'Times New Roman',
+ fill: '#78716c',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: 'AUTHENTIC',
+ x: 150,
+ y: 150,
+ fontSize: 24,
+ fontFamily: 'Times New Roman',
+ fill: '#78716c',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: 'QUALITY',
+ x: 150,
+ y: 180,
+ fontSize: 24,
+ fontFamily: 'Times New Roman',
+ fill: '#78716c',
+ rotation: 0,
+ },
+ ],
+ },
+ {
+ id: 'nature-lover',
+ name: 'Nature Lover',
+ category: 'Nature',
+ description: 'Mountain and nature themed',
+ elements: [
+ {
+ type: 'text',
+ text: '🏔️',
+ x: 150,
+ y: 90,
+ fontSize: 56,
+ fontFamily: 'Arial',
+ fill: '#000000',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: 'ADVENTURE',
+ x: 150,
+ y: 160,
+ fontSize: 28,
+ fontFamily: 'Impact',
+ fill: '#059669',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: 'AWAITS',
+ x: 150,
+ y: 190,
+ fontSize: 20,
+ fontFamily: 'Arial',
+ fill: '#6b7280',
+ rotation: 0,
+ },
+ ],
+ },
+ {
+ id: 'tech-geek',
+ name: 'Tech Geek',
+ category: 'Tech',
+ description: 'Programming themed design',
+ elements: [
+ {
+ type: 'text',
+ text: '>',
+ x: 150,
+ y: 100,
+ fontSize: 64,
+ fontFamily: 'Courier New',
+ fill: '#3b82f6',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: 'Hello, World!',
+ x: 150,
+ y: 170,
+ fontSize: 20,
+ fontFamily: 'Courier New',
+ fill: '#1e293b',
+ rotation: 0,
+ },
+ {
+ type: 'text',
+ text: '// Code is life',
+ x: 150,
+ y: 195,
+ fontSize: 14,
+ fontFamily: 'Courier New',
+ fill: '#94a3b8',
+ rotation: 0,
+ },
+ ],
+ },
+];
+
+export const TEMPLATE_CATEGORIES = [
+ 'All',
+ 'Sports',
+ 'Music',
+ 'Quotes',
+ 'Animals',
+ 'Abstract',
+ 'Vintage',
+ 'Nature',
+ 'Tech',
+];
diff --git a/client/src/hooks/useBackgroundRemoval.js b/client/src/hooks/useBackgroundRemoval.js
new file mode 100644
index 0000000..5d2cd77
--- /dev/null
+++ b/client/src/hooks/useBackgroundRemoval.js
@@ -0,0 +1,120 @@
+import { useState, useCallback } from 'react';
+import { env, AutoModel, AutoProcessor, RawImage } from '@xenova/transformers';
+
+// Use local models only
+env.allowLocalModels = true;
+env.useBrowserCache = true;
+
+export function useBackgroundRemoval() {
+ const [loading, setLoading] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [model, setModel] = useState(null);
+ const [processor, setProcessor] = useState(null);
+
+ const loadModel = useCallback(async () => {
+ if (model && processor) return true;
+
+ setLoading(true);
+ setProgress(0);
+
+ try {
+ const loadedModel = await AutoModel.from_pretrained('Xenova/rmbg-1.4', {
+ progress_callback: (data) => {
+ if (data.status === 'progress') {
+ setProgress(Math.round(data.progress));
+ }
+ },
+ local_model_path: '/models/rmbg-1.4',
+ });
+
+ const loadedProcessor = await AutoProcessor.from_pretrained('Xenova/rmbg-1.4', {
+ local_model_path: '/models/rmbg-1.4',
+ });
+
+ setModel(loadedModel);
+ setProcessor(loadedProcessor);
+ setLoading(false);
+ return true;
+ } catch (error) {
+ console.error('Failed to load background removal model:', error);
+ setLoading(false);
+ return false;
+ }
+ }, [model, processor]);
+
+ const removeBackground = useCallback(async (imageSrc) => {
+ if (!model || !processor) {
+ const loaded = await loadModel();
+ if (!loaded) return null;
+ }
+
+ setLoading(true);
+
+ try {
+ // Load the image
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.src = imageSrc;
+ await new Promise((resolve, reject) => {
+ img.onload = resolve;
+ img.onerror = reject;
+ });
+
+ // Process image through the model
+ const inputs = await processor(img);
+ const { pixel_values } = inputs;
+
+ // Run inference
+ const { output } = await model({ pixel_values });
+
+ // Get the mask
+ const maskData = await RawImage.fromTensor(output[0].mul(255).to('uint8')).resize(
+ img.width,
+ img.height
+ );
+
+ // Create canvas to apply mask
+ const canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ const ctx = canvas.getContext('2d');
+
+ // Draw original image
+ ctx.drawImage(img, 0, 0);
+
+ // Get image data
+ const imageData = ctx.getImageData(0, 0, img.width, img.height);
+ const data = imageData.data;
+ const maskPixels = maskData.data;
+
+ // Apply alpha mask
+ for (let i = 0; i < maskPixels.length; i++) {
+ const alpha = maskPixels[i];
+ data[i * 4 + 3] = alpha; // Set alpha channel
+ }
+
+ ctx.putImageData(imageData, 0, 0);
+
+ // Convert to blob URL
+ const blob = await new Promise((resolve) => {
+ canvas.toBlob(resolve, 'image/png');
+ });
+
+ const url = URL.createObjectURL(blob);
+ setLoading(false);
+ return url;
+ } catch (error) {
+ console.error('Background removal failed:', error);
+ setLoading(false);
+ return null;
+ }
+ }, [model, processor, loadModel]);
+
+ return {
+ loading,
+ progress,
+ hasModel: !!model,
+ loadModel,
+ removeBackground,
+ };
+}
diff --git a/client/src/hooks/useDesignEditor.js b/client/src/hooks/useDesignEditor.js
index 5646759..0cdd4ca 100644
--- a/client/src/hooks/useDesignEditor.js
+++ b/client/src/hooks/useDesignEditor.js
@@ -1,31 +1,70 @@
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useRef } from 'react';
+
+const MAX_HISTORY = 50;
export function useDesignEditor() {
const [elements, setElements] = useState([]);
const [selectedId, setSelectedId] = useState(null);
+ // History for undo/redo
+ const historyRef = useRef([]);
+ const historyIndexRef = useRef(-1);
+
+ const saveToHistory = useCallback((newElements) => {
+ // Remove any future history if we're in the middle of the stack
+ if (historyIndexRef.current < historyRef.current.length - 1) {
+ historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
+ }
+
+ // Add new state to history
+ historyRef.current.push(JSON.stringify(newElements));
+
+ // Limit history size
+ if (historyRef.current.length > MAX_HISTORY) {
+ historyRef.current.shift();
+ } else {
+ historyIndexRef.current++;
+ }
+ }, []);
+
+ const canUndo = historyIndexRef.current > 0;
+ const canRedo = historyIndexRef.current < historyRef.current.length - 1;
+
const addElement = useCallback((element) => {
const newElement = {
...element,
id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
};
- setElements((prev) => [...prev, newElement]);
+
+ setElements((prev) => {
+ const newElements = [...prev, newElement];
+ saveToHistory(newElements);
+ return newElements;
+ });
+
setSelectedId(newElement.id);
return newElement.id;
- }, []);
+ }, [saveToHistory]);
const updateElement = useCallback((id, attrs) => {
- setElements((prev) =>
- prev.map((el) => (el.id === id ? { ...el, ...attrs } : el))
- );
- }, []);
+ setElements((prev) => {
+ const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
+ saveToHistory(newElements);
+ return newElements;
+ });
+ }, [saveToHistory]);
const deleteElement = useCallback((id) => {
- setElements((prev) => prev.filter((el) => el.id !== id));
+ setElements((prev) => {
+ const newElements = prev.filter((el) => el.id !== id);
+ saveToHistory(newElements);
+ return newElements;
+ });
+
if (selectedId === id) {
setSelectedId(null);
}
- }, [selectedId]);
+ }, [selectedId, saveToHistory]);
const selectElement = useCallback((id) => {
setSelectedId(id);
@@ -39,11 +78,38 @@ export function useDesignEditor() {
setElements((prev) => {
const index = prev.findIndex((el) => el.id === id);
if (index === -1 || index === newOrder) return prev;
+
const newElements = [...prev];
const [removed] = newElements.splice(index, 1);
newElements.splice(newOrder, 0, removed);
+
+ saveToHistory(newElements);
return newElements;
});
+ }, [saveToHistory]);
+
+ const undo = useCallback(() => {
+ if (historyIndexRef.current > 0) {
+ historyIndexRef.current--;
+ const prevState = JSON.parse(historyRef.current[historyIndexRef.current]);
+ setElements(prevState);
+ setSelectedId(null);
+ }
+ }, []);
+
+ const redo = useCallback(() => {
+ if (historyIndexRef.current < historyRef.current.length - 1) {
+ historyIndexRef.current++;
+ const nextState = JSON.parse(historyRef.current[historyIndexRef.current]);
+ setElements(nextState);
+ setSelectedId(null);
+ }
+ }, []);
+
+ // Initialize history with empty state
+ const initializeHistory = useCallback(() => {
+ historyRef.current = [JSON.stringify([])];
+ historyIndexRef.current = 0;
}, []);
return {
@@ -55,5 +121,10 @@ export function useDesignEditor() {
selectElement,
deselectAll,
reorderElement,
+ undo,
+ redo,
+ canUndo,
+ canRedo,
+ initializeHistory,
};
}
diff --git a/client/src/index.css b/client/src/index.css
index 6f8251f..ddf7946 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -109,3 +109,625 @@ button:focus-visible {
input, textarea, select {
font-family: inherit;
}
+
+/* App Layout - Three Column */
+.app-layout {
+ display: flex;
+ min-height: 100vh;
+ background: var(--bg-secondary);
+}
+
+.sidebar-container {
+ width: 280px;
+ background: var(--bg-primary);
+ border-right: 1px solid var(--border);
+ flex-shrink: 0;
+ overflow-y: auto;
+}
+
+.canvas-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 2rem;
+ overflow-y: auto;
+}
+
+.properties-container {
+ width: 260px;
+ background: var(--bg-primary);
+ border-left: 1px solid var(--border);
+ flex-shrink: 0;
+ overflow-y: auto;
+}
+
+.app-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin: 0 0 0.25rem 0;
+ color: var(--text-primary);
+}
+
+.app-subtitle {
+ color: var(--text-secondary);
+ margin: 0;
+ font-size: 0.875rem;
+}
+
+.canvas-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 1rem;
+ width: 100%;
+ max-width: 400px;
+}
+
+.undo-redo-buttons {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.icon-btn {
+ padding: 0.5rem 0.75rem;
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ color: var(--text-secondary);
+}
+
+.icon-btn:hover:not(:disabled) {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: white;
+}
+
+.icon-btn.disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.icon-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.canvas-wrapper {
+ margin-bottom: 1rem;
+}
+
+.debug-info {
+ padding: 1rem;
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ text-align: center;
+}
+
+.debug-info p {
+ margin: 0.25rem 0;
+}
+
+.debug-info .tip {
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+/* Sidebar */
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.sidebar-tabs {
+ display: flex;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-tertiary);
+}
+
+.sidebar-tab {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0.75rem 0.5rem;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+.sidebar-tab:hover {
+ background: var(--bg-primary);
+}
+
+.sidebar-tab.active {
+ border-bottom-color: var(--accent);
+ color: var(--text-primary);
+}
+
+.tab-icon {
+ font-size: 1.25rem;
+ margin-bottom: 0.25rem;
+}
+
+.tab-label {
+ font-size: 0.625rem;
+}
+
+.sidebar-content {
+ padding: 1rem;
+ flex: 1;
+}
+
+/* Upload Tab */
+.upload-tab h3 {
+ font-size: 0.875rem;
+ margin: 0 0 1rem 0;
+ color: var(--text-primary);
+}
+
+.upload-zone {
+ border: 2px dashed var(--border);
+ border-radius: var(--radius-md);
+ padding: 2rem 1rem;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.2s;
+ background: var(--bg-secondary);
+}
+
+.upload-zone:hover {
+ border-color: var(--accent);
+ background: var(--accent-bg);
+}
+
+.upload-zone.dragging {
+ border-color: var(--accent);
+ background: var(--accent-bg);
+}
+
+.upload-zone.uploading {
+ opacity: 0.7;
+ pointer-events: none;
+}
+
+.upload-icon {
+ font-size: 2.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.upload-zone p {
+ margin: 0.25rem 0;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+.upload-hint {
+ font-size: 0.75rem !important;
+ color: var(--text-muted) !important;
+}
+
+.uploading-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.spinner {
+ width: 24px;
+ height: 24px;
+ border: 2px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Stickers Tab */
+.stickers-tab h3 {
+ font-size: 0.875rem;
+ margin: 0 0 1rem 0;
+}
+
+.category-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.category-pill {
+ padding: 0.25rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-secondary);
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.category-pill:hover {
+ border-color: var(--accent);
+}
+
+.category-pill.active {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: white;
+}
+
+.sticker-grid {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 0.5rem;
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.sticker-button {
+ font-size: 1.5rem;
+ padding: 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.sticker-button:hover {
+ transform: scale(1.1);
+ border-color: var(--accent);
+}
+
+/* Text Tab */
+.text-tab h3 {
+ font-size: 0.875rem;
+ margin: 0 0 1rem 0;
+}
+
+.text-input-group {
+ margin-bottom: 1rem;
+}
+
+.text-input-group label {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.25rem;
+}
+
+.text-input,
+.font-select {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-size: 0.875rem;
+}
+
+.text-input:focus,
+.font-select:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.size-slider {
+ width: 100%;
+}
+
+.color-picker-row {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.color-picker {
+ width: 40px;
+ height: 36px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+}
+
+.color-input {
+ flex: 1;
+ padding: 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+
+.add-text-btn {
+ width: 100%;
+ padding: 0.75rem;
+ background: var(--accent);
+ color: white;
+ border: none;
+ border-radius: var(--radius-md);
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.add-text-btn:hover {
+ background: var(--accent-hover);
+}
+
+.text-preview {
+ margin-top: 1rem;
+ padding: 1rem;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-md);
+ text-align: center;
+}
+
+/* Templates Tab */
+.templates-tab h3 {
+ font-size: 0.875rem;
+ margin: 0 0 1rem 0;
+}
+
+.templates-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.75rem;
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.template-card {
+ display: flex;
+ flex-direction: column;
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-primary);
+ cursor: pointer;
+ transition: all 0.2s;
+ text-align: left;
+}
+
+.template-card:hover {
+ border-color: var(--accent);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+.template-preview {
+ height: 60px;
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-sm);
+ margin-bottom: 0.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ flex-wrap: wrap;
+ padding: 0.25rem;
+}
+
+.template-preview-element {
+ font-weight: 500;
+}
+
+.template-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.template-name {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.template-category {
+ font-size: 0.625rem;
+ color: var(--text-muted);
+}
+
+/* Properties Panel */
+.properties-panel {
+ padding: 1rem;
+}
+
+.properties-panel h3 {
+ font-size: 0.875rem;
+ margin: 0 0 1rem 0;
+ color: var(--text-primary);
+}
+
+.no-selection {
+ padding: 2rem 1rem;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 0.875rem;
+}
+
+.element-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem;
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-md);
+ margin-bottom: 1rem;
+}
+
+.element-icon {
+ font-size: 1.25rem;
+}
+
+.element-name {
+ font-weight: 500;
+ font-size: 0.875rem;
+}
+
+.property-group {
+ margin-bottom: 1rem;
+}
+
+.property-group label {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
+}
+
+.property-row {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.property-input {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.property-label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ min-width: 12px;
+}
+
+.property-input input {
+ flex: 1;
+ padding: 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-size: 0.875rem;
+ text-align: center;
+}
+
+.property-input input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.rotation-slider {
+ width: 100%;
+}
+
+.delete-btn {
+ width: 100%;
+ padding: 0.75rem;
+ background: transparent;
+ color: var(--error);
+ border: 1px solid var(--error);
+ border-radius: var(--radius-md);
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.delete-btn:hover {
+ background: var(--error);
+ color: white;
+}
+
+.bg-removal-container {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+.bg-removal-btn {
+ width: 100%;
+ padding: 0.75rem;
+ background: linear-gradient(135deg, #8b5cf6, #ec4899);
+ color: white;
+ border: none;
+ border-radius: var(--radius-md);
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.bg-removal-btn:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.bg-removal-btn:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+.bg-removal-hint {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ margin: 0.5rem 0 0 0;
+ line-height: 1.4;
+}
+
+.spinner-small {
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+/* Filerobot Editor Overlay */
+.filerobot-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.filerobot-container {
+ width: 90%;
+ height: 90%;
+ background: #1e1e1e;
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+/* Responsive */
+@media (max-width: 900px) {
+ .app-layout {
+ flex-direction: column;
+ }
+
+ .sidebar-container,
+ .properties-container {
+ width: 100%;
+ border: none;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .sidebar-tabs {
+ overflow-x: auto;
+ }
+
+ .sticker-grid {
+ grid-template-columns: repeat(8, 1fr);
+ }
+}