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:
Hiren
2026-03-09 13:57:49 +05:30
commit e47777d237
43 changed files with 10276 additions and 0 deletions
+25
View File
@@ -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>
+3516
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+6
View File
@@ -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

+51
View File
@@ -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>
);
}
+114
View File
@@ -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;
+85
View File
@@ -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
);
}
+263
View File
@@ -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;
+83
View File
@@ -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();
}
}
+207
View File
@@ -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);
};
}
+213
View File
@@ -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);
};
}
+136
View File
@@ -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,
};
+151
View File
@@ -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>
);
}
+93
View File
@@ -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>
);
}
+194
View File
@@ -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>
);
}
+323
View File
@@ -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' },
};
+67
View File
@@ -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>
);
}
+341
View File
@@ -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>
);
}
+129
View File
@@ -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>
);
}
+49
View File
@@ -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;
}
+10
View File
@@ -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>
);
+343
View File
@@ -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>
);
}
+314
View File
@@ -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
+163
View File
@@ -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',
};
+46
View File
@@ -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 };
+23
View File
@@ -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"]
}
+22
View File
@@ -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,
},
},
},
});