Flatted and issues fixed with Claude Desktop.
@@ -10,3 +10,5 @@ exports/*
|
|||||||
!exports/.gitkeep
|
!exports/.gitkeep
|
||||||
dist
|
dist
|
||||||
.cache
|
.cache
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# Server Configuration
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
# CORS_ORIGIN=https://your-domain.com
|
||||||
# Client Configuration
|
|
||||||
VITE_API_URL=http://localhost:3001
|
|
||||||
|
|||||||
27
.gitignore
vendored
@@ -1,33 +1,16 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
node_modules/
|
||||||
*/node_modules/
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
dist/
|
dist/
|
||||||
*/dist/
|
npm-debug.log*
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
uploads/*
|
||||||
# Uploads and exports (keep .gitkeep)
|
!uploads/.gitkeep
|
||||||
server/uploads/*
|
exports/*
|
||||||
!server/uploads/.gitkeep
|
!exports/.gitkeep
|
||||||
server/exports/*
|
|
||||||
!server/exports/.gitkeep
|
|
||||||
|
|||||||
46
Dockerfile
@@ -1,54 +1,28 @@
|
|||||||
# Build stage
|
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev pixman-dev python3 make g++
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install all dependencies (including client devDependencies for build)
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
# Copy client source for build
|
|
||||||
COPY client/ ./client/
|
|
||||||
|
|
||||||
# Build the client
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install system dependencies for node-canvas and sharp
|
RUN apk add --no-cache cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev pixman-dev python3 make g++
|
||||||
RUN apk add --no-cache \
|
|
||||||
cairo-dev \
|
|
||||||
pango-dev \
|
|
||||||
libjpeg-turbo-dev \
|
|
||||||
giflib-dev \
|
|
||||||
librsvg-dev \
|
|
||||||
pixman-dev \
|
|
||||||
python3 \
|
|
||||||
make \
|
|
||||||
g++
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files and install production dependencies only
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --production
|
RUN npm install --omit=dev && apk del python3 make g++
|
||||||
|
|
||||||
# Copy server source
|
COPY server.js ./
|
||||||
COPY server/ ./server/
|
COPY --from=builder /app/dist ./dist
|
||||||
|
RUN mkdir -p /app/uploads /app/exports
|
||||||
|
|
||||||
# Copy built client from builder
|
|
||||||
COPY --from=builder /app/client/dist ./server/dist
|
|
||||||
|
|
||||||
# Create data directories
|
|
||||||
RUN mkdir -p /app/server/uploads /app/server/exports
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
ENV NODE_ENV=production
|
||||||
CMD ["node", "server/index.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
119
README.md
@@ -4,80 +4,75 @@ T-shirt customization editor with drag-and-drop design, background removal, and
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Canvas Editor** - React-Konva based drag/drop/resize/rotate for images and text
|
- **Canvas Editor** — React-Konva based drag/drop/resize/rotate for images and text
|
||||||
- **Background Removal** - Client-side AI using Transformers.js (RMBG-1.4 model)
|
- **Background Removal** — Client-side AI using Transformers.js (RMBG-1.4, 8-bit quantized)
|
||||||
- **Photo Pre-Editor** - Filerobot integration for crop, filters, and adjustments
|
- **Photo Pre-Editor** — Filerobot integration for crop, filters, and adjustments
|
||||||
- **Stickers** - 40+ emoji stickers across 6 categories
|
- **Stickers** — 140+ emoji stickers across 6 categories
|
||||||
- **Text Tool** - Multiple fonts, sizes, and colors
|
- **Text Tool** — 20 Google Fonts, sizes, and colors
|
||||||
- **Templates** - 8 pre-designed templates across various categories
|
- **Templates** — 8 pre-designed templates across various categories
|
||||||
- **Undo/Redo** - Full history with 50-state limit
|
- **Undo/Redo** — Full history with keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z)
|
||||||
- **High-Res Export** - 4500x4500px PNG @ 300 DPI (15"x15" print size)
|
- **High-Res Export** — 4500×4500px PNG @ 300 DPI (15"×15" print size)
|
||||||
- **PWA Support** - Offline caching for models, fonts, and assets
|
- **PWA Support** — Offline caching for models, fonts, and assets
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
**Frontend:**
|
| Layer | Choice |
|
||||||
- React 19 + Vite
|
|-------|--------|
|
||||||
- React-Konva (canvas)
|
| Frontend | React 19, Vite, react-konva 19, Konva 10 |
|
||||||
- Transformers.js (background removal)
|
| Background Removal | @huggingface/transformers, RMBG-1.4 (q8) |
|
||||||
- Filerobot Image Editor
|
| Photo Editor | Filerobot Image Editor |
|
||||||
- Workbox (PWA caching)
|
| PWA | Workbox via vite-plugin-pwa |
|
||||||
|
| Server | Express, Multer, Sharp |
|
||||||
**Backend:**
|
| Export | node-canvas (4500×4500 server-side render) |
|
||||||
- Express.js
|
|
||||||
- Multer (file uploads)
|
|
||||||
- Sharp (image processing)
|
|
||||||
- Node-Canvas (high-res export)
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18+
|
|
||||||
- npm or yarn
|
|
||||||
- Docker (optional, for containerized deployment)
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
|
||||||
git clone https://git.kadil.dev/khalidadil/apparel-designer.git
|
|
||||||
cd apparel-designer
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Start development servers (client on :3000, server on :3001)
|
# Start development (client on :3000, server on :3001)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
|
# macOS Sharp fix is built into the dev script
|
||||||
|
# On Windows use:
|
||||||
|
npm run dev:win
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
apparel-designer/
|
apparel-designer/
|
||||||
├── client/ # React frontend
|
├── server.js # Express API (upload, export, health)
|
||||||
│ ├── src/
|
├── vite.config.js # Vite + PWA config
|
||||||
│ │ ├── components/ # UI components
|
├── package.json # Single package — all deps
|
||||||
│ │ │ ├── canvas/ # DesignCanvas, ImageElement, TextElement
|
├── index.html # Entry HTML with Google Fonts
|
||||||
│ │ │ ├── sidebar/ # Tabs: Upload, Stickers, Text, Templates
|
├── src/
|
||||||
│ │ │ ├── panels/ # LayersPanel, PropertiesPanel
|
│ ├── main.jsx # React entry + SW registration
|
||||||
│ │ │ └── editor/ # PhotoPreEditor (Filerobot)
|
│ ├── App.jsx # Root layout (sidebar / canvas / properties)
|
||||||
│ │ ├── hooks/ # useDesignEditor, useExport, useBackgroundRemoval
|
│ ├── App.css
|
||||||
│ │ └── constants/ # Templates, stickers data
|
│ ├── index.css # Design tokens + layout styles
|
||||||
│ ├── vite.config.js # Vite + PWA config
|
│ ├── components/
|
||||||
│ └── package.json
|
│ │ ├── canvas/ # DesignCanvas, ImageElement, TextElement, TShirtSVG, TemplateLayer, SlotPlaceholder
|
||||||
├── server/
|
│ │ ├── sidebar/ # Sidebar, UploadTab, StickersTab, TextTab, TemplatesTab, BackgroundRemovalButton
|
||||||
│ ├── index.js # Express server
|
│ │ ├── panels/ # LayersPanel, PropertiesPanel
|
||||||
│ ├── uploads/ # Uploaded files
|
│ │ ├── editor/ # PhotoPreEditor (Filerobot wrapper)
|
||||||
│ └── exports/ # Exported designs
|
│ │ ├── PWAInstall.jsx
|
||||||
├── docker-compose.yml
|
│ │ └── OfflineIndicator.jsx
|
||||||
└── package.json
|
│ ├── hooks/ # useDesignEditor, useBackgroundRemoval, useExport, useTemplate
|
||||||
|
│ └── constants/ # fonts, stickers, templates
|
||||||
|
├── public/ # Favicon, PWA icons
|
||||||
|
├── uploads/ # User uploads (gitignored)
|
||||||
|
├── exports/ # Exported PNGs (gitignored)
|
||||||
|
├── docs/ # Template JSON schema
|
||||||
|
├── Dockerfile
|
||||||
|
└── docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
@@ -85,21 +80,9 @@ apparel-designer/
|
|||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/api/health` | Health check |
|
| GET | `/api/health` | Health check |
|
||||||
| POST | `/api/upload` | Upload image (20MB max) |
|
| POST | `/api/upload` | Upload image (20MB max, JPEG/PNG/WebP) |
|
||||||
| POST | `/api/export` | Export design as PNG |
|
| POST | `/api/export` | Export design as 4500×4500 PNG |
|
||||||
|
| GET | `/api/download/:filename` | Download exported file |
|
||||||
## Build Plan Status
|
|
||||||
|
|
||||||
- [x] Phase 1: Project Setup & Upload API
|
|
||||||
- [x] Phase 2: Canvas Editor Core (react-konva)
|
|
||||||
- [x] Phase 3: Sidebar & Properties Panel
|
|
||||||
- [x] Phase 4: Background Removal (Transformers.js)
|
|
||||||
- [x] Phase 5: Photo Pre-Editor (Filerobot)
|
|
||||||
- [x] Phase 6: Template System
|
|
||||||
- [x] Phase 7: Undo/Redo
|
|
||||||
- [x] Phase 8: High-Resolution Export
|
|
||||||
- [x] Phase 9: PWA & Workbox Caching
|
|
||||||
- [x] Phase 10: Polish & QA
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
24
client/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# React + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
|
||||||
2617
client/package-lock.json
generated
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
|
||||||
<rect width="192" height="192" fill="#38bdf8"/>
|
|
||||||
<rect x="24" y="24" width="144" height="144" rx="24" fill="#ffffff"/>
|
|
||||||
<text x="96" y="120" font-size="72" text-anchor="middle" fill="#38bdf8" font-family="Arial, sans-serif">T</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 332 B |
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
|
||||||
<rect width="512" height="512" fill="#38bdf8"/>
|
|
||||||
<rect x="64" y="64" width="384" height="384" rx="64" fill="#ffffff"/>
|
|
||||||
<text x="256" y="310" font-size="192" text-anchor="middle" fill="#38bdf8" font-family="Arial, sans-serif">T</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 334 B |
@@ -1,184 +0,0 @@
|
|||||||
.counter {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
}
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.base,
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
inset-inline: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.base {
|
|
||||||
width: 170px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework {
|
|
||||||
z-index: 1;
|
|
||||||
top: 34px;
|
|
||||||
height: 28px;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
|
||||||
scale(1.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vite {
|
|
||||||
z-index: 0;
|
|
||||||
top: 107px;
|
|
||||||
height: 26px;
|
|
||||||
width: auto;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
|
||||||
scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 25px;
|
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
flex: 1 1 0;
|
|
||||||
padding: 32px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#docs {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-h);
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--social-bg);
|
|
||||||
display: flex;
|
|
||||||
padding: 6px 12px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: box-shadow 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
margin-top: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
li {
|
|
||||||
flex: 1 1 calc(50% - 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#spacer {
|
|
||||||
height: 88px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticks {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -4.5px;
|
|
||||||
border: 5px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
left: 0;
|
|
||||||
border-left-color: var(--border);
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
right: 0;
|
|
||||||
border-right-color: var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { DesignCanvas } from './components/canvas/DesignCanvas';
|
|
||||||
import { Sidebar } from './components/sidebar/Sidebar';
|
|
||||||
import { LayersPanel } from './components/panels/LayersPanel';
|
|
||||||
import { PropertiesPanel } from './components/panels/PropertiesPanel';
|
|
||||||
import { PWAInstall } from './components/PWAInstall';
|
|
||||||
import { OfflineIndicator } from './components/OfflineIndicator';
|
|
||||||
import { PhotoPreEditor } from './components/editor/PhotoPreEditor';
|
|
||||||
import { useDesignEditor } from './hooks/useDesignEditor';
|
|
||||||
import { useExport } from './hooks/useExport';
|
|
||||||
import { useTemplate } from './hooks/useTemplate';
|
|
||||||
import { TEMPLATES } from './constants/templates';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [editingElement, setEditingElement] = useState(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
elements,
|
|
||||||
selectedId,
|
|
||||||
addElement,
|
|
||||||
updateElement,
|
|
||||||
deleteElement,
|
|
||||||
selectElement,
|
|
||||||
deselectAll,
|
|
||||||
commitHistory,
|
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
canUndo,
|
|
||||||
canRedo,
|
|
||||||
initializeHistory,
|
|
||||||
} = useDesignEditor();
|
|
||||||
|
|
||||||
const { exporting, progress, exportDesign, error, clearExport } = useExport();
|
|
||||||
|
|
||||||
// Template management
|
|
||||||
const {
|
|
||||||
currentTemplate,
|
|
||||||
currentTemplateId,
|
|
||||||
assignedSlots,
|
|
||||||
loadTemplate,
|
|
||||||
clearTemplate,
|
|
||||||
getSlots,
|
|
||||||
assignImageToSlot,
|
|
||||||
getDragBoundFunc,
|
|
||||||
isSlotFilled,
|
|
||||||
} = useTemplate(TEMPLATES);
|
|
||||||
|
|
||||||
const selectedElement = elements.find((el) => el.id === selectedId);
|
|
||||||
|
|
||||||
// Initialize history on mount
|
|
||||||
useEffect(() => {
|
|
||||||
initializeHistory();
|
|
||||||
}, [initializeHistory]);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo: Ctrl/Cmd + Z
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (canUndo) undo();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y
|
|
||||||
if ((e.ctrlKey || e.metaKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (canRedo) redo();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete/Backspace removes selected element
|
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
||||||
if (selectedId) {
|
|
||||||
deleteElement(selectedId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [selectedId, deleteElement, undo, redo, canUndo, canRedo]);
|
|
||||||
|
|
||||||
// Handler callbacks for sidebar tabs
|
|
||||||
const handleAddImage = (imageData) => {
|
|
||||||
addElement(imageData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSticker = (stickerData) => {
|
|
||||||
addElement(stickerData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddText = (textData) => {
|
|
||||||
addElement(textData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddTemplate = (templateId) => {
|
|
||||||
if (templateId === 'freeform') {
|
|
||||||
clearTemplate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load template using useTemplate hook
|
|
||||||
const success = loadTemplate(templateId);
|
|
||||||
if (success) {
|
|
||||||
const template = TEMPLATES.find(t => t.id === templateId);
|
|
||||||
// Clear existing elements first
|
|
||||||
// Apply template elements to canvas
|
|
||||||
if (template?.elements) {
|
|
||||||
template.elements.forEach((el, index) => {
|
|
||||||
setTimeout(() => addElement({ ...el }), index * 50);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle image upload for slot-based templates
|
|
||||||
const handleSlotImageUpload = (slotId, imageData) => {
|
|
||||||
const elementData = assignImageToSlot(slotId, imageData);
|
|
||||||
if (elementData) {
|
|
||||||
addElement(elementData);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle photo editing
|
|
||||||
const handleEditPhoto = (element) => {
|
|
||||||
setEditingElement(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePhotoEditComplete = (editedImageUrl) => {
|
|
||||||
if (editingElement) {
|
|
||||||
updateElement(editingElement.id, { src: editedImageUrl });
|
|
||||||
}
|
|
||||||
setEditingElement(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePhotoEditClose = () => {
|
|
||||||
setEditingElement(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="editor-layout">
|
|
||||||
{/* Offline Indicator */}
|
|
||||||
<OfflineIndicator />
|
|
||||||
|
|
||||||
{/* PWA Install Prompt */}
|
|
||||||
<PWAInstall />
|
|
||||||
|
|
||||||
{/* Left Sidebar */}
|
|
||||||
<Sidebar
|
|
||||||
onAddImage={handleAddImage}
|
|
||||||
onAddSticker={handleAddSticker}
|
|
||||||
onAddText={handleAddText}
|
|
||||||
onAddTemplate={handleAddTemplate}
|
|
||||||
onSlotImageUpload={handleSlotImageUpload}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Center Canvas Area */}
|
|
||||||
<div className="canvas-area">
|
|
||||||
<div style={{ marginBottom: '1rem', textAlign: 'center' }}>
|
|
||||||
<h1 style={{ margin: '0 0 0.25rem 0', fontSize: '20px', color: 'var(--text-primary)' }}>
|
|
||||||
Apparel Designer
|
|
||||||
</h1>
|
|
||||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text-secondary)' }}>
|
|
||||||
T-shirt customization editor
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', justifyContent: 'center' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => canUndo && undo()}
|
|
||||||
disabled={!canUndo}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
background: canUndo ? 'var(--bg-primary)' : 'var(--bg-tertiary)',
|
|
||||||
cursor: canUndo ? 'pointer' : 'not-allowed',
|
|
||||||
opacity: canUndo ? 1 : 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
↶ Undo
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => canRedo && redo()}
|
|
||||||
disabled={!canRedo}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
background: canRedo ? 'var(--bg-primary)' : 'var(--bg-tertiary)',
|
|
||||||
cursor: canRedo ? 'pointer' : 'not-allowed',
|
|
||||||
opacity: canRedo ? 1 : 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
↷ Redo
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => exportDesign(elements, 'tshirt-design', currentTemplate)}
|
|
||||||
disabled={exporting || elements.length === 0}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
background: elements.length === 0 ? 'var(--bg-tertiary)' : 'var(--success)',
|
|
||||||
color: elements.length === 0 ? 'var(--text-muted)' : '#fff',
|
|
||||||
cursor: elements.length === 0 ? 'not-allowed' : 'pointer',
|
|
||||||
opacity: elements.length === 0 ? 0.5 : 1,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{exporting ? `Exporting... ${progress}%` : '⬇ Export HD'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Export error banner */}
|
|
||||||
{error && (
|
|
||||||
<div style={{
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: '#fef2f2',
|
|
||||||
border: '1px solid #fecaca',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
color: '#dc2626',
|
|
||||||
fontSize: '12px',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
maxWidth: '400px',
|
|
||||||
marginLeft: 'auto',
|
|
||||||
marginRight: 'auto',
|
|
||||||
}}>
|
|
||||||
<span>⚠️ Export failed: {error}</span>
|
|
||||||
<button onClick={clearExport} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}>✕</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DesignCanvas
|
|
||||||
elements={elements}
|
|
||||||
selectedId={selectedId}
|
|
||||||
onSelect={selectElement}
|
|
||||||
onDeselect={deselectAll}
|
|
||||||
onUpdate={(id, attrs) => updateElement(id, attrs)}
|
|
||||||
onCommit={commitHistory}
|
|
||||||
currentTemplate={currentTemplate}
|
|
||||||
assignedSlots={assignedSlots}
|
|
||||||
getDragBoundFunc={getDragBoundFunc}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Layers panel below canvas */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: '1.5rem',
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '400px',
|
|
||||||
background: 'var(--bg-primary)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
padding: '1rem',
|
|
||||||
boxShadow: 'var(--shadow-md)',
|
|
||||||
}}>
|
|
||||||
<LayersPanel
|
|
||||||
elements={elements}
|
|
||||||
selectedId={selectedId}
|
|
||||||
onSelect={selectElement}
|
|
||||||
onDelete={deleteElement}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Properties Panel */}
|
|
||||||
<PropertiesPanel
|
|
||||||
element={selectedElement}
|
|
||||||
onUpdate={(attrs) => updateElement(selectedId, attrs)}
|
|
||||||
onDelete={deleteElement}
|
|
||||||
onEditPhoto={handleEditPhoto}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Photo Pre-Editor Modal */}
|
|
||||||
{editingElement && (
|
|
||||||
<PhotoPreEditor
|
|
||||||
imageSrc={editingElement.src}
|
|
||||||
onComplete={handlePhotoEditComplete}
|
|
||||||
onClose={handlePhotoEditClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,125 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
export function PWAInstall() {
|
|
||||||
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
|
||||||
const [showInstall, setShowInstall] = useState(false);
|
|
||||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
|
||||||
const [newWorker, setNewWorker] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleBeforeInstallPrompt = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDeferredPrompt(e);
|
|
||||||
setShowInstall(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
||||||
|
|
||||||
// Listen for service worker updates
|
|
||||||
window.addEventListener('swUpdated', handleSWUpdated);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
||||||
window.removeEventListener('swUpdated', handleSWUpdated);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSWUpdated = (event) => {
|
|
||||||
setNewWorker(event.detail);
|
|
||||||
setUpdateAvailable(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
|
||||||
if (!deferredPrompt) return;
|
|
||||||
|
|
||||||
deferredPrompt.prompt();
|
|
||||||
const { outcome } = await deferredPrompt.userChoice;
|
|
||||||
|
|
||||||
if (outcome === 'accepted') {
|
|
||||||
setShowInstall(false);
|
|
||||||
setDeferredPrompt(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = () => {
|
|
||||||
if (newWorker) {
|
|
||||||
newWorker.postMessage({ type: 'SKIP_WAITING' });
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const dismissUpdate = () => {
|
|
||||||
setUpdateAvailable(false);
|
|
||||||
setNewWorker(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!showInstall && !updateAvailable) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showInstall && (
|
|
||||||
<div className="pwa-install-banner">
|
|
||||||
<p>Install Apparel Designer for offline access!</p>
|
|
||||||
<div className="pwa-install-buttons">
|
|
||||||
<button onClick={handleInstall} className="install-btn">
|
|
||||||
Install
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setShowInstall(false)} className="dismiss-btn">
|
|
||||||
Later
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{updateAvailable && (
|
|
||||||
<div className="pwa-update-banner" style={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: '1rem',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
background: 'var(--accent)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '0.75rem 1.5rem',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
zIndex: 9999,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '1rem',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}>
|
|
||||||
<span>🔄 New version available!</span>
|
|
||||||
<button
|
|
||||||
onClick={handleUpdate}
|
|
||||||
style={{
|
|
||||||
padding: '0.375rem 0.75rem',
|
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--accent)',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={dismissUpdate}
|
|
||||||
style={{
|
|
||||||
padding: '0.375rem 0.5rem',
|
|
||||||
background: 'transparent',
|
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '16px',
|
|
||||||
opacity: 0.8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { Stage, Layer } from 'react-konva';
|
|
||||||
import { TShirtSVG } from './TShirtSVG';
|
|
||||||
import { ImageElement } from './ImageElement';
|
|
||||||
import { TextElement } from './TextElement';
|
|
||||||
import { TemplateLayer } from './TemplateLayer';
|
|
||||||
import { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
|
|
||||||
import { useRef, useEffect, memo } from 'react';
|
|
||||||
|
|
||||||
const CANVAS_SIZE = 300;
|
|
||||||
|
|
||||||
export const DesignCanvas = memo(function DesignCanvas({
|
|
||||||
elements,
|
|
||||||
selectedId,
|
|
||||||
onSelect,
|
|
||||||
onDeselect,
|
|
||||||
onUpdate,
|
|
||||||
onCommit,
|
|
||||||
currentTemplate,
|
|
||||||
assignedSlots,
|
|
||||||
getDragBoundFunc,
|
|
||||||
}) {
|
|
||||||
// Get slots from current template
|
|
||||||
const slots = currentTemplate?.slots || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
|
||||||
{/* T-shirt SVG background */}
|
|
||||||
<TShirtSVG size={CANVAS_SIZE} />
|
|
||||||
|
|
||||||
{/* Canvas Stage */}
|
|
||||||
<Stage
|
|
||||||
width={CANVAS_SIZE}
|
|
||||||
height={CANVAS_SIZE}
|
|
||||||
onClick={onDeselect}
|
|
||||||
onTap={onDeselect}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
border: selectedId ? '2px solid #38bdf8' : '1px dashed #cbd5e1',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: 'rgba(255, 255, 255, 0.5)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Template Layer - Background and Overlay */}
|
|
||||||
<Layer>
|
|
||||||
{currentTemplate && (
|
|
||||||
<TemplateLayer template={currentTemplate} canvasSize={CANVAS_SIZE} />
|
|
||||||
)}
|
|
||||||
</Layer>
|
|
||||||
|
|
||||||
{/* Slot Bounds Guides - visible even when slots have content */}
|
|
||||||
<Layer listening={false}>
|
|
||||||
{slots.map((slot) => (
|
|
||||||
<SlotBoundsGuide key={slot.id} slot={slot} />
|
|
||||||
))}
|
|
||||||
</Layer>
|
|
||||||
|
|
||||||
{/* User Elements Layer */}
|
|
||||||
<Layer>
|
|
||||||
{elements.map((el) => {
|
|
||||||
if (el.type === 'image') {
|
|
||||||
return (
|
|
||||||
<ImageElement
|
|
||||||
key={el.id}
|
|
||||||
id={el.id}
|
|
||||||
x={el.x}
|
|
||||||
y={el.y}
|
|
||||||
width={el.width}
|
|
||||||
height={el.height}
|
|
||||||
rotation={el.rotation}
|
|
||||||
src={el.src}
|
|
||||||
crop={el.crop}
|
|
||||||
isSelected={el.id === selectedId}
|
|
||||||
onSelect={() => onSelect(el.id)}
|
|
||||||
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
|
||||||
onCommit={onCommit}
|
|
||||||
dragBoundFunc={el.slotId ? getDragBoundFunc?.(el.slotId, { width: el.width, height: el.height }) : null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (el.type === 'text') {
|
|
||||||
return (
|
|
||||||
<TextElement
|
|
||||||
key={el.id}
|
|
||||||
id={el.id}
|
|
||||||
x={el.x}
|
|
||||||
y={el.y}
|
|
||||||
text={el.text}
|
|
||||||
fontSize={el.fontSize}
|
|
||||||
fontFamily={el.fontFamily}
|
|
||||||
fill={el.fill}
|
|
||||||
rotation={el.rotation}
|
|
||||||
isSelected={el.id === selectedId}
|
|
||||||
onSelect={() => onSelect(el.id)}
|
|
||||||
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
|
||||||
onCommit={onCommit}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</Layer>
|
|
||||||
|
|
||||||
{/* Slot Placeholders - show when slots are empty */}
|
|
||||||
<Layer listening={false}>
|
|
||||||
{slots.map((slot) => {
|
|
||||||
const isFilled = !!assignedSlots?.[slot.id];
|
|
||||||
return (
|
|
||||||
<SlotPlaceholder
|
|
||||||
key={slot.id}
|
|
||||||
slot={slot}
|
|
||||||
isEmpty={!isFilled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Layer>
|
|
||||||
</Stage>
|
|
||||||
|
|
||||||
{/* Canvas info bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '-40px',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
fontSize: '12px',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
textAlign: 'center',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Design Area: 15" × 15" • Export: 4500 × 4500px @ 300 DPI
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { Group, Rect, Text, Line } from 'react-konva';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SlotPlaceholder - Visual indicator for empty template slots
|
|
||||||
* Shows a dashed border with label when slot is empty
|
|
||||||
*
|
|
||||||
* @param {Object} slot - Slot configuration
|
|
||||||
* @param {string} slot.id - Unique slot identifier
|
|
||||||
* @param {Object} slot.bounds - Slot bounds {x, y, width, height}
|
|
||||||
* @param {string} slot.label - Human-readable label
|
|
||||||
* @param {boolean} isEmpty - Whether slot has no image assigned
|
|
||||||
*/
|
|
||||||
export function SlotPlaceholder({ slot, isEmpty = true }) {
|
|
||||||
const { bounds, label } = slot;
|
|
||||||
const { x, y, width, height } = bounds;
|
|
||||||
|
|
||||||
if (!isEmpty) return null; // Don't show placeholder when slot has content
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group name={`slot-placeholder-${slot.id}`}>
|
|
||||||
{/* Dashed border rectangle */}
|
|
||||||
<Rect
|
|
||||||
x={x}
|
|
||||||
y={y}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
stroke="#94a3b8"
|
|
||||||
strokeWidth={2}
|
|
||||||
dash={[8, 4]}
|
|
||||||
cornerRadius={4}
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Background fill (semi-transparent) */}
|
|
||||||
<Rect
|
|
||||||
x={x}
|
|
||||||
y={y}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
fill="rgba(148, 163, 184, 0.1)"
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Drop icon */}
|
|
||||||
<Text
|
|
||||||
text="📷"
|
|
||||||
x={x + width / 2}
|
|
||||||
y={y + height / 2 - 20}
|
|
||||||
fontSize={24}
|
|
||||||
align="center"
|
|
||||||
offsetX={12}
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Label text */}
|
|
||||||
<Text
|
|
||||||
text={label || 'Drop image here'}
|
|
||||||
x={x + width / 2}
|
|
||||||
y={y + height / 2 + 10}
|
|
||||||
fontSize={11}
|
|
||||||
fontFamily="DM Sans"
|
|
||||||
fill="#64748b"
|
|
||||||
align="center"
|
|
||||||
offsetX={width / 2}
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Corner markers */}
|
|
||||||
<Line
|
|
||||||
points={[x, y, x + 20, y]}
|
|
||||||
stroke="#38bdf8"
|
|
||||||
strokeWidth={3}
|
|
||||||
lineCap="round"
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
points={[x, y, x, y + 20]}
|
|
||||||
stroke="#38bdf8"
|
|
||||||
strokeWidth={3}
|
|
||||||
lineCap="round"
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
points={[x + width, y, x + width - 20, y]}
|
|
||||||
stroke="#38bdf8"
|
|
||||||
strokeWidth={3}
|
|
||||||
lineCap="round"
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
points={[x + width, y, x + width, y + 20]}
|
|
||||||
stroke="#38bdf8"
|
|
||||||
strokeWidth={3}
|
|
||||||
lineCap="round"
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
points={[x, y + height, x + 20, y + height]}
|
|
||||||
stroke="#38bdf8"
|
|
||||||
strokeWidth={3}
|
|
||||||
lineCap="round"
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
points={[x, y + height, x, y + height - 20]}
|
|
||||||
stroke="#38bdf8"
|
|
||||||
strokeWidth={3}
|
|
||||||
lineCap="round"
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
points={[x + width, y + height, x + width - 20, y + height]}
|
|
||||||
stroke="#38bdf8"
|
|
||||||
strokeWidth={3}
|
|
||||||
lineCap="round"
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
points={[x + width, y + height, x + width, y + height - 20]}
|
|
||||||
stroke="#38bdf8"
|
|
||||||
strokeWidth={3}
|
|
||||||
lineCap="round"
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SlotBoundsGuide - Shows the slot boundary during design
|
|
||||||
* Less prominent than placeholder, visible even when slot has content
|
|
||||||
*
|
|
||||||
* @param {Object} slot - Slot configuration
|
|
||||||
* @param {Object} slot.bounds - Slot bounds {x, y, width, height}
|
|
||||||
* @param {string} slot.id - Slot identifier
|
|
||||||
*/
|
|
||||||
export function SlotBoundsGuide({ slot }) {
|
|
||||||
const { bounds, id } = slot;
|
|
||||||
const { x, y, width, height } = bounds;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group name={`slot-bounds-${id}`} listening={false}>
|
|
||||||
<Rect
|
|
||||||
x={x}
|
|
||||||
y={y}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
stroke="rgba(56, 189, 248, 0.3)"
|
|
||||||
strokeWidth={1}
|
|
||||||
dash={[4, 4]}
|
|
||||||
cornerRadius={2}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
export function TShirtSVG({ size = 300 }) {
|
|
||||||
const padding = size * 0.1;
|
|
||||||
const innerSize = size - padding * 2;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* T-shirt outline */}
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{/* Chest area indicator (design zone) */}
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{/* Label */}
|
|
||||||
<text
|
|
||||||
x={size / 2}
|
|
||||||
y={size * 0.45}
|
|
||||||
textAnchor="middle"
|
|
||||||
fill="var(--text-muted)"
|
|
||||||
fontSize="10"
|
|
||||||
fontFamily="var(--font-mono)"
|
|
||||||
>
|
|
||||||
Print Zone
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { Group, Image as KonvaImage, Rect, Text as KonvaText } from 'react-konva';
|
|
||||||
import useImage from 'use-image';
|
|
||||||
|
|
||||||
// Helper component to load and render images
|
|
||||||
function TemplateImage({ src, x, y, width, height, opacity = 1, listening = false }) {
|
|
||||||
const [img] = useImage(src, 'anonymous');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KonvaImage
|
|
||||||
image={img}
|
|
||||||
x={x}
|
|
||||||
y={y}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
opacity={opacity}
|
|
||||||
listening={listening}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper component for text elements
|
|
||||||
function TemplateText({ text, x, y, fontSize, fontFamily, fill, rotation = 0 }) {
|
|
||||||
return (
|
|
||||||
<KonvaText
|
|
||||||
text={text}
|
|
||||||
x={x}
|
|
||||||
y={y}
|
|
||||||
fontSize={fontSize}
|
|
||||||
fontFamily={fontFamily}
|
|
||||||
fill={fill}
|
|
||||||
rotation={rotation}
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TemplateLayer - Renders background and overlay layers for templates
|
|
||||||
* Background: Base image/color that appears behind user elements
|
|
||||||
* Overlay: Decorative elements that appear on top of user elements
|
|
||||||
*
|
|
||||||
* @param {Object} template - Template configuration object
|
|
||||||
* @param {Object} template.background - Background layer config
|
|
||||||
* @param {Array} template.overlay - Overlay elements array
|
|
||||||
* @param {number} canvasSize - Size of the canvas (default: 300)
|
|
||||||
*/
|
|
||||||
export function TemplateLayer({ template, canvasSize = 300 }) {
|
|
||||||
if (!template) return null;
|
|
||||||
|
|
||||||
const { background, overlay } = template;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group name="template-layer">
|
|
||||||
{/* Background Layer */}
|
|
||||||
{background && (
|
|
||||||
<Group name="template-background">
|
|
||||||
{background.type === 'color' ? (
|
|
||||||
<Rect
|
|
||||||
x={0}
|
|
||||||
y={0}
|
|
||||||
width={canvasSize}
|
|
||||||
height={canvasSize}
|
|
||||||
fill={background.color}
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
) : background.type === 'image' ? (
|
|
||||||
<TemplateImage
|
|
||||||
src={background.src}
|
|
||||||
x={0}
|
|
||||||
y={0}
|
|
||||||
width={canvasSize}
|
|
||||||
height={canvasSize}
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Overlay Layer */}
|
|
||||||
{overlay && overlay.map((el, index) => {
|
|
||||||
if (el.nonPrintable) return null; // Skip guides and watermarks
|
|
||||||
|
|
||||||
const key = `overlay-${index}`;
|
|
||||||
|
|
||||||
if (el.type === 'image') {
|
|
||||||
return (
|
|
||||||
<TemplateImage
|
|
||||||
key={key}
|
|
||||||
src={el.src}
|
|
||||||
x={el.x || 0}
|
|
||||||
y={el.y || 0}
|
|
||||||
width={el.width || 100}
|
|
||||||
height={el.height || 100}
|
|
||||||
opacity={el.opacity}
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (el.type === 'text') {
|
|
||||||
return (
|
|
||||||
<TemplateText
|
|
||||||
key={key}
|
|
||||||
text={el.text}
|
|
||||||
x={el.x || 0}
|
|
||||||
y={el.y || 0}
|
|
||||||
fontSize={el.fontSize}
|
|
||||||
fontFamily={el.fontFamily}
|
|
||||||
fill={el.fill}
|
|
||||||
rotation={el.rotation}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import FilerobotImageEditor from 'react-filerobot-image-editor';
|
|
||||||
|
|
||||||
export function PhotoPreEditor({ imageSrc, onComplete, onClose, triggerElementRef }) {
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const modalContentRef = useRef(null);
|
|
||||||
const previousFocusRef = useRef(null);
|
|
||||||
|
|
||||||
// Focus management - trap focus inside modal and restore on close
|
|
||||||
useEffect(() => {
|
|
||||||
// Store the element that had focus before modal opened
|
|
||||||
previousFocusRef.current = document.activeElement;
|
|
||||||
|
|
||||||
// Focus the modal content when it opens
|
|
||||||
const focusableElement = modalContentRef.current?.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
||||||
focusableElement?.focus();
|
|
||||||
|
|
||||||
// Focus trap handler
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'Tab') {
|
|
||||||
const modalContent = modalContentRef.current;
|
|
||||||
if (!modalContent) return;
|
|
||||||
|
|
||||||
const focusableElements = modalContent.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
||||||
const firstElement = focusableElements[0];
|
|
||||||
const lastElement = focusableElements[focusableElements.length - 1];
|
|
||||||
|
|
||||||
if (e.shiftKey && document.activeElement === firstElement) {
|
|
||||||
e.preventDefault();
|
|
||||||
lastElement.focus();
|
|
||||||
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
|
||||||
e.preventDefault();
|
|
||||||
firstElement.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
// Restore focus to the element that triggered the modal
|
|
||||||
previousFocusRef.current?.focus();
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
className="filerobot-overlay"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="photo-editor-title"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="filerobot-container"
|
|
||||||
ref={modalContentRef}
|
|
||||||
role="document"
|
|
||||||
>
|
|
||||||
<FilerobotImageEditor
|
|
||||||
source={imageSrc}
|
|
||||||
onSave={handleComplete}
|
|
||||||
onClose={onClose}
|
|
||||||
annotationsCommon={{
|
|
||||||
fill: '#ff0000',
|
|
||||||
stroke: '#000000',
|
|
||||||
strokeWidth: 0,
|
|
||||||
}}
|
|
||||||
annotations={[
|
|
||||||
'Text',
|
|
||||||
'Rectangle',
|
|
||||||
'Ellipse',
|
|
||||||
'Line',
|
|
||||||
'Pen',
|
|
||||||
'Eraser',
|
|
||||||
]}
|
|
||||||
tabs={['adjust', 'filters', 'finetune', 'annotate', 'watermark']}
|
|
||||||
defaultTabId="adjust"
|
|
||||||
theme={{
|
|
||||||
accentColor: '#38bdf8',
|
|
||||||
palettePrimary: '#38bdf8',
|
|
||||||
}}
|
|
||||||
saveButtonProps={{
|
|
||||||
label: saving ? 'Exporting...' : 'Use Edited Image',
|
|
||||||
}}
|
|
||||||
closeOnSave
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 id="photo-editor-title" className="sr-only" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)' }}>
|
|
||||||
Photo Editor
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
export const LayersPanel = memo(function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
|
|
||||||
const getIcon = (element) => {
|
|
||||||
switch (element.type) {
|
|
||||||
case 'image':
|
|
||||||
return element.bgRemoved ? '🖼️' : '📷';
|
|
||||||
case 'text':
|
|
||||||
return '📝';
|
|
||||||
case 'sticker':
|
|
||||||
return '🎨';
|
|
||||||
default:
|
|
||||||
return '📁';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getName = (element) => {
|
|
||||||
switch (element.type) {
|
|
||||||
case 'image':
|
|
||||||
return element.bgRemoved ? 'Image (BG ✓)' : 'Image';
|
|
||||||
case 'text':
|
|
||||||
return element.text?.substring(0, 20) || 'Text';
|
|
||||||
case 'sticker':
|
|
||||||
return 'Sticker';
|
|
||||||
default:
|
|
||||||
return 'Element';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (elements.length === 0) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
padding: '1rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}>
|
|
||||||
No elements yet. Add images, text, or stickers to your design.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 style={{
|
|
||||||
margin: '0 0 0.75rem 0',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Layers ({elements.length})
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '4px',
|
|
||||||
}}>
|
|
||||||
{elements.map((element, index) => (
|
|
||||||
<div
|
|
||||||
key={element.id}
|
|
||||||
onClick={() => onSelect(element.id)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.5rem',
|
|
||||||
padding: '0.5rem 0.75rem',
|
|
||||||
background: selectedId === element.id ? 'var(--accent-bg)' : 'transparent',
|
|
||||||
border: `1px solid ${selectedId === element.id ? 'var(--accent)' : 'var(--border)'}`,
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (selectedId !== element.id) {
|
|
||||||
e.target.style.borderColor = 'var(--accent)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (selectedId !== element.id) {
|
|
||||||
e.target.style.borderColor = 'var(--border)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '14px' }}>{getIcon(element)}</span>
|
|
||||||
<span style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: '12px',
|
|
||||||
color: selectedId === element.id ? 'var(--accent)' : 'var(--text-primary)',
|
|
||||||
fontWeight: selectedId === element.id ? '600' : '400',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}>
|
|
||||||
{getName(element)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete(element.id);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
background: 'transparent',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.target.style.background = 'var(--error)';
|
|
||||||
e.target.style.color = '#fff';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.target.style.background = 'transparent';
|
|
||||||
e.target.style.color = 'var(--text-muted)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete, onEditPhoto }) {
|
|
||||||
if (!element) {
|
|
||||||
return (
|
|
||||||
<div className="properties-panel">
|
|
||||||
<div style={{
|
|
||||||
padding: '1rem',
|
|
||||||
borderBottom: `1px solid var(--border)`,
|
|
||||||
}}>
|
|
||||||
<h3 style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}>
|
|
||||||
Properties
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '1rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
fontSize: '12px',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}>
|
|
||||||
Select an element to edit its properties
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePositionChange = (axis, value) => {
|
|
||||||
onUpdate({ [axis]: parseFloat(value) || 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSizeChange = (axis, value) => {
|
|
||||||
const numValue = parseFloat(value) || 20;
|
|
||||||
onUpdate({ [axis]: Math.max(20, numValue) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRotationChange = (value) => {
|
|
||||||
const numValue = parseFloat(value) || 0;
|
|
||||||
onUpdate({ rotation: Math.max(-180, Math.min(180, numValue)) });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="properties-panel">
|
|
||||||
<div style={{
|
|
||||||
padding: '1rem',
|
|
||||||
borderBottom: `1px solid var(--border)`,
|
|
||||||
}}>
|
|
||||||
<h3 style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}>
|
|
||||||
Properties
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
flex: 1,
|
|
||||||
overflow: 'auto',
|
|
||||||
padding: '1rem',
|
|
||||||
}}>
|
|
||||||
{/* Element type badge */}
|
|
||||||
<div style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
padding: '4px 8px',
|
|
||||||
background: 'var(--accent-bg)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--accent)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
}}>
|
|
||||||
{element.type}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Position */}
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Position
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>X</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={Math.round(element.x)}
|
|
||||||
onChange={(e) => handlePositionChange('x', e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.5rem',
|
|
||||||
border: `1px solid var(--border)`,
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Y</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={Math.round(element.y)}
|
|
||||||
onChange={(e) => handlePositionChange('y', e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.5rem',
|
|
||||||
border: `1px solid var(--border)`,
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Size (for images and stickers) */}
|
|
||||||
{(element.type === 'image' || element.type === 'sticker') && (
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Size
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>W</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={Math.round(element.width)}
|
|
||||||
onChange={(e) => handleSizeChange('width', e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.5rem',
|
|
||||||
border: `1px solid var(--border)`,
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>H</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={Math.round(element.height)}
|
|
||||||
onChange={(e) => handleSizeChange('height', e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.5rem',
|
|
||||||
border: `1px solid var(--border)`,
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Photo button (for images only) */}
|
|
||||||
{element.type === 'image' && onEditPhoto && (
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => onEditPhoto(element)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
|
||||||
border: `1px solid var(--accent)`,
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
background: 'var(--accent-bg)',
|
|
||||||
color: 'var(--accent)',
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '600',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.target.style.background = 'var(--accent)';
|
|
||||||
e.target.style.color = '#fff';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.target.style.background = 'var(--accent-bg)';
|
|
||||||
e.target.style.color = 'var(--accent)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✏️ Edit Photo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Font size (for text) */}
|
|
||||||
{element.type === 'text' && (
|
|
||||||
<>
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Font Size: {Math.round(element.fontSize)}px
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="12"
|
|
||||||
max="120"
|
|
||||||
value={element.fontSize}
|
|
||||||
onChange={(e) => onUpdate({ fontSize: parseInt(e.target.value, 10) })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Color
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={element.fill}
|
|
||||||
onChange={(e) => onUpdate({ fill: e.target.value })}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '36px',
|
|
||||||
border: `1px solid var(--border)`,
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '2px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rotation */}
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Rotation: {Math.round(element.rotation)}°
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="-180"
|
|
||||||
max="180"
|
|
||||||
value={element.rotation}
|
|
||||||
onChange={(e) => handleRotationChange(e.target.value)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete button */}
|
|
||||||
<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',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
marginTop: '1rem',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.target.style.background = '#dc2626';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.target.style.background = 'var(--error)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete Element
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { BackgroundRemovalButton } from '../sidebar/BackgroundRemovalButton';
|
|
||||||
|
|
||||||
export function PropertiesPanel({ selectedElement, onUpdate, onDelete }) {
|
|
||||||
if (!selectedElement) {
|
|
||||||
return (
|
|
||||||
<div className="properties-panel">
|
|
||||||
<h3>Properties</h3>
|
|
||||||
<div className="no-selection">
|
|
||||||
<p>Select an element to edit its properties</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="properties-panel">
|
|
||||||
<h3>Properties</h3>
|
|
||||||
<div className="element-header">
|
|
||||||
<span className="element-icon">{getIcon()}</span>
|
|
||||||
<span className="element-name">
|
|
||||||
{selectedElement.type === 'text'
|
|
||||||
? selectedElement.text?.substring(0, 20) || 'Text'
|
|
||||||
: `${selectedElement.type}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="property-group">
|
|
||||||
<label>Position</label>
|
|
||||||
<div className="property-row">
|
|
||||||
<div className="property-input">
|
|
||||||
<span className="property-label">X</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={Math.round(selectedElement.x)}
|
|
||||||
onChange={(e) => handlePositionChange('x', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="property-input">
|
|
||||||
<span className="property-label">Y</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={Math.round(selectedElement.y)}
|
|
||||||
onChange={(e) => handlePositionChange('y', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="property-group">
|
|
||||||
<label>Size</label>
|
|
||||||
<div className="property-row">
|
|
||||||
<div className="property-input">
|
|
||||||
<span className="property-label">W</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={Math.round(selectedElement.width || selectedElement.fontSize || 0)}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleSizeChange(selectedElement.text ? 'fontSize' : 'width', e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{selectedElement.type !== 'text' && (
|
|
||||||
<div className="property-input">
|
|
||||||
<span className="property-label">H</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={Math.round(selectedElement.height || 0)}
|
|
||||||
onChange={(e) => handleSizeChange('height', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="property-group">
|
|
||||||
<label>Rotation: {Math.round(selectedElement.rotation || 0)}°</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="360"
|
|
||||||
value={selectedElement.rotation || 0}
|
|
||||||
onChange={(e) => handleRotationChange(e.target.value)}
|
|
||||||
className="rotation-slider"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedElement.type === 'image' && (
|
|
||||||
<BackgroundRemovalButton
|
|
||||||
selectedElement={selectedElement}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button className="delete-btn" onClick={() => onDelete(selectedElement.id)}>
|
|
||||||
Delete Element
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { PropertiesPanel } from './PropertiesPanel';
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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 (
|
|
||||||
<div className="bg-removal-container">
|
|
||||||
<button
|
|
||||||
className="bg-removal-btn"
|
|
||||||
onClick={handleRemoveBackground}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<div className="spinner-small" />
|
|
||||||
{progress > 0 ? `Loading: ${progress}%` : 'Removing Background...'}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>✨ Remove Background</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{!hasModel && (
|
|
||||||
<p className="bg-removal-hint">
|
|
||||||
First use requires downloading ~170MB model. Subsequent uses are cached.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { UploadTab } from './UploadTab';
|
|
||||||
import { StickersTab } from './StickersTab';
|
|
||||||
import { TextTab } from './TextTab';
|
|
||||||
import { TemplatesTab } from './TemplatesTab';
|
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ id: 'upload', label: 'Upload', icon: '📁' },
|
|
||||||
{ id: 'stickers', label: 'Stickers', icon: '🎨' },
|
|
||||||
{ id: 'text', label: 'Text', icon: '📝' },
|
|
||||||
{ id: 'templates', label: 'Templates', icon: '📋' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, onSlotImageUpload }) {
|
|
||||||
const [activeTab, setActiveTab] = useState('upload');
|
|
||||||
|
|
||||||
const renderTabContent = () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'upload':
|
|
||||||
return <UploadTab onAddImage={onAddImage} />;
|
|
||||||
case 'stickers':
|
|
||||||
return <StickersTab onAddSticker={onAddSticker} />;
|
|
||||||
case 'text':
|
|
||||||
return <TextTab onAddText={onAddText} />;
|
|
||||||
case 'templates':
|
|
||||||
return <TemplatesTab onAddTemplate={onAddTemplate} onSlotImageUpload={onSlotImageUpload} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sidebar">
|
|
||||||
{/* Tab headers */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
borderBottom: `1px solid var(--border)`,
|
|
||||||
background: 'var(--bg-primary)',
|
|
||||||
}}>
|
|
||||||
{TABS.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '12px 8px',
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: activeTab === tab.id ? '600' : '400',
|
|
||||||
color: activeTab === tab.id ? 'var(--accent)' : 'var(--text-secondary)',
|
|
||||||
borderBottom: activeTab === tab.id ? `2px solid var(--accent)` : '2px solid transparent',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: '16px', marginBottom: '2px' }}>{tab.icon}</div>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
<div style={{
|
|
||||||
flex: 1,
|
|
||||||
overflow: 'auto',
|
|
||||||
padding: '1rem',
|
|
||||||
}}>
|
|
||||||
{renderTabContent()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { STICKERS, STICKER_CATEGORIES } from '../../constants/stickers';
|
|
||||||
|
|
||||||
export function StickersTab({ onAddSticker }) {
|
|
||||||
const [activeCategory, setActiveCategory] = useState('all');
|
|
||||||
|
|
||||||
const categories = ['all', ...STICKER_CATEGORIES];
|
|
||||||
|
|
||||||
const filteredStickers = activeCategory === 'all'
|
|
||||||
? STICKERS
|
|
||||||
: STICKERS.filter(s => s.category === activeCategory);
|
|
||||||
|
|
||||||
const handleAddSticker = (emoji) => {
|
|
||||||
// Create a canvas element with the emoji
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const size = 100;
|
|
||||||
canvas.width = size;
|
|
||||||
canvas.height = size;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
ctx.font = `${size * 0.8}px Arial`;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(emoji, size / 2, size / 2);
|
|
||||||
|
|
||||||
const dataUrl = canvas.toDataURL('image/png');
|
|
||||||
|
|
||||||
onAddSticker({
|
|
||||||
type: 'sticker',
|
|
||||||
x: 125,
|
|
||||||
y: 125,
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
rotation: 0,
|
|
||||||
src: dataUrl,
|
|
||||||
emoji,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>
|
|
||||||
Stickers
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Category pills */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '6px',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat}
|
|
||||||
onClick={() => setActiveCategory(cat)}
|
|
||||||
style={{
|
|
||||||
padding: '6px 12px',
|
|
||||||
border: `1px solid ${activeCategory === cat ? 'var(--accent)' : 'var(--border)'}`,
|
|
||||||
borderRadius: 'var(--radius-xl)',
|
|
||||||
background: activeCategory === cat ? 'var(--accent)' : 'var(--bg-primary)',
|
|
||||||
color: activeCategory === cat ? '#fff' : 'var(--text-secondary)',
|
|
||||||
fontSize: '11px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sticker grid */}
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(5, 1fr)',
|
|
||||||
gap: '8px',
|
|
||||||
}}>
|
|
||||||
{filteredStickers.map((sticker, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={() => handleAddSticker(sticker.emoji)}
|
|
||||||
style={{
|
|
||||||
aspectRatio: '1',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
background: 'var(--bg-primary)',
|
|
||||||
fontSize: '28px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.target.style.background = 'var(--accent-bg)';
|
|
||||||
e.target.style.transform = 'scale(1.1)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.target.style.background = 'var(--bg-primary)';
|
|
||||||
e.target.style.transform = 'scale(1)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sticker.emoji}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
|
|
||||||
|
|
||||||
// Helper to get emoji for category
|
|
||||||
function getCategoryEmoji(category) {
|
|
||||||
const emojis = {
|
|
||||||
Sports: '⚽',
|
|
||||||
Music: '🎸',
|
|
||||||
Quotes: '💬',
|
|
||||||
Animals: '🐱',
|
|
||||||
Abstract: '🌈',
|
|
||||||
Vintage: '🏅',
|
|
||||||
Nature: '🏔️',
|
|
||||||
Tech: '💻',
|
|
||||||
};
|
|
||||||
return emojis[category] || '🎨';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
|
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
|
|
||||||
const [uploadSlotId, setUploadSlotId] = useState(null);
|
|
||||||
|
|
||||||
const templates = [
|
|
||||||
{
|
|
||||||
id: 'freeform',
|
|
||||||
name: 'Freeform',
|
|
||||||
description: 'No template - design freely',
|
|
||||||
thumbnail: '🎨',
|
|
||||||
},
|
|
||||||
...TEMPLATES.map(t => ({
|
|
||||||
id: t.id,
|
|
||||||
name: t.name,
|
|
||||||
description: t.description,
|
|
||||||
thumbnail: getCategoryEmoji(t.category),
|
|
||||||
hasSlots: !!t.slots,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSelectTemplate = (template) => {
|
|
||||||
setSelectedTemplateId(template.id);
|
|
||||||
onAddTemplate(template.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSlotClick = (slotId) => {
|
|
||||||
setUploadSlotId(slotId);
|
|
||||||
// Trigger file input click
|
|
||||||
document.getElementById('slot-file-input')?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = (e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file && uploadSlotId) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
onSlotImageUpload?.(uploadSlotId, event.target.result);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
e.target.value = '';
|
|
||||||
setUploadSlotId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get slots for selected template
|
|
||||||
const selectedTemplate = TEMPLATES.find(t => t.id === selectedTemplateId);
|
|
||||||
const slots = selectedTemplate?.slots || [];
|
|
||||||
const templates = [
|
|
||||||
{
|
|
||||||
id: 'freeform',
|
|
||||||
name: 'Freeform',
|
|
||||||
description: 'No template - design freely',
|
|
||||||
thumbnail: '🎨',
|
|
||||||
},
|
|
||||||
...TEMPLATES.map(t => ({
|
|
||||||
id: t.id,
|
|
||||||
name: t.name,
|
|
||||||
description: t.description,
|
|
||||||
thumbnail: getCategoryEmoji(t.category),
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSelectTemplate = (template) => {
|
|
||||||
onAddTemplate(template.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hidden file input for slot image uploads
|
|
||||||
const renderFileInput = () => (
|
|
||||||
<input
|
|
||||||
id="slot-file-input"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render slot upload buttons for template with slots
|
|
||||||
const renderSlotUploads = () => {
|
|
||||||
if (!selectedTemplateId || selectedTemplateId === 'freeform' || slots.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
marginTop: '1rem',
|
|
||||||
padding: '1rem',
|
|
||||||
background: 'var(--bg-tertiary)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}>
|
|
||||||
<h4 style={{
|
|
||||||
margin: '0 0 0.75rem 0',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}>
|
|
||||||
Template Slots
|
|
||||||
</h4>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.5rem',
|
|
||||||
}}>
|
|
||||||
{slots.map((slot) => (
|
|
||||||
<button
|
|
||||||
key={slot.id}
|
|
||||||
onClick={() => handleSlotClick(slot.id)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.5rem',
|
|
||||||
padding: '0.5rem 0.75rem',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
background: 'var(--bg-primary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.target.style.borderColor = 'var(--accent)';
|
|
||||||
e.target.style.background = 'var(--bg-secondary)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.target.style.borderColor = 'var(--border)';
|
|
||||||
e.target.style.background = 'var(--bg-primary)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '16px' }}>📷</span>
|
|
||||||
<span>{slot.label}</span>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
marginLeft: 'auto',
|
|
||||||
}}>
|
|
||||||
{slot.bounds.width}×{slot.bounds.height}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{renderFileInput()}
|
|
||||||
|
|
||||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>
|
|
||||||
Templates
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
}}>
|
|
||||||
Choose a template to get started or design freely.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.75rem',
|
|
||||||
}}>
|
|
||||||
{templates.map((template) => (
|
|
||||||
<button
|
|
||||||
key={template.id}
|
|
||||||
onClick={() => handleSelectTemplate(template)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.75rem',
|
|
||||||
padding: '0.75rem',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
background: template.id === selectedTemplateId ? 'var(--bg-secondary)' : 'var(--bg-primary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
opacity: 1,
|
|
||||||
textAlign: 'left',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'var(--accent)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'var(--border)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: '48px',
|
|
||||||
height: '48px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'var(--bg-tertiary)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: '24px',
|
|
||||||
}}>
|
|
||||||
{template.thumbnail}
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}>
|
|
||||||
{template.name}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
{template.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{template.hasSlots && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
padding: '2px 6px',
|
|
||||||
background: 'var(--accent)',
|
|
||||||
color: '#fff',
|
|
||||||
borderRadius: 'var(--radius-xs)',
|
|
||||||
fontWeight: '600',
|
|
||||||
}}>
|
|
||||||
SLOTS
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderSlotUploads()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { FONTS } from '../../constants/fonts';
|
|
||||||
|
|
||||||
export function TextTab({ onAddText }) {
|
|
||||||
const [text, setText] = useState('Your text here');
|
|
||||||
const [fontFamily, setFontFamily] = useState('Roboto');
|
|
||||||
const [fontSize, setFontSize] = useState(48);
|
|
||||||
const [fill, setFill] = useState('#0f172a');
|
|
||||||
|
|
||||||
const handleAddText = () => {
|
|
||||||
onAddText({
|
|
||||||
type: 'text',
|
|
||||||
x: 150,
|
|
||||||
y: 150,
|
|
||||||
text,
|
|
||||||
fontFamily,
|
|
||||||
fontSize,
|
|
||||||
fill,
|
|
||||||
rotation: 0,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>
|
|
||||||
Add Text
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Text input */}
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Text Content
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
|
||||||
border: `1px solid var(--border)`,
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontFamily: 'var(--font-body)',
|
|
||||||
resize: 'vertical',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Font selector */}
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Font
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={fontFamily}
|
|
||||||
onChange={(e) => setFontFamily(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
|
||||||
border: `1px solid var(--border)`,
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
fontSize: '13px',
|
|
||||||
fontFamily,
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: 'var(--bg-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{FONTS.map((font) => (
|
|
||||||
<option key={font.family} value={font.family}>
|
|
||||||
{font.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Font size */}
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Font Size: {fontSize}px
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="12"
|
|
||||||
max="120"
|
|
||||||
value={fontSize}
|
|
||||||
onChange={(e) => setFontSize(parseInt(e.target.value, 10))}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Color picker */}
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Color
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={fill}
|
|
||||||
onChange={(e) => setFill(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
border: `1px solid var(--border)`,
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '2px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={fill}
|
|
||||||
onChange={(e) => setFill(e.target.value)}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '0.75rem',
|
|
||||||
border: `1px solid var(--border)`,
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
fontSize: '13px',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<div style={{
|
|
||||||
padding: '1rem',
|
|
||||||
background: 'var(--bg-primary)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
fontFamily,
|
|
||||||
fontSize: `${fontSize * 0.5}px`,
|
|
||||||
color: fill,
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
}}>
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Text button */}
|
|
||||||
<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',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.target.style.background = 'var(--accent-hover)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.target.style.background = 'var(--accent)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Text to Canvas
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { useRef, useState } from 'react';
|
|
||||||
|
|
||||||
export function UploadTab({ onAddImage }) {
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
|
|
||||||
const handleFiles = async (files) => {
|
|
||||||
const file = files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
|
||||||
if (!validTypes.includes(file.type)) {
|
|
||||||
alert('Please upload a JPEG, PNG, or WebP image');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (20MB)
|
|
||||||
if (file.size > 20 * 1024 * 1024) {
|
|
||||||
alert('File size must be under 20MB');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('image', file);
|
|
||||||
|
|
||||||
const response = await fetch('/api/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Add the uploaded image to canvas (use preview for canvas)
|
|
||||||
onAddImage({
|
|
||||||
type: 'image',
|
|
||||||
x: 75,
|
|
||||||
y: 75,
|
|
||||||
width: 150,
|
|
||||||
height: 150,
|
|
||||||
rotation: 0,
|
|
||||||
src: data.preview.url,
|
|
||||||
originalUrl: data.original.url,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
alert('Failed to upload image. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
handleFiles(e.dataTransfer.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = (e) => {
|
|
||||||
handleFiles(e.target.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>
|
|
||||||
Upload Image
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div
|
|
||||||
onClick={handleClick}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
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)',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: '32px', marginBottom: '0.5rem' }}>📁</div>
|
|
||||||
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>
|
|
||||||
Click to upload or drag and drop
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
|
|
||||||
JPEG, PNG, WebP (max 20MB)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isUploading && (
|
|
||||||
<div style={{
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: 'var(--accent-bg)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: '12px',
|
|
||||||
color: 'var(--accent)',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}>
|
|
||||||
Uploading...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
marginTop: '1rem',
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: 'var(--bg-primary)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
}}>
|
|
||||||
<strong>Tip:</strong> After uploading, you can remove the background from your image using the background removal tool.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { useDesignEditor } from './useDesignEditor';
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
||||||
|
|
||||||
const MAX_HISTORY = 50;
|
|
||||||
const DEBOUNCE_DELAY_MS = 300;
|
|
||||||
|
|
||||||
export function useDesignEditor() {
|
|
||||||
const [elements, setElements] = useState([]);
|
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
|
||||||
|
|
||||||
// History for undo/redo
|
|
||||||
const historyRef = useRef([]);
|
|
||||||
const historyIndexRef = useRef(-1);
|
|
||||||
|
|
||||||
// Debounce timer for rapid changes (drag/transform)
|
|
||||||
const historyTimerRef = useRef(null);
|
|
||||||
const pendingChangesRef = useRef(null);
|
|
||||||
|
|
||||||
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++;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Flush pending changes to history
|
|
||||||
const flushPendingChanges = useCallback(() => {
|
|
||||||
if (pendingChangesRef.current) {
|
|
||||||
saveToHistory(pendingChangesRef.current);
|
|
||||||
pendingChangesRef.current = null;
|
|
||||||
}
|
|
||||||
if (historyTimerRef.current) {
|
|
||||||
clearTimeout(historyTimerRef.current);
|
|
||||||
historyTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}, [saveToHistory]);
|
|
||||||
|
|
||||||
// Cleanup timer on unmount
|
|
||||||
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) => {
|
|
||||||
// Flush any pending debounced changes first
|
|
||||||
flushPendingChanges();
|
|
||||||
|
|
||||||
const newElement = {
|
|
||||||
...element,
|
|
||||||
id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
setElements((prev) => {
|
|
||||||
const newElements = [...prev, newElement];
|
|
||||||
saveToHistory(newElements);
|
|
||||||
return newElements;
|
|
||||||
});
|
|
||||||
|
|
||||||
setSelectedId(newElement.id);
|
|
||||||
return newElement.id;
|
|
||||||
}, [flushPendingChanges, saveToHistory]);
|
|
||||||
|
|
||||||
const updateElement = useCallback((id, attrs) => {
|
|
||||||
setElements((prev) => {
|
|
||||||
const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
|
|
||||||
|
|
||||||
// Debounce history commits for rapid changes (drag/transform)
|
|
||||||
// Store pending changes but don't commit yet
|
|
||||||
pendingChangesRef.current = newElements;
|
|
||||||
|
|
||||||
// Clear existing timer
|
|
||||||
if (historyTimerRef.current) {
|
|
||||||
clearTimeout(historyTimerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set timer to commit changes after delay
|
|
||||||
historyTimerRef.current = setTimeout(() => {
|
|
||||||
flushPendingChanges();
|
|
||||||
}, DEBOUNCE_DELAY_MS);
|
|
||||||
|
|
||||||
return newElements;
|
|
||||||
});
|
|
||||||
}, [flushPendingChanges]);
|
|
||||||
|
|
||||||
const deleteElement = useCallback((id) => {
|
|
||||||
// Flush any pending debounced changes first
|
|
||||||
flushPendingChanges();
|
|
||||||
|
|
||||||
setElements((prev) => {
|
|
||||||
const newElements = prev.filter((el) => el.id !== id);
|
|
||||||
saveToHistory(newElements);
|
|
||||||
return newElements;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selectedId === id) {
|
|
||||||
setSelectedId(null);
|
|
||||||
}
|
|
||||||
}, [selectedId, flushPendingChanges, saveToHistory]);
|
|
||||||
|
|
||||||
const selectElement = useCallback((id) => {
|
|
||||||
setSelectedId(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const deselectAll = useCallback(() => {
|
|
||||||
setSelectedId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reorderElement = useCallback((id, newOrder) => {
|
|
||||||
// Flush any pending debounced changes first
|
|
||||||
flushPendingChanges();
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}, [flushPendingChanges, saveToHistory]);
|
|
||||||
|
|
||||||
// Commit history immediately (called on dragEnd/transformEnd)
|
|
||||||
const commitHistory = useCallback(() => {
|
|
||||||
flushPendingChanges();
|
|
||||||
}, [flushPendingChanges]);
|
|
||||||
|
|
||||||
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 {
|
|
||||||
elements,
|
|
||||||
selectedId,
|
|
||||||
addElement,
|
|
||||||
updateElement,
|
|
||||||
deleteElement,
|
|
||||||
selectElement,
|
|
||||||
deselectAll,
|
|
||||||
reorderElement,
|
|
||||||
commitHistory, // Call this on dragEnd/transformEnd to commit debounced changes
|
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
canUndo,
|
|
||||||
canRedo,
|
|
||||||
initializeHistory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate auto-crop for fitting an image into a slot
|
|
||||||
* Implements object-fit: cover behavior
|
|
||||||
*
|
|
||||||
* @param {Object} imageSize - Original image dimensions {width, height}
|
|
||||||
* @param {Object} slotSize - Slot dimensions {width, height}
|
|
||||||
* @returns {Object} Crop coordinates {sx, sy, sWidth, sHeight}
|
|
||||||
*/
|
|
||||||
export function calculateAutoCrop(imageSize, slotSize) {
|
|
||||||
const imageRatio = imageSize.width / imageSize.height;
|
|
||||||
const slotRatio = slotSize.width / slotSize.height;
|
|
||||||
|
|
||||||
let sx, sy, sWidth, sHeight;
|
|
||||||
|
|
||||||
if (imageRatio > slotRatio) {
|
|
||||||
// Image is wider than slot - crop sides
|
|
||||||
sHeight = imageSize.height;
|
|
||||||
sWidth = imageSize.height * slotRatio;
|
|
||||||
sx = (imageSize.width - sWidth) / 2;
|
|
||||||
sy = 0;
|
|
||||||
} else {
|
|
||||||
// Image is taller than slot - crop top/bottom
|
|
||||||
sWidth = imageSize.width;
|
|
||||||
sHeight = imageSize.width / slotRatio;
|
|
||||||
sx = 0;
|
|
||||||
sy = (imageSize.height - sHeight) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sx, sy, sWidth, sHeight };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a drag bound function that constrains movement to slot bounds
|
|
||||||
*
|
|
||||||
* @param {Object} slot - Slot configuration with bounds
|
|
||||||
* @param {Object} elementSize - Element dimensions {width, height}
|
|
||||||
* @returns {Function} Drag bound function for Konva
|
|
||||||
*/
|
|
||||||
export function createDragBoundFunc(slot, elementSize) {
|
|
||||||
const { bounds } = slot;
|
|
||||||
const minX = bounds.x;
|
|
||||||
const minY = bounds.y;
|
|
||||||
const maxX = bounds.x + bounds.width - elementSize.width;
|
|
||||||
const maxY = bounds.y + bounds.height - elementSize.height;
|
|
||||||
|
|
||||||
return (oldBox, newBox) => {
|
|
||||||
return {
|
|
||||||
x: Math.max(minX, Math.min(newBox.x, maxX)),
|
|
||||||
y: Math.max(minY, Math.min(newBox.y, maxY)),
|
|
||||||
width: newBox.width,
|
|
||||||
height: newBox.height,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useTemplate - Hook for managing template state and slot operations
|
|
||||||
*
|
|
||||||
* @param {Array} templates - Available templates
|
|
||||||
* @returns {Object} Template management functions and state
|
|
||||||
*/
|
|
||||||
export function useTemplate(templates = []) {
|
|
||||||
const [currentTemplateId, setCurrentTemplateId] = useState(null);
|
|
||||||
const [assignedSlots, setAssignedSlots] = useState({});
|
|
||||||
const templateRef = useRef(null);
|
|
||||||
|
|
||||||
// Get current template object
|
|
||||||
const currentTemplate = templates.find(t => t.id === currentTemplateId) || null;
|
|
||||||
|
|
||||||
// Get slots for current template
|
|
||||||
const getSlots = useCallback(() => {
|
|
||||||
if (!currentTemplate || !currentTemplate.slots) return [];
|
|
||||||
return currentTemplate.slots;
|
|
||||||
}, [currentTemplate]);
|
|
||||||
|
|
||||||
// Load a template
|
|
||||||
const loadTemplate = useCallback((templateId) => {
|
|
||||||
const template = templates.find(t => t.id === templateId);
|
|
||||||
if (template) {
|
|
||||||
setCurrentTemplateId(templateId);
|
|
||||||
setAssignedSlots({});
|
|
||||||
templateRef.current = template;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [templates]);
|
|
||||||
|
|
||||||
// Clear template (freeform mode)
|
|
||||||
const clearTemplate = useCallback(() => {
|
|
||||||
setCurrentTemplateId(null);
|
|
||||||
setAssignedSlots({});
|
|
||||||
templateRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Assign image to slot
|
|
||||||
const assignImageToSlot = useCallback((slotId, imageData) => {
|
|
||||||
const slots = getSlots();
|
|
||||||
const slot = slots.find(s => s.id === slotId);
|
|
||||||
|
|
||||||
if (!slot) {
|
|
||||||
console.error(`Slot ${slotId} not found`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load image to get dimensions
|
|
||||||
const img = new Image();
|
|
||||||
img.src = imageData;
|
|
||||||
|
|
||||||
const elementData = {
|
|
||||||
type: 'image',
|
|
||||||
src: imageData,
|
|
||||||
x: slot.bounds.x,
|
|
||||||
y: slot.bounds.y,
|
|
||||||
width: slot.bounds.width,
|
|
||||||
height: slot.bounds.height,
|
|
||||||
slotId,
|
|
||||||
// Will be set after image loads
|
|
||||||
crop: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate crop once image is loaded
|
|
||||||
img.onload = () => {
|
|
||||||
const crop = calculateAutoCrop(
|
|
||||||
{ width: img.width, height: img.height },
|
|
||||||
{ width: slot.bounds.width, height: slot.bounds.height }
|
|
||||||
);
|
|
||||||
elementData.crop = crop;
|
|
||||||
};
|
|
||||||
|
|
||||||
setAssignedSlots(prev => ({
|
|
||||||
...prev,
|
|
||||||
[slotId]: elementData,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return elementData;
|
|
||||||
}, [getSlots]);
|
|
||||||
|
|
||||||
// Get assigned slot data
|
|
||||||
const getAssignedSlot = useCallback((slotId) => {
|
|
||||||
return assignedSlots[slotId] || null;
|
|
||||||
}, [assignedSlots]);
|
|
||||||
|
|
||||||
// Remove image from slot
|
|
||||||
const removeFromSlot = useCallback((slotId) => {
|
|
||||||
setAssignedSlots(prev => {
|
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[slotId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get drag bound function for a slot
|
|
||||||
const getDragBoundFunc = useCallback((slotId, elementSize) => {
|
|
||||||
const slot = getSlots().find(s => s.id === slotId);
|
|
||||||
if (!slot) return null;
|
|
||||||
return createDragBoundFunc(slot, elementSize);
|
|
||||||
}, [getSlots]);
|
|
||||||
|
|
||||||
// Check if slot is filled
|
|
||||||
const isSlotFilled = useCallback((slotId) => {
|
|
||||||
return !!assignedSlots[slotId];
|
|
||||||
}, [assignedSlots]);
|
|
||||||
|
|
||||||
// Get all assigned elements
|
|
||||||
const getAssignedElements = useCallback(() => {
|
|
||||||
return Object.values(assignedSlots);
|
|
||||||
}, [assignedSlots]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
currentTemplateId,
|
|
||||||
currentTemplate,
|
|
||||||
assignedSlots,
|
|
||||||
|
|
||||||
// Template management
|
|
||||||
loadTemplate,
|
|
||||||
clearTemplate,
|
|
||||||
getSlots,
|
|
||||||
|
|
||||||
// Slot operations
|
|
||||||
assignImageToSlot,
|
|
||||||
getAssignedSlot,
|
|
||||||
removeFromSlot,
|
|
||||||
isSlotFilled,
|
|
||||||
getAssignedElements,
|
|
||||||
|
|
||||||
// Constraints
|
|
||||||
getDragBoundFunc,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
1035
client/src/index.css
@@ -1,160 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
VitePWA({
|
|
||||||
registerType: 'prompt',
|
|
||||||
includeAssets: ['favicon.ico', 'pwa-192x192.svg', 'pwa-512x512.svg'],
|
|
||||||
manifest: {
|
|
||||||
name: 'Apparel Designer',
|
|
||||||
short_name: 'ApparelDesigner',
|
|
||||||
description: 'T-shirt customization editor',
|
|
||||||
theme_color: '#38bdf8',
|
|
||||||
background_color: '#ffffff',
|
|
||||||
display: 'standalone',
|
|
||||||
orientation: 'any',
|
|
||||||
scope: '/',
|
|
||||||
start_url: '/',
|
|
||||||
icons: [
|
|
||||||
{
|
|
||||||
src: 'pwa-192x192.svg',
|
|
||||||
sizes: '192x192',
|
|
||||||
type: 'image/svg+xml',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'pwa-512x512.svg',
|
|
||||||
sizes: '512x512',
|
|
||||||
type: 'image/svg+xml',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'pwa-512x512.svg',
|
|
||||||
sizes: '512x512',
|
|
||||||
type: 'image/svg+xml',
|
|
||||||
purpose: 'any maskable',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
workbox: {
|
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
|
||||||
runtimeCaching: [
|
|
||||||
{
|
|
||||||
urlPattern: /^https:\/\/cdn\.huggingface\.co\/.*/i,
|
|
||||||
handler: 'CacheFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'transformers-models',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 10,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
|
||||||
},
|
|
||||||
cacheableResponse: {
|
|
||||||
statuses: [0, 200],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// HuggingFace LFS (Large File Storage) for model weights
|
|
||||||
{
|
|
||||||
urlPattern: /^https:\/\/cdn-lfs\.huggingface\.co\/.*/i,
|
|
||||||
handler: 'CacheFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'transformers-lfs',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 10,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
|
||||||
},
|
|
||||||
cacheableResponse: {
|
|
||||||
statuses: [0, 200],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
urlPattern: /^\/api\/uploads\/.*/i,
|
|
||||||
handler: 'CacheFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'uploaded-images',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 50,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 7,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Template data caching
|
|
||||||
{
|
|
||||||
urlPattern: /^\/api\/templates\/.*/i,
|
|
||||||
handler: 'StaleWhileRevalidate',
|
|
||||||
options: {
|
|
||||||
cacheName: 'template-data',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 20,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 7,
|
|
||||||
},
|
|
||||||
cacheableResponse: {
|
|
||||||
statuses: [0, 200],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// API responses caching
|
|
||||||
{
|
|
||||||
urlPattern: /^\/api\/.*/i,
|
|
||||||
handler: 'NetworkFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'api-responses',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 50,
|
|
||||||
maxAgeSeconds: 300,
|
|
||||||
},
|
|
||||||
cacheableResponse: {
|
|
||||||
statuses: [0, 200],
|
|
||||||
},
|
|
||||||
networkTimeoutSeconds: 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
|
||||||
handler: 'StaleWhileRevalidate',
|
|
||||||
options: {
|
|
||||||
cacheName: 'google-fonts',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 10,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 365,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
|
||||||
handler: 'CacheFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'gstatic-fonts',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 10,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 365,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
server: {
|
|
||||||
port: 3000,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/uploads': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/exports': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,26 +1,15 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
apparel-designer:
|
apparel-designer:
|
||||||
build:
|
build: { context: ., dockerfile: Dockerfile }
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: apparel-designer
|
container_name: apparel-designer
|
||||||
ports:
|
ports: ["3001:3001"]
|
||||||
- "3001:3001"
|
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/server/uploads
|
- uploads_data:/app/uploads
|
||||||
- exports_data:/app/server/exports
|
- exports_data:/app/exports
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { defineConfig, globalIgnores } from 'eslint/config'
|
|||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx}'],
|
files: ['src/**/*.{js,jsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs.flat.recommended,
|
||||||
@@ -23,7 +23,19 @@ export default defineConfig([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['server.js'],
|
||||||
|
extends: [js.configs.recommended],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.node,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -5,11 +5,9 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Apparel Designer</title>
|
<title>Apparel Designer</title>
|
||||||
<!-- Google Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=Open+Sans:wght@400;600;700&family=Lato:wght@400;700&family=Montserrat:wght@400;600;700&family=Oswald:wght@400;500;700&family=Raleway:wght@400;600;700&family=Poppins:wght@400;500;600;700&family=Roboto+Condensed:wght@400;700&family=Source+Sans+3:wght@400;600;700&family=Roboto+Slab:wght@400;700&family=Merriweather:wght@400;700&family=Ubuntu:wght@400;500;700&family=Playfair+Display:wght@400;600;700&family=Nunito:wght@400;600;700&family=Rubik:wght@400;500;600;700&family=Work+Sans:wght@400;500;600;700&family=Lora:wght@400;500;600;700&family=Fira+Sans:wght@400;500;600;700&family=Barlow:wght@400;500;600;700&family=Bebas+Neue&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=Open+Sans:wght@400;600;700&family=Lato:wght@400;700&family=Montserrat:wght@400;600;700&family=Oswald:wght@400;500;700&family=Raleway:wght@400;600;700&family=Poppins:wght@400;500;600;700&family=Roboto+Condensed:wght@400;700&family=Source+Sans+3:wght@400;600;700&family=Roboto+Slab:wght@400;700&family=Merriweather:wght@400;700&family=Ubuntu:wght@400;500;700&family=Playfair+Display:wght@400;600;700&family=Nunito:wght@400;600;700&family=Rubik:wght@400;500;600;700&family=Work+Sans:wght@400;500;600;700&family=Lora:wght@400;500;600;700&family=Fira+Sans:wght@400;500;600;700&family=Barlow:wght@400;500;600;700&family=Bebas+Neue&display=swap" rel="stylesheet">
|
||||||
<!-- UI Fonts -->
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
373
package-lock.json
generated
@@ -1,373 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "apparel-designer",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "apparel-designer",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"devDependencies": {
|
|
||||||
"concurrently": "^8.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/runtime": {
|
|
||||||
"version": "7.29.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
|
||||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ansi-regex": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ansi-styles": {
|
|
||||||
"version": "4.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-convert": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chalk": {
|
|
||||||
"version": "4.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
|
||||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^4.1.0",
|
|
||||||
"supports-color": "^7.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chalk/node_modules/supports-color": {
|
|
||||||
"version": "7.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
|
||||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"has-flag": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cliui": {
|
|
||||||
"version": "8.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
|
||||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"string-width": "^4.2.0",
|
|
||||||
"strip-ansi": "^6.0.1",
|
|
||||||
"wrap-ansi": "^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-convert": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "~1.1.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=7.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-name": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/concurrently": {
|
|
||||||
"version": "8.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
|
||||||
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chalk": "^4.1.2",
|
|
||||||
"date-fns": "^2.30.0",
|
|
||||||
"lodash": "^4.17.21",
|
|
||||||
"rxjs": "^7.8.1",
|
|
||||||
"shell-quote": "^1.8.1",
|
|
||||||
"spawn-command": "0.0.2",
|
|
||||||
"supports-color": "^8.1.1",
|
|
||||||
"tree-kill": "^1.2.2",
|
|
||||||
"yargs": "^17.7.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"conc": "dist/bin/concurrently.js",
|
|
||||||
"concurrently": "dist/bin/concurrently.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^14.13.0 || >=16.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/date-fns": {
|
|
||||||
"version": "2.30.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
|
||||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.21.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.11"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/date-fns"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/emoji-regex": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/escalade": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-caller-file": {
|
|
||||||
"version": "2.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-flag": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-fullwidth-code-point": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lodash": {
|
|
||||||
"version": "4.18.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/require-directory": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rxjs": {
|
|
||||||
"version": "7.8.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
|
||||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/shell-quote": {
|
|
||||||
"version": "1.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
|
||||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/spawn-command": {
|
|
||||||
"version": "0.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
|
|
||||||
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/string-width": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"emoji-regex": "^8.0.0",
|
|
||||||
"is-fullwidth-code-point": "^3.0.0",
|
|
||||||
"strip-ansi": "^6.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strip-ansi": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/supports-color": {
|
|
||||||
"version": "8.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
|
||||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"has-flag": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tree-kill": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"tree-kill": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^4.0.0",
|
|
||||||
"string-width": "^4.1.0",
|
|
||||||
"strip-ansi": "^6.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/y18n": {
|
|
||||||
"version": "5.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
|
||||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yargs": {
|
|
||||||
"version": "17.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
|
||||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cliui": "^8.0.1",
|
|
||||||
"escalade": "^3.1.1",
|
|
||||||
"get-caller-file": "^2.0.5",
|
|
||||||
"require-directory": "^2.1.1",
|
|
||||||
"string-width": "^4.2.3",
|
|
||||||
"y18n": "^5.0.5",
|
|
||||||
"yargs-parser": "^21.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yargs-parser": {
|
|
||||||
"version": "21.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
|
||||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
29
package.json
@@ -4,21 +4,27 @@
|
|||||||
"description": "T-shirt customization editor with background removal, stickers, text, and export",
|
"description": "T-shirt customization editor with background removal, stickers, text, and export",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server/index.js",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
|
"dev": "concurrently \"vite\" \"DYLD_INSERT_LIBRARIES='' node --watch server.js\"",
|
||||||
"dev:client": "cd client && vite",
|
"dev:win": "concurrently \"vite\" \"node --watch server.js\"",
|
||||||
"dev:server": "node --watch server/index.js",
|
"build": "vite build",
|
||||||
"build": "cd client && vite build",
|
"start": "node server.js",
|
||||||
"start": "node server/index.js",
|
"lint": "eslint .",
|
||||||
"install:all": "npm install && cd client && npm install"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@huggingface/transformers": "^3.4.0",
|
||||||
"canvas": "^2.11.2",
|
"canvas": "^2.11.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"konva": "^10.0.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"react-filerobot-image-editor": "^4.8.1",
|
||||||
|
"react-konva": "^19.2.3",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
|
"use-image": "^1.1.1",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -33,14 +39,7 @@
|
|||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"vite": "^8.0.9",
|
"vite": "^8.0.9",
|
||||||
"vite-plugin-pwa": "^0.20.5",
|
"vite-plugin-pwa": "^0.20.5",
|
||||||
"workbox-window": "^7.1.0",
|
"workbox-window": "^7.1.0"
|
||||||
"react": "^19.2.5",
|
|
||||||
"react-dom": "^19.2.5",
|
|
||||||
"react-konva": "^18.2.10",
|
|
||||||
"konva": "^9.3.18",
|
|
||||||
"use-image": "^1.1.1",
|
|
||||||
"@xenova/transformers": "^2.17.2",
|
|
||||||
"react-filerobot-image-editor": "^4.8.1"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
|
<path d="M16 4L4 12L10 16L10 40C10 41.1 10.9 42 12 42H36C37.1 42 38 41.1 38 40V16L44 12L32 4C32 4 29.5 8 24 8C18.5 8 16 4 16 4Z" fill="#38bdf8" stroke="#0ea5e9" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 4C16 4 18.5 8 24 8C29.5 8 32 4 32 4" fill="none" stroke="#0ea5e9" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 432 B |
1
public/icons.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"><!-- Project icon sprites --></svg>
|
||||||
|
After Width: | Height: | Size: 76 B |
1
public/pwa-192x192.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192"><rect width="192" height="192" fill="#38bdf8"/><rect x="24" y="24" width="144" height="144" rx="24" fill="#ffffff"/><text x="96" y="120" font-size="72" text-anchor="middle" fill="#38bdf8" font-family="Arial, sans-serif">T</text></svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
1
public/pwa-512x512.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#38bdf8"/><rect x="64" y="64" width="384" height="384" rx="64" fill="#ffffff"/><text x="256" y="310" font-size="192" text-anchor="middle" fill="#38bdf8" font-family="Arial, sans-serif">T</text></svg>
|
||||||
|
After Width: | Height: | Size: 324 B |
@@ -13,6 +13,7 @@ const __dirname = dirname(__filename);
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
// Ensure upload and export directories exist
|
// Ensure upload and export directories exist
|
||||||
const uploadsDir = join(__dirname, 'uploads');
|
const uploadsDir = join(__dirname, 'uploads');
|
||||||
@@ -21,8 +22,11 @@ const exportsDir = join(__dirname, 'exports');
|
|||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Middleware
|
// Middleware — restrict CORS in production
|
||||||
app.use(cors());
|
const corsOptions = IS_PRODUCTION
|
||||||
|
? { origin: process.env.CORS_ORIGIN || false }
|
||||||
|
: { origin: true };
|
||||||
|
app.use(cors(corsOptions));
|
||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
|
||||||
@@ -30,36 +34,32 @@ app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
|||||||
app.use('/uploads', express.static(uploadsDir));
|
app.use('/uploads', express.static(uploadsDir));
|
||||||
app.use('/exports', express.static(exportsDir));
|
app.use('/exports', express.static(exportsDir));
|
||||||
|
|
||||||
// Serve built client static files
|
// In production, serve the Vite-built client
|
||||||
const clientDistDir = join(__dirname, 'dist');
|
if (IS_PRODUCTION) {
|
||||||
if (existsSync(clientDistDir)) {
|
const clientDist = join(__dirname, 'dist');
|
||||||
app.use(express.static(clientDistDir));
|
app.use(express.static(clientDist));
|
||||||
|
}
|
||||||
|
|
||||||
// Serve index.html for all non-API routes (SPA routing)
|
// Map MIME types to file extensions
|
||||||
app.get('*', (req, res, next) => {
|
const MIME_TO_EXT = {
|
||||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/uploads') && !req.path.startsWith('/exports')) {
|
'image/jpeg': 'jpg',
|
||||||
res.sendFile(join(clientDistDir, 'index.html'));
|
'image/png': 'png',
|
||||||
} else {
|
'image/webp': 'webp',
|
||||||
next();
|
};
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure multer for image uploads
|
// Configure multer for image uploads
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (_req, _file, cb) => {
|
||||||
cb(null, uploadsDir);
|
cb(null, uploadsDir);
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (_req, file, cb) => {
|
||||||
const ext = file.originalname.split('.').pop();
|
const ext = MIME_TO_EXT[file.mimetype] || 'bin';
|
||||||
const filename = `${uuidv4()}.${ext}`;
|
cb(null, `${uuidv4()}.${ext}`);
|
||||||
cb(null, filename);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileFilter = (req, file, cb) => {
|
const fileFilter = (_req, file, cb) => {
|
||||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
if (MIME_TO_EXT[file.mimetype]) {
|
||||||
if (allowedTypes.includes(file.mimetype)) {
|
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Invalid file type. Only JPEG, PNG, and WebP are allowed.'), false);
|
cb(new Error('Invalid file type. Only JPEG, PNG, and WebP are allowed.'), false);
|
||||||
@@ -69,53 +69,45 @@ const fileFilter = (req, file, cb) => {
|
|||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
fileFilter,
|
fileFilter,
|
||||||
limits: {
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
fileSize: 20 * 1024 * 1024, // 20MB
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check endpoint
|
// ── API Routes ──────────────────────────────────────────────────────────────
|
||||||
app.get('/api/health', (req, res) => {
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload endpoint
|
// Upload
|
||||||
app.post('/api/upload', upload.single('image'), async (req, res) => {
|
app.post('/api/upload', upload.single('image'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalPath = req.file.path;
|
|
||||||
const originalUrl = `/uploads/${req.file.filename}`;
|
const originalUrl = `/uploads/${req.file.filename}`;
|
||||||
|
|
||||||
// Create preview by resizing to max 1000px
|
// Create preview by resizing to max 1000px
|
||||||
const previewFilename = req.file.filename.replace(/\.[^.]+$/, '.png');
|
const previewFilename = `${uuidv4()}.png`;
|
||||||
const previewPath = join(uploadsDir, 'preview', previewFilename);
|
|
||||||
|
|
||||||
// Ensure preview directory exists
|
|
||||||
const previewDir = join(uploadsDir, 'preview');
|
const previewDir = join(uploadsDir, 'preview');
|
||||||
if (!existsSync(previewDir)) mkdirSync(previewDir, { recursive: true });
|
if (!existsSync(previewDir)) mkdirSync(previewDir, { recursive: true });
|
||||||
|
|
||||||
await sharp(originalPath)
|
await sharp(req.file.path)
|
||||||
.resize({ width: 1000, height: 1000, fit: 'inside' })
|
.resize({ width: 1000, height: 1000, fit: 'inside' })
|
||||||
.png()
|
.png()
|
||||||
.toFile(previewPath);
|
.toFile(join(previewDir, previewFilename));
|
||||||
|
|
||||||
const previewUrl = `/uploads/preview/${previewFilename}`;
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
original: {
|
original: {
|
||||||
path: originalPath,
|
|
||||||
url: originalUrl,
|
url: originalUrl,
|
||||||
filename: req.file.filename,
|
filename: req.file.filename,
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
mimetype: req.file.mimetype,
|
mimetype: req.file.mimetype,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
path: previewPath,
|
url: `/uploads/preview/${previewFilename}`,
|
||||||
url: previewUrl,
|
|
||||||
filename: previewFilename,
|
filename: previewFilename,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -125,18 +117,7 @@ app.post('/api/upload', upload.single('image'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Error handling for multer
|
// High-resolution export (300×300 → 4500×4500 @ 300 DPI)
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
if (err instanceof multer.MulterError) {
|
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
||||||
return res.status(400).json({ error: 'File too large. Maximum size is 20MB.' });
|
|
||||||
}
|
|
||||||
return res.status(400).json({ error: err.message });
|
|
||||||
}
|
|
||||||
next(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// High-resolution export endpoint (300x300px -> 4500x4500px @ 300 DPI)
|
|
||||||
const EXPORT_SCALE = 15;
|
const EXPORT_SCALE = 15;
|
||||||
const EXPORT_SIZE = 4500;
|
const EXPORT_SIZE = 4500;
|
||||||
|
|
||||||
@@ -151,8 +132,8 @@ app.post('/api/export', async (req, res) => {
|
|||||||
const canvas = createCanvas(EXPORT_SIZE, EXPORT_SIZE);
|
const canvas = createCanvas(EXPORT_SIZE, EXPORT_SIZE);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
// Render template background layer first (if template active)
|
// Template background
|
||||||
if (template && template.background) {
|
if (template?.background) {
|
||||||
const bg = template.background;
|
const bg = template.background;
|
||||||
if (bg.type === 'color') {
|
if (bg.type === 'color') {
|
||||||
ctx.fillStyle = bg.color;
|
ctx.fillStyle = bg.color;
|
||||||
@@ -171,12 +152,11 @@ app.post('/api/export', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default white background
|
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
|
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to render a single element
|
// Render a single element
|
||||||
const renderElement = async (el) => {
|
const renderElement = async (el) => {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
|
|
||||||
@@ -186,7 +166,7 @@ app.post('/api/export', async (req, res) => {
|
|||||||
const centerY = y + ((el.height || el.fontSize || 100) * EXPORT_SCALE) / 2;
|
const centerY = y + ((el.height || el.fontSize || 100) * EXPORT_SCALE) / 2;
|
||||||
|
|
||||||
ctx.translate(centerX, centerY);
|
ctx.translate(centerX, centerY);
|
||||||
ctx.rotate((el.rotation || 0) * Math.PI / 180);
|
ctx.rotate(((el.rotation || 0) * Math.PI) / 180);
|
||||||
ctx.translate(-centerX, -centerY);
|
ctx.translate(-centerX, -centerY);
|
||||||
|
|
||||||
if (el.type === 'image' && el.src) {
|
if (el.type === 'image' && el.src) {
|
||||||
@@ -198,14 +178,8 @@ app.post('/api/export', async (req, res) => {
|
|||||||
const width = (el.width || 100) * EXPORT_SCALE;
|
const width = (el.width || 100) * EXPORT_SCALE;
|
||||||
const height = (el.height || 100) * EXPORT_SCALE;
|
const height = (el.height || 100) * EXPORT_SCALE;
|
||||||
|
|
||||||
// Apply crop if slot crop region specified
|
|
||||||
if (el.crop) {
|
if (el.crop) {
|
||||||
const { sx, sy, sWidth, sHeight } = el.crop;
|
ctx.drawImage(img, el.crop.sx, el.crop.sy, el.crop.sWidth, el.crop.sHeight, x, y, width, height);
|
||||||
ctx.drawImage(
|
|
||||||
img,
|
|
||||||
sx, sy, sWidth, sHeight, // source crop
|
|
||||||
x, y, width, height // destination
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
ctx.drawImage(img, x, y, width, height);
|
ctx.drawImage(img, x, y, width, height);
|
||||||
}
|
}
|
||||||
@@ -213,7 +187,7 @@ app.post('/api/export', async (req, res) => {
|
|||||||
console.error('Failed to load image for export:', imgError);
|
console.error('Failed to load image for export:', imgError);
|
||||||
}
|
}
|
||||||
} else if (el.type === 'text') {
|
} else if (el.type === 'text') {
|
||||||
const fontSize = (el.fontSize || 32) * EXPORT_SCALE / 32;
|
const fontSize = ((el.fontSize || 32) * EXPORT_SCALE) / 32;
|
||||||
ctx.font = `${fontSize}px "${el.fontFamily || 'Arial'}"`;
|
ctx.font = `${fontSize}px "${el.fontFamily || 'Arial'}"`;
|
||||||
ctx.fillStyle = el.fill || '#000000';
|
ctx.fillStyle = el.fill || '#000000';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
@@ -226,13 +200,12 @@ app.post('/api/export', async (req, res) => {
|
|||||||
|
|
||||||
// Render user elements
|
// Render user elements
|
||||||
for (const el of elements) {
|
for (const el of elements) {
|
||||||
// Skip non-printable elements (guides, watermarks, template-only layers)
|
|
||||||
if (el.nonPrintable) continue;
|
if (el.nonPrintable) continue;
|
||||||
await renderElement(el);
|
await renderElement(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render template overlay layer last (if template active)
|
// Render template overlay
|
||||||
if (template && template.overlay) {
|
if (template?.overlay) {
|
||||||
for (const overlayEl of template.overlay) {
|
for (const overlayEl of template.overlay) {
|
||||||
if (overlayEl.nonPrintable) continue;
|
if (overlayEl.nonPrintable) continue;
|
||||||
await renderElement(overlayEl);
|
await renderElement(overlayEl);
|
||||||
@@ -242,14 +215,12 @@ app.post('/api/export', async (req, res) => {
|
|||||||
// Save to file
|
// Save to file
|
||||||
const exportFilename = `${designName.replace(/[^a-z0-9]/gi, '_')}_${uuidv4()}.png`;
|
const exportFilename = `${designName.replace(/[^a-z0-9]/gi, '_')}_${uuidv4()}.png`;
|
||||||
const exportPath = join(exportsDir, exportFilename);
|
const exportPath = join(exportsDir, exportFilename);
|
||||||
const buffer = canvas.toBuffer('image/png');
|
writeFileSync(exportPath, canvas.toBuffer('image/png'));
|
||||||
writeFileSync(exportPath, buffer);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
export: {
|
export: {
|
||||||
url: `/exports/${exportFilename}`,
|
url: `/exports/${exportFilename}`,
|
||||||
path: exportPath,
|
|
||||||
filename: exportFilename,
|
filename: exportFilename,
|
||||||
width: EXPORT_SIZE,
|
width: EXPORT_SIZE,
|
||||||
height: EXPORT_SIZE,
|
height: EXPORT_SIZE,
|
||||||
@@ -263,8 +234,40 @@ app.post('/api/export', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Download
|
||||||
|
app.get('/api/download/:filename', (req, res) => {
|
||||||
|
const filePath = join(exportsDir, req.params.filename);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'File not found' });
|
||||||
|
}
|
||||||
|
res.download(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 404 catch-all (before SPA catch-all)
|
||||||
|
app.all('/api/*', (_req, res) => {
|
||||||
|
res.status(404).json({ error: 'API route not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// In production, SPA catch-all
|
||||||
|
if (IS_PRODUCTION) {
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(join(__dirname, 'dist', 'index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(400).json({ error: 'File too large. Maximum size is 20MB.' });
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', details: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server running on http://localhost:${PORT}`);
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
console.log(`Health check: http://localhost:${PORT}/api/health`);
|
console.log(`Mode: ${IS_PRODUCTION ? 'production' : 'development'}`);
|
||||||
console.log(`Upload endpoint: POST http://localhost:${PORT}/api/upload`);
|
|
||||||
});
|
});
|
||||||
1525
server/package-lock.json
generated
1
src/App.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* App-level styles — project styles go here */
|
||||||
138
src/App.jsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { DesignCanvas } from './components/canvas/DesignCanvas';
|
||||||
|
import { Sidebar } from './components/sidebar/Sidebar';
|
||||||
|
import { LayersPanel } from './components/panels/LayersPanel';
|
||||||
|
import { PropertiesPanel } from './components/panels/PropertiesPanel';
|
||||||
|
import { PWAInstall } from './components/PWAInstall';
|
||||||
|
import { OfflineIndicator } from './components/OfflineIndicator';
|
||||||
|
import { PhotoPreEditor } from './components/editor/PhotoPreEditor';
|
||||||
|
import { useDesignEditor } from './hooks/useDesignEditor';
|
||||||
|
import { useExport } from './hooks/useExport';
|
||||||
|
import { useTemplate } from './hooks/useTemplate';
|
||||||
|
import { TEMPLATES } from './constants/templates';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [editingElement, setEditingElement] = useState(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
elements, selectedId, addElement, updateElement, deleteElement,
|
||||||
|
selectElement, deselectAll, commitHistory, undo, redo, canUndo, canRedo, initializeHistory,
|
||||||
|
} = useDesignEditor();
|
||||||
|
|
||||||
|
const { exporting, progress, exportDesign, error, clearExport } = useExport();
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentTemplate, currentTemplateId, assignedSlots,
|
||||||
|
loadTemplate, clearTemplate, getSlots, assignImageToSlot, getDragBoundFunc, isSlotFilled,
|
||||||
|
} = useTemplate(TEMPLATES);
|
||||||
|
|
||||||
|
const selectedElement = elements.find((el) => el.id === selectedId);
|
||||||
|
|
||||||
|
useEffect(() => { initializeHistory(); }, [initializeHistory]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canUndo) undo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canRedo) redo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId) {
|
||||||
|
deleteElement(selectedId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [selectedId, deleteElement, undo, redo, canUndo, canRedo]);
|
||||||
|
|
||||||
|
const handleAddTemplate = (templateId) => {
|
||||||
|
if (templateId === 'freeform') { clearTemplate(); return; }
|
||||||
|
const success = loadTemplate(templateId);
|
||||||
|
if (success) {
|
||||||
|
const template = TEMPLATES.find(t => t.id === templateId);
|
||||||
|
if (template?.elements) {
|
||||||
|
template.elements.forEach((el, index) => {
|
||||||
|
setTimeout(() => addElement({ ...el }), index * 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlotImageUpload = (slotId, imageData) => {
|
||||||
|
const elementData = assignImageToSlot(slotId, imageData);
|
||||||
|
if (elementData) addElement(elementData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-layout">
|
||||||
|
<OfflineIndicator />
|
||||||
|
<PWAInstall />
|
||||||
|
|
||||||
|
<Sidebar
|
||||||
|
onAddImage={(data) => addElement(data)}
|
||||||
|
onAddSticker={(data) => addElement(data)}
|
||||||
|
onAddText={(data) => addElement(data)}
|
||||||
|
onAddTemplate={handleAddTemplate}
|
||||||
|
onSlotImageUpload={handleSlotImageUpload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="canvas-area">
|
||||||
|
<div style={{ marginBottom: '1rem', textAlign: 'center' }}>
|
||||||
|
<h1 style={{ margin: '0 0 0.25rem 0', fontSize: '20px', color: 'var(--text-primary)' }}>Apparel Designer</h1>
|
||||||
|
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text-secondary)' }}>T-shirt customization editor</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => canUndo && undo()} disabled={!canUndo} className="icon-btn" style={{ opacity: canUndo ? 1 : 0.5 }}>↶ Undo</button>
|
||||||
|
<button onClick={() => canRedo && redo()} disabled={!canRedo} className="icon-btn" style={{ opacity: canRedo ? 1 : 0.5 }}>↷ Redo</button>
|
||||||
|
<button onClick={() => exportDesign(elements, 'tshirt-design', currentTemplate)} disabled={exporting || elements.length === 0} className="export-btn" style={{ opacity: elements.length === 0 ? 0.5 : 1 }}>
|
||||||
|
{exporting ? `Exporting... ${progress}%` : '⬇ Export HD'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="export-error">
|
||||||
|
<p>⚠️ Export failed: {error}</p>
|
||||||
|
<button onClick={clearExport} className="close-error">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DesignCanvas
|
||||||
|
elements={elements} selectedId={selectedId}
|
||||||
|
onSelect={selectElement} onDeselect={deselectAll}
|
||||||
|
onUpdate={(id, attrs) => updateElement(id, attrs)} onCommit={commitHistory}
|
||||||
|
currentTemplate={currentTemplate} assignedSlots={assignedSlots} getDragBoundFunc={getDragBoundFunc}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1.5rem', width: '100%', maxWidth: '400px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-md)', padding: '1rem', boxShadow: 'var(--shadow-md)' }}>
|
||||||
|
<LayersPanel elements={elements} selectedId={selectedId} onSelect={selectElement} onDelete={deleteElement} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PropertiesPanel
|
||||||
|
element={selectedElement}
|
||||||
|
onUpdate={(attrs) => updateElement(selectedId, attrs)}
|
||||||
|
onDelete={deleteElement}
|
||||||
|
onEditPhoto={(el) => setEditingElement(el)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editingElement && (
|
||||||
|
<PhotoPreEditor
|
||||||
|
imageSrc={editingElement.src}
|
||||||
|
onComplete={(url) => { updateElement(editingElement.id, { src: url }); setEditingElement(null); }}
|
||||||
|
onClose={() => setEditingElement(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -6,22 +6,11 @@ export function OfflineIndicator() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => setIsOffline(false);
|
const handleOnline = () => setIsOffline(false);
|
||||||
const handleOffline = () => setIsOffline(true);
|
const handleOffline = () => setIsOffline(true);
|
||||||
|
|
||||||
window.addEventListener('online', handleOnline);
|
window.addEventListener('online', handleOnline);
|
||||||
window.addEventListener('offline', handleOffline);
|
window.addEventListener('offline', handleOffline);
|
||||||
|
return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); };
|
||||||
return () => {
|
|
||||||
window.removeEventListener('online', handleOnline);
|
|
||||||
window.removeEventListener('offline', handleOffline);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isOffline) return null;
|
if (!isOffline) return null;
|
||||||
|
return <div className="offline-indicator"><span>⚠️</span><span>You're offline — changes are saved locally</span></div>;
|
||||||
return (
|
|
||||||
<div className="offline-indicator">
|
|
||||||
<span>⚠️</span>
|
|
||||||
<span>You're offline - changes are saved locally</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
48
src/components/PWAInstall.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function PWAInstall() {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
||||||
|
const [showInstall, setShowInstall] = useState(false);
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
|
const [newWorker, setNewWorker] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeInstallPrompt = (e) => { e.preventDefault(); setDeferredPrompt(e); setShowInstall(true); };
|
||||||
|
const handleSWUpdated = (event) => { setNewWorker(event.detail); setUpdateAvailable(true); };
|
||||||
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||||
|
window.addEventListener('swUpdated', handleSWUpdated);
|
||||||
|
return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('swUpdated', handleSWUpdated); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
if (outcome === 'accepted') { setShowInstall(false); setDeferredPrompt(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = () => { if (newWorker) { newWorker.postMessage({ type: 'SKIP_WAITING' }); window.location.reload(); } };
|
||||||
|
|
||||||
|
if (!showInstall && !updateAvailable) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInstall && (
|
||||||
|
<div className="pwa-install-banner">
|
||||||
|
<p>Install Apparel Designer for offline access!</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button onClick={handleInstall} className="install-btn">Install</button>
|
||||||
|
<button onClick={() => setShowInstall(false)} className="dismiss-btn">Later</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{updateAvailable && (
|
||||||
|
<div style={{ position: 'fixed', bottom: '1rem', left: '50%', transform: 'translateX(-50%)', background: 'var(--accent)', color: '#fff', padding: '0.75rem 1.5rem', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-lg)', zIndex: 9999, display: 'flex', alignItems: 'center', gap: '1rem', fontSize: '13px' }}>
|
||||||
|
<span>🔄 New version available!</span>
|
||||||
|
<button onClick={handleUpdate} style={{ padding: '0.375rem 0.75rem', background: '#fff', color: 'var(--accent)', border: 'none', borderRadius: 'var(--radius-sm)', fontWeight: '600', fontSize: '12px', cursor: 'pointer' }}>Refresh</button>
|
||||||
|
<button onClick={() => { setUpdateAvailable(false); setNewWorker(null); }} style={{ padding: '0.375rem 0.5rem', background: 'transparent', color: '#fff', border: 'none', cursor: 'pointer', fontSize: '16px', opacity: 0.8 }}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/canvas/DesignCanvas.jsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Stage, Layer } from 'react-konva';
|
||||||
|
import { TShirtSVG } from './TShirtSVG';
|
||||||
|
import { ImageElement } from './ImageElement';
|
||||||
|
import { TextElement } from './TextElement';
|
||||||
|
import { TemplateLayer } from './TemplateLayer';
|
||||||
|
import { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
const CANVAS_SIZE = 300;
|
||||||
|
|
||||||
|
export const DesignCanvas = memo(function DesignCanvas({
|
||||||
|
elements, selectedId, onSelect, onDeselect, onUpdate, onCommit,
|
||||||
|
currentTemplate, assignedSlots, getDragBoundFunc,
|
||||||
|
}) {
|
||||||
|
const slots = currentTemplate?.slots || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<TShirtSVG size={CANVAS_SIZE} />
|
||||||
|
<Stage
|
||||||
|
width={CANVAS_SIZE} height={CANVAS_SIZE}
|
||||||
|
onClick={onDeselect} onTap={onDeselect}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
||||||
|
border: selectedId ? '2px solid #38bdf8' : '1px dashed #cbd5e1',
|
||||||
|
borderRadius: '8px', background: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layer>
|
||||||
|
{currentTemplate && <TemplateLayer template={currentTemplate} canvasSize={CANVAS_SIZE} />}
|
||||||
|
</Layer>
|
||||||
|
<Layer listening={false}>
|
||||||
|
{slots.map((slot) => <SlotBoundsGuide key={slot.id} slot={slot} />)}
|
||||||
|
</Layer>
|
||||||
|
<Layer>
|
||||||
|
{elements.map((el) => {
|
||||||
|
if (el.type === 'image') {
|
||||||
|
return (
|
||||||
|
<ImageElement key={el.id} id={el.id} x={el.x} y={el.y} width={el.width} height={el.height}
|
||||||
|
rotation={el.rotation} src={el.src} crop={el.crop} isSelected={el.id === selectedId}
|
||||||
|
onSelect={() => onSelect(el.id)} onUpdate={(attrs) => onUpdate(el.id, attrs)} onCommit={onCommit}
|
||||||
|
dragBoundFunc={el.slotId ? getDragBoundFunc?.(el.slotId, { width: el.width, height: el.height }) : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (el.type === 'text') {
|
||||||
|
return (
|
||||||
|
<TextElement key={el.id} id={el.id} x={el.x} y={el.y} text={el.text} fontSize={el.fontSize}
|
||||||
|
fontFamily={el.fontFamily} fill={el.fill} rotation={el.rotation} isSelected={el.id === selectedId}
|
||||||
|
onSelect={() => onSelect(el.id)} onUpdate={(attrs) => onUpdate(el.id, attrs)} onCommit={onCommit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Layer>
|
||||||
|
<Layer listening={false}>
|
||||||
|
{slots.map((slot) => <SlotPlaceholder key={slot.id} slot={slot} isEmpty={!assignedSlots?.[slot.id]} />)}
|
||||||
|
</Layer>
|
||||||
|
</Stage>
|
||||||
|
<div style={{ position: 'absolute', bottom: '-40px', left: '50%', transform: 'translateX(-50%)', fontSize: '12px', color: 'var(--text-secondary)', textAlign: 'center', whiteSpace: 'nowrap' }}>
|
||||||
|
Design Area: 15" × 15" • Export: 4500 × 4500px @ 300 DPI
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,30 +1,30 @@
|
|||||||
import { useEffect, useState, memo } from 'react';
|
import { useEffect, useRef, memo } from 'react';
|
||||||
import { Image, Transformer } from 'react-konva';
|
import { Image, Transformer } from 'react-konva';
|
||||||
import useImage from 'use-image';
|
import useImage from 'use-image';
|
||||||
|
|
||||||
function URLImage({ src, ...props }) {
|
function URLImage({ src, innerRef, ...props }) {
|
||||||
const [img] = useImage(src, 'anonymous');
|
const [img] = useImage(src, 'anonymous');
|
||||||
return <Image image={img} {...props} />;
|
return <Image image={img} ref={innerRef} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageElement = memo(function ImageElement({
|
export const ImageElement = memo(function ImageElement({
|
||||||
id,
|
id,
|
||||||
x,
|
x = 0,
|
||||||
y,
|
y = 0,
|
||||||
width,
|
width = 100,
|
||||||
height,
|
height = 100,
|
||||||
rotation,
|
rotation = 0,
|
||||||
src,
|
src,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onCommit,
|
onCommit,
|
||||||
}) {
|
}) {
|
||||||
const shapeRef = null;
|
const shapeRef = useRef(null);
|
||||||
const trRef = null;
|
const trRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSelected && trRef.current) {
|
if (isSelected && trRef.current && shapeRef.current) {
|
||||||
trRef.current.nodes([shapeRef.current]);
|
trRef.current.nodes([shapeRef.current]);
|
||||||
trRef.current.getLayer().batchDraw();
|
trRef.current.getLayer().batchDraw();
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ export const ImageElement = memo(function ImageElement({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<URLImage
|
<URLImage
|
||||||
ref={shapeRef}
|
innerRef={shapeRef}
|
||||||
x={x}
|
x={x}
|
||||||
y={y}
|
y={y}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -44,14 +44,12 @@ export const ImageElement = memo(function ImageElement({
|
|||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
onTap={onSelect}
|
onTap={onSelect}
|
||||||
onDragEnd={(e) => {
|
onDragEnd={(e) => {
|
||||||
onUpdate({
|
onUpdate({ x: e.target.x(), y: e.target.y() });
|
||||||
x: e.target.x(),
|
|
||||||
y: e.target.y(),
|
|
||||||
});
|
|
||||||
onCommit?.();
|
onCommit?.();
|
||||||
}}
|
}}
|
||||||
onTransformEnd={(e) => {
|
onTransformEnd={() => {
|
||||||
const node = shapeRef.current;
|
const node = shapeRef.current;
|
||||||
|
if (!node) return;
|
||||||
const scaleX = node.scaleX();
|
const scaleX = node.scaleX();
|
||||||
const scaleY = node.scaleY();
|
const scaleY = node.scaleY();
|
||||||
node.scaleX(1);
|
node.scaleX(1);
|
||||||
@@ -65,22 +63,12 @@ export const ImageElement = memo(function ImageElement({
|
|||||||
});
|
});
|
||||||
onCommit?.();
|
onCommit?.();
|
||||||
}}
|
}}
|
||||||
boundBoxFunc={(oldBox, newBox) => {
|
|
||||||
// Minimum size constraint
|
|
||||||
if (newBox.width < 20 || newBox.height < 20) {
|
|
||||||
return oldBox;
|
|
||||||
}
|
|
||||||
return newBox;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<Transformer
|
<Transformer
|
||||||
ref={trRef}
|
ref={trRef}
|
||||||
boundBoxFunc={(oldBox, newBox) => {
|
boundBoxFunc={(oldBox, newBox) => {
|
||||||
// Limit resize to minimum size
|
if (newBox.width < 20 || newBox.height < 20) return oldBox;
|
||||||
if (newBox.width < 20 || newBox.height < 20) {
|
|
||||||
return oldBox;
|
|
||||||
}
|
|
||||||
return newBox;
|
return newBox;
|
||||||
}}
|
}}
|
||||||
anchorSize={8}
|
anchorSize={8}
|
||||||
@@ -92,10 +80,4 @@ export const ImageElement = memo(function ImageElement({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
ImageElement.defaultProps = {
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
rotation: 0,
|
|
||||||
});
|
});
|
||||||
33
src/components/canvas/SlotPlaceholder.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Group, Rect, Text, Line } from 'react-konva';
|
||||||
|
|
||||||
|
export function SlotPlaceholder({ slot, isEmpty = true }) {
|
||||||
|
const { bounds, label } = slot;
|
||||||
|
const { x, y, width, height } = bounds;
|
||||||
|
if (!isEmpty) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group name={`slot-placeholder-${slot.id}`}>
|
||||||
|
<Rect x={x} y={y} width={width} height={height} stroke="#94a3b8" strokeWidth={2} dash={[8, 4]} cornerRadius={4} listening={false} />
|
||||||
|
<Rect x={x} y={y} width={width} height={height} fill="rgba(148, 163, 184, 0.1)" listening={false} />
|
||||||
|
<Text text="📷" x={x + width / 2} y={y + height / 2 - 20} fontSize={24} align="center" offsetX={12} listening={false} />
|
||||||
|
<Text text={label || 'Drop image here'} x={x + width / 2} y={y + height / 2 + 10} fontSize={11} fontFamily="DM Sans" fill="#64748b" align="center" offsetX={width / 2} listening={false} />
|
||||||
|
<Line points={[x, y, x + 20, y]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||||
|
<Line points={[x, y, x, y + 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||||
|
<Line points={[x + width, y, x + width - 20, y]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||||
|
<Line points={[x + width, y, x + width, y + 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||||
|
<Line points={[x, y + height, x + 20, y + height]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||||
|
<Line points={[x, y + height, x, y + height - 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||||
|
<Line points={[x + width, y + height, x + width - 20, y + height]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||||
|
<Line points={[x + width, y + height, x + width, y + height - 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlotBoundsGuide({ slot }) {
|
||||||
|
const { bounds, id } = slot;
|
||||||
|
return (
|
||||||
|
<Group name={`slot-bounds-${id}`} listening={false}>
|
||||||
|
<Rect x={bounds.x} y={bounds.y} width={bounds.width} height={bounds.height} stroke="rgba(56, 189, 248, 0.3)" strokeWidth={1} dash={[4, 4]} cornerRadius={2} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/canvas/TShirtSVG.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export function TShirtSVG({ size = 300 }) {
|
||||||
|
const padding = size * 0.1;
|
||||||
|
const innerSize = size - padding * 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}
|
||||||
|
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', pointerEvents: 'none', zIndex: 0 }}>
|
||||||
|
<path d={`M ${padding} ${padding + innerSize * 0.15} L ${padding + innerSize * 0.15} ${padding} L ${size - padding - innerSize * 0.15} ${padding} L ${size - padding} ${padding + innerSize * 0.15} L ${size - padding} ${size - padding} L ${padding} ${size - padding} Z`}
|
||||||
|
fill="none" stroke="var(--border)" strokeWidth="2" strokeDasharray="4,4" />
|
||||||
|
<rect x={size * 0.3} y={size * 0.25} width={size * 0.4} height={size * 0.35} fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.5" />
|
||||||
|
<text x={size / 2} y={size * 0.45} textAnchor="middle" fill="var(--text-muted)" fontSize="10" fontFamily="var(--font-mono)">Print Zone</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/canvas/TemplateLayer.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Group, Image as KonvaImage, Rect, Text as KonvaText } from 'react-konva';
|
||||||
|
import useImage from 'use-image';
|
||||||
|
|
||||||
|
function TemplateImage({ src, x, y, width, height, opacity = 1, listening = false }) {
|
||||||
|
const [img] = useImage(src, 'anonymous');
|
||||||
|
return <KonvaImage image={img} x={x} y={y} width={width} height={height} opacity={opacity} listening={listening} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateText({ text, x, y, fontSize, fontFamily, fill, rotation = 0 }) {
|
||||||
|
return <KonvaText text={text} x={x} y={y} fontSize={fontSize} fontFamily={fontFamily} fill={fill} rotation={rotation} listening={false} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateLayer({ template, canvasSize = 300 }) {
|
||||||
|
if (!template) return null;
|
||||||
|
const { background, overlay } = template;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group name="template-layer">
|
||||||
|
{background && (
|
||||||
|
<Group name="template-background">
|
||||||
|
{background.type === 'color' ? (
|
||||||
|
<Rect x={0} y={0} width={canvasSize} height={canvasSize} fill={background.color} listening={false} />
|
||||||
|
) : background.type === 'image' ? (
|
||||||
|
<TemplateImage src={background.src} x={0} y={0} width={canvasSize} height={canvasSize} listening={false} />
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{overlay && overlay.map((el, index) => {
|
||||||
|
if (el.nonPrintable) return null;
|
||||||
|
const key = `overlay-${index}`;
|
||||||
|
if (el.type === 'image') return <TemplateImage key={key} src={el.src} x={el.x || 0} y={el.y || 0} width={el.width || 100} height={el.height || 100} opacity={el.opacity} listening={false} />;
|
||||||
|
if (el.type === 'text') return <TemplateText key={key} text={el.text} x={el.x || 0} y={el.y || 0} fontSize={el.fontSize} fontFamily={el.fontFamily} fill={el.fill} rotation={el.rotation} />;
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,25 @@
|
|||||||
import { useEffect, memo } from 'react';
|
import { useEffect, useRef, memo } 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,
|
||||||
x,
|
x = 0,
|
||||||
y,
|
y = 0,
|
||||||
text,
|
text = '',
|
||||||
fontSize,
|
fontSize = 24,
|
||||||
fontFamily,
|
fontFamily = 'DM Sans',
|
||||||
fill,
|
fill = '#0f172a',
|
||||||
rotation,
|
rotation = 0,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onCommit,
|
onCommit,
|
||||||
}, prevProps) {
|
}) {
|
||||||
// Custom comparison for memo
|
const textRef = useRef(null);
|
||||||
if (!prevProps) return true;
|
const trRef = useRef(null);
|
||||||
return (
|
|
||||||
prevProps.x === x &&
|
|
||||||
prevProps.y === y &&
|
|
||||||
prevProps.text === text &&
|
|
||||||
prevProps.fontSize === fontSize &&
|
|
||||||
prevProps.fontFamily === fontFamily &&
|
|
||||||
prevProps.fill === fill &&
|
|
||||||
prevProps.rotation === rotation &&
|
|
||||||
prevProps.isSelected === isSelected
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const textRef = null;
|
|
||||||
const trRef = null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSelected && trRef.current) {
|
if (isSelected && trRef.current && textRef.current) {
|
||||||
trRef.current.nodes([textRef.current]);
|
trRef.current.nodes([textRef.current]);
|
||||||
trRef.current.getLayer().batchDraw();
|
trRef.current.getLayer().batchDraw();
|
||||||
}
|
}
|
||||||
@@ -53,14 +40,12 @@ export const TextElement = memo(function TextElement({
|
|||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
onTap={onSelect}
|
onTap={onSelect}
|
||||||
onDragEnd={(e) => {
|
onDragEnd={(e) => {
|
||||||
onUpdate({
|
onUpdate({ x: e.target.x(), y: e.target.y() });
|
||||||
x: e.target.x(),
|
|
||||||
y: e.target.y(),
|
|
||||||
});
|
|
||||||
onCommit?.();
|
onCommit?.();
|
||||||
}}
|
}}
|
||||||
onTransformEnd={(e) => {
|
onTransformEnd={() => {
|
||||||
const node = textRef.current;
|
const node = textRef.current;
|
||||||
|
if (!node) return;
|
||||||
const scaleX = node.scaleX();
|
const scaleX = node.scaleX();
|
||||||
node.scaleX(1);
|
node.scaleX(1);
|
||||||
node.scaleY(1);
|
node.scaleY(1);
|
||||||
@@ -86,11 +71,4 @@ export const TextElement = memo(function TextElement({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
TextElement.defaultProps = {
|
|
||||||
fontSize: 24,
|
|
||||||
fontFamily: 'DM Sans',
|
|
||||||
fill: '#0f172a',
|
|
||||||
rotation: 0,
|
|
||||||
});
|
});
|
||||||
43
src/components/editor/PhotoPreEditor.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import FilerobotImageEditor from 'react-filerobot-image-editor';
|
||||||
|
|
||||||
|
export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const modalContentRef = useRef(null);
|
||||||
|
const previousFocusRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
previousFocusRef.current = document.activeElement;
|
||||||
|
const handleKeyDown = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
previousFocusRef.current?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleComplete = (editedImageObject) => {
|
||||||
|
setSaving(true);
|
||||||
|
editedImageObject.exportAsync({ quality: 1, mimeType: 'image/png' })
|
||||||
|
.then((blob) => { setSaving(false); onComplete(URL.createObjectURL(blob)); })
|
||||||
|
.catch((error) => { console.error('Export failed:', error); setSaving(false); onClose(); });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="filerobot-overlay" role="dialog" aria-modal="true" aria-labelledby="photo-editor-title">
|
||||||
|
<div className="filerobot-container" ref={modalContentRef} role="document">
|
||||||
|
<FilerobotImageEditor
|
||||||
|
source={imageSrc} onSave={handleComplete} onClose={onClose}
|
||||||
|
annotationsCommon={{ fill: '#ff0000', stroke: '#000000', strokeWidth: 0 }}
|
||||||
|
annotations={['Text', 'Rectangle', 'Ellipse', 'Line', 'Pen', 'Eraser']}
|
||||||
|
tabs={['adjust', 'filters', 'finetune', 'annotate', 'watermark']}
|
||||||
|
defaultTabId="adjust"
|
||||||
|
theme={{ accentColor: '#38bdf8', palettePrimary: '#38bdf8' }}
|
||||||
|
saveButtonProps={{ label: saving ? 'Exporting...' : 'Use Edited Image' }}
|
||||||
|
closeOnSave
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 id="photo-editor-title" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)' }}>Photo Editor</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/panels/LayersPanel.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
export const LayersPanel = memo(function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
|
||||||
|
const getIcon = (el) => el.type === 'image' ? (el.bgRemoved ? '🖼️' : '📷') : el.type === 'text' ? '📝' : '🎨';
|
||||||
|
const getName = (el) => el.type === 'image' ? (el.bgRemoved ? 'Image (BG ✓)' : 'Image') : el.type === 'text' ? (el.text?.substring(0, 20) || 'Text') : 'Sticker';
|
||||||
|
|
||||||
|
if (elements.length === 0) {
|
||||||
|
return <div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '12px' }}>No elements yet. Add images, text, or stickers to your design.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '12px', fontWeight: '600', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>Layers ({elements.length})</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
{elements.map((element) => (
|
||||||
|
<div key={element.id} onClick={() => onSelect(element.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem',
|
||||||
|
background: selectedId === element.id ? 'var(--accent-bg)' : 'transparent',
|
||||||
|
border: `1px solid ${selectedId === element.id ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '14px' }}>{getIcon(element)}</span>
|
||||||
|
<span style={{ flex: 1, fontSize: '12px', color: selectedId === element.id ? 'var(--accent)' : 'var(--text-primary)', fontWeight: selectedId === element.id ? '600' : '400', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{getName(element)}
|
||||||
|
</span>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onDelete(element.id); }}
|
||||||
|
style={{ width: '24px', height: '24px', border: 'none', borderRadius: 'var(--radius-sm)', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px', color: 'var(--text-muted)' }}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
113
src/components/panels/PropertiesPanel.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { BackgroundRemovalButton } from '../sidebar/BackgroundRemovalButton';
|
||||||
|
|
||||||
|
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete, onEditPhoto }) {
|
||||||
|
if (!element) {
|
||||||
|
return (
|
||||||
|
<div className="properties-panel">
|
||||||
|
<div style={{ padding: '1rem', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: 'var(--text-primary)' }}>Properties</h3>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem', color: 'var(--text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||||
|
Select an element to edit its properties
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePositionChange = (axis, value) => onUpdate({ [axis]: parseFloat(value) || 0 });
|
||||||
|
const handleSizeChange = (axis, value) => onUpdate({ [axis]: Math.max(20, parseFloat(value) || 20) });
|
||||||
|
const handleRotationChange = (value) => onUpdate({ rotation: Math.max(-180, Math.min(180, parseFloat(value) || 0)) });
|
||||||
|
|
||||||
|
const inputStyle = { width: '100%', padding: '0.5rem', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', fontSize: '13px' };
|
||||||
|
const labelStyle = { display: 'block', fontSize: '11px', fontWeight: '600', color: 'var(--text-secondary)', marginBottom: '0.5rem', textTransform: 'uppercase' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="properties-panel">
|
||||||
|
<div style={{ padding: '1rem', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: 'var(--text-primary)' }}>Properties</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '1rem' }}>
|
||||||
|
{/* Element type badge */}
|
||||||
|
<div style={{ display: 'inline-block', padding: '4px 8px', background: 'var(--accent-bg)', borderRadius: 'var(--radius-sm)', fontSize: '11px', fontWeight: '600', color: 'var(--accent)', textTransform: 'uppercase', marginBottom: '1rem' }}>
|
||||||
|
{element.type}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position */}
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Position</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>X</label>
|
||||||
|
<input type="number" value={Math.round(element.x)} onChange={(e) => handlePositionChange('x', e.target.value)} style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Y</label>
|
||||||
|
<input type="number" value={Math.round(element.y)} onChange={(e) => handlePositionChange('y', e.target.value)} style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size (for images and stickers) */}
|
||||||
|
{(element.type === 'image' || element.type === 'sticker') && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Size</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>W</label>
|
||||||
|
<input type="number" value={Math.round(element.width)} onChange={(e) => handleSizeChange('width', e.target.value)} style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{ fontSize: '10px', color: 'var(--text-muted)' }}>H</label>
|
||||||
|
<input type="number" value={Math.round(element.height)} onChange={(e) => handleSizeChange('height', e.target.value)} style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Photo button */}
|
||||||
|
{element.type === 'image' && onEditPhoto && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<button onClick={() => onEditPhoto(element)} style={{ width: '100%', padding: '0.75rem', border: '1px solid var(--accent)', borderRadius: 'var(--radius-md)', background: 'var(--accent-bg)', color: 'var(--accent)', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
|
||||||
|
✏️ Edit Photo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text-specific controls */}
|
||||||
|
{element.type === 'text' && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Font Size: {Math.round(element.fontSize)}px</label>
|
||||||
|
<input type="range" min="12" max="120" value={element.fontSize} onChange={(e) => onUpdate({ fontSize: parseInt(e.target.value, 10) })} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Color</label>
|
||||||
|
<input type="color" value={element.fill} onChange={(e) => onUpdate({ fill: e.target.value })} style={{ width: '100%', height: '36px', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer', padding: '2px' }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rotation */}
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Rotation: {Math.round(element.rotation)}°</label>
|
||||||
|
<input type="range" min="-180" max="180" value={element.rotation} onChange={(e) => handleRotationChange(e.target.value)} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Background Removal (for images) */}
|
||||||
|
{element.type === 'image' && (
|
||||||
|
<BackgroundRemovalButton
|
||||||
|
selectedElement={element}
|
||||||
|
onUpdate={(_id, attrs) => onUpdate(attrs)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button onClick={() => onDelete(element.id)} style={{ width: '100%', padding: '0.75rem', border: 'none', borderRadius: 'var(--radius-md)', background: 'var(--error)', color: '#fff', fontSize: '13px', fontWeight: '600', cursor: 'pointer', marginTop: '1rem' }}>
|
||||||
|
Delete Element
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
23
src/components/sidebar/BackgroundRemovalButton.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useBackgroundRemoval } from '../../hooks/useBackgroundRemoval';
|
||||||
|
|
||||||
|
export function BackgroundRemovalButton({ selectedElement, onUpdate }) {
|
||||||
|
const { loading, progress, hasModel, loadModel, removeBackground } = useBackgroundRemoval();
|
||||||
|
|
||||||
|
const handleRemoveBackground = async () => {
|
||||||
|
if (!selectedElement || selectedElement.type !== 'image') return;
|
||||||
|
if (!hasModel) { const loaded = await loadModel(); if (!loaded) return; }
|
||||||
|
const resultUrl = await removeBackground(selectedElement.src);
|
||||||
|
if (resultUrl) onUpdate(selectedElement.id, { src: resultUrl, bgRemoved: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedElement || selectedElement.type !== 'image') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-removal-container">
|
||||||
|
<button className="bg-removal-btn" onClick={handleRemoveBackground} disabled={loading}>
|
||||||
|
{loading ? (<><div className="spinner-small" />{progress > 0 ? `Loading: ${progress}%` : 'Removing Background...'}</>) : (<>✨ Remove Background</>)}
|
||||||
|
</button>
|
||||||
|
{!hasModel && <p className="bg-removal-hint">First use requires downloading ~86MB model. Subsequent uses are cached.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/sidebar/Sidebar.jsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { UploadTab } from './UploadTab';
|
||||||
|
import { StickersTab } from './StickersTab';
|
||||||
|
import { TextTab } from './TextTab';
|
||||||
|
import { TemplatesTab } from './TemplatesTab';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'upload', label: 'Upload', icon: '📁' },
|
||||||
|
{ id: 'stickers', label: 'Stickers', icon: '🎨' },
|
||||||
|
{ id: 'text', label: 'Text', icon: '📝' },
|
||||||
|
{ id: 'templates', label: 'Templates', icon: '📋' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, onSlotImageUpload }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('upload');
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'upload': return <UploadTab onAddImage={onAddImage} />;
|
||||||
|
case 'stickers': return <StickersTab onAddSticker={onAddSticker} />;
|
||||||
|
case 'text': return <TextTab onAddText={onAddText} />;
|
||||||
|
case 'templates': return <TemplatesTab onAddTemplate={onAddTemplate} onSlotImageUpload={onSlotImageUpload} />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar">
|
||||||
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', background: 'var(--bg-primary)' }}>
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '12px 8px', border: 'none', background: 'transparent', cursor: 'pointer',
|
||||||
|
fontSize: '11px', fontWeight: activeTab === tab.id ? '600' : '400',
|
||||||
|
color: activeTab === tab.id ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
borderBottom: activeTab === tab.id ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '16px', marginBottom: '2px' }}>{tab.icon}</div>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '1rem' }}>{renderTabContent()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/sidebar/StickersTab.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { STICKERS, STICKER_CATEGORIES } from '../../constants/stickers';
|
||||||
|
|
||||||
|
export function StickersTab({ onAddSticker }) {
|
||||||
|
const [activeCategory, setActiveCategory] = useState('all');
|
||||||
|
|
||||||
|
const filteredStickers = activeCategory === 'all'
|
||||||
|
? STICKERS
|
||||||
|
: STICKERS.filter(s => s.category === activeCategory);
|
||||||
|
|
||||||
|
const handleAddSticker = (emoji) => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const size = 100;
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.font = `${size * 0.8}px Arial`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(emoji, size / 2, size / 2);
|
||||||
|
|
||||||
|
onAddSticker({
|
||||||
|
type: 'image',
|
||||||
|
x: 125, y: 125, width: 80, height: 80, rotation: 0,
|
||||||
|
src: canvas.toDataURL('image/png'),
|
||||||
|
emoji,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>Stickers</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
{STICKER_CATEGORIES.map((cat) => (
|
||||||
|
<button key={cat} onClick={() => setActiveCategory(cat)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px', border: `1px solid ${activeCategory === cat ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 'var(--radius-xl)', background: activeCategory === cat ? 'var(--accent)' : 'var(--bg-primary)',
|
||||||
|
color: activeCategory === cat ? '#fff' : 'var(--text-secondary)', fontSize: '11px', cursor: 'pointer', textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '8px' }}>
|
||||||
|
{filteredStickers.map((sticker, index) => (
|
||||||
|
<button key={index} onClick={() => handleAddSticker(sticker.emoji)}
|
||||||
|
style={{ aspectRatio: '1', border: 'none', borderRadius: 'var(--radius-md)', background: 'var(--bg-primary)', fontSize: '28px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
{sticker.emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/sidebar/TemplatesTab.jsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
|
||||||
|
|
||||||
|
function getCategoryEmoji(category) {
|
||||||
|
const emojis = {
|
||||||
|
Sports: '⚽', Music: '🎸', Quotes: '💬', Animals: '🐱',
|
||||||
|
Abstract: '🌈', Vintage: '🏅', Nature: '🏔️', Tech: '💻',
|
||||||
|
};
|
||||||
|
return emojis[category] || '🎨';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
|
||||||
|
const [uploadSlotId, setUploadSlotId] = useState(null);
|
||||||
|
|
||||||
|
const templates = [
|
||||||
|
{ id: 'freeform', name: 'Freeform', description: 'No template - design freely', thumbnail: '🎨' },
|
||||||
|
...TEMPLATES.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
thumbnail: getCategoryEmoji(t.category),
|
||||||
|
hasSlots: !!t.slots,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSelectTemplate = (template) => {
|
||||||
|
setSelectedTemplateId(template.id);
|
||||||
|
onAddTemplate(template.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlotClick = (slotId) => {
|
||||||
|
setUploadSlotId(slotId);
|
||||||
|
document.getElementById('slot-file-input')?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file && uploadSlotId) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
onSlotImageUpload?.(uploadSlotId, event.target.result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
setUploadSlotId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedTemplate = TEMPLATES.find(t => t.id === selectedTemplateId);
|
||||||
|
const slots = selectedTemplate?.slots || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input id="slot-file-input" type="file" accept="image/*" onChange={handleFileChange} style={{ display: 'none' }} />
|
||||||
|
|
||||||
|
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>Templates</h3>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '1rem', lineHeight: '1.4' }}>
|
||||||
|
Choose a template to get started or design freely.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
onClick={() => handleSelectTemplate(template)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
|
||||||
|
background: template.id === selectedTemplateId ? 'var(--bg-secondary)' : 'var(--bg-primary)',
|
||||||
|
cursor: 'pointer', textAlign: 'left', transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '48px', height: '48px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)', fontSize: '24px',
|
||||||
|
}}>
|
||||||
|
{template.thumbnail}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>{template.name}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{template.description}</div>
|
||||||
|
</div>
|
||||||
|
{template.hasSlots && (
|
||||||
|
<span style={{ fontSize: '10px', padding: '2px 6px', background: 'var(--accent)', color: '#fff', borderRadius: '4px', fontWeight: '600' }}>SLOTS</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTemplateId && selectedTemplateId !== 'freeform' && slots.length > 0 && (
|
||||||
|
<div style={{ marginTop: '1rem', padding: '1rem', background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
|
||||||
|
<h4 style={{ margin: '0 0 0.75rem 0', fontSize: '12px', fontWeight: '600', color: 'var(--text-primary)' }}>Template Slots</h4>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<button
|
||||||
|
key={slot.id}
|
||||||
|
onClick={() => handleSlotClick(slot.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--bg-primary)', cursor: 'pointer', fontSize: '12px', color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '16px' }}>📷</span>
|
||||||
|
<span>{slot.label}</span>
|
||||||
|
<span style={{ fontSize: '10px', color: 'var(--text-muted)', marginLeft: 'auto' }}>
|
||||||
|
{slot.bounds.width}×{slot.bounds.height}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/sidebar/TextTab.jsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FONTS } from '../../constants/fonts';
|
||||||
|
|
||||||
|
export function TextTab({ onAddText }) {
|
||||||
|
const [text, setText] = useState('Your text here');
|
||||||
|
const [fontFamily, setFontFamily] = useState('Roboto');
|
||||||
|
const [fontSize, setFontSize] = useState(48);
|
||||||
|
const [fill, setFill] = useState('#0f172a');
|
||||||
|
|
||||||
|
const handleAddText = () => {
|
||||||
|
onAddText({ type: 'text', x: 150, y: 150, text, fontFamily, fontSize, fill, rotation: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle = { display: 'block', fontSize: '11px', fontWeight: '600', color: 'var(--text-secondary)', marginBottom: '0.5rem', textTransform: 'uppercase' };
|
||||||
|
const inputStyle = { width: '100%', padding: '0.75rem', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', fontSize: '14px', fontFamily: 'var(--font-body)' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>Add Text</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Text Content</label>
|
||||||
|
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Font</label>
|
||||||
|
<select value={fontFamily} onChange={(e) => setFontFamily(e.target.value)} style={{ ...inputStyle, fontSize: '13px', fontFamily, cursor: 'pointer', background: 'var(--bg-primary)' }}>
|
||||||
|
{FONTS.map((font) => <option key={font.family} value={font.family}>{font.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Font Size: {fontSize}px</label>
|
||||||
|
<input type="range" min="12" max="120" value={fontSize} onChange={(e) => setFontSize(parseInt(e.target.value, 10))} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Color</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<input type="color" value={fill} onChange={(e) => setFill(e.target.value)} style={{ width: '40px', height: '40px', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer', padding: '2px' }} />
|
||||||
|
<input type="text" value={fill} onChange={(e) => setFill(e.target.value)} style={{ flex: 1, padding: '0.75rem', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', fontSize: '13px', fontFamily: 'var(--font-mono)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '1rem', background: 'var(--bg-primary)', borderRadius: 'var(--radius-md)', marginBottom: '1rem', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontFamily, fontSize: `${fontSize * 0.5}px`, color: fill, wordBreak: 'break-word' }}>{text}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={handleAddText} style={{ width: '100%', padding: '0.875rem', border: 'none', borderRadius: 'var(--radius-md)', background: 'var(--accent)', color: '#fff', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
|
||||||
|
Add Text to Canvas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/sidebar/UploadTab.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export function UploadTab({ onAddImage }) {
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
const handleFiles = async (files) => {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
if (!validTypes.includes(file.type)) { alert('Please upload a JPEG, PNG, or WebP image'); return; }
|
||||||
|
if (file.size > 20 * 1024 * 1024) { alert('File size must be under 20MB'); return; }
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
const response = await fetch('/api/upload', { method: 'POST', body: formData });
|
||||||
|
if (!response.ok) throw new Error('Upload failed');
|
||||||
|
const data = await response.json();
|
||||||
|
onAddImage({ type: 'image', x: 75, y: 75, width: 150, height: 150, rotation: 0, src: data.preview.url, originalUrl: data.original.url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
alert('Failed to upload image. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 1rem 0', fontSize: '14px', color: 'var(--text-primary)' }}>Upload Image</h3>
|
||||||
|
<div onClick={() => fileInputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }}
|
||||||
|
style={{ border: `2px dashed ${isDragging ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 'var(--radius-md)', padding: '2rem 1rem', textAlign: 'center', cursor: 'pointer', background: isDragging ? 'var(--accent-bg)' : 'var(--bg-primary)', marginBottom: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '32px', marginBottom: '0.5rem' }}>📁</div>
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>Click to upload or drag and drop</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>JPEG, PNG, WebP (max 20MB)</div>
|
||||||
|
</div>
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => handleFiles(e.target.files)} style={{ display: 'none' }} />
|
||||||
|
{isUploading && <div style={{ padding: '0.75rem', background: 'var(--accent-bg)', borderRadius: 'var(--radius-sm)', fontSize: '12px', color: 'var(--accent)', textAlign: 'center' }}>Uploading...</div>}
|
||||||
|
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', fontSize: '11px', color: 'var(--text-muted)', lineHeight: '1.4' }}>
|
||||||
|
<strong>Tip:</strong> After uploading, you can remove the background using the background removal tool in the properties panel.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/hooks/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { useDesignEditor } from './useDesignEditor';
|
||||||
|
export { useBackgroundRemoval } from './useBackgroundRemoval';
|
||||||
|
export { useExport } from './useExport';
|
||||||
|
export { useTemplate } from './useTemplate';
|
||||||
90
src/hooks/useBackgroundRemoval.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { AutoModel, AutoProcessor, RawImage } from '@huggingface/transformers';
|
||||||
|
|
||||||
|
export function useBackgroundRemoval() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const modelRef = useRef(null);
|
||||||
|
const processorRef = useRef(null);
|
||||||
|
|
||||||
|
const loadModel = useCallback(async () => {
|
||||||
|
if (modelRef.current && processorRef.current) return true;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
modelRef.current = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
|
||||||
|
dtype: 'q8',
|
||||||
|
device: navigator.gpu ? 'webgpu' : 'wasm',
|
||||||
|
progress_callback: (p) => {
|
||||||
|
if (p.progress != null) setProgress(Math.round(p.progress * 50));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
processorRef.current = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
|
||||||
|
|
||||||
|
setProgress(50);
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load background removal model:', error);
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeBackground = useCallback(async (imageSrc) => {
|
||||||
|
if (!modelRef.current || !processorRef.current) {
|
||||||
|
const loaded = await loadModel();
|
||||||
|
if (!loaded) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setProgress(50);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = await RawImage.fromURL(imageSrc);
|
||||||
|
const { pixel_values } = await processorRef.current(image);
|
||||||
|
|
||||||
|
setProgress(70);
|
||||||
|
|
||||||
|
const { output } = await modelRef.current({ input: pixel_values });
|
||||||
|
|
||||||
|
setProgress(90);
|
||||||
|
|
||||||
|
const mask = await RawImage.fromTensor(
|
||||||
|
output[0].mul(255).to('uint8')
|
||||||
|
).resize(image.width, image.height);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = image.width;
|
||||||
|
canvas.height = image.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
ctx.drawImage(await image.toCanvas(), 0, 0);
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
for (let i = 0; i < mask.data.length; i++) {
|
||||||
|
imageData.data[i * 4 + 3] = mask.data[i];
|
||||||
|
}
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
setProgress(100);
|
||||||
|
setLoading(false);
|
||||||
|
return canvas.toDataURL('image/png');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Background removal failed:', error);
|
||||||
|
setLoading(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [loadModel]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
progress,
|
||||||
|
hasModel: !!(modelRef.current),
|
||||||
|
loadModel,
|
||||||
|
removeBackground,
|
||||||
|
};
|
||||||
|
}
|
||||||
72
src/hooks/useDesignEditor.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
const DEBOUNCE_DELAY_MS = 300;
|
||||||
|
|
||||||
|
export function useDesignEditor() {
|
||||||
|
const [elements, setElements] = useState([]);
|
||||||
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
|
const historyRef = useRef([]);
|
||||||
|
const historyIndexRef = useRef(-1);
|
||||||
|
const historyTimerRef = useRef(null);
|
||||||
|
const pendingChangesRef = useRef(null);
|
||||||
|
|
||||||
|
const saveToHistory = useCallback((newElements) => {
|
||||||
|
if (historyIndexRef.current < historyRef.current.length - 1) {
|
||||||
|
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
|
||||||
|
}
|
||||||
|
historyRef.current.push(JSON.stringify(newElements));
|
||||||
|
if (historyRef.current.length > MAX_HISTORY) { historyRef.current.shift(); }
|
||||||
|
else { historyIndexRef.current++; }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushPendingChanges = useCallback(() => {
|
||||||
|
if (pendingChangesRef.current) { saveToHistory(pendingChangesRef.current); pendingChangesRef.current = null; }
|
||||||
|
if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; }
|
||||||
|
}, [saveToHistory]);
|
||||||
|
|
||||||
|
useEffect(() => { return () => { if (historyTimerRef.current) clearTimeout(historyTimerRef.current); }; }, []);
|
||||||
|
|
||||||
|
const canUndo = historyIndexRef.current > 0;
|
||||||
|
const canRedo = historyIndexRef.current < historyRef.current.length - 1;
|
||||||
|
|
||||||
|
const addElement = useCallback((element) => {
|
||||||
|
flushPendingChanges();
|
||||||
|
const newElement = { ...element, id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` };
|
||||||
|
setElements((prev) => { const newElements = [...prev, newElement]; saveToHistory(newElements); return newElements; });
|
||||||
|
setSelectedId(newElement.id);
|
||||||
|
return newElement.id;
|
||||||
|
}, [flushPendingChanges, saveToHistory]);
|
||||||
|
|
||||||
|
const updateElement = useCallback((id, attrs) => {
|
||||||
|
setElements((prev) => {
|
||||||
|
const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
|
||||||
|
pendingChangesRef.current = newElements;
|
||||||
|
if (historyTimerRef.current) clearTimeout(historyTimerRef.current);
|
||||||
|
historyTimerRef.current = setTimeout(() => { flushPendingChanges(); }, DEBOUNCE_DELAY_MS);
|
||||||
|
return newElements;
|
||||||
|
});
|
||||||
|
}, [flushPendingChanges]);
|
||||||
|
|
||||||
|
const deleteElement = useCallback((id) => {
|
||||||
|
flushPendingChanges();
|
||||||
|
setElements((prev) => { const newElements = prev.filter((el) => el.id !== id); saveToHistory(newElements); return newElements; });
|
||||||
|
if (selectedId === id) setSelectedId(null);
|
||||||
|
}, [selectedId, flushPendingChanges, saveToHistory]);
|
||||||
|
|
||||||
|
const selectElement = useCallback((id) => setSelectedId(id), []);
|
||||||
|
const deselectAll = useCallback(() => setSelectedId(null), []);
|
||||||
|
const commitHistory = useCallback(() => flushPendingChanges(), [flushPendingChanges]);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (historyIndexRef.current > 0) { historyIndexRef.current--; setElements(JSON.parse(historyRef.current[historyIndexRef.current])); setSelectedId(null); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
if (historyIndexRef.current < historyRef.current.length - 1) { historyIndexRef.current++; setElements(JSON.parse(historyRef.current[historyIndexRef.current])); setSelectedId(null); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initializeHistory = useCallback(() => { historyRef.current = [JSON.stringify([])]; historyIndexRef.current = 0; }, []);
|
||||||
|
|
||||||
|
return { elements, selectedId, addElement, updateElement, deleteElement, selectElement, deselectAll, commitHistory, undo, redo, canUndo, canRedo, initializeHistory };
|
||||||
|
}
|
||||||
@@ -7,37 +7,25 @@ export function useExport() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const exportDesign = useCallback(async (elements, designName = 'design', template = null) => {
|
const exportDesign = useCallback(async (elements, designName = 'design', template = null) => {
|
||||||
setExporting(true);
|
setExporting(true); setProgress(0); setError(null); setExportUrl(null);
|
||||||
setProgress(0);
|
|
||||||
setError(null);
|
|
||||||
setExportUrl(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate progress during export
|
const progressInterval = setInterval(() => { setProgress((prev) => Math.min(prev + 10, 90)); }, 200);
|
||||||
const progressInterval = setInterval(() => {
|
|
||||||
setProgress((prev) => Math.min(prev + 10, 90));
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
const response = await fetch('/api/export', {
|
const response = await fetch('/api/export', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ elements, designName, template }),
|
body: JSON.stringify({ elements, designName, template }),
|
||||||
});
|
});
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
setProgress(100);
|
setProgress(100);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Export failed'); }
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Export failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setExportUrl(data.export.url);
|
setExportUrl(data.export.url);
|
||||||
|
|
||||||
// Trigger download
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = data.export.url;
|
link.href = data.export.url;
|
||||||
link.download = data.export.filename;
|
link.download = data.export.filename;
|
||||||
@@ -53,17 +41,7 @@ export function useExport() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearExport = useCallback(() => {
|
const clearExport = useCallback(() => { setExportUrl(null); setError(null); }, []);
|
||||||
setExportUrl(null);
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return { exporting, progress, exportUrl, error, exportDesign, clearExport };
|
||||||
exporting,
|
|
||||||
progress,
|
|
||||||
exportUrl,
|
|
||||||
error,
|
|
||||||
exportDesign,
|
|
||||||
clearExport,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
57
src/hooks/useTemplate.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export function calculateAutoCrop(imageSize, slotSize) {
|
||||||
|
const imageRatio = imageSize.width / imageSize.height;
|
||||||
|
const slotRatio = slotSize.width / slotSize.height;
|
||||||
|
let sx, sy, sWidth, sHeight;
|
||||||
|
if (imageRatio > slotRatio) { sHeight = imageSize.height; sWidth = imageSize.height * slotRatio; sx = (imageSize.width - sWidth) / 2; sy = 0; }
|
||||||
|
else { sWidth = imageSize.width; sHeight = imageSize.width / slotRatio; sx = 0; sy = (imageSize.height - sHeight) / 2; }
|
||||||
|
return { sx, sy, sWidth, sHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDragBoundFunc(slot, elementSize) {
|
||||||
|
const { bounds } = slot;
|
||||||
|
const minX = bounds.x, minY = bounds.y;
|
||||||
|
const maxX = bounds.x + bounds.width - elementSize.width;
|
||||||
|
const maxY = bounds.y + bounds.height - elementSize.height;
|
||||||
|
return (_oldBox, newBox) => ({ x: Math.max(minX, Math.min(newBox.x, maxX)), y: Math.max(minY, Math.min(newBox.y, maxY)), width: newBox.width, height: newBox.height });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTemplate(templates = []) {
|
||||||
|
const [currentTemplateId, setCurrentTemplateId] = useState(null);
|
||||||
|
const [assignedSlots, setAssignedSlots] = useState({});
|
||||||
|
const templateRef = useRef(null);
|
||||||
|
|
||||||
|
const currentTemplate = templates.find(t => t.id === currentTemplateId) || null;
|
||||||
|
const getSlots = useCallback(() => currentTemplate?.slots || [], [currentTemplate]);
|
||||||
|
|
||||||
|
const loadTemplate = useCallback((templateId) => {
|
||||||
|
const template = templates.find(t => t.id === templateId);
|
||||||
|
if (template) { setCurrentTemplateId(templateId); setAssignedSlots({}); templateRef.current = template; return true; }
|
||||||
|
return false;
|
||||||
|
}, [templates]);
|
||||||
|
|
||||||
|
const clearTemplate = useCallback(() => { setCurrentTemplateId(null); setAssignedSlots({}); templateRef.current = null; }, []);
|
||||||
|
|
||||||
|
const assignImageToSlot = useCallback((slotId, imageData) => {
|
||||||
|
const slots = getSlots();
|
||||||
|
const slot = slots.find(s => s.id === slotId);
|
||||||
|
if (!slot) return null;
|
||||||
|
const elementData = { type: 'image', src: imageData, x: slot.bounds.x, y: slot.bounds.y, width: slot.bounds.width, height: slot.bounds.height, slotId, crop: null };
|
||||||
|
const img = new Image();
|
||||||
|
img.src = imageData;
|
||||||
|
img.onload = () => { elementData.crop = calculateAutoCrop({ width: img.width, height: img.height }, { width: slot.bounds.width, height: slot.bounds.height }); };
|
||||||
|
setAssignedSlots(prev => ({ ...prev, [slotId]: elementData }));
|
||||||
|
return elementData;
|
||||||
|
}, [getSlots]);
|
||||||
|
|
||||||
|
const getDragBoundFunc = useCallback((slotId, elementSize) => {
|
||||||
|
const slot = getSlots().find(s => s.id === slotId);
|
||||||
|
if (!slot) return null;
|
||||||
|
return createDragBoundFunc(slot, elementSize);
|
||||||
|
}, [getSlots]);
|
||||||
|
|
||||||
|
const isSlotFilled = useCallback((slotId) => !!assignedSlots[slotId], [assignedSlots]);
|
||||||
|
|
||||||
|
return { currentTemplateId, currentTemplate, assignedSlots, loadTemplate, clearTemplate, getSlots, assignImageToSlot, getDragBoundFunc, isSlotFilled };
|
||||||
|
}
|
||||||
144
src/index.css
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
:root {
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--accent-hover: #0ea5e9;
|
||||||
|
--accent-bg: rgba(56, 189, 248, 0.1);
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f8fafc;
|
||||||
|
--bg-tertiary: #f1f5f9;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--border-focus: #38bdf8;
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--error: #ef4444;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'Space Mono', ui-monospace, Consolas, monospace;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-height: 100vh; }
|
||||||
|
#root { min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
button { font-family: inherit; cursor: pointer; outline: none; }
|
||||||
|
button:focus-visible { box-shadow: 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--accent); }
|
||||||
|
input, textarea, select { font-family: inherit; }
|
||||||
|
|
||||||
|
.editor-layout { display: flex; flex: 1; overflow: hidden; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 320px; background: var(--bg-secondary); border-right: 1px solid var(--border);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg-tertiary); overflow: auto; padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel {
|
||||||
|
width: 280px; background: var(--bg-secondary); border-left: 1px solid var(--border);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
padding: 0.5rem 0.75rem; background: var(--bg-primary); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm); font-size: 0.75rem; cursor: pointer; transition: all 0.2s; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.icon-btn:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: white; }
|
||||||
|
.icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.export-btn {
|
||||||
|
padding: 0.5rem 1rem; background: linear-gradient(135deg, #22c55e, #16a34a); color: white;
|
||||||
|
border: none; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: 500;
|
||||||
|
cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.export-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||||
|
.export-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.export-error {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem; background: #fef2f2; border: 1px solid #fecaca;
|
||||||
|
border-radius: var(--radius-md); color: #dc2626; font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem; width: 100%; max-width: 400px;
|
||||||
|
}
|
||||||
|
.export-error p { margin: 0; }
|
||||||
|
.close-error { background: transparent; border: none; color: #dc2626; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
|
||||||
|
|
||||||
|
.bg-removal-container { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||||
|
.bg-removal-btn {
|
||||||
|
width: 100%; padding: 0.75rem; background: linear-gradient(135deg, #8b5cf6, #ec4899); color: white;
|
||||||
|
border: none; border-radius: var(--radius-md); font-weight: 500; cursor: pointer; transition: all 0.2s;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.bg-removal-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||||
|
.bg-removal-btn:disabled { opacity: 0.7; cursor: not-allowed; }
|
||||||
|
.bg-removal-hint { font-size: 0.7rem; color: var(--text-muted); margin: 0.5rem 0 0 0; line-height: 1.4; }
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white; border-radius: 50%; animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.filerobot-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8); display: flex; align-items: center;
|
||||||
|
justify-content: center; z-index: 1000;
|
||||||
|
}
|
||||||
|
.filerobot-container { width: 90%; height: 90%; background: #1e1e1e; border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
|
|
||||||
|
.pwa-install-banner {
|
||||||
|
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: var(--bg-primary); border: 1px solid var(--accent); border-radius: var(--radius-lg);
|
||||||
|
padding: 1rem 1.5rem; box-shadow: var(--shadow-lg); z-index: 1000;
|
||||||
|
display: flex; align-items: center; gap: 1rem; animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideUp { from { opacity: 0; transform: translateX(-50%) translateY(20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
|
||||||
|
.pwa-install-banner p { margin: 0; font-size: 0.875rem; color: var(--text-primary); }
|
||||||
|
.install-btn { padding: 0.5rem 1rem; background: var(--accent); color: white; border: none; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: 500; cursor: pointer; }
|
||||||
|
.dismiss-btn { padding: 0.5rem 1rem; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.75rem; cursor: pointer; }
|
||||||
|
|
||||||
|
.offline-indicator {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0;
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706); color: white;
|
||||||
|
padding: 0.5rem 1rem; text-align: center; font-size: 12px; font-weight: 500;
|
||||||
|
z-index: 9999; display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) and (min-width: 768px) {
|
||||||
|
.sidebar { width: 280px; }
|
||||||
|
.properties-panel { width: 240px; }
|
||||||
|
.canvas-area { padding: 1.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.editor-layout { flex-direction: column; }
|
||||||
|
.sidebar { width: 100%; height: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); }
|
||||||
|
.canvas-area { padding: 1rem; width: 100%; }
|
||||||
|
.properties-panel { width: 100%; border-left: none; border-top: 1px solid var(--border); max-height: 40vh; }
|
||||||
|
.pwa-install-banner { left: 10px; right: 10px; transform: none; width: auto; flex-direction: column; text-align: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.sidebar { max-height: 40vh; }
|
||||||
|
.canvas-area { padding: 0.5rem; }
|
||||||
|
button { min-height: 44px; }
|
||||||
|
}
|
||||||
@@ -3,17 +3,13 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
// Service worker update handling
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.ready.then((registration) => {
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
// Listen for updates from the service worker
|
|
||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
const newWorker = registration.installing;
|
const newWorker = registration.installing;
|
||||||
|
|
||||||
newWorker.addEventListener('statechange', () => {
|
newWorker.addEventListener('statechange', () => {
|
||||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
// Dispatch custom event for PWAInstall component
|
|
||||||
window.dispatchEvent(new CustomEvent('swUpdated', { detail: newWorker }));
|
window.dispatchEvent(new CustomEvent('swUpdated', { detail: newWorker }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
95
vite.config.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'prompt',
|
||||||
|
includeAssets: ['favicon.svg', 'pwa-192x192.svg', 'pwa-512x512.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Apparel Designer',
|
||||||
|
short_name: 'ApparelDesigner',
|
||||||
|
description: 'T-shirt customization editor',
|
||||||
|
theme_color: '#38bdf8',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{ src: 'pwa-192x192.svg', sizes: '192x192', type: 'image/svg+xml' },
|
||||||
|
{ src: 'pwa-512x512.svg', sizes: '512x512', type: 'image/svg+xml' },
|
||||||
|
{ src: 'pwa-512x512.svg', sizes: '512x512', type: 'image/svg+xml', purpose: 'any maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/cdn\.huggingface\.co\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'transformers-models',
|
||||||
|
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/cdn-lfs\.huggingface\.co\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'transformers-lfs',
|
||||||
|
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^\/uploads\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'uploaded-images',
|
||||||
|
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 7 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^\/api\/.*/i,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-responses',
|
||||||
|
expiration: { maxEntries: 50, maxAgeSeconds: 300 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
networkTimeoutSeconds: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||||
|
handler: 'StaleWhileRevalidate',
|
||||||
|
options: {
|
||||||
|
cacheName: 'google-fonts',
|
||||||
|
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'gstatic-fonts',
|
||||||
|
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
|
'/uploads': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
|
'/exports': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: { outDir: 'dist' },
|
||||||
|
});
|
||||||