feat: RefBoard v0.4.0 — collaborative reference board with layers, groups & polished UI
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
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
+165
@@ -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
|
||||
```
|
||||
+35
@@ -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"]
|
||||
@@ -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,
|
||||
};
|
||||
+334
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
const { getBoard, getCollection, getCollectionMember } = require('../db');
|
||||
|
||||
// Track active users per room: Map<roomName, Map<userId, userInfo>>
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RefBoard</title>
|
||||
<style>
|
||||
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;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3516
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect x="6" y="6" width="52" height="52" rx="4" stroke="#4a9eff" stroke-width="4" fill="#2d2d2d"/>
|
||||
<rect x="14" y="14" width="36" height="36" rx="2" stroke="#4a9eff" stroke-width="2" fill="#1e1e1e"/>
|
||||
<circle cx="26" cy="28" r="5" fill="#4a9eff" opacity="0.8"/>
|
||||
<polygon points="20,44 32,30 38,36 44,28 44,44" fill="#4a9eff" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 428 B |
@@ -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 (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100vh', background: '#1a1a1a', color: '#e0e0e0', fontSize: '18px',
|
||||
}}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><CollectionList /></ProtectedRoute>} />
|
||||
<Route path="/collection/:collectionId" element={<ProtectedRoute><CollectionDetail /></ProtectedRoute>} />
|
||||
<Route path="/board/:boardId" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
|
||||
<Route path="/c/:shareToken" element={<CollectionDetail isPublicView />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {};
|
||||
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;
|
||||
@@ -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<AuthContextType>({
|
||||
user: null,
|
||||
loading: true,
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(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
|
||||
);
|
||||
}
|
||||
@@ -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<FabricCanvasHandle, FabricCanvasProps>(
|
||||
({ canvasState, currentTool, onChange }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasElRef = useRef<HTMLCanvasElement>(null);
|
||||
const fabricRef = useRef<Canvas | null>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: '#1e1e1e',
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasElRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FabricCanvas.displayName = 'FabricCanvas';
|
||||
|
||||
export default FabricCanvas;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
let moveTimer: ReturnType<typeof setTimeout> | 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);
|
||||
};
|
||||
}
|
||||
@@ -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<string, ToolType> = {
|
||||
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,
|
||||
};
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={wrapperRef} style={styles.wrapper}>
|
||||
<button
|
||||
style={{ ...styles.trigger, background: color }}
|
||||
onClick={() => setOpen(!open)}
|
||||
title="Pick color"
|
||||
/>
|
||||
{open && (
|
||||
<div style={styles.popup}>
|
||||
<div style={styles.grid}>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
style={{
|
||||
...styles.swatch,
|
||||
background: c,
|
||||
borderColor: c === color ? '#4a9eff' : 'transparent',
|
||||
}}
|
||||
onClick={() => {
|
||||
onChange(c);
|
||||
setOpen(false);
|
||||
}}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={styles.hexRow}>
|
||||
<span style={styles.hexLabel}>#</span>
|
||||
<input
|
||||
style={styles.hexInput}
|
||||
value={hexInput.replace('#', '')}
|
||||
onChange={(e) => setHexInput('#' + e.target.value)}
|
||||
onBlur={handleHexSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleHexSubmit();
|
||||
}}
|
||||
maxLength={8}
|
||||
placeholder="hex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={ref} style={style}>
|
||||
{items.map((item, i) => {
|
||||
if (item.divider) {
|
||||
return <div key={i} style={{ height: '1px', background: '#3d3d3d', margin: '4px 0' }} />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
disabled={item.disabled}
|
||||
onClick={() => { item.onClick(); onClose(); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '6px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: item.disabled ? '#555' : item.danger ? '#ff6b6b' : '#ddd',
|
||||
fontSize: '12px',
|
||||
cursor: item.disabled ? 'default' : 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!item.disabled) e.currentTarget.style.background = '#363636';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span style={{ color: '#666', fontSize: '11px', marginLeft: '20px' }}>{item.shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(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 (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 0, bottom: 0, width: '28px',
|
||||
background: '#1e1e1e', borderLeft: '1px solid #2a2a2a', zIndex: 100,
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '8px',
|
||||
}}>
|
||||
<button onClick={() => setCollapsed(false)} title="Show layers"
|
||||
style={{ background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: '14px', padding: '4px' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M7 2L2 7l5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 0, bottom: 0, width: '200px',
|
||||
background: '#1e1e1e', borderLeft: '1px solid #2a2a2a', zIndex: 100,
|
||||
display: 'flex', flexDirection: 'column', userSelect: 'none',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 8px', borderBottom: '1px solid #2a2a2a', flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: '#999', letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
||||
Layers
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '2px' }}>
|
||||
<SmallBtn title="Group (Ctrl+G)" disabled={!hasSelection} onClick={onGroup}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
<rect x="1" y="1" width="4" height="4" rx="0.5" /><rect x="7" y="7" width="4" height="4" rx="0.5" />
|
||||
<path d="M5 6h2M6 5v2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</SmallBtn>
|
||||
<SmallBtn title="Ungroup (Ctrl+Shift+G)" disabled={!hasGroupSelection} onClick={onUngroup}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
<rect x="1" y="1" width="4" height="4" rx="0.5" /><rect x="7" y="7" width="4" height="4" rx="0.5" />
|
||||
<path d="M4 6h4" strokeLinecap="round" />
|
||||
</svg>
|
||||
</SmallBtn>
|
||||
<SmallBtn title="Collapse panel" onClick={() => setCollapsed(true)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M5 2l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</SmallBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layer list — reversed so top layer is first */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{[...layers].reverse().map((layer, i) => {
|
||||
const realIdx = layers.length - 1 - i;
|
||||
const selected = selectedIds.includes(layer.id);
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
draggable
|
||||
onDragStart={() => 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 */}
|
||||
<button onClick={(e) => { e.stopPropagation(); onToggleVisible(layer.id); }}
|
||||
title={layer.visible ? 'Hide' : 'Show'}
|
||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: layer.visible ? '#888' : '#444', flexShrink: 0 }}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
{layer.visible ? (
|
||||
<><ellipse cx="5" cy="5" rx="4" ry="2.5" /><circle cx="5" cy="5" r="1" fill="currentColor" /></>
|
||||
) : (
|
||||
<><line x1="1" y1="1" x2="9" y2="9" /><ellipse cx="5" cy="5" rx="4" ry="2.5" /></>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Lock toggle */}
|
||||
<button onClick={(e) => { e.stopPropagation(); onToggleLock(layer.id); }}
|
||||
title={layer.locked ? 'Unlock' : 'Lock'}
|
||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: layer.locked ? '#e8a946' : '#444', flexShrink: 0 }}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
{layer.locked ? (
|
||||
<><rect x="2" y="5" width="6" height="4" rx="0.5" /><path d="M3.5 5V3.5a1.5 1.5 0 013 0V5" /></>
|
||||
) : (
|
||||
<><rect x="2" y="5" width="6" height="4" rx="0.5" /><path d="M3.5 5V3.5a1.5 1.5 0 013 0" /></>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Type icon */}
|
||||
<span style={{ fontSize: '9px', color: '#555', flexShrink: 0, width: '12px', textAlign: 'center' }}>
|
||||
{layer.isGroup ? '📁' : layer.type === 'image' ? '🖼' : layer.type === 'i-text' ? 'T' : layer.type === 'path' ? '✏' : '◇'}
|
||||
</span>
|
||||
|
||||
{/* Name */}
|
||||
<span style={{
|
||||
flex: 1, fontSize: '11px', color: selected ? '#ccc' : '#999',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{layer.name}
|
||||
</span>
|
||||
|
||||
{/* Delete */}
|
||||
<button onClick={(e) => { e.stopPropagation(); onDelete(layer.id); }}
|
||||
title="Delete"
|
||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: '#444', flexShrink: 0, opacity: 0.5 }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = '#ff6b6b'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.5'; e.currentTarget.style.color = '#444'; }}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<line x1="2" y1="2" x2="8" y2="8" /><line x1="8" y1="2" x2="2" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{layers.length === 0 && (
|
||||
<div style={{ padding: '12px', textAlign: 'center', color: '#444', fontSize: '11px' }}>
|
||||
No objects
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmallBtn({ onClick, title, disabled, children }: {
|
||||
onClick: () => void; title: string; disabled?: boolean; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick} title={title} disabled={disabled}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: '3px', cursor: disabled ? 'default' : 'pointer',
|
||||
color: disabled ? '#333' : '#888', borderRadius: '3px',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.background = '#333'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'none'; }}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
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<UserResult[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserResult | null>(null);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div style={s.overlay} onClick={onClose}>
|
||||
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ color: '#666', textAlign: 'center', padding: '20px', fontSize: '13px' }}>Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={onClose}>
|
||||
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={s.header}>
|
||||
<h2 style={s.title}>Share Collection</h2>
|
||||
<button style={s.closeBtn} onClick={onClose}>{'\u00D7'}</button>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div style={s.section}>
|
||||
<div style={s.sectionTitle}>Visibility</div>
|
||||
<div style={s.toggleRow}>
|
||||
<span style={{ fontSize: '13px', color: '#ccc' }}>
|
||||
{pub ? 'Public — anyone with link can view' : 'Private — members only'}
|
||||
</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
style={{ ...s.toggleSwitch, background: pub ? '#4a9eff' : '#555' }}
|
||||
onClick={handleTogglePublic}
|
||||
>
|
||||
<div style={{ ...s.toggleKnob, left: pub ? '23px' : '3px' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pub && shareUrl && (
|
||||
<div style={s.linkRow}>
|
||||
<input style={s.linkInput} value={shareUrl} readOnly onClick={(e) => (e.target as HTMLInputElement).select()} />
|
||||
<button style={{ ...s.copyBtn, background: copied ? '#4ade80' : '#4a9eff' }} onClick={handleCopy}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
<div style={s.section}>
|
||||
<div style={s.sectionTitle}>Members ({members.length})</div>
|
||||
|
||||
{isOwner && (
|
||||
<div style={{ position: 'relative', marginBottom: '12px' }}>
|
||||
<div style={s.addRow}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
style={s.input}
|
||||
type="text"
|
||||
placeholder="Search by name or email..."
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
onFocus={() => { if (suggestions.length > 0) setShowSuggestions(true); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddMember(); }}
|
||||
/>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} style={s.roleSelect}>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button style={{ ...s.addBtn, opacity: adding ? 0.7 : 1 }} onClick={handleAddMember} disabled={adding}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && (
|
||||
<div style={s.dropdown}>
|
||||
{suggestions.map((u) => (
|
||||
<button key={u.id} style={s.dropdownItem} onClick={() => selectUser(u)}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: '13px', color: '#e0e0e0' }}>{u.display_name}</span>
|
||||
<span style={{ fontSize: '11px', color: '#888' }}>{u.email}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member list */}
|
||||
{members.map((m) => (
|
||||
<div key={m.user_id} style={s.memberRow}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
|
||||
<div style={s.avatar}>
|
||||
{(m.display_name || m.email)[0].toUpperCase()}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: '13px', color: '#e0e0e0', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{m.display_name}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{m.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||
<span style={getRoleBadgeStyle(m.role)}>{m.role}</span>
|
||||
{isOwner && m.role !== 'owner' && (
|
||||
<button style={s.removeBtn} onClick={() => handleRemove(m.user_id)}>Remove</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{members.length === 0 && (
|
||||
<div style={{ fontSize: '13px', color: '#555', padding: '10px 0' }}>No members yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRoleBadgeStyle(role: string): React.CSSProperties {
|
||||
const colors: Record<string, { bg: string; color: string }> = {
|
||||
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' },
|
||||
};
|
||||
@@ -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<SaveStatus, { label: string; color: string }> = {
|
||||
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 (
|
||||
<div style={styles.bar}>
|
||||
<div style={styles.left}>
|
||||
<span style={styles.name}>{boardName}</span>
|
||||
<span>{imageCount} image{imageCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div style={styles.right}>
|
||||
<span style={{ ...styles.dot, background: status.color }} />
|
||||
<span>{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 2L3 13L7 9.5L11 13.5L13 11.5L9 7.5L13 4L3 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPan() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M8 1v14M1 8h14M4 4L8 1L12 4M4 12L8 15L12 12M1 4L4 8L1 12M15 4L12 8L15 12" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPen() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M2 14L3.5 8.5L11 1L15 5L7.5 12.5L2 14Z" strokeLinejoin="round" />
|
||||
<path d="M3.5 8.5L7.5 12.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconText() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M3 3h10M8 3v11M5 14h6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconEraser() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6.5 14H14M2 10l4.5-4.5 4 4L6 14H3l-1-1v-3z" strokeLinejoin="round" />
|
||||
<path d="M6.5 5.5L14 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconUndo() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h6a3 3 0 010 6H7" strokeLinecap="round" />
|
||||
<path d="M5 3L3 5L5 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconRedo() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M11 5H5a3 3 0 000 6h2" strokeLinecap="round" />
|
||||
<path d="M9 3L11 5L9 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconFit() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<path d="M1 5V1h4M9 1h4v4M13 9v4H9M5 13H1V9" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconLayers() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<path d="M7 1L1 4.5L7 8L13 4.5L7 1Z" strokeLinejoin="round" />
|
||||
<path d="M1 7l6 3.5L13 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M1 9.5L7 13l6-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '4px 8px',
|
||||
background: '#1a1a1a',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
flexShrink: 0,
|
||||
height: '44px',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{/* Tool buttons */}
|
||||
<div style={{ display: 'flex', gap: '1px', background: '#222', borderRadius: '8px', padding: '2px' }}>
|
||||
{toolButtons.map(({ tool, label, shortcut, numKey, Icon }) => {
|
||||
const active = activeTool === tool;
|
||||
return (
|
||||
<button
|
||||
key={tool}
|
||||
onClick={() => onToolChange(tool)}
|
||||
title={`${label} (${shortcut} or ${numKey})`}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: '4px', height: '32px', padding: '0 10px',
|
||||
background: active ? 'linear-gradient(135deg, #4a9eff, #3d7dd8)' : 'transparent',
|
||||
border: 'none', borderRadius: '6px',
|
||||
color: active ? '#fff' : '#777',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: active ? '0 1px 4px rgba(74,158,255,0.3)' : 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!active) { e.currentTarget.style.background = '#2a2a2a'; e.currentTarget.style.color = '#bbb'; } }}
|
||||
onMouseLeave={(e) => { if (!active) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#777'; } }}
|
||||
>
|
||||
<Icon />
|
||||
<span style={{ fontSize: '11px', fontWeight: active ? 600 : 400, letterSpacing: '0.2px' }}>{label}</span>
|
||||
<span style={{
|
||||
fontSize: '9px', color: active ? 'rgba(255,255,255,0.5)' : '#555',
|
||||
background: active ? 'rgba(255,255,255,0.1)' : '#1a1a1a',
|
||||
padding: '1px 4px', borderRadius: '3px', fontWeight: 500,
|
||||
lineHeight: '14px', minWidth: '14px', textAlign: 'center',
|
||||
}}>
|
||||
{numKey}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Color */}
|
||||
<ColorPicker color={color} onChange={onColorChange} />
|
||||
|
||||
{/* Stroke width (pen) */}
|
||||
{showStroke && (
|
||||
<>
|
||||
<Divider />
|
||||
<span style={{ fontSize: '10px', color: '#555', marginLeft: '4px' }}>Width</span>
|
||||
<input
|
||||
type="range" min={2} max={20} value={strokeWidth}
|
||||
onChange={(e) => onStrokeWidthChange(Number(e.target.value))}
|
||||
style={{ width: '60px', height: '3px', accentColor: '#4a9eff', cursor: 'pointer' }}
|
||||
/>
|
||||
<span style={{ fontSize: '10px', color: '#666', minWidth: '18px', textAlign: 'center' }}>{strokeWidth}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Font size (text) */}
|
||||
{showFontSize && (
|
||||
<>
|
||||
<Divider />
|
||||
<span style={{ fontSize: '10px', color: '#555', marginLeft: '4px' }}>Size</span>
|
||||
<input
|
||||
type="range" min={12} max={72} value={fontSize}
|
||||
onChange={(e) => onFontSizeChange(Number(e.target.value))}
|
||||
style={{ width: '60px', height: '3px', accentColor: '#4a9eff', cursor: 'pointer' }}
|
||||
/>
|
||||
<span style={{ fontSize: '10px', color: '#666', minWidth: '18px', textAlign: 'center' }}>{fontSize}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<ActionBtn onClick={onUndo} disabled={!canUndo} title="Undo (Ctrl+Z)"><IconUndo /></ActionBtn>
|
||||
<ActionBtn onClick={onRedo} disabled={!canRedo} title="Redo (Ctrl+Shift+Z)"><IconRedo /></ActionBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Zoom */}
|
||||
{onZoomOut && (
|
||||
<ActionBtn onClick={onZoomOut} title="Zoom out (Ctrl+-)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<line x1="3" y1="7" x2="11" y2="7" strokeLinecap="round" />
|
||||
</svg>
|
||||
</ActionBtn>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: '11px', color: '#888', minWidth: '40px', textAlign: 'center',
|
||||
userSelect: 'none', fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
{onZoomIn && (
|
||||
<ActionBtn onClick={onZoomIn} title="Zoom in (Ctrl+=)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<line x1="3" y1="7" x2="11" y2="7" strokeLinecap="round" />
|
||||
<line x1="7" y1="3" x2="7" y2="11" strokeLinecap="round" />
|
||||
</svg>
|
||||
</ActionBtn>
|
||||
)}
|
||||
<ActionBtn onClick={onFitAll} title="Fit all (Ctrl+0)"><IconFit /></ActionBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Layers toggle */}
|
||||
{onToggleLayers && (
|
||||
<ActionBtn onClick={onToggleLayers} title="Layers panel"
|
||||
active={showLayers}>
|
||||
<IconLayers />
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Online users */}
|
||||
{onlineUsers.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '-4px', marginRight: '8px' }}
|
||||
title={onlineUsers.map((u) => u.displayName).join(', ')}>
|
||||
{onlineUsers.slice(0, 5).map((u, i) => (
|
||||
<div key={u.userId} style={{
|
||||
width: '24px', height: '24px', borderRadius: '50%',
|
||||
background: `linear-gradient(135deg, ${u.color}, ${u.color}dd)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '10px', fontWeight: 700, color: '#fff',
|
||||
border: '2px solid #1a1a1a',
|
||||
marginLeft: i > 0 ? '-6px' : '0',
|
||||
zIndex: 5 - i,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{(u.displayName || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{onlineUsers.length > 5 && (
|
||||
<span style={{ fontSize: '10px', color: '#666', marginLeft: '4px' }}>+{onlineUsers.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share */}
|
||||
{onShareClick && (
|
||||
<button onClick={onShareClick} style={{
|
||||
padding: '5px 14px', background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
border: 'none', borderRadius: '6px',
|
||||
color: '#fff', fontSize: '11px', fontWeight: 600,
|
||||
cursor: 'pointer', letterSpacing: '0.3px',
|
||||
boxShadow: '0 1px 4px rgba(74,158,255,0.3)',
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.85'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}>
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div style={{ width: '1px', height: '20px', background: '#2a2a2a', margin: '0 6px', flexShrink: 0 }} />;
|
||||
}
|
||||
|
||||
function ActionBtn({ onClick, disabled, title, children, active }: {
|
||||
onClick: () => void; disabled?: boolean; title: string; children: React.ReactNode; active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick} disabled={disabled} title={title}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '28px', height: '28px', background: active ? '#2a3a50' : 'transparent',
|
||||
border: 'none', borderRadius: '6px',
|
||||
color: active ? '#4a9eff' : disabled ? '#333' : '#777',
|
||||
cursor: disabled ? 'default' : 'pointer', padding: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!disabled) { e.currentTarget.style.background = active ? '#2a3a50' : '#2a2a2a'; } }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = active ? '#2a3a50' : 'transparent'; }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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<Map<string, CursorData>>(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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={cursor.userId}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: screenX,
|
||||
top: screenY,
|
||||
transform: 'translate(-2px, -2px)',
|
||||
transition: 'left 0.1s, top 0.1s',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="20" viewBox="0 0 16 20" fill="none">
|
||||
<path
|
||||
d="M1 1L6 18L8.5 10.5L15 8.5L1 1Z"
|
||||
fill={cursor.color}
|
||||
stroke="#000"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '14px',
|
||||
top: '14px',
|
||||
background: cursor.color,
|
||||
color: '#000',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{cursor.displayName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -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<any>(null);
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#0d0d0d', color: '#555', fontSize: '14px' }}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#0d0d0d', gap: '16px' }}>
|
||||
<div style={{ color: '#ff6b6b', fontSize: '14px' }}>{error}</div>
|
||||
<button onClick={() => navigate('/')} style={{
|
||||
padding: '8px 20px', background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
border: 'none', borderRadius: '8px', color: '#fff', cursor: 'pointer', fontSize: '13px',
|
||||
}}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: '#0d0d0d' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 28px', background: '#111', borderBottom: '1px solid #1a1a1a',
|
||||
flexShrink: 0, gap: '16px',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
style={{
|
||||
padding: '5px 12px', background: 'transparent', border: '1px solid #222',
|
||||
borderRadius: '6px', color: '#666', fontSize: '12px', cursor: 'pointer',
|
||||
whiteSpace: 'nowrap', transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#444'; e.currentTarget.style.color = '#aaa'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#222'; e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div style={{
|
||||
fontSize: '16px', fontWeight: 600, color: '#e0e0e0', flex: 1,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
letterSpacing: '-0.2px',
|
||||
}}>
|
||||
{collection?.name || 'Collection'}
|
||||
</div>
|
||||
{user && !isPublicView && (
|
||||
<button
|
||||
onClick={() => setShowShare(true)}
|
||||
style={{
|
||||
padding: '5px 16px', background: 'transparent',
|
||||
border: '1px solid rgba(74,158,255,0.3)', borderRadius: '6px',
|
||||
color: '#4a9eff', fontSize: '12px', fontWeight: 600, cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(74,158,255,0.08)';
|
||||
e.currentTarget.style.borderColor = '#4a9eff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = 'rgba(74,158,255,0.3)';
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '16px 28px', flexShrink: 0 }}>
|
||||
{isEditor && (
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
style={{
|
||||
padding: '9px 20px', background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
||||
fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
boxShadow: '0 2px 12px rgba(74,158,255,0.25)', transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.85'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||
>
|
||||
+ New Board
|
||||
</button>
|
||||
)}
|
||||
{collection?.description && (
|
||||
<span style={{ fontSize: '13px', color: '#555' }}>{collection.description}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{
|
||||
flex: 1, overflow: 'auto', padding: '0 28px 28px',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: '16px', alignContent: 'start',
|
||||
}}>
|
||||
{boards.length === 0 && (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1', textAlign: 'center', padding: '80px 20px', color: '#444', fontSize: '14px',
|
||||
}}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '12px', opacity: 0.2 }}>+</div>
|
||||
No boards in this collection yet.
|
||||
</div>
|
||||
)}
|
||||
{boards.map((board) => (
|
||||
<div
|
||||
key={board.id}
|
||||
onClick={() => 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';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: '90px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: gradients[hashCode(board.id) % gradients.length],
|
||||
fontSize: '28px', color: 'rgba(255,255,255,0.4)', fontWeight: 700,
|
||||
}}>
|
||||
{board.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ padding: '12px 14px 8px' }}>
|
||||
<p style={{
|
||||
fontSize: '14px', fontWeight: 600, color: '#e0e0e0', margin: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{board.name}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 14px', borderTop: '1px solid #1a1a1a',
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', color: '#555' }}>
|
||||
{board.image_count} img{board.image_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#444' }}>
|
||||
{new Date(board.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteBoard(e, board.id)}
|
||||
style={{
|
||||
padding: '3px 8px', background: 'transparent', border: '1px solid #2a1515',
|
||||
borderRadius: '4px', color: '#ff6b6b', fontSize: '10px', cursor: 'pointer',
|
||||
opacity: 0.6, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create board modal */}
|
||||
{showModal && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
|
||||
backdropFilter: 'blur(4px)',
|
||||
}} onClick={() => setShowModal(false)}>
|
||||
<div style={{
|
||||
background: '#161616', borderRadius: '16px', padding: '32px',
|
||||
width: '100%', maxWidth: '420px', border: '1px solid #222',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
||||
}} onClick={(e) => e.stopPropagation()}>
|
||||
<h2 style={{ margin: '0 0 24px', fontSize: '18px', fontWeight: 600, color: '#e0e0e0' }}>New Board</h2>
|
||||
<input
|
||||
type="text" placeholder="Board name" value={newName}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text" placeholder="Description (optional)" value={newDesc}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
||||
<button onClick={() => setShowModal(false)} style={{
|
||||
padding: '8px 18px', background: 'transparent', border: '1px solid #222',
|
||||
borderRadius: '8px', color: '#888', fontSize: '13px', cursor: 'pointer',
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleCreateBoard} disabled={creating} style={{
|
||||
padding: '8px 20px',
|
||||
background: creating ? '#333' : 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
||||
fontWeight: 600, cursor: creating ? 'default' : 'pointer',
|
||||
opacity: creating ? 0.6 : 1,
|
||||
}}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showShare && resolvedId && (
|
||||
<ShareDialog collectionId={resolvedId} onClose={() => setShowShare(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Collection[]>([]);
|
||||
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 (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: '#0d0d0d' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 28px', background: '#111', borderBottom: '1px solid #1a1a1a', flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div style={{
|
||||
width: '28px', height: '28px', borderRadius: '7px',
|
||||
background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(74,158,255,0.2)',
|
||||
}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span style={{ fontSize: '18px', fontWeight: 700, color: '#e8e8e8', letterSpacing: '-0.3px' }}>RefBoard</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '28px', height: '28px', borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #4a9eff, #667eea)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '12px', fontWeight: 700, color: '#fff',
|
||||
}}>
|
||||
{(user?.display_name || user?.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', color: '#777' }}>{user?.display_name || user?.email}</span>
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/login', { replace: true }); }}
|
||||
style={{
|
||||
padding: '5px 14px', background: 'transparent', border: '1px solid #222',
|
||||
borderRadius: '6px', color: '#666', fontSize: '12px', cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#444'; e.currentTarget.style.color = '#aaa'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#222'; e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '20px 28px', flexShrink: 0 }}>
|
||||
<div style={{ position: 'relative', flex: 1, maxWidth: '400px' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="#555" strokeWidth="1.5"
|
||||
style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<circle cx="6" cy="6" r="4.5" /><line x1="9.5" y1="9.5" x2="13" y2="13" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="text" placeholder="Search collections..."
|
||||
value={search} onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
style={{
|
||||
padding: '9px 20px', background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
||||
fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
boxShadow: '0 2px 12px rgba(74,158,255,0.25)', transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.85'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||
>
|
||||
+ New Collection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{
|
||||
flex: 1, overflow: 'auto', padding: '0 28px 28px',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: '16px', alignContent: 'start',
|
||||
}}>
|
||||
{collections.length === 0 && (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1', textAlign: 'center', padding: '80px 20px', color: '#444', fontSize: '14px',
|
||||
}}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '12px', opacity: 0.2 }}>+</div>
|
||||
No collections yet. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
{collections.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
onClick={() => 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';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: '100px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: gradients[hashCode(col.id) % gradients.length],
|
||||
fontSize: '32px', color: 'rgba(255,255,255,0.4)', fontWeight: 700,
|
||||
}}>
|
||||
{col.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ padding: '14px 16px 10px' }}>
|
||||
<p style={{
|
||||
fontSize: '15px', fontWeight: 600, color: '#e0e0e0', margin: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{col.name}
|
||||
</p>
|
||||
{col.description && (
|
||||
<p style={{
|
||||
fontSize: '12px', color: '#666', margin: '4px 0 0',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{col.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 16px', borderTop: '1px solid #1a1a1a',
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', color: '#555' }}>
|
||||
{col.board_count} board{col.board_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#444' }}>
|
||||
{new Date(col.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
{col.created_by === user?.id && (
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, col.id)}
|
||||
style={{
|
||||
padding: '3px 8px', background: 'transparent', border: '1px solid #2a1515',
|
||||
borderRadius: '4px', color: '#ff6b6b', fontSize: '10px', cursor: 'pointer',
|
||||
opacity: 0.6, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create modal */}
|
||||
{showModal && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
|
||||
backdropFilter: 'blur(4px)',
|
||||
}} onClick={() => setShowModal(false)}>
|
||||
<div style={{
|
||||
background: '#161616', borderRadius: '16px', padding: '32px',
|
||||
width: '100%', maxWidth: '420px', border: '1px solid #222',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
||||
}} onClick={(e) => e.stopPropagation()}>
|
||||
<h2 style={{ margin: '0 0 24px', fontSize: '18px', fontWeight: 600, color: '#e0e0e0' }}>
|
||||
New Collection
|
||||
</h2>
|
||||
<input
|
||||
type="text" placeholder="Collection name" value={newName}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text" placeholder="Description (optional)" value={newDesc}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
||||
<button onClick={() => setShowModal(false)} style={{
|
||||
padding: '8px 18px', background: 'transparent', border: '1px solid #222',
|
||||
borderRadius: '8px', color: '#888', fontSize: '13px', cursor: 'pointer',
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleCreate} disabled={creating} style={{
|
||||
padding: '8px 20px',
|
||||
background: creating ? '#333' : 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
||||
fontWeight: 600, cursor: creating ? 'default' : 'pointer',
|
||||
opacity: creating ? 0.6 : 1,
|
||||
}}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100vh', background: '#0d0d0d',
|
||||
backgroundImage: 'radial-gradient(ellipse at 50% 0%, rgba(74,158,255,0.08) 0%, transparent 60%)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#161616', borderRadius: '16px', padding: '48px 40px',
|
||||
width: '100%', maxWidth: '400px',
|
||||
border: '1px solid #222', boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '48px', height: '48px', borderRadius: '12px',
|
||||
background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
marginBottom: '16px', boxShadow: '0 4px 16px rgba(74,158,255,0.3)',
|
||||
}}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 style={{ margin: '0 0 4px', fontSize: '24px', fontWeight: 700, color: '#f0f0f0', letterSpacing: '-0.5px' }}>
|
||||
RefBoard
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: '13px', color: '#555', letterSpacing: '0.2px' }}>
|
||||
{isRegister ? 'Create your account' : 'Sign in to continue'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.15)',
|
||||
color: '#ff8a8a', padding: '10px 14px', borderRadius: '8px', marginBottom: '20px',
|
||||
fontSize: '13px', lineHeight: '1.4',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{isRegister && (
|
||||
<>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '12px', color: '#666', fontWeight: 500, letterSpacing: '0.3px', textTransform: 'uppercase' }}>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="text" value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="username" required autoComplete="username"
|
||||
/>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '12px', color: '#666', fontWeight: 500, letterSpacing: '0.3px', textTransform: 'uppercase' }}>
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="text" value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your name" autoComplete="name"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '12px', color: '#666', fontWeight: 500, letterSpacing: '0.3px', textTransform: 'uppercase' }}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="email" value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com" required autoComplete="email"
|
||||
/>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '12px', color: '#666', fontWeight: 500, letterSpacing: '0.3px', textTransform: 'uppercase' }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password" required
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="submit" disabled={submitting}
|
||||
style={{
|
||||
width: '100%', padding: '12px', marginTop: '4px',
|
||||
background: submitting ? '#333' : 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
color: '#fff', border: 'none', borderRadius: '8px',
|
||||
fontSize: '14px', fontWeight: 600, cursor: submitting ? 'default' : 'pointer',
|
||||
transition: 'opacity 0.2s', opacity: submitting ? 0.6 : 1,
|
||||
boxShadow: submitting ? 'none' : '0 2px 12px rgba(74,158,255,0.3)',
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Please wait...' : isRegister ? 'Create Account' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '20px', textAlign: 'center', fontSize: '13px', color: '#555' }}>
|
||||
{isRegister ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||
<button
|
||||
onClick={() => { setIsRegister(!isRegister); setError(''); }}
|
||||
style={{
|
||||
color: '#4a9eff', cursor: 'pointer', background: 'none',
|
||||
border: 'none', fontSize: '13px', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{isRegister ? 'Sign In' : 'Register'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user