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}`} + +
+ +
+ +
+
+ X + handlePositionChange('x', e.target.value)} + /> +
+
+ Y + handlePositionChange('y', e.target.value)} + /> +
+
+
+ +
+ +
+
+ W + + handleSizeChange(selectedElement.text ? 'fontSize' : 'width', e.target.value) + } + /> +
+ {selectedElement.type !== 'text' && ( +
+ H + handleSizeChange('height', e.target.value)} + /> +
+ )} +
+
+ +
+ + 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); + } +}