From e47777d237e61e4ca0f94300b6ab066f1d858316 Mon Sep 17 00:00:00 2001 From: Hiren Date: Mon, 9 Mar 2026 13:57:49 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20RefBoard=20v0.4.0=20=E2=80=94=20collabo?= =?UTF-8?q?rative=20reference=20board=20with=20layers,=20groups=20&=20poli?= =?UTF-8?q?shed=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-featured PureRef-style collaborative canvas for game dev teams: - Layer panel with visibility, lock, drag reorder, group/ungroup (Ctrl+G/Shift+G) - Arrangement tools (grid, row, column) via right-click context menu - Copy to system clipboard (Ctrl+C writes PNG for external paste in Paint etc.) - Number shortcuts (1-5) for tool selection with visible shortcut badges - Premium dark UI across all pages (Login, Collections, Boards, Editor) - Socket.IO rooms for cursors, transforms, and presence notifications - MinIO image storage with backend proxy, drag/drop and paste upload --- .gitignore | 4 + CHANGELOG.md | 165 ++ Dockerfile | 35 + backend/auth.js | 93 + backend/db.js | 334 +++ backend/minio.js | 113 + backend/package.json | 21 + backend/routes/admin.js | 202 ++ backend/routes/auth.js | 178 ++ backend/routes/boards.js | 186 ++ backend/routes/collections.js | 312 ++ backend/routes/upload.js | 248 ++ backend/server.js | 178 ++ backend/socket/board-room.js | 142 + backend/socket/index.js | 61 + frontend/index.html | 25 + frontend/package-lock.json | 3516 +++++++++++++++++++++++ frontend/package.json | 26 + frontend/public/favicon.svg | 6 + frontend/src/App.tsx | 51 + frontend/src/api.ts | 114 + frontend/src/auth.ts | 85 + frontend/src/canvas/FabricCanvas.tsx | 263 ++ frontend/src/canvas/history.ts | 83 + frontend/src/canvas/image-drop.ts | 207 ++ frontend/src/canvas/sync.ts | 213 ++ frontend/src/canvas/tools.ts | 136 + frontend/src/components/ColorPicker.tsx | 151 + frontend/src/components/ContextMenu.tsx | 93 + frontend/src/components/LayerPanel.tsx | 194 ++ frontend/src/components/ShareDialog.tsx | 323 +++ frontend/src/components/StatusBar.tsx | 67 + frontend/src/components/Toolbar.tsx | 341 +++ frontend/src/components/UserCursors.tsx | 129 + frontend/src/index.css | 49 + frontend/src/main.tsx | 10 + frontend/src/pages/CollectionDetail.tsx | 343 +++ frontend/src/pages/CollectionList.tsx | 314 ++ frontend/src/pages/Editor.tsx | 1011 +++++++ frontend/src/pages/Login.tsx | 163 ++ frontend/src/socket.ts | 46 + frontend/tsconfig.json | 23 + frontend/vite.config.ts | 22 + 43 files changed, 10276 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 backend/auth.js create mode 100644 backend/db.js create mode 100644 backend/minio.js create mode 100644 backend/package.json create mode 100644 backend/routes/admin.js create mode 100644 backend/routes/auth.js create mode 100644 backend/routes/boards.js create mode 100644 backend/routes/collections.js create mode 100644 backend/routes/upload.js create mode 100644 backend/server.js create mode 100644 backend/socket/board-room.js create mode 100644 backend/socket/index.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/auth.ts create mode 100644 frontend/src/canvas/FabricCanvas.tsx create mode 100644 frontend/src/canvas/history.ts create mode 100644 frontend/src/canvas/image-drop.ts create mode 100644 frontend/src/canvas/sync.ts create mode 100644 frontend/src/canvas/tools.ts create mode 100644 frontend/src/components/ColorPicker.tsx create mode 100644 frontend/src/components/ContextMenu.tsx create mode 100644 frontend/src/components/LayerPanel.tsx create mode 100644 frontend/src/components/ShareDialog.tsx create mode 100644 frontend/src/components/StatusBar.tsx create mode 100644 frontend/src/components/Toolbar.tsx create mode 100644 frontend/src/components/UserCursors.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/CollectionDetail.tsx create mode 100644 frontend/src/pages/CollectionList.tsx create mode 100644 frontend/src/pages/Editor.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/socket.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..78ef2fb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,165 @@ +# RefBoard Changelog + +Collaborative reference image board for game dev teams (PureRef-style, web-based). + +--- + +## v0.4.0 — Layers, Groups & UI Overhaul (2026-03-09) + +### Added +- **Layer panel** — collapsible sidebar showing all canvas objects with visibility toggle, lock toggle, drag-to-reorder, and delete +- **Grouping** — Ctrl+G to group selected objects, Ctrl+Shift+G to ungroup; also available in right-click context menu +- **Arrangement tools** — right-click to auto-arrange selected objects in Grid, Row, or Column layout +- **Number shortcuts** — keys 1-5 select tools (1=Select, 2=Pan, 3=Draw, 4=Text, 5=Eraser) +- **Shortcut labels on toolbar** — each tool button shows its name and number key badge +- **Layers toggle** in toolbar — click to show/hide the layer panel + +### Changed +- **All pages redesigned** — Login, CollectionList, CollectionDetail, and Editor all share a cohesive dark theme with refined spacing, gradients, subtle shadows, and premium typography +- **Background**: deeper `#0d0d0d` base with subtle radial gradient accents +- **Cards**: refined borders, hover lift animations, gradient thumbnails +- **Modals**: backdrop blur, deeper shadows, better spacing +- **Buttons**: gradient fills with glow shadows, smooth transitions +- **Toolbar**: taller (44px), tool buttons show name + number key, gradient active state, stacked user avatars +- **Toast notifications**: frosted glass effect with backdrop blur + +### Fixed +- **Copy/paste creating empty outlines** — images now properly cloned using `FabricImage.fromURL` instead of generic `fromObject` which didn't reload image data +- **Copy includes `src`** — serialization now includes `['id', 'src']` so image URLs survive clipboard operations + +--- + +## v0.3.0 — UX Polish & Clipboard (2026-03-09) + +### Added +- **Right-click context menu** — Copy, Paste, Duplicate, Copy as Image, Layer ordering, Select All, Fit All, Delete +- **Copy to system clipboard** — Ctrl+Shift+C copies selected objects as PNG image (paste in Paint, Photoshop, etc.) +- **Layer ordering** — `]` bring forward, `[` send backward +- **Zoom +/- buttons** in toolbar with Ctrl+=/Ctrl+- keyboard shortcuts +- **Toast notifications** — user join/leave alerts, copy confirmation +- **Empty canvas guide** — shows instructions when canvas is blank (drop, paste, draw, text) + +### Fixed +- Fixed `FabricObj` undefined error in paste handler +- Fixed all Fabric.js TypeScript type mismatches (`toJSON`, `fromObject`, `bringObjectForward`) +- Zero TypeScript errors across the project + +--- + +## v0.2.0 — Real-time Collaboration Rewrite (2026-03-09) + +### Added +- **Full-scene sync (Excalidraw approach)** — broadcast entire `canvas.toJSON()` on every change, `loadFromJSON` on receive +- **Lightweight transform events** — 50ms throttled position/scale/angle during drag for smooth real-time +- **Interaction deferral** — remote scene updates queued during active mouse interaction to prevent conflicts +- **Suppress/resume broadcasts** — prevents flooding during initial load and remote scene application +- **User search autocomplete** in ShareDialog — searches by name/email via `/api/users/search` +- **Copy/Paste/Duplicate** — Ctrl+C/V for canvas objects, Ctrl+D for duplicate with offset +- **Breadcrumb navigation** — Username / Collection / Board with clickable links +- **User cursors** — real-time cursor position display with colored name labels + +### Changed +- Save debounce reduced from 5s to 2s +- Remote changes no longer reset save timer (checks `isRemoteUpdate()`) +- Scene sync throttle: 300ms for full scene, 50ms for transforms + +### Fixed +- Images now served through backend proxy (`/api/images/*`) instead of internal Docker URLs +- Canvas height not filling viewport +- `cursor:move` vs `cursor:moved` event name mismatch +- User joined/left field name mismatches (`userId`/`id`, `displayName`/`display_name`) +- "Unsaved" status stuck due to remote changes triggering save debounce + +--- + +## v0.1.0 — Initial Scaffold (2026-03-08) + +### Architecture +- **Frontend**: React + Vite + TypeScript + Fabric.js v6 +- **Backend**: Express + SQLite (WAL mode) + Socket.IO +- **Storage**: MinIO (S3-compatible) for images +- **Auth**: JWT-based with login/register +- **Deployment**: Docker single-container (multi-stage build) + +### Features +- Collections with boards hierarchy +- Board editor with Fabric.js canvas +- Tools: Select, Pan, Draw (PencilBrush), Text (IText), Eraser +- Image upload via drag & drop and clipboard paste +- URL drag support (drag image URL from browser) +- Auto-save with debounce +- Canvas state persistence (JSON in SQLite) +- Collection sharing with role-based access (owner, editor, viewer) +- Undo/Redo history +- Scroll-to-zoom, middle-mouse pan, space+drag pan +- Fit All (Ctrl+0) +- Select All (Ctrl+A) +- Delete selected (Del/Backspace) +- Dark theme UI +- Compact toolbar with SVG icons +- Status bar with save indicator +- Color picker for draw/text tools +- Stroke width slider (draw mode) +- Font size slider (text mode) +- Online user avatars in toolbar + +### Backend Endpoints +- `POST /api/auth/register` — create account +- `POST /api/auth/login` — JWT login +- `GET /api/collections` — list user's collections +- `POST /api/collections` — create collection +- `GET /api/boards/:id` — get board with canvas state +- `POST /api/boards/:id/save` — save canvas state +- `POST /api/boards/:id/images` — upload image to MinIO +- `POST /api/boards/:id/images/from-url` — upload from URL +- `GET /api/images/*` — image proxy (MinIO → browser) +- `GET /api/users/search` — user search for sharing + +### Socket.IO Events +- `board:join` / `board:leave` — room management +- `scene:update` — full canvas JSON broadcast +- `object:transform` — lightweight transform during drag +- `cursor:move` / `cursor:moved` — real-time cursor positions +- `user:joined` / `user:left` — presence notifications + +### File Structure (38 files) +``` +refboard/ +├── backend/ +│ ├── server.js # Express + Socket.IO setup +│ ├── db.js # SQLite with WAL mode +│ ├── auth.js # JWT auth middleware +│ ├── minio.js # MinIO client + image proxy URLs +│ ├── routes/ +│ │ ├── auth.js # Login/register +│ │ ├── boards.js # Board CRUD + image upload +│ │ └── collections.js # Collection CRUD + sharing +│ └── socket/ +│ ├── index.js # Socket.IO auth + setup +│ └── board-room.js # Room management + sync relay +├── frontend/ +│ ├── src/ +│ │ ├── canvas/ +│ │ │ ├── FabricCanvas.tsx # Fabric.js canvas wrapper +│ │ │ ├── image-drop.ts # Drag/drop + paste handlers +│ │ │ ├── sync.ts # Full-scene sync engine +│ │ │ ├── history.ts # Undo/redo manager +│ │ │ └── tools.ts # Tool activation logic +│ │ ├── components/ +│ │ │ ├── Toolbar.tsx # Tool bar with SVG icons +│ │ │ ├── StatusBar.tsx # Bottom status bar +│ │ │ ├── UserCursors.tsx # Remote cursor display +│ │ │ ├── ContextMenu.tsx # Right-click menu +│ │ │ ├── ShareDialog.tsx # Collection sharing +│ │ │ └── ColorPicker.tsx # Color selection +│ │ ├── pages/ +│ │ │ ├── Editor.tsx # Main board editor +│ │ │ ├── Dashboard.tsx # Collections list +│ │ │ └── Login.tsx # Auth page +│ │ ├── api.ts # HTTP API client +│ │ ├── auth.ts # Auth context +│ │ └── socket.ts # Socket.IO client +│ └── vite.config.ts +├── Dockerfile +└── docker-compose.yml +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e947adf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM node:20-alpine AS frontend-build +WORKDIR /build +COPY frontend/package.json ./frontend/ +RUN cd frontend && npm install +COPY frontend/ ./frontend/ +RUN cd frontend && npm run build + +FROM node:20-alpine +WORKDIR /app + +# Build tools for native modules (better-sqlite3, sharp) +RUN apk add --no-cache python3 make g++ + +# Backend dependencies +COPY backend/package.json ./backend/ +RUN cd backend && npm install --production + +# Remove build tools to keep image smaller +RUN apk del python3 make g++ + +# Backend source +COPY backend/ ./backend/ + +# Frontend build output +COPY --from=frontend-build /build/frontend/dist ./frontend/dist + +# Data directory for SQLite +RUN mkdir -p /app/data + +# Health check utility +RUN apk add --no-cache wget + +EXPOSE 8000 + +CMD ["node", "backend/server.js"] diff --git a/backend/auth.js b/backend/auth.js new file mode 100644 index 0000000..28c4fea --- /dev/null +++ b/backend/auth.js @@ -0,0 +1,93 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); + +const JWT_SECRET = process.env.JWT_SECRET || (() => { + if (process.env.NODE_ENV === 'production') { + throw new Error('JWT_SECRET environment variable is required in production'); + } + return 'refboard-dev-secret-do-not-use-in-prod'; +})(); + +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; +const BCRYPT_ROUNDS = 12; +const REFBOARD_API_KEY = process.env.REFBOARD_API_KEY || ''; + +function generateToken(user) { + return jwt.sign( + { id: user.id, email: user.email, role: user.role }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); +} + +function verifyToken(token) { + return jwt.verify(token, JWT_SECRET); +} + +async function hashPassword(password) { + return bcrypt.hash(password, BCRYPT_ROUNDS); +} + +async function comparePassword(password, hash) { + return bcrypt.compare(password, hash); +} + +/** + * Express middleware: validates Bearer token from Authorization header. + * Attaches decoded user to req.user. + */ +function authMiddleware(req, res, next) { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const token = authHeader.slice(7); + const decoded = verifyToken(token); + req.user = decoded; + next(); + } catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired' }); + } + return res.status(401).json({ error: 'Invalid token' }); + } +} + +/** + * Express middleware: checks that authenticated user has admin role. + * Must be used after authMiddleware. + */ +function adminMiddleware(req, res, next) { + if (!req.user || req.user.role !== 'admin') { + return res.status(403).json({ error: 'Admin access required' }); + } + next(); +} + +/** + * Express middleware: validates X-API-Key header against REFBOARD_API_KEY env var. + * Used for admin/bot API routes. + */ +function apiKeyMiddleware(req, res, next) { + const apiKey = req.headers['x-api-key']; + if (!REFBOARD_API_KEY) { + return res.status(503).json({ error: 'API key not configured on server' }); + } + if (!apiKey || apiKey !== REFBOARD_API_KEY) { + return res.status(401).json({ error: 'Invalid or missing API key' }); + } + next(); +} + +module.exports = { + generateToken, + verifyToken, + hashPassword, + comparePassword, + authMiddleware, + adminMiddleware, + apiKeyMiddleware, + JWT_SECRET, +}; diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 0000000..8bbb07e --- /dev/null +++ b/backend/db.js @@ -0,0 +1,334 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const DB_PATH = process.env.DB_PATH || '/app/data/refboard.db'; + +// Ensure directory exists +const dbDir = path.dirname(DB_PATH); +if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); +} + +const db = new Database(DB_PATH); + +// Enable WAL mode for better concurrent read performance +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// --------------------- +// Schema +// --------------------- +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '', + created_by TEXT NOT NULL REFERENCES users(id), + share_token TEXT UNIQUE, + is_public INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS collection_members ( + collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'editor', + added_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (collection_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS boards ( + id TEXT PRIMARY KEY, + collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT DEFAULT '', + canvas_state TEXT DEFAULT '{}', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, + width INTEGER, + height INTEGER, + minio_path TEXT NOT NULL, + public_url TEXT, + uploaded_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_collections_created_by ON collections(created_by); + CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id); + CREATE INDEX IF NOT EXISTS idx_boards_collection ON boards(collection_id); + CREATE INDEX IF NOT EXISTS idx_boards_created_by ON boards(created_by); + CREATE INDEX IF NOT EXISTS idx_images_board ON images(board_id); +`); + +// --------------------- +// User helpers +// --------------------- +function getUserByEmail(email) { + return db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1').get(email); +} + +function getUserById(id) { + return db.prepare('SELECT * FROM users WHERE id = ? AND is_active = 1').get(id); +} + +function getUserByUsername(username) { + return db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username); +} + +function createUser({ id, email, username, passwordHash, displayName, role }) { + db.prepare(` + INSERT INTO users (id, email, username, password_hash, display_name, role) + VALUES (?, ?, ?, ?, ?, ?) + `).run(id, email, username, passwordHash, displayName, role || 'member'); + return getUserById(id); +} + +function getAllUsers() { + return db.prepare('SELECT id, email, username, display_name, role, is_active, created_at, updated_at FROM users').all(); +} + +function updateUserPassword(userId, passwordHash) { + db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(passwordHash, userId); +} + +function deactivateUser(userId) { + db.prepare("UPDATE users SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(userId); +} + +function getUserCount() { + return db.prepare('SELECT COUNT(*) as count FROM users').get().count; +} + +// --------------------- +// Collection helpers +// --------------------- +function getCollections(userId, search, limit = 50, offset = 0) { + let query = ` + SELECT DISTINCT c.*, cm.role as member_role, + (SELECT COUNT(*) FROM boards WHERE collection_id = c.id) as board_count + FROM collections c + LEFT JOIN collection_members cm ON c.id = cm.collection_id AND cm.user_id = ? + WHERE (cm.user_id IS NOT NULL OR c.is_public = 1) + `; + const params = [userId]; + + if (search) { + query += ' AND (c.name LIKE ? OR c.description LIKE ?)'; + const searchTerm = `%${search}%`; + params.push(searchTerm, searchTerm); + } + + query += ' ORDER BY c.updated_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + return db.prepare(query).all(...params); +} + +function getCollection(collectionId) { + return db.prepare('SELECT * FROM collections WHERE id = ?').get(collectionId); +} + +function getCollectionByShareToken(shareToken) { + return db.prepare('SELECT * FROM collections WHERE share_token = ?').get(shareToken); +} + +function createCollection({ id, name, description, createdBy }) { + db.prepare(` + INSERT INTO collections (id, name, description, created_by) + VALUES (?, ?, ?, ?) + `).run(id, name, description || '', createdBy); + return getCollection(id); +} + +function updateCollection(collectionId, { name, description, isPublic, shareToken }) { + const fields = []; + const params = []; + + if (name !== undefined) { fields.push('name = ?'); params.push(name); } + if (description !== undefined) { fields.push('description = ?'); params.push(description); } + if (isPublic !== undefined) { fields.push('is_public = ?'); params.push(isPublic ? 1 : 0); } + if (shareToken !== undefined) { fields.push('share_token = ?'); params.push(shareToken); } + + if (fields.length === 0) return getCollection(collectionId); + + fields.push("updated_at = datetime('now')"); + params.push(collectionId); + + db.prepare(`UPDATE collections SET ${fields.join(', ')} WHERE id = ?`).run(...params); + return getCollection(collectionId); +} + +function deleteCollection(collectionId) { + db.prepare('DELETE FROM collections WHERE id = ?').run(collectionId); +} + +// --------------------- +// Collection members +// --------------------- +function getCollectionMembers(collectionId) { + return db.prepare(` + SELECT cm.collection_id, cm.user_id, cm.role, cm.added_at, + u.email, u.username, u.display_name + FROM collection_members cm + JOIN users u ON cm.user_id = u.id + WHERE cm.collection_id = ? + `).all(collectionId); +} + +function getCollectionMember(collectionId, userId) { + return db.prepare('SELECT * FROM collection_members WHERE collection_id = ? AND user_id = ?').get(collectionId, userId); +} + +function addCollectionMember(collectionId, userId, role = 'editor') { + db.prepare(` + INSERT OR REPLACE INTO collection_members (collection_id, user_id, role) + VALUES (?, ?, ?) + `).run(collectionId, userId, role); +} + +function removeCollectionMember(collectionId, userId) { + db.prepare('DELETE FROM collection_members WHERE collection_id = ? AND user_id = ?').run(collectionId, userId); +} + +/** + * Check if user can access a collection (is member, or collection is public). + * Returns the member record or null. + */ +function checkCollectionAccess(collectionId, userId) { + const collection = getCollection(collectionId); + if (!collection) return { collection: null, member: null }; + const member = getCollectionMember(collectionId, userId); + if (!member && !collection.is_public) return { collection, member: null }; + return { collection, member: member || { role: 'viewer' } }; +} + +// --------------------- +// Board helpers +// --------------------- +function getCollectionBoards(collectionId, search, limit = 50, offset = 0) { + let query = ` + SELECT b.*, + (SELECT COUNT(*) FROM images WHERE board_id = b.id) as image_count + FROM boards b + WHERE b.collection_id = ? + `; + const params = [collectionId]; + + if (search) { + query += ' AND (b.name LIKE ? OR b.description LIKE ?)'; + const searchTerm = `%${search}%`; + params.push(searchTerm, searchTerm); + } + + query += ' ORDER BY b.updated_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + return db.prepare(query).all(...params); +} + +function getBoard(boardId) { + return db.prepare('SELECT * FROM boards WHERE id = ?').get(boardId); +} + +function createBoard({ id, collectionId, name, description, createdBy }) { + db.prepare(` + INSERT INTO boards (id, collection_id, name, description, created_by) + VALUES (?, ?, ?, ?, ?) + `).run(id, collectionId, name, description || '', createdBy); + return getBoard(id); +} + +function updateBoard(boardId, { name, description }) { + const fields = []; + const params = []; + + if (name !== undefined) { fields.push('name = ?'); params.push(name); } + if (description !== undefined) { fields.push('description = ?'); params.push(description); } + + if (fields.length === 0) return getBoard(boardId); + + fields.push("updated_at = datetime('now')"); + params.push(boardId); + + db.prepare(`UPDATE boards SET ${fields.join(', ')} WHERE id = ?`).run(...params); + return getBoard(boardId); +} + +function deleteBoard(boardId) { + db.prepare('DELETE FROM boards WHERE id = ?').run(boardId); +} + +function saveBoardCanvas(boardId, canvasState) { + db.prepare("UPDATE boards SET canvas_state = ?, updated_at = datetime('now') WHERE id = ?") + .run(typeof canvasState === 'string' ? canvasState : JSON.stringify(canvasState), boardId); +} + +// --------------------- +// Images +// --------------------- +function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy }) { + db.prepare(` + INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy); + return db.prepare('SELECT * FROM images WHERE id = ?').get(id); +} + +function getBoardImages(boardId) { + return db.prepare('SELECT * FROM images WHERE board_id = ? ORDER BY created_at DESC').all(boardId); +} + +function getImage(imageId) { + return db.prepare('SELECT * FROM images WHERE id = ?').get(imageId); +} + +function deleteImage(imageId) { + const image = getImage(imageId); + db.prepare('DELETE FROM images WHERE id = ?').run(imageId); + return image; +} + +function deleteBoardImageRecords(boardId) { + const images = getBoardImages(boardId); + db.prepare('DELETE FROM images WHERE board_id = ?').run(boardId); + return images; +} + +module.exports = { + db, + // Users + getUserByEmail, getUserById, getUserByUsername, createUser, + getAllUsers, updateUserPassword, deactivateUser, getUserCount, + // Collections + getCollections, getCollection, getCollectionByShareToken, + createCollection, updateCollection, deleteCollection, + getCollectionMembers, getCollectionMember, addCollectionMember, removeCollectionMember, + checkCollectionAccess, + // Boards + getCollectionBoards, getBoard, createBoard, updateBoard, deleteBoard, saveBoardCanvas, + // Images + createImage, getBoardImages, getImage, deleteImage, deleteBoardImageRecords, +}; diff --git a/backend/minio.js b/backend/minio.js new file mode 100644 index 0000000..8473fc8 --- /dev/null +++ b/backend/minio.js @@ -0,0 +1,113 @@ +const Minio = require('minio'); +const path = require('path'); + +const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'localhost'; +const MINIO_PORT = parseInt(process.env.MINIO_PORT || '9000', 10); +const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'minioadmin'; +const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || 'minioadmin'; +const MINIO_USE_SSL = process.env.MINIO_USE_SSL === 'true'; +const MINIO_BUCKET = process.env.MINIO_BUCKET || 'refboard'; +const PUBLIC_URL = process.env.PUBLIC_URL || `http://${MINIO_ENDPOINT}:${MINIO_PORT}`; + +const minioClient = new Minio.Client({ + endPoint: MINIO_ENDPOINT, + port: MINIO_PORT, + useSSL: MINIO_USE_SSL, + accessKey: MINIO_ACCESS_KEY, + secretKey: MINIO_SECRET_KEY, +}); + +const MIME_TO_EXT = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/svg+xml': '.svg', +}; + +/** + * Create the bucket if it doesn't already exist. + * Sets a public read policy so images are accessible via URL. + */ +async function initBucket() { + const exists = await minioClient.bucketExists(MINIO_BUCKET); + if (!exists) { + await minioClient.makeBucket(MINIO_BUCKET, ''); + // Set public read policy + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { AWS: ['*'] }, + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${MINIO_BUCKET}/*`], + }, + ], + }; + await minioClient.setBucketPolicy(MINIO_BUCKET, JSON.stringify(policy)); + } + console.log(`[minio] Bucket "${MINIO_BUCKET}" ready`); +} + +/** + * Upload an image buffer to MinIO. + * Returns the minio object path (used as key for future operations). + */ +async function uploadImage(boardId, imageId, buffer, mimeType) { + const ext = MIME_TO_EXT[mimeType] || '.bin'; + const objectName = `boards/${boardId}/${imageId}${ext}`; + + await minioClient.putObject(MINIO_BUCKET, objectName, buffer, buffer.length, { + 'Content-Type': mimeType, + }); + + return objectName; +} + +/** + * Delete a single object by its minio path. + */ +async function deleteImage(minioPath) { + await minioClient.removeObject(MINIO_BUCKET, minioPath); +} + +/** + * Delete all objects under the boards/{boardId}/ prefix. + */ +async function deleteBoardImages(boardId) { + const prefix = `boards/${boardId}/`; + const objectsList = []; + + return new Promise((resolve, reject) => { + const stream = minioClient.listObjectsV2(MINIO_BUCKET, prefix, true); + stream.on('data', (obj) => { + objectsList.push(obj.name); + }); + stream.on('error', reject); + stream.on('end', async () => { + if (objectsList.length > 0) { + await minioClient.removeObjects(MINIO_BUCKET, objectsList); + } + resolve(objectsList.length); + }); + }); +} + +/** + * Build the public URL for an image given its minio path. + */ +function getImageUrl(minioPath) { + // Return relative URL — images served via backend proxy at /api/images/* + return `/api/images/${minioPath}`; +} + +module.exports = { + minioClient, + initBucket, + uploadImage, + deleteImage, + deleteBoardImages, + getImageUrl, + MINIO_BUCKET, +}; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..b0e965f --- /dev/null +++ b/backend/package.json @@ -0,0 +1,21 @@ +{ + "name": "refboard-backend", + "version": "1.0.0", + "description": "RefBoard — collaborative reference board backend", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.7.0", + "cors": "^2.8.5", + "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", + "minio": "^8.0.2", + "multer": "^1.4.5-lts.1", + "sharp": "^0.33.2", + "socket.io": "^4.8.1", + "uuid": "^11.0.3" + } +} diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 0000000..d00cc0d --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,202 @@ +const { Router } = require('express'); +const { v4: uuidv4 } = require('uuid'); +const { apiKeyMiddleware, hashPassword } = require('../auth'); +const { + createUser, + getAllUsers, + getUserById, + getUserByEmail, + deactivateUser, + updateUserPassword, + createCollection, + getCollection, + updateCollection, + addCollectionMember, + createBoard, +} = require('../db'); + +const router = Router(); + +router.use(apiKeyMiddleware); + +// ---- User management ---- + +router.post('/users', async (req, res) => { + try { + const { email, username, password, display_name, role } = req.body; + if (!email || !username || !password) { + return res.status(400).json({ error: 'Email, username, and password are required' }); + } + + const existing = getUserByEmail(email); + if (existing) { + return res.status(409).json({ error: 'Email already registered' }); + } + + const passwordHash = await hashPassword(password); + const user = createUser({ + id: uuidv4(), + email, + username, + passwordHash, + displayName: display_name || username, + role: role || 'member', + }); + + return res.status(201).json({ + user: { + id: user.id, + email: user.email, + username: user.username, + display_name: user.display_name, + role: user.role, + created_at: user.created_at, + }, + }); + } catch (err) { + console.error('[admin] create user error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.get('/users', (req, res) => { + try { + const users = getAllUsers(); + return res.json({ users }); + } catch (err) { + console.error('[admin] list users error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.delete('/users/:userId', (req, res) => { + try { + const user = getUserById(req.params.userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + deactivateUser(req.params.userId); + return res.json({ message: 'User deactivated' }); + } catch (err) { + console.error('[admin] deactivate user error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.put('/users/:userId/password', async (req, res) => { + try { + const { password } = req.body; + if (!password) { + return res.status(400).json({ error: 'Password is required' }); + } + + const user = getUserById(req.params.userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const passwordHash = await hashPassword(password); + updateUserPassword(req.params.userId, passwordHash); + + return res.json({ message: 'Password reset successfully' }); + } catch (err) { + console.error('[admin] reset password error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ---- Collection management ---- + +router.post('/collections', (req, res) => { + try { + const { name, description, user_id } = req.body; + if (!name || !user_id) { + return res.status(400).json({ error: 'Collection name and user_id are required' }); + } + + const user = getUserById(user_id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const collectionId = uuidv4(); + const collection = createCollection({ + id: collectionId, + name: name.trim(), + description: description || '', + createdBy: user_id, + }); + + addCollectionMember(collectionId, user_id, 'owner'); + + return res.status(201).json({ collection }); + } catch (err) { + console.error('[admin] create collection error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.post('/collections/:collectionId/share', (req, res) => { + try { + const collection = getCollection(req.params.collectionId); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + + let shareToken = collection.share_token; + if (!shareToken) { + shareToken = uuidv4(); + } + + const updated = updateCollection(collection.id, { + isPublic: true, + shareToken, + }); + + const PUBLIC_URL = process.env.PUBLIC_URL || ''; + return res.json({ + is_public: true, + share_token: updated.share_token, + share_url: `${PUBLIC_URL}/c/${updated.share_token}`, + }); + } catch (err) { + console.error('[admin] share collection error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ---- Board management (create board in collection) ---- + +router.post('/boards', (req, res) => { + try { + const { collection_id, name, description, user_id } = req.body; + if (!collection_id || !name || !user_id) { + return res.status(400).json({ error: 'collection_id, name, and user_id are required' }); + } + + const user = getUserById(user_id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const collection = getCollection(collection_id); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + + const board = createBoard({ + id: uuidv4(), + collectionId: collection_id, + name: name.trim(), + description: description || '', + createdBy: user_id, + }); + + return res.status(201).json({ board }); + } catch (err) { + console.error('[admin] create board error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..73b4ab6 --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,178 @@ +const { Router } = require('express'); +const { v4: uuidv4 } = require('uuid'); +const { + getUserByEmail, + getUserByUsername, + createUser, + getUserById, + getUserCount, +} = require('../db'); +const { + hashPassword, + comparePassword, + generateToken, + authMiddleware, +} = require('../auth'); + +const router = Router(); + +/** + * POST /api/auth/register + * Create a new user. The first user automatically becomes admin. + */ +router.post('/register', async (req, res) => { + try { + const { email, username, password, display_name } = req.body; + + if (!email || !username || !password) { + return res.status(400).json({ error: 'Email, username, and password are required' }); + } + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + const existing = getUserByEmail(email); + if (existing) { + return res.status(409).json({ error: 'Email already registered' }); + } + + const existingUsername = getUserByUsername(username); + if (existingUsername) { + return res.status(409).json({ error: 'Username already taken' }); + } + + const passwordHash = await hashPassword(password); + const userCount = getUserCount(); + const role = userCount === 0 ? 'admin' : 'member'; + + const user = createUser({ + id: uuidv4(), + email, + username, + passwordHash, + displayName: display_name || username, + role, + }); + + const token = generateToken(user); + + return res.status(201).json({ + user: { + id: user.id, + email: user.email, + username: user.username, + display_name: user.display_name, + role: user.role, + created_at: user.created_at, + }, + token, + }); + } catch (err) { + console.error('[auth] register error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * POST /api/auth/login + * Authenticate with email + password, receive JWT. + */ +router.post('/login', async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + + const user = getUserByEmail(email); + if (!user) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const valid = await comparePassword(password, user.password_hash); + if (!valid) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const token = generateToken(user); + + return res.json({ + user: { + id: user.id, + email: user.email, + username: user.username, + display_name: user.display_name, + role: user.role, + created_at: user.created_at, + }, + token, + }); + } catch (err) { + console.error('[auth] login error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /api/auth/me + * Return the current authenticated user. + */ +router.get('/me', authMiddleware, (req, res) => { + try { + const user = getUserById(req.user.id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + return res.json({ + id: user.id, + email: user.email, + username: user.username, + display_name: user.display_name, + role: user.role, + created_at: user.created_at, + }); + } catch (err) { + console.error('[auth] me error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * PUT /api/auth/password + * Change password (requires current password). + */ +router.put('/password', authMiddleware, async (req, res) => { + try { + const { current_password, new_password } = req.body; + + if (!current_password || !new_password) { + return res.status(400).json({ error: 'Current password and new password are required' }); + } + if (new_password.length < 6) { + return res.status(400).json({ error: 'New password must be at least 6 characters' }); + } + + const user = getUserById(req.user.id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const valid = await comparePassword(current_password, user.password_hash); + if (!valid) { + return res.status(401).json({ error: 'Current password is incorrect' }); + } + + const { updateUserPassword } = require('../db'); + const passwordHash = await hashPassword(new_password); + updateUserPassword(user.id, passwordHash); + + return res.json({ message: 'Password updated successfully' }); + } catch (err) { + console.error('[auth] password change error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/backend/routes/boards.js b/backend/routes/boards.js new file mode 100644 index 0000000..05087b3 --- /dev/null +++ b/backend/routes/boards.js @@ -0,0 +1,186 @@ +const { Router } = require('express'); +const { v4: uuidv4 } = require('uuid'); +const { authMiddleware } = require('../auth'); +const { + getBoard, + createBoard, + updateBoard, + deleteBoard, + saveBoardCanvas, + getBoardImages, + getCollection, + getCollectionMember, +} = require('../db'); +const { deleteBoardImages: deleteBoardMinioImages } = require('../minio'); + +const router = Router(); + +router.use(authMiddleware); + +function hasCollectionRole(member, minRole) { + if (!member) return false; + const hierarchy = { owner: 3, editor: 2, viewer: 1 }; + return (hierarchy[member.role] || 0) >= (hierarchy[minRole] || 0); +} + +/** + * Resolve board + check collection access. Returns { board, member } or sends error. + */ +function resolveBoard(req, res, minRole = 'viewer') { + const board = getBoard(req.params.boardId); + if (!board) { + res.status(404).json({ error: 'Board not found' }); + return null; + } + + const collection = getCollection(board.collection_id); + if (!collection) { + res.status(404).json({ error: 'Collection not found' }); + return null; + } + + const member = getCollectionMember(board.collection_id, req.user.id); + // Allow access if member has sufficient role, or collection is public (viewer-level) + if (minRole === 'viewer' && collection.is_public) { + return { board, collection, member: member || { role: 'viewer' } }; + } + if (!hasCollectionRole(member, minRole)) { + res.status(403).json({ error: `${minRole} access required` }); + return null; + } + + return { board, collection, member }; +} + +/** + * POST /api/boards + * Create a board inside a collection. Editor+ on the collection. + */ +router.post('/', (req, res) => { + try { + const { collection_id, name, description } = req.body; + if (!collection_id || !name || !name.trim()) { + return res.status(400).json({ error: 'collection_id and name are required' }); + } + + const collection = getCollection(collection_id); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + + const member = getCollectionMember(collection_id, req.user.id); + if (!hasCollectionRole(member, 'editor')) { + return res.status(403).json({ error: 'Editor access required on collection' }); + } + + const board = createBoard({ + id: uuidv4(), + collectionId: collection_id, + name: name.trim(), + description: description || '', + createdBy: req.user.id, + }); + + return res.status(201).json({ board }); + } catch (err) { + console.error('[boards] create error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /api/boards/:boardId + * Get board with canvas_state and images. Viewer+ on collection. + */ +router.get('/:boardId', (req, res) => { + try { + const result = resolveBoard(req, res, 'viewer'); + if (!result) return; + + const { board, collection } = result; + const images = getBoardImages(board.id); + + return res.json({ + board: { + ...board, + canvas_state: board.canvas_state ? JSON.parse(board.canvas_state) : {}, + }, + collection: { + id: collection.id, + name: collection.name, + }, + images, + }); + } catch (err) { + console.error('[boards] get error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * PUT /api/boards/:boardId + * Update board name/description. Editor+ on collection. + */ +router.put('/:boardId', (req, res) => { + try { + const result = resolveBoard(req, res, 'editor'); + if (!result) return; + + const { name, description } = req.body; + const updated = updateBoard(result.board.id, { name, description }); + + return res.json({ board: updated }); + } catch (err) { + console.error('[boards] update error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * DELETE /api/boards/:boardId + * Delete board + images. Owner on collection. + */ +router.delete('/:boardId', async (req, res) => { + try { + const result = resolveBoard(req, res, 'owner'); + if (!result) return; + + try { + await deleteBoardMinioImages(result.board.id); + } catch (e) { + console.error('[boards] MinIO cleanup error:', e); + } + + deleteBoard(result.board.id); + + return res.json({ message: 'Board deleted' }); + } catch (err) { + console.error('[boards] delete error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * POST /api/boards/:boardId/save + * Save canvas state. Editor+ on collection. + */ +router.post('/:boardId/save', (req, res) => { + try { + const result = resolveBoard(req, res, 'editor'); + if (!result) return; + + const { canvas_state } = req.body; + if (canvas_state === undefined) { + return res.status(400).json({ error: 'canvas_state is required' }); + } + + saveBoardCanvas(result.board.id, canvas_state); + + return res.json({ message: 'Canvas saved' }); + } catch (err) { + console.error('[boards] save error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/backend/routes/collections.js b/backend/routes/collections.js new file mode 100644 index 0000000..c5af8c3 --- /dev/null +++ b/backend/routes/collections.js @@ -0,0 +1,312 @@ +const { Router } = require('express'); +const { v4: uuidv4 } = require('uuid'); +const { authMiddleware } = require('../auth'); +const { + getCollections, + getCollection, + createCollection, + updateCollection, + deleteCollection, + getCollectionMembers, + getCollectionMember, + addCollectionMember, + removeCollectionMember, + getCollectionBoards, + getUserByEmail, +} = require('../db'); +const { deleteBoardImages: deleteBoardMinioImages } = require('../minio'); + +const router = Router(); + +router.use(authMiddleware); + +function hasRole(member, minRole) { + if (!member) return false; + const hierarchy = { owner: 3, editor: 2, viewer: 1 }; + return (hierarchy[member.role] || 0) >= (hierarchy[minRole] || 0); +} + +/** + * GET /api/collections + * List collections the user has access to. + */ +router.get('/', (req, res) => { + try { + const { search, limit, offset } = req.query; + const collections = getCollections( + req.user.id, + search || null, + parseInt(limit, 10) || 50, + parseInt(offset, 10) || 0 + ); + return res.json({ collections }); + } catch (err) { + console.error('[collections] list error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * POST /api/collections + * Create a new collection. Creator becomes owner. + */ +router.post('/', (req, res) => { + try { + const { name, description } = req.body; + if (!name || !name.trim()) { + return res.status(400).json({ error: 'Collection name is required' }); + } + + const collectionId = uuidv4(); + const collection = createCollection({ + id: collectionId, + name: name.trim(), + description: description || '', + createdBy: req.user.id, + }); + + addCollectionMember(collectionId, req.user.id, 'owner'); + + return res.status(201).json({ collection }); + } catch (err) { + console.error('[collections] create error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /api/collections/:collectionId + * Get collection with boards and members. + */ +router.get('/:collectionId', (req, res) => { + try { + const collection = getCollection(req.params.collectionId); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + + const member = getCollectionMember(collection.id, req.user.id); + if (!member && !collection.is_public) { + return res.status(403).json({ error: 'Access denied' }); + } + + const members = getCollectionMembers(collection.id); + const boards = getCollectionBoards(collection.id); + + return res.json({ collection, members, boards }); + } catch (err) { + console.error('[collections] get error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * PUT /api/collections/:collectionId + * Update collection. Editor+ only. + */ +router.put('/:collectionId', (req, res) => { + try { + const collection = getCollection(req.params.collectionId); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + + const member = getCollectionMember(collection.id, req.user.id); + if (!hasRole(member, 'editor')) { + return res.status(403).json({ error: 'Editor or owner access required' }); + } + + const { name, description } = req.body; + const updated = updateCollection(collection.id, { name, description }); + + return res.json({ collection: updated }); + } catch (err) { + console.error('[collections] update error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * DELETE /api/collections/:collectionId + * Delete collection and all boards + images. Owner only. + */ +router.delete('/:collectionId', async (req, res) => { + try { + const collection = getCollection(req.params.collectionId); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + + const member = getCollectionMember(collection.id, req.user.id); + if (!hasRole(member, 'owner')) { + return res.status(403).json({ error: 'Owner access required' }); + } + + // Delete all board images from MinIO + const boards = getCollectionBoards(collection.id); + for (const board of boards) { + try { + await deleteBoardMinioImages(board.id); + } catch (e) { + console.error(`[collections] MinIO cleanup error for board ${board.id}:`, e); + } + } + + // Cascade deletes boards, images via FK + deleteCollection(collection.id); + + return res.json({ message: 'Collection deleted' }); + } catch (err) { + console.error('[collections] delete error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /api/collections/:collectionId/share + * Get share info. + */ +router.get('/:collectionId/share', (req, res) => { + try { + const collection = getCollection(req.params.collectionId); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + + const member = getCollectionMember(collection.id, req.user.id); + if (!member) { + return res.status(403).json({ error: 'Access denied' }); + } + + const PUBLIC_URL = process.env.PUBLIC_URL || ''; + return res.json({ + is_public: !!collection.is_public, + share_token: collection.share_token, + share_url: collection.share_token + ? `${PUBLIC_URL}/c/${collection.share_token}` + : null, + }); + } catch (err) { + console.error('[collections] share info error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * POST /api/collections/:collectionId/share + * Toggle public sharing. Owner only. + */ +router.post('/:collectionId/share', (req, res) => { + try { + const collection = getCollection(req.params.collectionId); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + + const member = getCollectionMember(collection.id, req.user.id); + if (!hasRole(member, 'owner')) { + return res.status(403).json({ error: 'Owner access required' }); + } + + const { is_public } = req.body; + const makePublic = is_public !== undefined ? !!is_public : !collection.is_public; + + let shareToken = collection.share_token; + if (makePublic && !shareToken) { + shareToken = uuidv4(); + } + + const updated = updateCollection(collection.id, { + isPublic: makePublic, + shareToken: makePublic ? shareToken : collection.share_token, + }); + + const PUBLIC_URL = process.env.PUBLIC_URL || ''; + return res.json({ + is_public: !!updated.is_public, + share_token: updated.share_token, + share_url: updated.share_token + ? `${PUBLIC_URL}/c/${updated.share_token}` + : null, + }); + } catch (err) { + console.error('[collections] share toggle error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * POST /api/collections/:collectionId/members + * Add a member. Owner only. + */ +router.post('/:collectionId/members', (req, res) => { + try { + const collection = getCollection(req.params.collectionId); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + + const member = getCollectionMember(collection.id, req.user.id); + if (!hasRole(member, 'owner')) { + return res.status(403).json({ error: 'Owner access required' }); + } + + const { email, role } = req.body; + const validRoles = ['viewer', 'editor']; + const memberRole = validRoles.includes(role) ? role : 'editor'; + + const targetUser = getUserByEmail(email); + if (!targetUser) { + return res.status(404).json({ error: 'User not found' }); + } + if (targetUser.id === req.user.id) { + return res.status(400).json({ error: 'Cannot add yourself' }); + } + + addCollectionMember(collection.id, targetUser.id, memberRole); + + return res.status(201).json({ + member: { + collection_id: collection.id, + user_id: targetUser.id, + email: targetUser.email, + display_name: targetUser.display_name, + role: memberRole, + }, + }); + } catch (err) { + console.error('[collections] add member error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * DELETE /api/collections/:collectionId/members/:userId + * Remove a member. Owner only. + */ +router.delete('/:collectionId/members/:userId', (req, res) => { + try { + const collection = getCollection(req.params.collectionId); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + + const member = getCollectionMember(collection.id, req.user.id); + if (!hasRole(member, 'owner')) { + return res.status(403).json({ error: 'Owner access required' }); + } + + if (req.params.userId === req.user.id) { + return res.status(400).json({ error: 'Cannot remove yourself' }); + } + + removeCollectionMember(collection.id, req.params.userId); + + return res.json({ message: 'Member removed' }); + } catch (err) { + console.error('[collections] remove member error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/backend/routes/upload.js b/backend/routes/upload.js new file mode 100644 index 0000000..b7f89b5 --- /dev/null +++ b/backend/routes/upload.js @@ -0,0 +1,248 @@ +const { Router } = require('express'); +const multer = require('multer'); +const sharp = require('sharp'); +const { v4: uuidv4 } = require('uuid'); +const https = require('https'); +const http = require('http'); +const { URL } = require('url'); +const { authMiddleware } = require('../auth'); +const { getBoard, getCollectionMember, createImage } = require('../db'); +const { uploadImage, getImageUrl } = require('../minio'); + +const router = Router(); + +const ALLOWED_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', +]; + +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB + +// Multer config: memory storage, 50MB limit, image types only +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_FILE_SIZE }, + fileFilter: (_req, file, cb) => { + if (ALLOWED_MIME_TYPES.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error(`Unsupported file type: ${file.mimetype}`)); + } + }, +}); + +// All routes require auth +router.use(authMiddleware); + +/** + * Helper: check board access for editor+ + */ +function checkEditorAccess(req, res) { + const board = getBoard(req.params.boardId); + if (!board) { + res.status(404).json({ error: 'Board not found' }); + return null; + } + + const member = getCollectionMember(board.collection_id, req.user.id); + const hierarchy = { owner: 3, editor: 2, viewer: 1 }; + if (!member || (hierarchy[member.role] || 0) < 2) { + res.status(403).json({ error: 'Editor or owner access required' }); + return null; + } + + return board; +} + +/** + * Get image dimensions using sharp. Returns { width, height } or null for SVG. + */ +async function getImageDimensions(buffer, mimeType) { + if (mimeType === 'image/svg+xml') { + return { width: null, height: null }; + } + try { + const metadata = await sharp(buffer).metadata(); + return { width: metadata.width || null, height: metadata.height || null }; + } catch { + return { width: null, height: null }; + } +} + +/** + * POST /api/upload/boards/:boardId/images + * Upload an image file to a board. + */ +router.post('/boards/:boardId/images', upload.single('image'), async (req, res) => { + try { + const board = checkEditorAccess(req, res); + if (!board) return; + + if (!req.file) { + return res.status(400).json({ error: 'No image file provided' }); + } + + const imageId = uuidv4(); + const { buffer, originalname, mimetype, size } = req.file; + + // Get dimensions + const { width, height } = await getImageDimensions(buffer, mimetype); + + // Upload to MinIO + const minioPath = await uploadImage(board.id, imageId, buffer, mimetype); + const publicUrl = getImageUrl(minioPath); + + // Save record + const image = createImage({ + id: imageId, + boardId: board.id, + filename: originalname, + mimeType: mimetype, + fileSize: size, + width, + height, + minioPath, + publicUrl, + uploadedBy: req.user.id, + }); + + return res.status(201).json({ + id: image.id, + url: publicUrl, + public_url: publicUrl, + width: image.width, + height: image.height, + file_size: image.file_size, + mime_type: image.mime_type, + }); + } catch (err) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ error: 'File too large (max 50MB)' }); + } + console.error('[upload] error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * Download an image from a URL. Returns { buffer, mimeType, filename }. + */ +function downloadImage(imageUrl) { + return new Promise((resolve, reject) => { + const parsed = new URL(imageUrl); + const client = parsed.protocol === 'https:' ? https : http; + + client.get(imageUrl, { timeout: 30000 }, (response) => { + // Follow redirects (up to 5) + if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) { + return downloadImage(response.headers.location).then(resolve).catch(reject); + } + + if (response.statusCode !== 200) { + return reject(new Error(`Failed to download: HTTP ${response.statusCode}`)); + } + + const contentType = (response.headers['content-type'] || '').split(';')[0].trim(); + if (!ALLOWED_MIME_TYPES.includes(contentType)) { + return reject(new Error(`Unsupported content type: ${contentType}`)); + } + + const chunks = []; + let totalSize = 0; + + response.on('data', (chunk) => { + totalSize += chunk.length; + if (totalSize > MAX_FILE_SIZE) { + response.destroy(); + return reject(new Error('Downloaded file too large (max 50MB)')); + } + chunks.push(chunk); + }); + + response.on('end', () => { + const buffer = Buffer.concat(chunks); + const filename = parsed.pathname.split('/').pop() || 'image'; + resolve({ buffer, mimeType: contentType, filename }); + }); + + response.on('error', reject); + }).on('error', reject); + }); +} + +/** + * POST /api/upload/boards/:boardId/images/from-url + * Download an image from a URL and store it. + */ +router.post('/boards/:boardId/images/from-url', async (req, res) => { + try { + const board = checkEditorAccess(req, res); + if (!board) return; + + const { url } = req.body; + if (!url) { + return res.status(400).json({ error: 'URL is required' }); + } + + // Download image + const { buffer, mimeType, filename } = await downloadImage(url); + + const imageId = uuidv4(); + + // Get dimensions + const { width, height } = await getImageDimensions(buffer, mimeType); + + // Upload to MinIO + const minioPath = await uploadImage(board.id, imageId, buffer, mimeType); + const publicUrl = getImageUrl(minioPath); + + // Save record + const image = createImage({ + id: imageId, + boardId: board.id, + filename, + mimeType, + fileSize: buffer.length, + width, + height, + minioPath, + publicUrl, + uploadedBy: req.user.id, + }); + + return res.status(201).json({ + id: image.id, + url: publicUrl, + public_url: publicUrl, + width: image.width, + height: image.height, + file_size: image.file_size, + mime_type: image.mime_type, + }); + } catch (err) { + console.error('[upload] from-url error:', err); + const message = err.message.includes('Unsupported') || err.message.includes('too large') + ? err.message + : 'Failed to download image from URL'; + return res.status(400).json({ error: message }); + } +}); + +// Handle multer errors +router.use((err, req, res, next) => { + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ error: 'File too large (max 50MB)' }); + } + return res.status(400).json({ error: err.message }); + } + if (err.message && err.message.includes('Unsupported file type')) { + return res.status(400).json({ error: err.message }); + } + next(err); +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..bcc74c1 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,178 @@ +const http = require('http'); +const express = require('express'); +const cors = require('cors'); +const path = require('path'); + +// ---- Load environment ---- +const PORT = parseInt(process.env.PORT || '8000', 10); + +// ---- Express app ---- +const app = express(); + +app.use(cors({ origin: process.env.CORS_ORIGIN || '*' })); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); + +// ---- Health check ---- +app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// ---- Image proxy (MinIO → browser) ---- +app.get('/api/images/*', async (req, res) => { + try { + const objectPath = req.params[0]; // everything after /api/images/ + if (!objectPath) return res.status(400).json({ error: 'Missing path' }); + const { minioClient, MINIO_BUCKET } = require('./minio'); + const stat = await minioClient.statObject(MINIO_BUCKET, objectPath); + if (stat.metaData?.['content-type']) { + res.setHeader('Content-Type', stat.metaData['content-type']); + } + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + const stream = await minioClient.getObject(MINIO_BUCKET, objectPath); + stream.pipe(res); + } catch (err) { + if (err.code === 'NoSuchKey' || err.code === 'NotFound') { + return res.status(404).json({ error: 'Image not found' }); + } + console.error('[server] image proxy error:', err); + return res.status(500).json({ error: 'Failed to serve image' }); + } +}); + +// ---- User search (authenticated) ---- +app.get('/api/users/search', (req, res) => { + try { + const { authMiddleware } = require('./auth'); + authMiddleware(req, res, () => { + const { q } = req.query; + if (!q || q.length < 1) return res.json({ users: [] }); + const { getAllUsers } = require('./db'); + const all = getAllUsers(); + const query = q.toLowerCase(); + const matched = all + .filter(u => u.is_active && + (u.email.toLowerCase().includes(query) || + u.username.toLowerCase().includes(query) || + (u.display_name || '').toLowerCase().includes(query))) + .slice(0, 10) + .map(u => ({ id: u.id, email: u.email, username: u.username, display_name: u.display_name })); + return res.json({ users: matched }); + }); + } catch (err) { + console.error('[server] user search error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ---- API routes ---- +const authRoutes = require('./routes/auth'); +const collectionRoutes = require('./routes/collections'); +const boardRoutes = require('./routes/boards'); +const uploadRoutes = require('./routes/upload'); +const adminRoutes = require('./routes/admin'); + +app.use('/api/auth', authRoutes); +app.use('/api/collections', collectionRoutes); +app.use('/api/boards', boardRoutes); +app.use('/api/upload', uploadRoutes); +app.use('/api/admin', adminRoutes); + +// Public shared collection route (no auth required) +app.get('/api/c/:shareToken', (req, res) => { + try { + const { getCollectionByShareToken, getCollectionBoards } = require('./db'); + const collection = getCollectionByShareToken(req.params.shareToken); + if (!collection || !collection.is_public) { + return res.status(404).json({ error: 'Collection not found' }); + } + const boards = getCollectionBoards(collection.id); + return res.json({ collection, boards }); + } catch (err) { + console.error('[server] shared collection error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ---- Static frontend files ---- +const frontendDist = path.join(__dirname, '..', 'frontend', 'dist'); +app.use(express.static(frontendDist)); + +// SPA fallback — send index.html for non-API routes +app.get('*', (req, res) => { + if (req.path.startsWith('/api/')) { + return res.status(404).json({ error: 'API endpoint not found' }); + } + res.sendFile(path.join(frontendDist, 'index.html'), (err) => { + if (err) { + res.status(404).json({ error: 'Frontend not built yet' }); + } + }); +}); + +// ---- Global error handler ---- +app.use((err, _req, res, _next) => { + console.error('[server] Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +// ---- Create HTTP server + Socket.IO ---- +const server = http.createServer(app); + +const { setupSocket } = require('./socket'); +const io = setupSocket(server); + +// ---- Initialize services and start ---- +async function start() { + require('./db'); + console.log('[server] Database initialized'); + + try { + const { initBucket } = require('./minio'); + await initBucket(); + console.log('[server] MinIO initialized'); + } catch (err) { + console.error('[server] MinIO initialization failed:', err.message); + console.error('[server] Image uploads will not work until MinIO is available'); + } + + server.listen(PORT, '0.0.0.0', () => { + console.log(`[server] RefBoard backend listening on port ${PORT}`); + }); +} + +// ---- Graceful shutdown ---- +function shutdown(signal) { + console.log(`[server] Received ${signal}, shutting down gracefully...`); + + io.close(() => { + console.log('[server] Socket.IO closed'); + }); + + server.close(() => { + console.log('[server] HTTP server closed'); + + try { + const { db } = require('./db'); + db.close(); + console.log('[server] Database closed'); + } catch (e) { + // ignore + } + + process.exit(0); + }); + + setTimeout(() => { + console.error('[server] Forced shutdown after timeout'); + process.exit(1); + }, 10000).unref(); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +start().catch((err) => { + console.error('[server] Failed to start:', err); + process.exit(1); +}); diff --git a/backend/socket/board-room.js b/backend/socket/board-room.js new file mode 100644 index 0000000..154cb57 --- /dev/null +++ b/backend/socket/board-room.js @@ -0,0 +1,142 @@ +const { getBoard, getCollection, getCollectionMember } = require('../db'); + +// Track active users per room: Map> +const activeUsers = new Map(); + +function getRoomName(boardId) { + return `board:${boardId}`; +} + +function getActiveUsersInRoom(roomName) { + if (!activeUsers.has(roomName)) { + activeUsers.set(roomName, new Map()); + } + return activeUsers.get(roomName); +} + +function setupBoardRoom(io, socket) { + + // ---- Join / Leave ---- + + socket.on('board:join', ({ boardId }, callback) => { + try { + const board = getBoard(boardId); + if (!board) return callback?.({ error: 'Board not found' }); + + const collection = getCollection(board.collection_id); + if (!collection) return callback?.({ error: 'Collection not found' }); + + const member = getCollectionMember(board.collection_id, socket.userId); + if (!member && !collection.is_public) return callback?.({ error: 'Access denied' }); + + const roomName = getRoomName(boardId); + + // Leave any previous board room + for (const room of socket.rooms) { + if (room.startsWith('board:') && room !== roomName) { + leaveRoom(io, socket, room); + } + } + + socket.join(roomName); + socket.currentBoardId = boardId; + + const roomUsers = getActiveUsersInRoom(roomName); + const userInfo = { + id: socket.userId, + display_name: socket.userDisplayName, + email: socket.userEmail, + role: member?.role || 'viewer', + }; + roomUsers.set(socket.userId, userInfo); + + socket.to(roomName).emit('user:joined', userInfo); + + const users = Array.from(roomUsers.values()); + callback?.({ ok: true, users }); + + console.log(`[socket] ${socket.userDisplayName} joined ${roomName} (${users.length} users)`); + } catch (err) { + console.error('[socket] board:join error:', err); + callback?.({ error: 'Failed to join board' }); + } + }); + + socket.on('board:leave', ({ boardId }, callback) => { + const roomName = getRoomName(boardId); + leaveRoom(io, socket, roomName); + callback?.({ ok: true }); + }); + + // ---- Full scene sync (Excalidraw-style) ---- + + socket.on('scene:update', (data) => { + if (!socket.currentBoardId) return; + const roomName = getRoomName(socket.currentBoardId); + socket.to(roomName).emit('scene:update', { + ...data, + userId: socket.userId, + }); + }); + + // ---- Lightweight transform (during drag/resize/rotate) ---- + + socket.on('object:transform', (data) => { + if (!socket.currentBoardId) return; + const roomName = getRoomName(socket.currentBoardId); + socket.to(roomName).emit('object:transform', { + ...data, + userId: socket.userId, + }); + }); + + // ---- Cursor movement ---- + + socket.on('cursor:move', (data) => { + if (!socket.currentBoardId) return; + const roomName = getRoomName(socket.currentBoardId); + socket.to(roomName).emit('cursor:moved', { + ...data, + userId: socket.userId, + displayName: socket.userDisplayName, + }); + }); + + // ---- Disconnect cleanup ---- + + socket.on('disconnect', () => { + for (const room of socket.rooms) { + if (room.startsWith('board:')) { + leaveRoom(io, socket, room); + } + } + if (socket.currentBoardId) { + const roomName = getRoomName(socket.currentBoardId); + leaveRoom(io, socket, roomName); + } + }); +} + +function leaveRoom(io, socket, roomName) { + socket.leave(roomName); + + const roomUsers = getActiveUsersInRoom(roomName); + roomUsers.delete(socket.userId); + + socket.to(roomName).emit('user:left', { + id: socket.userId, + display_name: socket.userDisplayName, + }); + + if (roomUsers.size === 0) { + activeUsers.delete(roomName); + } + + if (socket.currentBoardId && roomName === getRoomName(socket.currentBoardId)) { + socket.currentBoardId = null; + } + + console.log(`[socket] ${socket.userDisplayName} left ${roomName}`); +} + +module.exports = { setupBoardRoom }; diff --git a/backend/socket/index.js b/backend/socket/index.js new file mode 100644 index 0000000..83de98e --- /dev/null +++ b/backend/socket/index.js @@ -0,0 +1,61 @@ +const { Server } = require('socket.io'); +const { verifyToken } = require('../auth'); +const { getUserById } = require('../db'); +const { setupBoardRoom } = require('./board-room'); + +/** + * Set up Socket.IO on the given HTTP server. + * Returns the io instance. + */ +function setupSocket(httpServer) { + const io = new Server(httpServer, { + cors: { + origin: process.env.CORS_ORIGIN || '*', + methods: ['GET', 'POST'], + }, + pingInterval: 25000, + pingTimeout: 60000, + }); + + // JWT authentication middleware + io.use((socket, next) => { + try { + const token = socket.handshake.auth?.token; + if (!token) { + return next(new Error('Authentication required')); + } + + const decoded = verifyToken(token); + const user = getUserById(decoded.id); + if (!user) { + return next(new Error('User not found')); + } + + // Store user info on socket + socket.userId = user.id; + socket.userEmail = user.email; + socket.userDisplayName = user.display_name; + socket.userRole = user.role; + + next(); + } catch (err) { + next(new Error('Invalid token')); + } + }); + + // Handle connections + io.on('connection', (socket) => { + console.log(`[socket] User connected: ${socket.userDisplayName} (${socket.userId})`); + + // Set up board room handlers + setupBoardRoom(io, socket); + + socket.on('disconnect', (reason) => { + console.log(`[socket] User disconnected: ${socket.userDisplayName} (${reason})`); + }); + }); + + return io; +} + +module.exports = { setupSocket }; diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..70f0b52 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,25 @@ + + + + + + + RefBoard + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f153666 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3516 @@ +{ + "name": "refboard-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "refboard-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.9", + "fabric": "^6.5.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "optional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.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==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT", + "optional": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT", + "optional": true + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT", + "optional": true + }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "license": "MIT", + "optional": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, + "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==", + "license": "MIT", + "optional": true + }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fabric": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-6.9.1.tgz", + "integrity": "sha512-TqG08Xbt4rtlPsXgCjSUcZz/RsyEP57Qo21nCVRkw7zz9nR0co4SLkL9Q/zQh3tC1Yxap6M5jKFHUKV6SgPovg==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "jsdom": "^20.0.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "optional": true + }, + "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==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT", + "optional": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "license": "MIT", + "optional": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "optional": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT", + "optional": true + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT", + "optional": true + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "optional": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "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==", + "license": "MIT", + "optional": true, + "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT", + "optional": true + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "license": "MIT", + "optional": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT", + "optional": true + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f8188eb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "refboard-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.9", + "fabric": "^6.5.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..affa93d --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f4b8bbf --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './auth'; +import Login from './pages/Login'; +import CollectionList from './pages/CollectionList'; +import CollectionDetail from './pages/CollectionDetail'; +import Editor from './pages/Editor'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (!user) { + return ; + } + + return <>{children}; +} + +function AppRoutes() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} + +export default function App() { + return ( + + + + + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..36a6b25 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,114 @@ +import axios from 'axios'; +import { getToken, removeToken } from './auth'; + +const api = axios.create({ + baseURL: window.location.origin, +}); + +api.interceptors.request.use((config) => { + const token = getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + removeToken(); + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + } + return Promise.reject(error); + } +); + +// Auth +export function login(email: string, password: string) { + return api.post('/api/auth/login', { email, password }); +} + +export function register(email: string, username: string, password: string, displayName: string) { + return api.post('/api/auth/register', { email, username, password, display_name: displayName }); +} + +export function getMe() { + return api.get('/api/auth/me'); +} + +// Collections +export function getCollections(search?: string) { + const params: Record = {}; + if (search) params.search = search; + return api.get('/api/collections', { params }); +} + +export function createCollection(name: string, description?: string) { + return api.post('/api/collections', { name, description }); +} + +export function getCollectionDetail(collectionId: string) { + return api.get(`/api/collections/${collectionId}`); +} + +export function deleteCollection(collectionId: string) { + return api.delete(`/api/collections/${collectionId}`); +} + +export function getCollectionShareInfo(collectionId: string) { + return api.get(`/api/collections/${collectionId}/share`); +} + +export function shareCollection(collectionId: string, isPublic: boolean) { + return api.post(`/api/collections/${collectionId}/share`, { is_public: isPublic }); +} + +export function addCollectionMember(collectionId: string, email: string, role: string = 'editor') { + return api.post(`/api/collections/${collectionId}/members`, { email, role }); +} + +export function removeCollectionMember(collectionId: string, userId: string) { + return api.delete(`/api/collections/${collectionId}/members/${userId}`); +} + +export function searchUsers(query: string) { + return api.get('/api/users/search', { params: { q: query } }); +} + +export function getCollectionByShareToken(shareToken: string) { + return api.get(`/api/c/${shareToken}`); +} + +// Boards +export function createBoard(collectionId: string, name: string, description?: string) { + return api.post('/api/boards', { collection_id: collectionId, name, description }); +} + +export function getBoard(boardId: string) { + return api.get(`/api/boards/${boardId}`); +} + +export function deleteBoard(boardId: string) { + return api.delete(`/api/boards/${boardId}`); +} + +export function saveCanvas(boardId: string, canvasState: string) { + return api.post(`/api/boards/${boardId}/save`, { canvas_state: canvasState }); +} + +export function uploadImage(boardId: string, file: File) { + const formData = new FormData(); + formData.append('image', file); + return api.post(`/api/upload/boards/${boardId}/images`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); +} + +export function uploadImageFromUrl(boardId: string, url: string) { + return api.post(`/api/upload/boards/${boardId}/images/from-url`, { url }); +} + +export default api; diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts new file mode 100644 index 0000000..53ea06b --- /dev/null +++ b/frontend/src/auth.ts @@ -0,0 +1,85 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; +import { getMe } from './api'; + +const TOKEN_KEY = 'refboard_token'; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function removeToken(): void { + localStorage.removeItem(TOKEN_KEY); +} + +export function isAuthenticated(): boolean { + return !!getToken(); +} + +export interface User { + id: string; + email: string; + username: string; + display_name: string; + role: string; +} + +interface AuthContextType { + user: User | null; + loading: boolean; + login: (token: string, user: User) => void; + logout: () => void; +} + +export const AuthContext = createContext({ + user: null, + loading: true, + login: () => {}, + logout: () => {}, +}); + +export function useAuth(): AuthContextType { + return useContext(AuthContext); +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = getToken(); + if (token) { + getMe() + .then((res) => { + setUser(res.data.user || res.data); + }) + .catch(() => { + removeToken(); + }) + .finally(() => { + setLoading(false); + }); + } else { + setLoading(false); + } + }, []); + + const login = useCallback((token: string, userData: User) => { + setToken(token); + setUser(userData); + }, []); + + const logout = useCallback(() => { + removeToken(); + setUser(null); + }, []); + + return React.createElement( + AuthContext.Provider, + { value: { user, loading, login, logout } }, + children + ); +} diff --git a/frontend/src/canvas/FabricCanvas.tsx b/frontend/src/canvas/FabricCanvas.tsx new file mode 100644 index 0000000..5c5b294 --- /dev/null +++ b/frontend/src/canvas/FabricCanvas.tsx @@ -0,0 +1,263 @@ +import React, { useRef, useEffect, useImperativeHandle, forwardRef, useCallback } from 'react'; +import { Canvas, FabricImage, FabricObject } from 'fabric'; +import { suppressBroadcasts, resumeBroadcasts } from './sync'; + +export interface FabricCanvasHandle { + getCanvas: () => Canvas | null; + fitAll: () => void; + getZoom: () => number; + setZoom: (zoom: number) => void; +} + +interface FabricCanvasProps { + canvasState?: string | null; + currentTool: string; + onChange?: () => void; +} + +const MIN_ZOOM = 0.1; +const MAX_ZOOM = 5.0; + +const FabricCanvas = forwardRef( + ({ canvasState, currentTool, onChange }, ref) => { + const containerRef = useRef(null); + const canvasElRef = useRef(null); + const fabricRef = useRef(null); + const isPanning = useRef(false); + const lastPanPoint = useRef<{ x: number; y: number } | null>(null); + const spaceHeld = useRef(false); + const initialLoadDone = useRef(false); + + // Initialize Fabric canvas + useEffect(() => { + if (!canvasElRef.current || fabricRef.current) return; + + const container = containerRef.current!; + const width = container.clientWidth; + const height = container.clientHeight; + + const canvas = new Canvas(canvasElRef.current, { + width, + height, + backgroundColor: '#1e1e1e', + selection: true, + selectionColor: 'rgba(74, 158, 255, 0.15)', + selectionBorderColor: '#4a9eff', + selectionLineWidth: 1, + preserveObjectStacking: true, + }); + + fabricRef.current = canvas; + + // Handle resize + const observer = new ResizeObserver(() => { + const w = container.clientWidth; + const h = container.clientHeight; + canvas.setDimensions({ width: w, height: h }); + canvas.requestRenderAll(); + }); + observer.observe(container); + + // Middle-mouse pan + canvas.on('mouse:down', (e: any) => { + if (e.e.button === 1 || (spaceHeld.current && e.e.button === 0)) { + isPanning.current = true; + lastPanPoint.current = { x: e.e.clientX, y: e.e.clientY }; + canvas.defaultCursor = 'grabbing'; + canvas.upperCanvasEl.style.cursor = 'grabbing'; + e.e.preventDefault(); + } + }); + + canvas.on('mouse:move', (e: any) => { + if (!isPanning.current || !lastPanPoint.current) return; + const vpt = canvas.viewportTransform!; + vpt[4] += e.e.clientX - lastPanPoint.current.x; + vpt[5] += e.e.clientY - lastPanPoint.current.y; + lastPanPoint.current = { x: e.e.clientX, y: e.e.clientY }; + canvas.requestRenderAll(); + }); + + canvas.on('mouse:up', () => { + if (isPanning.current) { + isPanning.current = false; + lastPanPoint.current = null; + if (!spaceHeld.current) { + canvas.defaultCursor = 'default'; + canvas.upperCanvasEl.style.cursor = 'default'; + } + } + }); + + // Ctrl+Scroll zoom + canvas.on('mouse:wheel', (opt: any) => { + const e = opt.e as WheelEvent; + e.preventDefault(); + e.stopPropagation(); + + const delta = e.deltaY; + let zoom = canvas.getZoom(); + zoom *= 0.999 ** delta; + zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); + + const point = canvas.getScenePoint(e); + canvas.zoomToPoint(point, zoom); + canvas.requestRenderAll(); + }); + + // Space key for pan + function onKeyDown(e: KeyboardEvent) { + if (e.code === 'Space' && !spaceHeld.current && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) { + e.preventDefault(); + spaceHeld.current = true; + canvas.defaultCursor = 'grab'; + canvas.upperCanvasEl.style.cursor = 'grab'; + } + } + + function onKeyUp(e: KeyboardEvent) { + if (e.code === 'Space') { + spaceHeld.current = false; + if (currentTool !== 'PAN') { + canvas.defaultCursor = 'default'; + canvas.upperCanvasEl.style.cursor = 'default'; + } + } + } + + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + + // Notify parent of changes + const changeEvents = ['object:added', 'object:modified', 'object:removed', 'path:created']; + const changeHandler = () => { + onChange?.(); + }; + changeEvents.forEach((evt) => canvas.on(evt as any, changeHandler)); + + return () => { + observer.disconnect(); + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + changeEvents.forEach((evt) => canvas.off(evt as any, changeHandler)); + canvas.dispose(); + fabricRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Load initial canvas state + useEffect(() => { + const canvas = fabricRef.current; + if (!canvas || initialLoadDone.current) return; + if (!canvasState) return; + + initialLoadDone.current = true; + + let parsed: any; + try { + parsed = typeof canvasState === 'string' ? JSON.parse(canvasState) : canvasState; + } catch { + return; + } + + if (!parsed || (!parsed.objects && !parsed.version)) return; + + // Suppress socket broadcasts during initial load — prevents flooding + // other clients with object:add events for objects they already have + suppressBroadcasts(); + canvas.loadFromJSON(parsed).then(() => { + canvas.getObjects().forEach((obj: FabricObject) => { + if (obj.type === 'image') { + const imgObj = obj as FabricImage; + const src = (imgObj as any).src || imgObj.getSrc?.(); + if (src) { + (imgObj as any).crossOrigin = 'anonymous'; + } + } + }); + canvas.requestRenderAll(); + // Small delay to ensure all deferred events have fired before resuming + setTimeout(() => resumeBroadcasts(), 100); + }).catch((err: Error) => { + console.error('Failed to load canvas state:', err); + resumeBroadcasts(); + }); + }, [canvasState]); + + const fitAll = useCallback(() => { + const canvas = fabricRef.current; + if (!canvas) return; + + const objects = canvas.getObjects(); + if (objects.length === 0) { + canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); + canvas.requestRenderAll(); + return; + } + + // Get bounding rect of all objects + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + objects.forEach((obj) => { + const bound = obj.getBoundingRect(); + minX = Math.min(minX, bound.left); + minY = Math.min(minY, bound.top); + maxX = Math.max(maxX, bound.left + bound.width); + maxY = Math.max(maxY, bound.top + bound.height); + }); + + const objWidth = maxX - minX; + const objHeight = maxY - minY; + if (objWidth === 0 || objHeight === 0) return; + + const padding = 40; + const canvasW = canvas.width!; + const canvasH = canvas.height!; + const scaleX = (canvasW - padding * 2) / objWidth; + const scaleY = (canvasH - padding * 2) / objHeight; + const zoom = Math.min(scaleX, scaleY, MAX_ZOOM); + + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + + canvas.setViewportTransform([ + zoom, 0, 0, zoom, + canvasW / 2 - cx * zoom, + canvasH / 2 - cy * zoom, + ]); + canvas.requestRenderAll(); + }, []); + + useImperativeHandle(ref, () => ({ + getCanvas: () => fabricRef.current, + fitAll, + getZoom: () => fabricRef.current?.getZoom() ?? 1, + setZoom: (zoom: number) => { + const canvas = fabricRef.current; + if (!canvas) return; + const center = canvas.getCenterPoint(); + canvas.zoomToPoint(center, Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))); + canvas.requestRenderAll(); + }, + }), [fitAll]); + + return ( +
+ +
+ ); + } +); + +FabricCanvas.displayName = 'FabricCanvas'; + +export default FabricCanvas; diff --git a/frontend/src/canvas/history.ts b/frontend/src/canvas/history.ts new file mode 100644 index 0000000..0f5a7e7 --- /dev/null +++ b/frontend/src/canvas/history.ts @@ -0,0 +1,83 @@ +import { Canvas } from 'fabric'; + +export class UndoManager { + private stack: string[] = []; + private pointer: number = -1; + private maxEntries: number = 50; + private locked: boolean = false; + private canvas: Canvas; + + constructor(canvas: Canvas) { + this.canvas = canvas; + // Save initial state + this.saveState(); + } + + isLocked(): boolean { + return this.locked; + } + + saveState(): void { + if (this.locked) return; + + const json = JSON.stringify((this.canvas as any).toJSON(['id'])); + + // If we're not at the end, discard forward history + if (this.pointer < this.stack.length - 1) { + this.stack = this.stack.slice(0, this.pointer + 1); + } + + // Don't save if identical to current state + if (this.stack.length > 0 && this.stack[this.pointer] === json) { + return; + } + + this.stack.push(json); + + // Enforce max entries + if (this.stack.length > this.maxEntries) { + this.stack.shift(); + } + + this.pointer = this.stack.length - 1; + } + + undo(): void { + if (!this.canUndo()) return; + + this.pointer--; + this.restoreState(); + } + + redo(): void { + if (!this.canRedo()) return; + + this.pointer++; + this.restoreState(); + } + + canUndo(): boolean { + return this.pointer > 0; + } + + canRedo(): boolean { + return this.pointer < this.stack.length - 1; + } + + private restoreState(): void { + const state = this.stack[this.pointer]; + if (!state) return; + + this.locked = true; + this.canvas.loadFromJSON(JSON.parse(state)).then(() => { + this.canvas.requestRenderAll(); + this.locked = false; + }); + } + + clear(): void { + this.stack = []; + this.pointer = -1; + this.saveState(); + } +} diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts new file mode 100644 index 0000000..1285f9f --- /dev/null +++ b/frontend/src/canvas/image-drop.ts @@ -0,0 +1,207 @@ +import { Canvas, FabricImage, Rect } from 'fabric'; +import { uploadImage, uploadImageFromUrl } from '../api'; + +type OnImageAdded = () => void; + +export function setupDragDrop( + canvas: Canvas, + boardId: string, + onImageAdded: OnImageAdded +): () => void { + const canvasEl = canvas.getSelectionElement(); + const upperCanvas = canvas.upperCanvasEl || canvasEl; + const wrapper = upperCanvas?.parentElement || canvasEl.parentElement; + if (!wrapper) return () => {}; + + function onDragOver(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + } + + async function onDrop(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + const files = e.dataTransfer?.files; + + // Handle URL drops (dragged image URL from browser) + if (!files || files.length === 0) { + const url = e.dataTransfer?.getData('text/uri-list') || e.dataTransfer?.getData('text/plain') || ''; + if (url && (url.startsWith('http://') || url.startsWith('https://')) && /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(url)) { + const rect = wrapper!.getBoundingClientRect(); + const vpt = canvas.viewportTransform!; + const x = (e.clientX - rect.left - vpt[4]) / vpt[0]; + const y = (e.clientY - rect.top - vpt[5]) / vpt[3]; + + const placeholder = new Rect({ + left: x, top: y, width: 200, height: 150, + fill: '#3d3d3d', stroke: '#4a9eff', strokeWidth: 2, + strokeDashArray: [8, 4], selectable: false, evented: false, + }); + canvas.add(placeholder); + canvas.requestRenderAll(); + + try { + const res = await uploadImageFromUrl(boardId, url); + const imgData = res.data.image || res.data; + const imgUrl = imgData.public_url; + canvas.remove(placeholder); + + const imgEl = await FabricImage.fromURL(imgUrl, { crossOrigin: 'anonymous' }); + imgEl.set({ left: x, top: y, id: imgData.id } as any); + const maxDim = 600; + if (imgEl.width! > maxDim || imgEl.height! > maxDim) { + const scale = maxDim / Math.max(imgEl.width!, imgEl.height!); + imgEl.scale(scale); + } + canvas.add(imgEl); + canvas.requestRenderAll(); + onImageAdded(); + } catch (err) { + console.error('URL image upload failed:', err); + canvas.remove(placeholder); + canvas.requestRenderAll(); + } + } + return; + } + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (!file.type.startsWith('image/')) continue; + + // Calculate drop position in canvas coordinates + const rect = wrapper!.getBoundingClientRect(); + const vpt = canvas.viewportTransform!; + const x = (e.clientX - rect.left - vpt[4]) / vpt[0]; + const y = (e.clientY - rect.top - vpt[5]) / vpt[3]; + + // Create placeholder + const placeholder = new Rect({ + left: x, + top: y, + width: 200, + height: 150, + fill: '#3d3d3d', + stroke: '#4a9eff', + strokeWidth: 2, + strokeDashArray: [8, 4], + selectable: false, + evented: false, + }); + canvas.add(placeholder); + canvas.requestRenderAll(); + + try { + const res = await uploadImage(boardId, file); + const imgData = res.data.image || res.data; + const url = imgData.public_url; + + canvas.remove(placeholder); + + const imgEl = await FabricImage.fromURL(url, { crossOrigin: 'anonymous' }); + imgEl.set({ + left: x, + top: y, + id: imgData.id, + } as any); + // Scale down large images + const maxDim = 600; + if (imgEl.width! > maxDim || imgEl.height! > maxDim) { + const scale = maxDim / Math.max(imgEl.width!, imgEl.height!); + imgEl.scale(scale); + } + canvas.add(imgEl); + canvas.requestRenderAll(); + onImageAdded(); + } catch (err) { + console.error('Image upload failed:', err); + canvas.remove(placeholder); + canvas.requestRenderAll(); + } + } + } + + wrapper.addEventListener('dragover', onDragOver); + wrapper.addEventListener('drop', onDrop); + + return () => { + wrapper.removeEventListener('dragover', onDragOver); + wrapper.removeEventListener('drop', onDrop); + }; +} + +export function setupPaste( + canvas: Canvas, + boardId: string, + onImageAdded: OnImageAdded +): () => void { + async function onPaste(e: ClipboardEvent) { + const items = e.clipboardData?.items; + if (!items) return; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item.type.startsWith('image/')) continue; + + e.preventDefault(); + const file = item.getAsFile(); + if (!file) continue; + + // Place at canvas center + const vpt = canvas.viewportTransform!; + const cx = (canvas.width! / 2 - vpt[4]) / vpt[0]; + const cy = (canvas.height! / 2 - vpt[5]) / vpt[3]; + + const placeholder = new Rect({ + left: cx - 100, + top: cy - 75, + width: 200, + height: 150, + fill: '#3d3d3d', + stroke: '#4a9eff', + strokeWidth: 2, + strokeDashArray: [8, 4], + selectable: false, + evented: false, + }); + canvas.add(placeholder); + canvas.requestRenderAll(); + + try { + const res = await uploadImage(boardId, file); + const imgData = res.data.image || res.data; + const url = imgData.public_url; + + canvas.remove(placeholder); + + const imgEl = await FabricImage.fromURL(url, { crossOrigin: 'anonymous' }); + imgEl.set({ + left: cx - (imgEl.width! * (imgEl.scaleX || 1)) / 2, + top: cy - (imgEl.height! * (imgEl.scaleY || 1)) / 2, + id: imgData.id, + } as any); + const maxDim = 600; + if (imgEl.width! > maxDim || imgEl.height! > maxDim) { + const scale = maxDim / Math.max(imgEl.width!, imgEl.height!); + imgEl.scale(scale); + } + canvas.add(imgEl); + canvas.requestRenderAll(); + onImageAdded(); + } catch (err) { + console.error('Image paste upload failed:', err); + canvas.remove(placeholder); + canvas.requestRenderAll(); + } + } + } + + document.addEventListener('paste', onPaste); + return () => { + document.removeEventListener('paste', onPaste); + }; +} diff --git a/frontend/src/canvas/sync.ts b/frontend/src/canvas/sync.ts new file mode 100644 index 0000000..195b58a --- /dev/null +++ b/frontend/src/canvas/sync.ts @@ -0,0 +1,213 @@ +import { Canvas, FabricObject } from 'fabric'; +import { Socket } from 'socket.io-client'; + +/** + * Full-scene sync approach (like Excalidraw): + * + * 1. On any change → broadcast full canvas JSON (throttled) + * 2. On receive → loadFromJSON to replace entire canvas + * 3. During drag → lightweight position events for smooth real-time + * 4. No per-object tracking, no ID matching race conditions + * + * Images are URLs (stored in MinIO), so canvas JSON stays small. + */ + +let _suppress = false; + +export function isRemoteUpdate(): boolean { + return _suppress; +} + +export function suppressBroadcasts() { + _suppress = true; +} + +export function resumeBroadcasts() { + _suppress = false; +} + +export function setupSync( + canvas: Canvas, + socket: Socket, + boardId: string +): () => void { + let sceneTimer: ReturnType | null = null; + let moveTimer: ReturnType | null = null; + let userInteracting = false; + let pendingScene: any = null; + const SCENE_THROTTLE = 300; // ms + const MOVE_THROTTLE = 50; // ms + + // ---- Ensure objects have IDs ---- + + function ensureId(obj: FabricObject): string { + if (!(obj as any).id) { + (obj as any).id = crypto.randomUUID(); + } + return (obj as any).id; + } + + // ---- BROADCAST: full scene (throttled) ---- + + function scheduleBroadcast() { + if (_suppress) return; + if (sceneTimer) clearTimeout(sceneTimer); + sceneTimer = setTimeout(() => { + sceneTimer = null; + if (_suppress) return; + // Ensure all objects have IDs before serializing + canvas.getObjects().forEach(ensureId); + const scene = (canvas as any).toJSON(['id']); + socket.emit('scene:update', { boardId, scene }); + }, SCENE_THROTTLE); + } + + // ---- BROADCAST: lightweight transform during drag ---- + + function emitTransform(obj: FabricObject) { + if (_suppress) return; + const id = (obj as any).id; + if (!id) return; + socket.emit('object:transform', { + boardId, objectId: id, + left: obj.left, top: obj.top, + scaleX: obj.scaleX, scaleY: obj.scaleY, + angle: obj.angle, + }); + } + + // ---- RECEIVE: full scene ---- + + function applyRemoteScene(scene: any) { + if (userInteracting) { + // Defer — apply when user finishes interaction + pendingScene = scene; + return; + } + doApplyScene(scene); + } + + function doApplyScene(scene: any) { + _suppress = true; + canvas.loadFromJSON(scene) + .then(() => { + canvas.requestRenderAll(); + // Small delay for any deferred Fabric events + setTimeout(() => { _suppress = false; }, 50); + }) + .catch((err: any) => { + console.error('[sync] loadFromJSON failed:', err); + _suppress = false; + }); + } + + // ---- RECEIVE: lightweight transform ---- + + function applyRemoteTransform(payload: any) { + if (payload.boardId !== boardId) return; + const obj = canvas.getObjects().find((o) => (o as any).id === payload.objectId); + if (!obj) return; + + _suppress = true; + obj.set({ + left: payload.left, + top: payload.top, + scaleX: payload.scaleX, + scaleY: payload.scaleY, + angle: payload.angle, + }); + obj.setCoords(); + canvas.requestRenderAll(); + _suppress = false; + } + + // ---- Canvas event handlers ---- + + function onObjectAdded() { + if (!_suppress) scheduleBroadcast(); + } + + function onObjectModified() { + if (!_suppress) scheduleBroadcast(); + } + + function onObjectRemoved() { + if (!_suppress) scheduleBroadcast(); + } + + function onPathCreated() { + if (!_suppress) scheduleBroadcast(); + } + + function onObjectMoving(e: any) { + if (_suppress) return; + // Throttle transform events + if (moveTimer) return; + emitTransform(e.target); + moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE); + } + + function onMouseDown() { + userInteracting = true; + } + + function onMouseUp() { + userInteracting = false; + if (pendingScene) { + doApplyScene(pendingScene); + pendingScene = null; + } + } + + // ---- Bind canvas events ---- + + canvas.on('object:added', onObjectAdded); + canvas.on('object:modified', onObjectModified); + canvas.on('object:removed', onObjectRemoved); + canvas.on('path:created', onPathCreated); + canvas.on('object:moving', onObjectMoving); + canvas.on('object:scaling', onObjectMoving); + canvas.on('object:rotating', onObjectMoving); + canvas.on('mouse:down', onMouseDown); + canvas.on('mouse:up', onMouseUp); + + // ---- Bind socket events ---- + + function onSceneUpdate(payload: any) { + if (payload.boardId !== boardId) return; + applyRemoteScene(payload.scene); + } + + socket.on('scene:update', onSceneUpdate); + socket.on('object:transform', applyRemoteTransform); + + // ---- Join room ---- + + socket.emit('board:join', { boardId }, (response: any) => { + if (response?.users) { + socket.emit('room:users', { users: response.users }); + } + }); + + // ---- Cleanup ---- + + return () => { + canvas.off('object:added', onObjectAdded); + canvas.off('object:modified', onObjectModified); + canvas.off('object:removed', onObjectRemoved); + canvas.off('path:created', onPathCreated); + canvas.off('object:moving', onObjectMoving); + canvas.off('object:scaling', onObjectMoving); + canvas.off('object:rotating', onObjectMoving); + canvas.off('mouse:down', onMouseDown); + canvas.off('mouse:up', onMouseUp); + + socket.off('scene:update', onSceneUpdate); + socket.off('object:transform', applyRemoteTransform); + + socket.emit('board:leave', { boardId }); + + if (sceneTimer) clearTimeout(sceneTimer); + if (moveTimer) clearTimeout(moveTimer); + }; +} diff --git a/frontend/src/canvas/tools.ts b/frontend/src/canvas/tools.ts new file mode 100644 index 0000000..8b637ca --- /dev/null +++ b/frontend/src/canvas/tools.ts @@ -0,0 +1,136 @@ +import { Canvas, PencilBrush, IText, FabricObject } from 'fabric'; + +export enum ToolType { + SELECT = 'SELECT', + PAN = 'PAN', + PEN = 'PEN', + TEXT = 'TEXT', + ERASER = 'ERASER', +} + +export interface ToolOptions { + color?: string; + strokeWidth?: number; + fontSize?: number; +} + +const defaultOptions: ToolOptions = { + color: '#ffffff', + strokeWidth: 4, + fontSize: 24, +}; + +type CleanupFn = (() => void) | null; + +export function activateTool( + canvas: Canvas, + tool: ToolType, + options: ToolOptions = {} +): CleanupFn { + const opts = { ...defaultOptions, ...options }; + + // Reset common state + canvas.isDrawingMode = false; + canvas.selection = false; + canvas.defaultCursor = 'default'; + canvas.hoverCursor = 'default'; + canvas.forEachObject((obj: FabricObject) => { + obj.selectable = false; + obj.evented = false; + }); + + switch (tool) { + case ToolType.SELECT: { + canvas.selection = true; + canvas.defaultCursor = 'default'; + canvas.hoverCursor = 'move'; + canvas.forEachObject((obj: FabricObject) => { + obj.selectable = true; + obj.evented = true; + }); + return null; + } + + case ToolType.PAN: { + canvas.defaultCursor = 'grab'; + canvas.hoverCursor = 'grab'; + // Pan is handled by FabricCanvas component directly + return null; + } + + case ToolType.PEN: { + canvas.isDrawingMode = true; + const brush = new PencilBrush(canvas); + brush.color = opts.color!; + brush.width = opts.strokeWidth!; + canvas.freeDrawingBrush = brush; + return null; + } + + case ToolType.TEXT: { + canvas.defaultCursor = 'text'; + canvas.hoverCursor = 'text'; + + const handler = (e: any) => { + const pointer = canvas.getScenePoint(e.e); + const text = new IText('Type here', { + left: pointer.x, + top: pointer.y, + fontSize: opts.fontSize!, + fill: opts.color!, + fontFamily: 'sans-serif', + editable: true, + }); + canvas.add(text); + canvas.setActiveObject(text); + text.enterEditing(); + text.selectAll(); + // Remove handler after placing text + canvas.off('mouse:down', handler); + }; + + canvas.on('mouse:down', handler); + return () => { + canvas.off('mouse:down', handler); + }; + } + + case ToolType.ERASER: { + canvas.defaultCursor = 'crosshair'; + canvas.hoverCursor = 'crosshair'; + canvas.forEachObject((obj: FabricObject) => { + obj.selectable = false; + obj.evented = true; + }); + + const handler = (e: any) => { + const target = e.target; + if (target) { + canvas.remove(target); + canvas.requestRenderAll(); + } + }; + + canvas.on('mouse:down', handler); + return () => { + canvas.off('mouse:down', handler); + }; + } + + default: + return null; + } +} + +export const toolShortcuts: Record = { + v: ToolType.SELECT, + h: ToolType.PAN, + p: ToolType.PEN, + t: ToolType.TEXT, + e: ToolType.ERASER, + '1': ToolType.SELECT, + '2': ToolType.PAN, + '3': ToolType.PEN, + '4': ToolType.TEXT, + '5': ToolType.ERASER, +}; diff --git a/frontend/src/components/ColorPicker.tsx b/frontend/src/components/ColorPicker.tsx new file mode 100644 index 0000000..a6c98bb --- /dev/null +++ b/frontend/src/components/ColorPicker.tsx @@ -0,0 +1,151 @@ +import React, { useState, useRef, useEffect } from 'react'; + +const PRESET_COLORS = [ + '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', + '#00ffff', '#ffffff', '#000000', '#ff6600', '#9933ff', +]; + +interface ColorPickerProps { + color: string; + onChange: (color: string) => void; +} + +const styles = { + wrapper: { + position: 'relative' as const, + display: 'inline-block', + }, + trigger: { + width: '28px', + height: '28px', + borderRadius: '6px', + border: '2px solid #3d3d3d', + cursor: 'pointer', + padding: 0, + outline: 'none', + }, + popup: { + position: 'absolute' as const, + top: '36px', + left: '0', + background: '#2d2d2d', + border: '1px solid #3d3d3d', + borderRadius: '8px', + padding: '10px', + zIndex: 100, + display: 'flex', + flexDirection: 'column' as const, + gap: '8px', + boxShadow: '0 4px 16px rgba(0,0,0,0.4)', + }, + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(5, 1fr)', + gap: '4px', + }, + swatch: { + width: '26px', + height: '26px', + borderRadius: '4px', + border: '2px solid transparent', + cursor: 'pointer', + padding: 0, + outline: 'none', + }, + hexRow: { + display: 'flex', + gap: '6px', + alignItems: 'center', + }, + hexLabel: { + fontSize: '11px', + color: '#888', + }, + hexInput: { + flex: 1, + padding: '4px 6px', + background: '#1a1a1a', + border: '1px solid #3d3d3d', + borderRadius: '4px', + color: '#e0e0e0', + fontSize: '12px', + fontFamily: 'monospace', + outline: 'none', + width: '80px', + }, +}; + +export default function ColorPicker({ color, onChange }: ColorPickerProps) { + const [open, setOpen] = useState(false); + const [hexInput, setHexInput] = useState(color); + const wrapperRef = useRef(null); + + useEffect(() => { + setHexInput(color); + }, [color]); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + if (open) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open]); + + function handleHexSubmit() { + let hex = hexInput.trim(); + if (!hex.startsWith('#')) hex = '#' + hex; + if (/^#[0-9a-fA-F]{3,8}$/.test(hex)) { + onChange(hex); + } + } + + return ( +
+
+
+ # + setHexInput('#' + e.target.value)} + onBlur={handleHexSubmit} + onKeyDown={(e) => { + if (e.key === 'Enter') handleHexSubmit(); + }} + maxLength={8} + placeholder="hex" + /> +
+ + )} + + ); +} diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx new file mode 100644 index 0000000..fd8f677 --- /dev/null +++ b/frontend/src/components/ContextMenu.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useRef } from 'react'; + +interface MenuItem { + label: string; + shortcut?: string; + onClick: () => void; + disabled?: boolean; + danger?: boolean; + divider?: boolean; +} + +interface ContextMenuProps { + x: number; + y: number; + items: MenuItem[]; + onClose: () => void; +} + +export default function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { + const ref = useRef(null); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose(); + } + } + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('mousedown', handleClick); + document.addEventListener('keydown', handleKey); + return () => { + document.removeEventListener('mousedown', handleClick); + document.removeEventListener('keydown', handleKey); + }; + }, [onClose]); + + // Adjust position to stay within viewport + const style: React.CSSProperties = { + position: 'fixed', + left: x, + top: y, + zIndex: 1000, + background: '#2a2a2a', + border: '1px solid #3d3d3d', + borderRadius: '8px', + padding: '4px 0', + minWidth: '180px', + boxShadow: '0 8px 24px rgba(0,0,0,0.5)', + }; + + return ( +
+ {items.map((item, i) => { + if (item.divider) { + return
; + } + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/LayerPanel.tsx b/frontend/src/components/LayerPanel.tsx new file mode 100644 index 0000000..5415610 --- /dev/null +++ b/frontend/src/components/LayerPanel.tsx @@ -0,0 +1,194 @@ +import React, { useState, useCallback } from 'react'; + +interface LayerItem { + id: string; + name: string; + type: string; + visible: boolean; + locked: boolean; + isGroup: boolean; + children?: LayerItem[]; +} + +interface LayerPanelProps { + layers: LayerItem[]; + selectedIds: string[]; + onSelect: (id: string) => void; + onToggleVisible: (id: string) => void; + onToggleLock: (id: string) => void; + onReorder: (fromIndex: number, toIndex: number) => void; + onDelete: (id: string) => void; + onGroup: () => void; + onUngroup: () => void; + hasSelection: boolean; + hasGroupSelection: boolean; +} + +export default function LayerPanel({ + layers, selectedIds, onSelect, onToggleVisible, onToggleLock, + onReorder, onDelete, onGroup, onUngroup, hasSelection, hasGroupSelection, +}: LayerPanelProps) { + const [collapsed, setCollapsed] = useState(false); + const [dragIdx, setDragIdx] = useState(null); + + const onDragStart = useCallback((idx: number) => setDragIdx(idx), []); + const onDragOver = useCallback((e: React.DragEvent) => e.preventDefault(), []); + const onDrop = useCallback((targetIdx: number) => { + if (dragIdx !== null && dragIdx !== targetIdx) { + onReorder(dragIdx, targetIdx); + } + setDragIdx(null); + }, [dragIdx, onReorder]); + + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ + Layers + +
+ + + + + + + + + + + + + setCollapsed(true)}> + + + + +
+
+ + {/* Layer list — reversed so top layer is first */} +
+ {[...layers].reverse().map((layer, i) => { + const realIdx = layers.length - 1 - i; + const selected = selectedIds.includes(layer.id); + return ( +
onDragStart(realIdx)} + onDragOver={onDragOver} + onDrop={() => onDrop(realIdx)} + onClick={() => onSelect(layer.id)} + style={{ + display: 'flex', alignItems: 'center', gap: '4px', + padding: '3px 6px', cursor: 'pointer', + background: selected ? '#2a3a50' : dragIdx === realIdx ? '#2a2a2a' : 'transparent', + borderBottom: '1px solid #222', + opacity: layer.visible ? 1 : 0.4, + }} + onMouseEnter={(e) => { if (!selected) (e.currentTarget as HTMLDivElement).style.background = '#252525'; }} + onMouseLeave={(e) => { if (!selected) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }} + > + {/* Visibility toggle */} + + + {/* Lock toggle */} + + + {/* Type icon */} + + {layer.isGroup ? '📁' : layer.type === 'image' ? '🖼' : layer.type === 'i-text' ? 'T' : layer.type === 'path' ? '✏' : '◇'} + + + {/* Name */} + + {layer.name} + + + {/* Delete */} + +
+ ); + })} + {layers.length === 0 && ( +
+ No objects +
+ )} +
+
+ ); +} + +function SmallBtn({ onClick, title, disabled, children }: { + onClick: () => void; title: string; disabled?: boolean; children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx new file mode 100644 index 0000000..7d3c97d --- /dev/null +++ b/frontend/src/components/ShareDialog.tsx @@ -0,0 +1,323 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { shareCollection, getCollectionShareInfo, getCollectionDetail, addCollectionMember, removeCollectionMember, searchUsers } from '../api'; +import { useAuth } from '../auth'; + +interface Member { + user_id: string; + email: string; + display_name: string; + role: string; +} + +interface UserResult { + id: string; + email: string; + username: string; + display_name: string; +} + +interface ShareDialogProps { + collectionId: string; + onClose: () => void; +} + +export default function ShareDialog({ collectionId, onClose }: ShareDialogProps) { + const { user } = useAuth(); + const [pub, setPub] = useState(false); + const [shareToken, setShareToken] = useState(null); + const [members, setMembers] = useState([]); + const [ownerId, setOwnerId] = useState(''); + const [query, setQuery] = useState(''); + const [role, setRole] = useState('editor'); + const [copied, setCopied] = useState(false); + const [adding, setAdding] = useState(false); + const [loading, setLoading] = useState(true); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const searchTimer = useRef | null>(null); + const inputRef = useRef(null); + + const isOwner = user?.id === ownerId; + const shareUrl = shareToken ? `${window.location.origin}/c/${shareToken}` : ''; + + async function loadData() { + try { + const [shareRes, detailRes] = await Promise.all([ + getCollectionShareInfo(collectionId), + getCollectionDetail(collectionId), + ]); + setPub(shareRes.data.is_public); + setShareToken(shareRes.data.share_token); + setMembers(detailRes.data.members || []); + setOwnerId(detailRes.data.collection?.created_by || ''); + } catch (err) { + console.error('Failed to load share data:', err); + } finally { + setLoading(false); + } + } + + useEffect(() => { loadData(); }, [collectionId]); + + function handleQueryChange(val: string) { + setQuery(val); + setSelectedUser(null); + + if (searchTimer.current) clearTimeout(searchTimer.current); + if (val.length < 1) { + setSuggestions([]); + setShowSuggestions(false); + return; + } + + searchTimer.current = setTimeout(async () => { + try { + const res = await searchUsers(val); + const memberIds = new Set(members.map(m => m.user_id)); + const filtered = (res.data.users || []).filter( + (u: UserResult) => u.id !== user?.id && !memberIds.has(u.id) + ); + setSuggestions(filtered); + setShowSuggestions(filtered.length > 0); + } catch { + setSuggestions([]); + } + }, 200); + } + + function selectUser(u: UserResult) { + setSelectedUser(u); + setQuery(u.display_name || u.email); + setShowSuggestions(false); + } + + async function handleTogglePublic() { + try { + const res = await shareCollection(collectionId, !pub); + setPub(res.data.is_public); + setShareToken(res.data.share_token); + } catch (err) { + console.error('Failed to update share settings:', err); + } + } + + async function handleCopy() { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + } catch { + const input = document.createElement('input'); + input.value = shareUrl; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + async function handleAddMember() { + const target = selectedUser; + if (!target) { + // Try searching by exact email + if (!query.trim()) return; + setAdding(true); + try { + await addCollectionMember(collectionId, query.trim(), role); + setQuery(''); + setSelectedUser(null); + await loadData(); + } catch (err: any) { + alert(err.response?.data?.error || 'User not found'); + } finally { + setAdding(false); + } + return; + } + + setAdding(true); + try { + await addCollectionMember(collectionId, target.email, role); + setQuery(''); + setSelectedUser(null); + setSuggestions([]); + await loadData(); + } catch (err: any) { + alert(err.response?.data?.error || 'Failed to add member'); + } finally { + setAdding(false); + } + } + + async function handleRemove(userId: string) { + if (!confirm('Remove this member?')) return; + try { + await removeCollectionMember(collectionId, userId); + await loadData(); + } catch (err) { + console.error('Failed to remove member:', err); + } + } + + if (loading) { + return ( +
+
e.stopPropagation()}> +
Loading...
+
+
+ ); + } + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

Share Collection

+ +
+ + {/* Visibility */} +
+
Visibility
+
+ + {pub ? 'Public — anyone with link can view' : 'Private — members only'} + + {isOwner && ( + + )} +
+ + {pub && shareUrl && ( +
+ (e.target as HTMLInputElement).select()} /> + +
+ )} +
+ + {/* Members */} +
+
Members ({members.length})
+ + {isOwner && ( +
+
+ handleQueryChange(e.target.value)} + onFocus={() => { if (suggestions.length > 0) setShowSuggestions(true); }} + onKeyDown={(e) => { if (e.key === 'Enter') handleAddMember(); }} + /> + + +
+ + {/* Suggestions dropdown */} + {showSuggestions && ( +
+ {suggestions.map((u) => ( + + ))} +
+ )} +
+ )} + + {/* Member list */} + {members.map((m) => ( +
+
+
+ {(m.display_name || m.email)[0].toUpperCase()} +
+
+
+ {m.display_name} +
+
+ {m.email} +
+
+
+
+ {m.role} + {isOwner && m.role !== 'owner' && ( + + )} +
+
+ ))} + + {members.length === 0 && ( +
No members yet.
+ )} +
+
+
+ ); +} + +function getRoleBadgeStyle(role: string): React.CSSProperties { + const colors: Record = { + owner: { bg: 'rgba(255, 215, 0, 0.12)', color: '#ffd700' }, + editor: { bg: 'rgba(74, 158, 255, 0.12)', color: '#4a9eff' }, + viewer: { bg: 'rgba(136, 136, 136, 0.12)', color: '#888' }, + }; + const c = colors[role] || colors.viewer; + return { + fontSize: '10px', padding: '2px 8px', borderRadius: '4px', + fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.3px', + background: c.bg, color: c.color, + }; +} + +const s = { + overlay: { position: 'fixed' as const, inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }, + modal: { background: '#252525', borderRadius: '10px', padding: '24px', width: '100%', maxWidth: '440px', border: '1px solid #333', maxHeight: '80vh', overflow: 'auto' }, + header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px' }, + title: { margin: 0, fontSize: '16px', fontWeight: 600, color: '#e0e0e0' }, + closeBtn: { background: 'transparent', border: 'none', color: '#666', fontSize: '18px', cursor: 'pointer', padding: '4px', lineHeight: 1 }, + section: { marginBottom: '18px' }, + sectionTitle: { fontSize: '11px', fontWeight: 600, color: '#666', marginBottom: '8px', textTransform: 'uppercase' as const, letterSpacing: '0.5px' }, + toggleRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 12px', background: '#1e1e1e', borderRadius: '6px', border: '1px solid #333' }, + toggleSwitch: { position: 'relative' as const, width: '44px', height: '24px', borderRadius: '12px', cursor: 'pointer', transition: 'background 0.2s', border: 'none', padding: 0 }, + toggleKnob: { position: 'absolute' as const, top: '3px', width: '18px', height: '18px', borderRadius: '50%', background: '#fff', transition: 'left 0.2s' }, + linkRow: { display: 'flex', gap: '6px', marginTop: '8px' }, + linkInput: { flex: 1, padding: '7px 10px', background: '#1e1e1e', border: '1px solid #333', borderRadius: '5px', color: '#ccc', fontSize: '12px', outline: 'none', fontFamily: 'monospace' }, + copyBtn: { padding: '7px 14px', color: '#fff', border: 'none', borderRadius: '5px', fontSize: '12px', fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap' as const }, + addRow: { display: 'flex', gap: '6px' }, + input: { flex: 1, padding: '7px 10px', background: '#1e1e1e', border: '1px solid #333', borderRadius: '5px', color: '#e0e0e0', fontSize: '12px', outline: 'none' }, + roleSelect: { padding: '7px 8px', background: '#1e1e1e', border: '1px solid #333', borderRadius: '5px', color: '#e0e0e0', fontSize: '12px', cursor: 'pointer', outline: 'none' }, + addBtn: { padding: '7px 14px', background: '#4a9eff', color: '#fff', border: 'none', borderRadius: '5px', fontSize: '12px', fontWeight: 600, cursor: 'pointer' }, + dropdown: { position: 'absolute' as const, top: '100%', left: 0, right: 0, background: '#2d2d2d', border: '1px solid #444', borderRadius: '6px', marginTop: '4px', overflow: 'hidden', zIndex: 10, boxShadow: '0 4px 12px rgba(0,0,0,0.4)' }, + dropdownItem: { display: 'flex', width: '100%', padding: '8px 12px', background: 'transparent', border: 'none', borderBottom: '1px solid #333', cursor: 'pointer', textAlign: 'left' as const }, + memberRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 10px', background: '#1e1e1e', borderRadius: '6px', marginBottom: '4px', border: '1px solid #2a2a2a' }, + avatar: { width: '28px', height: '28px', borderRadius: '50%', background: '#4a9eff20', color: '#4a9eff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px', fontWeight: 700, flexShrink: 0 }, + removeBtn: { background: 'transparent', border: '1px solid #5a2d2d', borderRadius: '4px', color: '#ff6b6b', fontSize: '10px', padding: '3px 8px', cursor: 'pointer' }, +}; diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx new file mode 100644 index 0000000..1587b2b --- /dev/null +++ b/frontend/src/components/StatusBar.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +export type SaveStatus = 'saved' | 'saving' | 'unsaved'; + +interface StatusBarProps { + boardName: string; + imageCount: number; + saveStatus: SaveStatus; +} + +const styles = { + bar: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '4px 16px', + background: '#2d2d2d', + borderTop: '1px solid #3d3d3d', + fontSize: '12px', + color: '#888', + flexShrink: 0, + height: '28px', + }, + left: { + display: 'flex', + alignItems: 'center', + gap: '16px', + }, + name: { + color: '#aaa', + fontWeight: 500, + }, + right: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + dot: { + width: '6px', + height: '6px', + borderRadius: '50%', + display: 'inline-block', + }, +}; + +const statusConfig: Record = { + saved: { label: 'Saved', color: '#69db7c' }, + saving: { label: 'Saving...', color: '#ffd43b' }, + unsaved: { label: 'Unsaved changes', color: '#ff6b6b' }, +}; + +export default function StatusBar({ boardName, imageCount, saveStatus }: StatusBarProps) { + const status = statusConfig[saveStatus]; + + return ( +
+
+ {boardName} + {imageCount} image{imageCount !== 1 ? 's' : ''} +
+
+ + {status.label} +
+
+ ); +} diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx new file mode 100644 index 0000000..9f343b5 --- /dev/null +++ b/frontend/src/components/Toolbar.tsx @@ -0,0 +1,341 @@ +import React from 'react'; +import { ToolType } from '../canvas/tools'; +import ColorPicker from './ColorPicker'; + +interface OnlineUser { + userId: string; + displayName: string; + color: string; +} + +interface ToolbarProps { + activeTool: ToolType; + onToolChange: (tool: ToolType) => void; + color: string; + onColorChange: (color: string) => void; + strokeWidth: number; + onStrokeWidthChange: (width: number) => void; + fontSize: number; + onFontSizeChange: (size: number) => void; + zoom: number; + onFitAll: () => void; + onZoomIn?: () => void; + onZoomOut?: () => void; + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; + onlineUsers: OnlineUser[]; + onShareClick?: () => void; + onToggleLayers?: () => void; + showLayers?: boolean; + boardName?: string; +} + +// SVG icon components +function IconSelect() { + return ( + + + + ); +} + +function IconPan() { + return ( + + + + ); +} + +function IconPen() { + return ( + + + + + ); +} + +function IconText() { + return ( + + + + ); +} + +function IconEraser() { + return ( + + + + + ); +} + +function IconUndo() { + return ( + + + + + ); +} + +function IconRedo() { + return ( + + + + + ); +} + +function IconFit() { + return ( + + + + ); +} + +function IconLayers() { + return ( + + + + + + ); +} + +const toolButtons: { tool: ToolType; label: string; shortcut: string; numKey: string; Icon: React.FC }[] = [ + { tool: ToolType.SELECT, label: 'Select', shortcut: 'V', numKey: '1', Icon: IconSelect }, + { tool: ToolType.PAN, label: 'Pan', shortcut: 'H', numKey: '2', Icon: IconPan }, + { tool: ToolType.PEN, label: 'Draw', shortcut: 'P', numKey: '3', Icon: IconPen }, + { tool: ToolType.TEXT, label: 'Text', shortcut: 'T', numKey: '4', Icon: IconText }, + { tool: ToolType.ERASER, label: 'Eraser', shortcut: 'E', numKey: '5', Icon: IconEraser }, +]; + +export default function Toolbar({ + activeTool, + onToolChange, + color, + onColorChange, + strokeWidth, + onStrokeWidthChange, + fontSize, + onFontSizeChange, + zoom, + onFitAll, + onZoomIn, + onZoomOut, + canUndo, + canRedo, + onUndo, + onRedo, + onlineUsers, + onShareClick, + onToggleLayers, + showLayers, +}: ToolbarProps) { + const showStroke = activeTool === ToolType.PEN; + const showFontSize = activeTool === ToolType.TEXT; + + return ( +
+ {/* Tool buttons */} +
+ {toolButtons.map(({ tool, label, shortcut, numKey, Icon }) => { + const active = activeTool === tool; + return ( + + ); + })} +
+ + + + {/* Color */} + + + {/* Stroke width (pen) */} + {showStroke && ( + <> + + Width + onStrokeWidthChange(Number(e.target.value))} + style={{ width: '60px', height: '3px', accentColor: '#4a9eff', cursor: 'pointer' }} + /> + {strokeWidth} + + )} + + {/* Font size (text) */} + {showFontSize && ( + <> + + Size + onFontSizeChange(Number(e.target.value))} + style={{ width: '60px', height: '3px', accentColor: '#4a9eff', cursor: 'pointer' }} + /> + {fontSize} + + )} + + + + {/* Undo/Redo */} + + + + + + {/* Zoom */} + {onZoomOut && ( + + + + + + )} + + {Math.round(zoom * 100)}% + + {onZoomIn && ( + + + + + + + )} + + + + + {/* Layers toggle */} + {onToggleLayers && ( + + + + )} + + {/* Spacer */} +
+ + {/* Online users */} + {onlineUsers.length > 0 && ( +
u.displayName).join(', ')}> + {onlineUsers.slice(0, 5).map((u, i) => ( +
0 ? '-6px' : '0', + zIndex: 5 - i, + boxShadow: '0 1px 3px rgba(0,0,0,0.3)', + }}> + {(u.displayName || '?')[0].toUpperCase()} +
+ ))} + {onlineUsers.length > 5 && ( + +{onlineUsers.length - 5} + )} +
+ )} + + {/* Share */} + {onShareClick && ( + + )} +
+ ); +} + +function Divider() { + return
; +} + +function ActionBtn({ onClick, disabled, title, children, active }: { + onClick: () => void; disabled?: boolean; title: string; children: React.ReactNode; active?: boolean; +}) { + return ( + + ); +} diff --git a/frontend/src/components/UserCursors.tsx b/frontend/src/components/UserCursors.tsx new file mode 100644 index 0000000..8dfb0d4 --- /dev/null +++ b/frontend/src/components/UserCursors.tsx @@ -0,0 +1,129 @@ +import React, { useState, useEffect } from 'react'; +import { Socket } from 'socket.io-client'; + +interface CursorData { + userId: string; + displayName: string; + x: number; + y: number; + color: string; +} + +const CURSOR_COLORS = [ + '#ff6b6b', '#ffa94d', '#ffd43b', '#69db7c', '#38d9a9', + '#4dabf7', '#7950f2', '#e64980', '#20c997', '#ff922b', +]; + +function userColor(userId: string): string { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash) + userId.charCodeAt(i); + hash |= 0; + } + return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]; +} + +interface UserCursorsProps { + socket: Socket | null; + boardId: string; + canvasTransform: number[]; +} + +export default function UserCursors({ socket, boardId, canvasTransform }: UserCursorsProps) { + const [cursors, setCursors] = useState>(new Map()); + + useEffect(() => { + if (!socket) return; + + function handleCursorMove(data: any) { + const uid = data.userId || data.id; + const name = data.displayName || data.userName || data.display_name || ''; + if (!uid) return; + setCursors((prev) => { + const next = new Map(prev); + next.set(uid, { userId: uid, displayName: name, x: data.x, y: data.y, color: userColor(uid) }); + return next; + }); + } + + function handleUserLeft(data: any) { + const uid = data.userId || data.id; + if (!uid) return; + setCursors((prev) => { + const next = new Map(prev); + next.delete(uid); + return next; + }); + } + + socket.on('cursor:moved', handleCursorMove); + socket.on('user:left', handleUserLeft); + + return () => { + socket.off('cursor:moved', handleCursorMove); + socket.off('user:left', handleUserLeft); + }; + }, [socket]); + + if (cursors.size === 0) return null; + + const [zoom, , , , panX, panY] = canvasTransform.length >= 6 + ? canvasTransform + : [1, 0, 0, 1, 0, 0]; + + return ( +
+ {Array.from(cursors.values()).map((cursor) => { + // Transform canvas coords to screen coords + const screenX = cursor.x * zoom + panX; + const screenY = cursor.y * zoom + panY; + + return ( +
+ + + +
+ {cursor.displayName} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..8c7fc0c --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,49 @@ +*, *::before, *::after { + box-sizing: border-box; +} + +html, body, #root { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: #1a1a1a; + color: #e0e0e0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +a { + color: #4a9eff; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +input, button, select, textarea { + font-family: inherit; + font-size: inherit; +} + +button { + cursor: pointer; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #1a1a1a; +} + +::-webkit-scrollbar-thumb { + background: #3d3d3d; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..2339d59 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/pages/CollectionDetail.tsx b/frontend/src/pages/CollectionDetail.tsx new file mode 100644 index 0000000..0abe48e --- /dev/null +++ b/frontend/src/pages/CollectionDetail.tsx @@ -0,0 +1,343 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth'; +import { + getCollectionDetail, + getCollectionByShareToken, + createBoard, + deleteBoard as apiDeleteBoard, +} from '../api'; +import ShareDialog from '../components/ShareDialog'; + +interface Board { + id: string; + name: string; + description: string; + image_count: number; + created_at: string; + updated_at: string; +} + +interface CollectionDetailProps { + isPublicView?: boolean; +} + +const gradients = [ + 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', + 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', + 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', + 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', + 'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)', +]; + +function hashCode(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +export default function CollectionDetail({ isPublicView }: CollectionDetailProps) { + const { collectionId, shareToken } = useParams<{ collectionId?: string; shareToken?: string }>(); + const navigate = useNavigate(); + const { user } = useAuth(); + + const [collection, setCollection] = useState(null); + const [boards, setBoards] = useState([]); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [showModal, setShowModal] = useState(false); + const [showShare, setShowShare] = useState(false); + const [newName, setNewName] = useState(''); + const [newDesc, setNewDesc] = useState(''); + const [creating, setCreating] = useState(false); + + const load = useCallback(async () => { + try { + let res; + if (shareToken) { + res = await getCollectionByShareToken(shareToken); + setCollection(res.data.collection); + setBoards(res.data.boards || []); + setMembers([]); + } else if (collectionId) { + res = await getCollectionDetail(collectionId); + setCollection(res.data.collection); + setBoards(res.data.boards || []); + setMembers(res.data.members || []); + } + setLoading(false); + } catch (err: any) { + setError(err.response?.data?.error || 'Failed to load collection'); + setLoading(false); + } + }, [collectionId, shareToken]); + + useEffect(() => { load(); }, [load]); + + const resolvedId = collectionId || collection?.id; + const isOwner = members.some((m: any) => m.user_id === user?.id && m.role === 'owner'); + const isEditor = isOwner || members.some((m: any) => m.user_id === user?.id && (m.role === 'editor' || m.role === 'owner')); + + async function handleCreateBoard() { + if (!newName.trim() || !resolvedId) return; + setCreating(true); + try { + const res = await createBoard(resolvedId, newName.trim(), newDesc.trim()); + const board = res.data.board || res.data; + setShowModal(false); + setNewName(''); + setNewDesc(''); + navigate(`/board/${board.id}`); + } catch (err) { + console.error('Failed to create board:', err); + } finally { + setCreating(false); + } + } + + async function handleDeleteBoard(e: React.MouseEvent, boardId: string) { + e.stopPropagation(); + if (!confirm('Delete this board? This cannot be undone.')) return; + try { + await apiDeleteBoard(boardId); + setBoards((prev) => prev.filter((b) => b.id !== boardId)); + } catch (err) { + console.error('Failed to delete board:', err); + } + } + + if (loading) { + return ( +
+ Loading... +
+ ); + } + if (error) { + return ( +
+
{error}
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+ {collection?.name || 'Collection'} +
+ {user && !isPublicView && ( + + )} +
+ + {/* Controls */} +
+ {isEditor && ( + + )} + {collection?.description && ( + {collection.description} + )} +
+ + {/* Grid */} +
+ {boards.length === 0 && ( +
+
+
+ No boards in this collection yet. +
+ )} + {boards.map((board) => ( +
navigate(`/board/${board.id}`)} + style={{ + background: '#141414', borderRadius: '12px', border: '1px solid #1e1e1e', + overflow: 'hidden', cursor: 'pointer', transition: 'all 0.2s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.borderColor = '#333'; + (e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'; + (e.currentTarget as HTMLElement).style.boxShadow = '0 8px 24px rgba(0,0,0,0.3)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.borderColor = '#1e1e1e'; + (e.currentTarget as HTMLElement).style.transform = 'none'; + (e.currentTarget as HTMLElement).style.boxShadow = 'none'; + }} + > +
+ {board.name.charAt(0).toUpperCase()} +
+
+

+ {board.name} +

+
+
+ + {board.image_count} img{board.image_count !== 1 ? 's' : ''} + + + {new Date(board.updated_at).toLocaleDateString()} + + {isOwner && ( + + )} +
+
+ ))} +
+ + {/* Create board modal */} + {showModal && ( +
setShowModal(false)}> +
e.stopPropagation()}> +

New Board

+ setNewName(e.target.value)} autoFocus + onKeyDown={(e) => { if (e.key === 'Enter') handleCreateBoard(); }} + style={{ + width: '100%', padding: '10px 14px', marginBottom: '12px', + background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px', + color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box', + }} + /> + setNewDesc(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleCreateBoard(); }} + style={{ + width: '100%', padding: '10px 14px', marginBottom: '16px', + background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px', + color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box', + }} + /> +
+ + +
+
+
+ )} + + {showShare && resolvedId && ( + setShowShare(false)} /> + )} +
+ ); +} diff --git a/frontend/src/pages/CollectionList.tsx b/frontend/src/pages/CollectionList.tsx new file mode 100644 index 0000000..ba04c74 --- /dev/null +++ b/frontend/src/pages/CollectionList.tsx @@ -0,0 +1,314 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth'; +import { getCollections, createCollection, deleteCollection as apiDeleteCollection } from '../api'; + +interface Collection { + id: string; + name: string; + description: string; + is_public: number; + created_by: string; + board_count: number; + created_at: string; + updated_at: string; + member_role: string | null; +} + +const gradients = [ + 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', + 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', + 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', + 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', + 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)', + 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)', + 'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)', +]; + +function hashCode(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +export default function CollectionList() { + const [collections, setCollections] = useState([]); + const [search, setSearch] = useState(''); + const [showModal, setShowModal] = useState(false); + const [newName, setNewName] = useState(''); + const [newDesc, setNewDesc] = useState(''); + const [creating, setCreating] = useState(false); + const navigate = useNavigate(); + const { user, logout } = useAuth(); + + const load = useCallback(async (q?: string) => { + try { + const res = await getCollections(q); + setCollections(res.data.collections || []); + } catch (err) { + console.error('Failed to load collections:', err); + } + }, []); + + useEffect(() => { load(); }, [load]); + + useEffect(() => { + const timer = setTimeout(() => load(search || undefined), 300); + return () => clearTimeout(timer); + }, [search, load]); + + async function handleCreate() { + if (!newName.trim()) return; + setCreating(true); + try { + const res = await createCollection(newName.trim(), newDesc.trim()); + const col = res.data.collection || res.data; + setShowModal(false); + setNewName(''); + setNewDesc(''); + navigate(`/collection/${col.id}`); + } catch (err) { + console.error('Failed to create collection:', err); + } finally { + setCreating(false); + } + } + + async function handleDelete(e: React.MouseEvent, id: string) { + e.stopPropagation(); + if (!confirm('Delete this collection and ALL its boards? This cannot be undone.')) return; + try { + await apiDeleteCollection(id); + setCollections((prev) => prev.filter((c) => c.id !== id)); + } catch (err) { + console.error('Failed to delete collection:', err); + } + } + + return ( +
+ {/* Header */} +
+
+
+ + + + + + +
+ RefBoard +
+
+
+ {(user?.display_name || user?.email || '?')[0].toUpperCase()} +
+ {user?.display_name || user?.email} + +
+
+ + {/* Controls */} +
+
+ + + + setSearch(e.target.value)} + style={{ + width: '100%', padding: '9px 14px 9px 34px', + background: '#161616', border: '1px solid #222', borderRadius: '8px', + color: '#e0e0e0', fontSize: '13px', outline: 'none', boxSizing: 'border-box', + }} + /> +
+ +
+ + {/* Grid */} +
+ {collections.length === 0 && ( +
+
+
+ No collections yet. Create one to get started. +
+ )} + {collections.map((col) => ( +
navigate(`/collection/${col.id}`)} + style={{ + background: '#141414', borderRadius: '12px', border: '1px solid #1e1e1e', + overflow: 'hidden', cursor: 'pointer', transition: 'all 0.2s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.borderColor = '#333'; + (e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'; + (e.currentTarget as HTMLElement).style.boxShadow = '0 8px 24px rgba(0,0,0,0.3)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.borderColor = '#1e1e1e'; + (e.currentTarget as HTMLElement).style.transform = 'none'; + (e.currentTarget as HTMLElement).style.boxShadow = 'none'; + }} + > +
+ {col.name.charAt(0).toUpperCase()} +
+
+

+ {col.name} +

+ {col.description && ( +

+ {col.description} +

+ )} +
+
+ + {col.board_count} board{col.board_count !== 1 ? 's' : ''} + + + {new Date(col.updated_at).toLocaleDateString()} + + {col.created_by === user?.id && ( + + )} +
+
+ ))} +
+ + {/* Create modal */} + {showModal && ( +
setShowModal(false)}> +
e.stopPropagation()}> +

+ New Collection +

+ setNewName(e.target.value)} autoFocus + onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }} + style={{ + width: '100%', padding: '10px 14px', marginBottom: '12px', + background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px', + color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box', + }} + /> + setNewDesc(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }} + style={{ + width: '100%', padding: '10px 14px', marginBottom: '16px', + background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px', + color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box', + }} + /> +
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx new file mode 100644 index 0000000..83608fe --- /dev/null +++ b/frontend/src/pages/Editor.tsx @@ -0,0 +1,1011 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { FabricObject, FabricImage, Group } from 'fabric'; +import FabricCanvas, { FabricCanvasHandle } from '../canvas/FabricCanvas'; +import { ToolType, activateTool, toolShortcuts } from '../canvas/tools'; +import { setupSync, isRemoteUpdate } from '../canvas/sync'; +import { setupDragDrop, setupPaste } from '../canvas/image-drop'; +import { UndoManager } from '../canvas/history'; +import { connectSocket, disconnectSocket, getSocket } from '../socket'; +import { getBoard, saveCanvas } from '../api'; +import { useAuth } from '../auth'; +import Toolbar from '../components/Toolbar'; +import StatusBar, { SaveStatus } from '../components/StatusBar'; +import UserCursors from '../components/UserCursors'; +import ContextMenu from '../components/ContextMenu'; +import LayerPanel from '../components/LayerPanel'; + +interface EditorProps { + isPublicView?: boolean; +} + +interface OnlineUser { + userId: string; + displayName: string; + color: string; +} + +const CURSOR_COLORS = [ + '#ff6b6b', '#ffa94d', '#ffd43b', '#69db7c', '#38d9a9', + '#4dabf7', '#7950f2', '#e64980', '#20c997', '#ff922b', +]; + +function userColor(userId: string): string { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash) + userId.charCodeAt(i); + hash |= 0; + } + return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]; +} + +export default function Editor({ isPublicView }: EditorProps) { + const { boardId } = useParams<{ boardId?: string }>(); + const navigate = useNavigate(); + const { user } = useAuth(); + + const canvasRef = useRef(null); + const undoRef = useRef(null); + const saveTimerRef = useRef | null>(null); + const syncCleanupRef = useRef<(() => void) | null>(null); + const dropCleanupRef = useRef<(() => void) | null>(null); + const pasteCleanupRef = useRef<(() => void) | null>(null); + const toolCleanupRef = useRef<(() => void) | null>(null); + + const [boardData, setBoardData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [activeTool, setActiveTool] = useState(ToolType.SELECT); + const [color, setColor] = useState('#ffffff'); + const [strokeWidth, setStrokeWidth] = useState(4); + const [fontSize, setFontSize] = useState(24); + const [zoom, setZoom] = useState(1); + const [saveStatus, setSaveStatus] = useState('saved'); + const [onlineUsers, setOnlineUsers] = useState([]); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [canvasTransform, setCanvasTransform] = useState([1, 0, 0, 1, 0, 0]); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]); + const [showLayers, setShowLayers] = useState(false); + const [layerList, setLayerList] = useState([]); + const [selectedLayerIds, setSelectedLayerIds] = useState([]); + const clipboardRef = useRef([]); + + const resolvedBoardId = boardId || boardData?.board?.id; + + // Load board data + useEffect(() => { + let cancelled = false; + async function load() { + try { + if (!boardId) { + setError('No board specified'); + setLoading(false); + return; + } + const res = await getBoard(boardId); + if (!cancelled) { + setBoardData(res.data); + setLoading(false); + } + } catch (err: any) { + if (!cancelled) { + setError(err.response?.data?.error || 'Failed to load board'); + setLoading(false); + } + } + } + load(); + return () => { cancelled = true; }; + }, [boardId]); + + // Debounced save + const scheduleSave = useCallback(() => { + if (!resolvedBoardId || isPublicView) return; + setSaveStatus('unsaved'); + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(async () => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + setSaveStatus('saving'); + try { + const state = JSON.stringify((canvas as any).toJSON(['id'])); + await saveCanvas(resolvedBoardId, state); + setSaveStatus('saved'); + } catch { + setSaveStatus('unsaved'); + } + }, 2000); + }, [resolvedBoardId, isPublicView]); + + // Canvas change handler — skips remote updates to avoid resetting save timer + const onCanvasChange = useCallback(() => { + if (isRemoteUpdate()) return; + scheduleSave(); + if (undoRef.current && !undoRef.current.isLocked()) { + undoRef.current.saveState(); + setCanUndo(undoRef.current.canUndo()); + setCanRedo(undoRef.current.canRedo()); + } + const z = canvasRef.current?.getZoom() ?? 1; + setZoom(z); + const canvas = canvasRef.current?.getCanvas(); + if (canvas?.viewportTransform) { + setCanvasTransform([...canvas.viewportTransform]); + } + }, [scheduleSave]); + + // Toast helper + const showToast = useCallback((text: string) => { + const id = crypto.randomUUID(); + setToasts((prev) => [...prev.slice(-4), { id, text }]); + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000); + }, []); + + // Setup socket, sync, drag/drop, paste after canvas is ready + useEffect(() => { + if (!boardData || !resolvedBoardId) return; + + const timer = setTimeout(() => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + + undoRef.current = new UndoManager(canvas); + + if (user) { + const socket = connectSocket(); + + syncCleanupRef.current = setupSync(canvas, socket, resolvedBoardId); + + socket.on('user:joined', (data: any) => { + const uid = data.userId || data.id; + const name = data.displayName || data.display_name || data.username || ''; + setOnlineUsers((prev) => { + if (prev.find((u) => u.userId === uid)) return prev; + return [...prev, { userId: uid, displayName: name, color: userColor(uid) }]; + }); + if (name) showToast(`${name} joined`); + }); + + socket.on('user:left', (data: any) => { + const uid = data.userId || data.id; + const name = data.displayName || data.display_name || data.username || ''; + setOnlineUsers((prev) => prev.filter((u) => u.userId !== uid)); + if (name) showToast(`${name} left`); + }); + + socket.on('room:users', (data: any) => { + if (Array.isArray(data.users)) { + setOnlineUsers(data.users.map((u: any) => ({ + userId: u.userId || u.id, + displayName: u.displayName || u.display_name || u.username || '', + color: userColor(u.userId || u.id), + }))); + } + }); + + canvas.on('mouse:move', (e: any) => { + const pointer = canvas.getScenePoint(e.e); + socket.emit('cursor:move', { + boardId: resolvedBoardId, + x: pointer.x, + y: pointer.y, + }); + }); + } + + if (!isPublicView || user) { + dropCleanupRef.current = setupDragDrop(canvas, resolvedBoardId, onCanvasChange); + pasteCleanupRef.current = setupPaste(canvas, resolvedBoardId, onCanvasChange); + } + }, 100); + + return () => { + clearTimeout(timer); + syncCleanupRef.current?.(); + dropCleanupRef.current?.(); + pasteCleanupRef.current?.(); + disconnectSocket(); + }; + }, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast]); + + // Apply tool changes + useEffect(() => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + toolCleanupRef.current?.(); + toolCleanupRef.current = activateTool(canvas, activeTool, { color, strokeWidth, fontSize }); + }, [activeTool, color, strokeWidth, fontSize]); + + // Clone a fabric object properly (handles images by reloading from URL) + const cloneObject = useCallback(async (data: any, offsetX = 0, offsetY = 0): Promise => { + const newId = crypto.randomUUID(); + if (data.type === 'image' && data.src) { + const imgObj = await FabricImage.fromURL(data.src, { crossOrigin: 'anonymous' }); + imgObj.set({ + left: (data.left || 0) + offsetX, + top: (data.top || 0) + offsetY, + scaleX: data.scaleX || 1, + scaleY: data.scaleY || 1, + angle: data.angle || 0, + flipX: data.flipX || false, + flipY: data.flipY || false, + opacity: data.opacity ?? 1, + } as any); + (imgObj as any).id = newId; + return imgObj; + } + const obj = await (FabricObject as any).fromObject({ ...data, id: newId }); + obj.set({ left: (obj.left || 0) + offsetX, top: (obj.top || 0) + offsetY }); + obj.id = newId; + return obj; + }, []); + + // Copy selection as cropped image to system clipboard (for pasting in Paint etc.) + const copySelectionToClipboard = useCallback(async () => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + + const active = canvas.getActiveObjects(); + if (active.length === 0) { + showToast('Nothing selected to copy'); + return; + } + + try { + // Deselect temporarily so selection borders don't render + canvas.discardActiveObject(); + canvas.requestRenderAll(); + + // Get bounding rect of all selected objects (in canvas coords) + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + active.forEach((obj) => { + const bound = obj.getBoundingRect(); + minX = Math.min(minX, bound.left); + minY = Math.min(minY, bound.top); + maxX = Math.max(maxX, bound.left + bound.width); + maxY = Math.max(maxY, bound.top + bound.height); + }); + + const padding = 8; + const cropX = Math.max(0, Math.floor(minX - padding)); + const cropY = Math.max(0, Math.floor(minY - padding)); + const cropW = Math.ceil(maxX - minX + padding * 2); + const cropH = Math.ceil(maxY - minY + padding * 2); + + // Render full canvas to an offscreen element + const fullCanvas = (canvas as any).toCanvasElement(1); + + // Crop to selection bounds + const offscreen = document.createElement('canvas'); + offscreen.width = cropW; + offscreen.height = cropH; + const ctx = offscreen.getContext('2d')!; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, cropW, cropH); + ctx.drawImage(fullCanvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH); + + // Re-select objects + if (active.length === 1) { + canvas.setActiveObject(active[0]); + } else { + const fabricNs = (window as any).fabric; + if (fabricNs?.ActiveSelection) { + const sel = new fabricNs.ActiveSelection(active, { canvas }); + canvas.setActiveObject(sel); + } + } + canvas.requestRenderAll(); + + // Write to system clipboard + offscreen.toBlob(async (blob: Blob | null) => { + if (!blob) return; + try { + await navigator.clipboard.write([ + new ClipboardItem({ 'image/png': blob }), + ]); + showToast('Copied to clipboard'); + } catch { + showToast('Copy failed — browser may not support clipboard write'); + } + }, 'image/png'); + } catch (err) { + console.error('Copy to clipboard failed:', err); + showToast('Copy failed'); + } + }, [showToast]); + + // Refresh layer list from canvas + const refreshLayers = useCallback(() => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const objects = canvas.getObjects(); + const layers = objects.map((obj: any, i: number) => { + const id = obj.id || `layer-${i}`; + const isGroup = obj.type === 'group'; + let name = obj.name || ''; + if (!name) { + if (obj.type === 'image') name = 'Image'; + else if (obj.type === 'i-text') name = (obj.text || 'Text').slice(0, 20); + else if (obj.type === 'path') name = 'Drawing'; + else if (isGroup) name = `Group (${obj._objects?.length || 0})`; + else name = obj.type || 'Object'; + } + return { + id, + name: `${name}`, + type: obj.type || 'object', + visible: obj.visible !== false, + locked: !obj.selectable, + isGroup, + }; + }); + setLayerList(layers); + const activeIds = canvas.getActiveObjects().map((o: any) => o.id).filter(Boolean); + setSelectedLayerIds(activeIds); + }, []); + + // Update layers whenever canvas changes + useEffect(() => { + if (!showLayers) return; + refreshLayers(); + const interval = setInterval(refreshLayers, 500); + return () => clearInterval(interval); + }, [showLayers, refreshLayers]); + + // Group selected objects + const handleGroup = useCallback(() => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const active = canvas.getActiveObjects(); + if (active.length < 2) return; + canvas.discardActiveObject(); + const group = new Group(active, {}); + (group as any).id = crypto.randomUUID(); + active.forEach((obj) => canvas.remove(obj)); + canvas.add(group); + canvas.setActiveObject(group); + canvas.requestRenderAll(); + onCanvasChange(); + refreshLayers(); + }, [onCanvasChange, refreshLayers]); + + // Ungroup selected group + const handleUngroup = useCallback(() => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const active = canvas.getActiveObject(); + if (!active || active.type !== 'group') return; + const group = active as Group; + const items = group.removeAll(); + canvas.remove(group); + items.forEach((obj: any) => { + obj.id = obj.id || crypto.randomUUID(); + canvas.add(obj); + }); + canvas.discardActiveObject(); + canvas.requestRenderAll(); + onCanvasChange(); + refreshLayers(); + }, [onCanvasChange, refreshLayers]); + + // Arrange selected objects + const arrangeObjects = useCallback((mode: 'grid' | 'row' | 'column') => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const active = canvas.getActiveObjects(); + if (active.length < 2) return; + + const gap = 20; + const sorted = [...active].sort((a, b) => (a.left || 0) - (b.left || 0)); + const startX = sorted[0].left || 0; + const startY = sorted[0].top || 0; + + if (mode === 'row') { + let x = startX; + sorted.forEach((obj) => { + obj.set({ left: x } as any); + obj.setCoords(); + x += (obj.width || 100) * (obj.scaleX || 1) + gap; + }); + } else if (mode === 'column') { + let y = startY; + sorted.forEach((obj) => { + obj.set({ left: startX, top: y } as any); + obj.setCoords(); + y += (obj.height || 100) * (obj.scaleY || 1) + gap; + }); + } else { + // Grid + const cols = Math.ceil(Math.sqrt(active.length)); + let maxH = 0; + sorted.forEach((obj, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + const w = (obj.width || 100) * (obj.scaleX || 1); + const h = (obj.height || 100) * (obj.scaleY || 1); + if (col === 0 && i > 0) maxH = 0; + obj.set({ + left: startX + col * (200 + gap), + top: startY + row * (200 + gap), + } as any); + obj.setCoords(); + maxH = Math.max(maxH, h); + }); + } + + canvas.discardActiveObject(); + canvas.requestRenderAll(); + onCanvasChange(); + }, [onCanvasChange]); + + // Right-click context menu + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); + + const contextMenuItems = useCallback(() => { + const canvas = canvasRef.current?.getCanvas(); + const hasSelection = canvas ? canvas.getActiveObjects().length > 0 : false; + return [ + { label: 'Copy', shortcut: 'Ctrl+C', onClick: () => { + if (!canvas) return; + const active = canvas.getActiveObjects(); + if (active.length > 0) { + clipboardRef.current = active.map((obj) => (obj as any).toJSON(['id', 'src'])); + copySelectionToClipboard(); + } + }, disabled: !hasSelection }, + { label: 'Paste', shortcut: 'Ctrl+V', onClick: async () => { + if (!canvas || clipboardRef.current.length === 0) return; + for (const data of clipboardRef.current) { + try { + const obj = await cloneObject(data, 20, 20); + (canvas as any).add(obj); + } catch {} + } + canvas.requestRenderAll(); + onCanvasChange(); + }, disabled: clipboardRef.current.length === 0 }, + { label: 'Duplicate', shortcut: 'Ctrl+D', onClick: async () => { + if (!canvas) return; + const active = canvas.getActiveObjects(); + for (const original of active) { + const data = (original as any).toJSON(['id', 'src']); + try { + const obj = await cloneObject(data, 20, 20); + (canvas as any).add(obj); + } catch {} + } + canvas.discardActiveObject(); + canvas.requestRenderAll(); + onCanvasChange(); + }, disabled: !hasSelection }, + { label: '', shortcut: '', onClick: () => {}, divider: true }, + { label: 'Bring Forward', shortcut: ']', onClick: () => { + if (!canvas) return; + canvas.getActiveObjects().forEach((obj) => canvas.bringObjectForward(obj)); + canvas.requestRenderAll(); + onCanvasChange(); + }, disabled: !hasSelection }, + { label: 'Send Backward', shortcut: '[', onClick: () => { + if (!canvas) return; + canvas.getActiveObjects().forEach((obj) => canvas.sendObjectBackwards(obj)); + canvas.requestRenderAll(); + onCanvasChange(); + }, disabled: !hasSelection }, + { label: '', shortcut: '', onClick: () => {}, divider: true }, + { label: 'Group', shortcut: 'Ctrl+G', onClick: handleGroup, + disabled: !canvas || canvas.getActiveObjects().length < 2 }, + { label: 'Ungroup', shortcut: 'Ctrl+Shift+G', onClick: handleUngroup, + disabled: !canvas || canvas.getActiveObject()?.type !== 'group' }, + { label: '', shortcut: '', onClick: () => {}, divider: true }, + { label: 'Arrange Grid', shortcut: '', onClick: () => arrangeObjects('grid'), + disabled: !canvas || canvas.getActiveObjects().length < 2 }, + { label: 'Arrange Row', shortcut: '', onClick: () => arrangeObjects('row'), + disabled: !canvas || canvas.getActiveObjects().length < 2 }, + { label: 'Arrange Column', shortcut: '', onClick: () => arrangeObjects('column'), + disabled: !canvas || canvas.getActiveObjects().length < 2 }, + { label: '', shortcut: '', onClick: () => {}, divider: true }, + { label: 'Select All', shortcut: 'Ctrl+A', onClick: () => { + if (!canvas) return; + canvas.discardActiveObject(); + const fabricNs = (window as any).fabric; + if (fabricNs?.ActiveSelection) { + const sel = new fabricNs.ActiveSelection(canvas.getObjects(), { canvas }); + canvas.setActiveObject(sel); + } + canvas.requestRenderAll(); + } }, + { label: 'Fit All', shortcut: 'Ctrl+0', onClick: () => canvasRef.current?.fitAll() }, + { label: '', shortcut: '', onClick: () => {}, divider: true }, + { label: 'Delete', shortcut: 'Del', onClick: () => { + if (!canvas) return; + const active = canvas.getActiveObjects(); + active.forEach((obj) => canvas.remove(obj)); + canvas.discardActiveObject(); + canvas.requestRenderAll(); + onCanvasChange(); + }, disabled: !hasSelection, danger: true }, + ]; + }, [copySelectionToClipboard, onCanvasChange, handleGroup, handleUngroup, arrangeObjects, cloneObject]); + + // Keyboard shortcuts + useEffect(() => { + async function onKeyDown(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + + const tool = toolShortcuts[e.key.toLowerCase()]; + if (tool && !e.ctrlKey && !e.metaKey) { + setActiveTool(tool); + return; + } + + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + undoRef.current?.undo(); + setCanUndo(undoRef.current?.canUndo() ?? false); + setCanRedo(undoRef.current?.canRedo() ?? false); + onCanvasChange(); + return; + } + if ((e.ctrlKey || e.metaKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) { + e.preventDefault(); + undoRef.current?.redo(); + setCanUndo(undoRef.current?.canUndo() ?? false); + setCanRedo(undoRef.current?.canRedo() ?? false); + onCanvasChange(); + return; + } + + // Group (Ctrl+G) + if ((e.ctrlKey || e.metaKey) && e.key === 'g' && !e.shiftKey) { + e.preventDefault(); + handleGroup(); + return; + } + // Ungroup (Ctrl+Shift+G) + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'G') { + e.preventDefault(); + handleUngroup(); + return; + } + + // Copy selected objects (internal + system clipboard) + if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !e.shiftKey) { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const active = canvas.getActiveObjects(); + if (active.length > 0) { + // Internal clipboard for within-app paste + clipboardRef.current = active.map((obj) => (obj as any).toJSON(['id', 'src'])); + // Also write as image to system clipboard for external paste + copySelectionToClipboard(); + } + return; + } + + // Paste canvas objects (Ctrl+V) — only if we have internal clipboard + // Image paste from system clipboard is handled by setupPaste in image-drop.ts + if ((e.ctrlKey || e.metaKey) && e.key === 'v') { + if (clipboardRef.current.length === 0) return; // let setupPaste handle it + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + e.preventDefault(); + const pasteOffset = 20; + for (const data of clipboardRef.current) { + try { + const obj = await cloneObject(data, pasteOffset, pasteOffset); + (canvas as any).add(obj); + } catch (err) { + console.error('Paste object failed:', err); + } + } + clipboardRef.current = clipboardRef.current.map((d) => ({ + ...d, left: (d.left || 0) + pasteOffset, top: (d.top || 0) + pasteOffset, + })); + canvas.requestRenderAll(); + onCanvasChange(); + return; + } + + // Copy as image to system clipboard (Ctrl+Shift+C) + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') { + e.preventDefault(); + copySelectionToClipboard(); + return; + } + + // Duplicate selected (Ctrl+D) + if ((e.ctrlKey || e.metaKey) && e.key === 'd') { + e.preventDefault(); + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const active = canvas.getActiveObjects(); + if (active.length === 0) return; + for (const original of active) { + const data = (original as any).toJSON(['id', 'src']); + try { + const obj = await cloneObject(data, 20, 20); + (canvas as any).add(obj); + } catch (err) { + console.error('Duplicate failed:', err); + } + } + canvas.discardActiveObject(); + canvas.requestRenderAll(); + onCanvasChange(); + return; + } + + // Layer ordering + if (e.key === ']') { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const active = canvas.getActiveObjects(); + active.forEach((obj) => (canvas as any).bringObjectForward(obj)); + canvas.requestRenderAll(); + onCanvasChange(); + return; + } + if (e.key === '[') { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const active = canvas.getActiveObjects(); + active.forEach((obj) => (canvas as any).sendObjectBackwards(obj)); + canvas.requestRenderAll(); + onCanvasChange(); + return; + } + + // Zoom in/out with Ctrl+=/Ctrl+- + if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { + e.preventDefault(); + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const z = Math.min(canvas.getZoom() * 1.2, 5); + const center = canvas.getCenterPoint(); + canvas.zoomToPoint(center, z); + canvas.requestRenderAll(); + setZoom(z); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key === '-') { + e.preventDefault(); + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const z = Math.max(canvas.getZoom() / 1.2, 0.1); + const center = canvas.getCenterPoint(); + canvas.zoomToPoint(center, z); + canvas.requestRenderAll(); + setZoom(z); + return; + } + + if (e.key === 'Delete' || e.key === 'Backspace') { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const active = canvas.getActiveObjects(); + if (active.length > 0) { + active.forEach((obj) => canvas.remove(obj)); + canvas.discardActiveObject(); + canvas.requestRenderAll(); + onCanvasChange(); + } + return; + } + + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + e.preventDefault(); + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + canvas.discardActiveObject(); + const fabricNs = (window as any).fabric; + if (fabricNs?.ActiveSelection) { + const sel = new fabricNs.ActiveSelection(canvas.getObjects(), { canvas }); + canvas.setActiveObject(sel); + } + canvas.requestRenderAll(); + return; + } + + if ((e.ctrlKey || e.metaKey) && e.key === '0') { + e.preventDefault(); + canvasRef.current?.fitAll(); + return; + } + } + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onCanvasChange, copySelectionToClipboard, handleGroup, handleUngroup]); + + // Save on page unload + useEffect(() => { + function onBeforeUnload() { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas || !resolvedBoardId) return; + const state = JSON.stringify((canvas as any).toJSON(['id'])); + const blob = new Blob([JSON.stringify({ canvas_state: state })], { type: 'application/json' }); + navigator.sendBeacon(`/api/boards/${resolvedBoardId}/save`, blob); + } + window.addEventListener('beforeunload', onBeforeUnload); + return () => window.removeEventListener('beforeunload', onBeforeUnload); + }, [resolvedBoardId]); + + // Update zoom from canvas + useEffect(() => { + const interval = setInterval(() => { + const z = canvasRef.current?.getZoom() ?? 1; + setZoom(z); + }, 500); + return () => clearInterval(interval); + }, []); + + if (loading) { + return ( +
+ Loading board... +
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } + + const board = boardData?.board; + const collection = boardData?.collection; + const canvasState = board?.canvas_state; + const imageCount = boardData?.images?.length ?? 0; + + return ( +
+ {/* Breadcrumb header */} +
+
+ {user && ( + <> + navigate('/')} + onMouseEnter={(e) => e.currentTarget.style.color = '#aaa'} + onMouseLeave={(e) => e.currentTarget.style.color = '#666'} + > + {user.display_name || user.username || user.email} + + / + + )} + {collection && ( + <> + navigate(`/collection/${collection.id}`)} + onMouseEnter={(e) => e.currentTarget.style.color = '#ccc'} + onMouseLeave={(e) => e.currentTarget.style.color = '#888'} + > + {collection.name} + + / + + )} + + {board?.name || 'Untitled'} + +
+ +
+ +
+
+ + {/* Toolbar */} + canvasRef.current?.fitAll()} + canUndo={canUndo} + canRedo={canRedo} + onUndo={() => { + undoRef.current?.undo(); + setCanUndo(undoRef.current?.canUndo() ?? false); + setCanRedo(undoRef.current?.canRedo() ?? false); + }} + onRedo={() => { + undoRef.current?.redo(); + setCanUndo(undoRef.current?.canUndo() ?? false); + setCanRedo(undoRef.current?.canRedo() ?? false); + }} + onZoomIn={() => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const z = Math.min(canvas.getZoom() * 1.2, 5); + const center = canvas.getCenterPoint(); + canvas.zoomToPoint(center, z); + canvas.requestRenderAll(); + setZoom(z); + }} + onZoomOut={() => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const z = Math.max(canvas.getZoom() / 1.2, 0.1); + const center = canvas.getCenterPoint(); + canvas.zoomToPoint(center, z); + canvas.requestRenderAll(); + setZoom(z); + }} + onlineUsers={onlineUsers} + onToggleLayers={() => setShowLayers((v) => !v)} + showLayers={showLayers} + boardName={board?.name} + /> + + {/* Canvas */} +
+ + + + {/* Empty canvas guide */} + {imageCount === 0 && !canvasState?.objects?.length && ( +
+
+
+
Drop images here
+
+ or paste from clipboard (Ctrl+V) · Draw (P) · Add text (T) +
+
+ )} + + {/* Context menu */} + {contextMenu && ( + setContextMenu(null)} + /> + )} + + {/* Layer panel */} + {showLayers && ( + { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const obj = canvas.getObjects().find((o: any) => o.id === id); + if (obj) { + canvas.discardActiveObject(); + canvas.setActiveObject(obj); + canvas.requestRenderAll(); + } + }} + onToggleVisible={(id) => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const obj = canvas.getObjects().find((o: any) => o.id === id); + if (obj) { + obj.set('visible', !obj.visible); + canvas.requestRenderAll(); + onCanvasChange(); + refreshLayers(); + } + }} + onToggleLock={(id) => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const obj = canvas.getObjects().find((o: any) => o.id === id); + if (obj) { + obj.set({ selectable: !obj.selectable, evented: !obj.evented } as any); + canvas.requestRenderAll(); + refreshLayers(); + } + }} + onReorder={(from, to) => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const objects = canvas.getObjects(); + if (from < 0 || from >= objects.length || to < 0 || to >= objects.length) return; + const obj = objects[from]; + canvas.remove(obj); + const insertAt = to > from ? to : to; + canvas.insertAt(insertAt, obj); + canvas.requestRenderAll(); + onCanvasChange(); + refreshLayers(); + }} + onDelete={(id) => { + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; + const obj = canvas.getObjects().find((o: any) => o.id === id); + if (obj) { + canvas.remove(obj); + canvas.discardActiveObject(); + canvas.requestRenderAll(); + onCanvasChange(); + refreshLayers(); + } + }} + onGroup={handleGroup} + onUngroup={handleUngroup} + hasSelection={canvasRef.current?.getCanvas()?.getActiveObjects()?.length! > 1} + hasGroupSelection={canvasRef.current?.getCanvas()?.getActiveObject()?.type === 'group'} + /> + )} + + {/* Toasts */} +
+ {toasts.map((t) => ( +
+ {t.text} +
+ ))} +
+
+ + {/* Status bar */} + +
+ ); +} + +function StatusIndicator({ status }: { status: SaveStatus }) { + const cfg = { + saved: { color: '#4ade80', label: 'Saved' }, + saving: { color: '#facc15', label: 'Saving...' }, + unsaved: { color: '#f87171', label: 'Unsaved' }, + }; + const s = cfg[status]; + return ( +
+
+ {s.label} +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..1b52b3a --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,163 @@ +import React, { useState, FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth'; +import { login as apiLogin, register as apiRegister } from '../api'; + +export default function Login() { + const [isRegister, setIsRegister] = useState(false); + const [email, setEmail] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + const navigate = useNavigate(); + const { login } = useAuth(); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setSubmitting(true); + + try { + if (isRegister) { + const res = await apiRegister(email, username || email.split('@')[0], password, displayName || username || email.split('@')[0]); + login(res.data.token, res.data.user); + } else { + const res = await apiLogin(email, password); + login(res.data.token, res.data.user); + } + navigate('/', { replace: true }); + } catch (err: any) { + const msg = err.response?.data?.error || err.response?.data?.message || 'Something went wrong'; + setError(msg); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ {/* Logo */} +
+
+ + + + + + +
+

+ RefBoard +

+

+ {isRegister ? 'Create your account' : 'Sign in to continue'} +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ {isRegister && ( + <> + + setUsername(e.target.value)} + placeholder="username" required autoComplete="username" + /> + + setDisplayName(e.target.value)} + placeholder="Your name" autoComplete="name" + /> + + )} + + setEmail(e.target.value)} + placeholder="you@example.com" required autoComplete="email" + /> + + setPassword(e.target.value)} + placeholder="Password" required + autoComplete={isRegister ? 'new-password' : 'current-password'} + minLength={6} + /> + +
+ +
+ {isRegister ? 'Already have an account?' : "Don't have an account?"}{' '} + +
+
+
+ ); +} + +const inputStyle: React.CSSProperties = { + width: '100%', padding: '10px 14px', marginBottom: '16px', + background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px', + color: '#e0e0e0', fontSize: '14px', outline: 'none', + boxSizing: 'border-box', transition: 'border-color 0.2s', +}; diff --git a/frontend/src/socket.ts b/frontend/src/socket.ts new file mode 100644 index 0000000..3a5b3fc --- /dev/null +++ b/frontend/src/socket.ts @@ -0,0 +1,46 @@ +import { io, Socket } from 'socket.io-client'; +import { getToken } from './auth'; + +let socket: Socket | null = null; + +export function getSocket(): Socket | null { + return socket; +} + +export function connectSocket(): Socket { + if (socket?.connected) { + return socket; + } + + const token = getToken(); + socket = io(window.location.origin, { + auth: { token }, + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: 10, + reconnectionDelay: 1000, + }); + + socket.on('connect', () => { + console.log('[socket] connected:', socket?.id); + }); + + socket.on('disconnect', (reason) => { + console.log('[socket] disconnected:', reason); + }); + + socket.on('connect_error', (err) => { + console.error('[socket] connection error:', err.message); + }); + + return socket; +} + +export function disconnectSocket(): void { + if (socket) { + socket.disconnect(); + socket = null; + } +} + +export { Socket }; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1997b7e --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "esModuleInterop": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..bb42da3 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/socket.io': { + target: 'http://localhost:8000', + changeOrigin: true, + ws: true, + }, + }, + }, +});