diff --git a/backend/db.js b/backend/db.js index 8bbb07e..d62279d 100644 --- a/backend/db.js +++ b/backend/db.js @@ -57,6 +57,8 @@ db.exec(` name TEXT NOT NULL, description TEXT DEFAULT '', canvas_state TEXT DEFAULT '{}', + thumbnail TEXT DEFAULT NULL, + object_count INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -83,6 +85,18 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_images_board ON images(board_id); `); +// Migrations — add columns to existing tables +try { + db.prepare("SELECT thumbnail FROM boards LIMIT 0").get(); +} catch { + db.exec("ALTER TABLE boards ADD COLUMN thumbnail TEXT DEFAULT NULL"); +} +try { + db.prepare("SELECT object_count FROM boards LIMIT 0").get(); +} catch { + db.exec("ALTER TABLE boards ADD COLUMN object_count INTEGER NOT NULL DEFAULT 0"); +} + // --------------------- // User helpers // --------------------- @@ -128,7 +142,10 @@ function getUserCount() { function getCollections(userId, search, limit = 50, offset = 0) { let query = ` SELECT DISTINCT c.*, cm.role as member_role, - (SELECT COUNT(*) FROM boards WHERE collection_id = c.id) as board_count + (SELECT COUNT(*) FROM boards WHERE collection_id = c.id) as board_count, + (SELECT COUNT(*) FROM collection_members WHERE collection_id = c.id) as member_count, + (SELECT b.thumbnail FROM boards b WHERE b.collection_id = c.id AND b.thumbnail IS NOT NULL ORDER BY b.updated_at DESC LIMIT 1) as preview_thumbnail, + (SELECT GROUP_CONCAT(sub.thumbnail, '|||') FROM (SELECT thumbnail FROM boards WHERE collection_id = c.id AND thumbnail IS NOT NULL ORDER BY updated_at DESC LIMIT 4) sub) as preview_thumbnails FROM collections c LEFT JOIN collection_members cm ON c.id = cm.collection_id AND cm.user_id = ? WHERE (cm.user_id IS NOT NULL OR c.is_public = 1) @@ -230,8 +247,9 @@ function checkCollectionAccess(collectionId, userId) { // --------------------- function getCollectionBoards(collectionId, search, limit = 50, offset = 0) { let query = ` - SELECT b.*, - (SELECT COUNT(*) FROM images WHERE board_id = b.id) as image_count + SELECT b.id, b.collection_id, b.name, b.description, b.thumbnail, + b.created_by, b.created_at, b.updated_at, + b.object_count as image_count FROM boards b WHERE b.collection_id = ? `; @@ -281,9 +299,22 @@ function deleteBoard(boardId) { db.prepare('DELETE FROM boards WHERE id = ?').run(boardId); } -function saveBoardCanvas(boardId, canvasState) { - db.prepare("UPDATE boards SET canvas_state = ?, updated_at = datetime('now') WHERE id = ?") - .run(typeof canvasState === 'string' ? canvasState : JSON.stringify(canvasState), boardId); +function saveBoardCanvas(boardId, canvasState, thumbnail) { + const stateStr = typeof canvasState === 'string' ? canvasState : JSON.stringify(canvasState); + // Count objects from canvas state JSON + let objectCount = 0; + try { + const parsed = typeof canvasState === 'string' ? JSON.parse(canvasState) : canvasState; + if (parsed && Array.isArray(parsed.objects)) objectCount = parsed.objects.length; + } catch {} + + if (thumbnail) { + db.prepare("UPDATE boards SET canvas_state = ?, thumbnail = ?, object_count = ?, updated_at = datetime('now') WHERE id = ?") + .run(stateStr, thumbnail, objectCount, boardId); + } else { + db.prepare("UPDATE boards SET canvas_state = ?, object_count = ?, updated_at = datetime('now') WHERE id = ?") + .run(stateStr, objectCount, boardId); + } } // --------------------- diff --git a/backend/routes/boards.js b/backend/routes/boards.js index 05087b3..90517da 100644 --- a/backend/routes/boards.js +++ b/backend/routes/boards.js @@ -169,12 +169,12 @@ router.post('/:boardId/save', (req, res) => { const result = resolveBoard(req, res, 'editor'); if (!result) return; - const { canvas_state } = req.body; + const { canvas_state, thumbnail } = req.body; if (canvas_state === undefined) { return res.status(400).json({ error: 'canvas_state is required' }); } - saveBoardCanvas(result.board.id, canvas_state); + saveBoardCanvas(result.board.id, canvas_state, thumbnail || null); return res.json({ message: 'Canvas saved' }); } catch (err) { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 36a6b25..dc28819 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -54,6 +54,10 @@ export function getCollectionDetail(collectionId: string) { return api.get(`/api/collections/${collectionId}`); } +export function updateCollection(collectionId: string, data: { name?: string; description?: string }) { + return api.put(`/api/collections/${collectionId}`, data); +} + export function deleteCollection(collectionId: string) { return api.delete(`/api/collections/${collectionId}`); } @@ -91,12 +95,16 @@ export function getBoard(boardId: string) { return api.get(`/api/boards/${boardId}`); } +export function updateBoard(boardId: string, data: { name?: string; description?: string }) { + return api.put(`/api/boards/${boardId}`, data); +} + 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 saveCanvas(boardId: string, canvasState: string, thumbnail?: string) { + return api.post(`/api/boards/${boardId}/save`, { canvas_state: canvasState, thumbnail }); } export function uploadImage(boardId: string, file: File) { diff --git a/frontend/src/canvas/FabricCanvas.tsx b/frontend/src/canvas/FabricCanvas.tsx index 5e6e4d4..0700bfa 100644 --- a/frontend/src/canvas/FabricCanvas.tsx +++ b/frontend/src/canvas/FabricCanvas.tsx @@ -138,9 +138,9 @@ const FabricCanvas = forwardRef( zoom *= 1 - direction * ZOOM_STEP; zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); - // Zoom toward cursor position (industry standard) - const point = canvas.getScenePoint(e); - canvas.zoomToPoint(point, zoom); + // Zoom toward cursor — must use element-relative coords (offsetX/Y), + // NOT getScenePoint() which returns scene-space and causes zoom drift + canvas.zoomToPoint({ x: e.offsetX, y: e.offsetY } as any, zoom); canvas.requestRenderAll(); persistViewport(); }); diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 1587b2b..729d72f 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -56,7 +56,7 @@ export default function StatusBar({ boardName, imageCount, saveStatus }: StatusB
{boardName} - {imageCount} image{imageCount !== 1 ? 's' : ''} + {imageCount} object{imageCount !== 1 ? 's' : ''}
diff --git a/frontend/src/pages/CollectionDetail.tsx b/frontend/src/pages/CollectionDetail.tsx index 0abe48e..fc8ac15 100644 --- a/frontend/src/pages/CollectionDetail.tsx +++ b/frontend/src/pages/CollectionDetail.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useAuth } from '../auth'; import { @@ -6,6 +6,8 @@ import { getCollectionByShareToken, createBoard, deleteBoard as apiDeleteBoard, + updateBoard, + updateCollection, } from '../api'; import ShareDialog from '../components/ShareDialog'; @@ -14,6 +16,7 @@ interface Board { name: string; description: string; image_count: number; + thumbnail: string | null; created_at: string; updated_at: string; } @@ -22,22 +25,93 @@ 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 timeAgo(dateStr: string): string { + const d = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - d.getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `${days}d ago`; + return d.toLocaleDateString(); +} -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); +function CardMenu({ onRename, onDelete, canEdit, canDelete }: { + onRename: () => void; + onDelete?: () => void; + canEdit: boolean; + canDelete: boolean; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const close = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + if (!canEdit && !canDelete) return null; + + return ( +
+ + {open && ( +
e.stopPropagation()}> + {canEdit && ( + + )} + {canDelete && onDelete && ( + + )} +
+ )} +
+ ); } export default function CollectionDetail({ isPublicView }: CollectionDetailProps) { @@ -55,6 +129,12 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps const [newName, setNewName] = useState(''); const [newDesc, setNewDesc] = useState(''); const [creating, setCreating] = useState(false); + const [editingTitle, setEditingTitle] = useState(false); + const [titleText, setTitleText] = useState(''); + const [renamingBoardId, setRenamingBoardId] = useState(null); + const [renameBoardText, setRenameBoardText] = useState(''); + const titleRef = useRef(null); + const boardRenameRef = useRef(null); const load = useCallback(async () => { try { @@ -83,6 +163,9 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps 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')); + useEffect(() => { if (editingTitle) titleRef.current?.select(); }, [editingTitle]); + useEffect(() => { if (renamingBoardId) boardRenameRef.current?.select(); }, [renamingBoardId]); + async function handleCreateBoard() { if (!newName.trim() || !resolvedId) return; setCreating(true); @@ -100,8 +183,7 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps } } - async function handleDeleteBoard(e: React.MouseEvent, boardId: string) { - e.stopPropagation(); + async function handleDeleteBoard(boardId: string) { if (!confirm('Delete this board? This cannot be undone.')) return; try { await apiDeleteBoard(boardId); @@ -111,19 +193,43 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps } } + async function commitBoardRename(boardId: string) { + const trimmed = renameBoardText.trim(); + const board = boards.find(b => b.id === boardId); + if (trimmed && trimmed !== board?.name) { + try { + await updateBoard(boardId, { name: trimmed }); + setBoards((prev) => prev.map((b) => b.id === boardId ? { ...b, name: trimmed } : b)); + } catch (err) { + console.error('Failed to rename board:', err); + } + } + setRenamingBoardId(null); + } + + async function handleRenameCollection(name: string) { + if (!resolvedId) return; + try { + await updateCollection(resolvedId, { name }); + setCollection((prev: any) => ({ ...prev, name })); + } catch (err) { + console.error('Failed to rename collection:', err); + } + } + if (loading) { return ( -
+
Loading...
); } if (error) { return ( -
+
{error}
-
- {collection?.name || 'Collection'} -
- {user && !isPublicView && ( +
- )} + / + {editingTitle ? ( + setTitleText(e.target.value)} + onBlur={() => { + const t = titleText.trim(); + if (t && t !== collection?.name) handleRenameCollection(t); + setEditingTitle(false); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + if (e.key === 'Escape') setEditingTitle(false); + }} + style={{ + fontSize: '15px', fontWeight: 600, color: '#e0e0e0', + background: '#0a0a0a', border: '1px solid #4a9eff', borderRadius: '4px', + padding: '2px 8px', outline: 'none', minWidth: '120px', maxWidth: '300px', + }} + autoFocus + /> + ) : ( +
+ + {collection?.name || 'Collection'} + + {isEditor && ( + + )} +
+ )} +
+
+ {user && !isPublicView && ( + + )} +
{/* Controls */} -
- {isEditor && ( - - )} - {collection?.description && ( - {collection.description} - )} +
+
+ {isEditor && ( + + )} + {collection?.description && ( + {collection.description} + )} +
+ + {boards.length} board{boards.length !== 1 ? 's' : ''} +
- {/* Grid */} + {/* Board Grid */}
{boards.length === 0 && (
-
+
- No boards in this collection yet. + + + + + +
No boards yet
+
Create a board to start collecting references
)} {boards.map((board) => ( @@ -223,60 +395,101 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps 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', + background: '#131313', borderRadius: '10px', border: '1px solid #1c1c1c', + cursor: 'pointer', transition: 'all 0.2s ease', position: 'relative', }} 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)'; + const el = e.currentTarget as HTMLElement; + el.style.borderColor = '#2a2a2a'; + el.style.transform = 'translateY(-2px)'; + el.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'; + const el = e.currentTarget as HTMLElement; + el.style.borderColor = '#1c1c1c'; + el.style.transform = 'none'; + el.style.boxShadow = 'none'; }} > + {/* Preview — auto-generated from board content */}
- {board.name.charAt(0).toUpperCase()} -
-
-

- {board.name} -

-
-
- - {board.image_count} img{board.image_count !== 1 ? 's' : ''} - - - {new Date(board.updated_at).toLocaleDateString()} - - {isOwner && ( - + {board.thumbnail ? ( + + ) : ( +
+ + + + + + + {board.image_count > 0 ? `${board.image_count} image${board.image_count !== 1 ? 's' : ''}` : 'Empty board'} + +
)}
+ + {/* Board name + menu */} +
+
+ {renamingBoardId === board.id ? ( + setRenameBoardText(e.target.value)} + onBlur={() => commitBoardRename(board.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitBoardRename(board.id); + if (e.key === 'Escape') setRenamingBoardId(null); + e.stopPropagation(); + }} + onClick={(e) => e.stopPropagation()} + style={{ + width: '100%', fontSize: '13px', fontWeight: 600, color: '#e0e0e0', + background: '#0a0a0a', border: '1px solid #4a9eff', borderRadius: '4px', + padding: '2px 6px', outline: 'none', boxSizing: 'border-box', + }} + autoFocus + /> + ) : ( +

+ {board.name} +

+ )} +
+ { setRenameBoardText(board.name); setRenamingBoardId(board.id); }} + onDelete={() => handleDeleteBoard(board.id)} + canEdit={isEditor} + canDelete={isOwner} + /> +
+ + {/* Footer */} +
+ {board.image_count} img{board.image_count !== 1 ? 's' : ''} + | + {timeAgo(board.updated_at)} +
))}
@@ -284,49 +497,54 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps {/* Create board modal */} {showModal && (
setShowModal(false)}>
e.stopPropagation()}> -

New Board

+

New Board

setNewName(e.target.value)} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') handleCreateBoard(); }} style={{ - width: '100%', padding: '10px 14px', marginBottom: '12px', - background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px', + width: '100%', padding: '10px 14px', marginBottom: '10px', + background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: '8px', color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box', }} + onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#2a2a2a'; }} /> 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', + width: '100%', padding: '10px 14px', marginBottom: '20px', + background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: '8px', color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box', }} + onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#2a2a2a'; }} /> -
+
- diff --git a/frontend/src/pages/CollectionList.tsx b/frontend/src/pages/CollectionList.tsx index ba04c74..c04c383 100644 --- a/frontend/src/pages/CollectionList.tsx +++ b/frontend/src/pages/CollectionList.tsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../auth'; -import { getCollections, createCollection, deleteCollection as apiDeleteCollection } from '../api'; +import { getCollections, createCollection, deleteCollection as apiDeleteCollection, updateCollection } from '../api'; +import ShareDialog from '../components/ShareDialog'; interface Collection { id: string; @@ -10,22 +11,14 @@ interface Collection { is_public: number; created_by: string; board_count: number; + member_count: number; created_at: string; updated_at: string; member_role: string | null; + preview_thumbnail: string | null; + preview_thumbnails: 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++) { @@ -35,6 +28,93 @@ function hashCode(str: string): number { return Math.abs(hash); } +function timeAgo(dateStr: string): string { + const d = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - d.getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `${days}d ago`; + return d.toLocaleDateString(); +} + +// Seeded pseudo-random for consistent offsets per card +function seededRandom(seed: number) { + let s = seed; + return () => { s = (s * 16807 + 0) % 2147483647; return (s - 1) / 2147483646; }; +} + +// Three-dot dropdown menu for card actions +function CardMenu({ onRename, onShare, onDelete, isOwner }: { + onRename: () => void; + onShare?: () => void; + onDelete?: () => void; + isOwner: boolean; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const close = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + return ( +
+ + {open && ( +
e.stopPropagation()}> + {[ + { label: 'Rename', onClick: onRename, show: true }, + { label: 'Share', onClick: onShare, show: isOwner && !!onShare }, + { label: 'Delete', onClick: onDelete, show: isOwner && !!onDelete, danger: true }, + ].filter(i => i.show).map((item, idx) => ( + + ))} +
+ )} +
+ ); +} + export default function CollectionList() { const [collections, setCollections] = useState([]); const [search, setSearch] = useState(''); @@ -42,6 +122,10 @@ export default function CollectionList() { const [newName, setNewName] = useState(''); const [newDesc, setNewDesc] = useState(''); const [creating, setCreating] = useState(false); + const [shareColId, setShareColId] = useState(null); + const [renamingId, setRenamingId] = useState(null); + const [renameText, setRenameText] = useState(''); + const renameRef = useRef(null); const navigate = useNavigate(); const { user, logout } = useAuth(); @@ -61,6 +145,10 @@ export default function CollectionList() { return () => clearTimeout(timer); }, [search, load]); + useEffect(() => { + if (renamingId) renameRef.current?.select(); + }, [renamingId]); + async function handleCreate() { if (!newName.trim()) return; setCreating(true); @@ -78,8 +166,7 @@ export default function CollectionList() { } } - async function handleDelete(e: React.MouseEvent, id: string) { - e.stopPropagation(); + async function handleDelete(id: string) { if (!confirm('Delete this collection and ALL its boards? This cannot be undone.')) return; try { await apiDeleteCollection(id); @@ -89,21 +176,50 @@ export default function CollectionList() { } } + async function commitRename(id: string) { + const trimmed = renameText.trim(); + const col = collections.find(c => c.id === id); + if (trimmed && trimmed !== col?.name) { + try { + await updateCollection(id, { name: trimmed }); + setCollections((prev) => prev.map((c) => c.id === id ? { ...c, name: trimmed } : c)); + } catch (err) { + console.error('Failed to rename:', err); + } + } + setRenamingId(null); + } + + function getThumbnails(col: Collection): string[] { + if (!col.preview_thumbnails) return []; + return col.preview_thumbnails.split('|||').filter(Boolean).slice(0, 4); + } + + // Stacked photo offsets — consistent per collection via seeded random + function getStackOffsets(id: string, count: number) { + const rng = seededRandom(hashCode(id)); + return Array.from({ length: count }, () => ({ + rotate: (rng() - 0.5) * 16, // -8 to +8 degrees + offsetX: (rng() - 0.5) * 20, // -10 to +10 px + offsetY: (rng() - 0.5) * 12, // -6 to +6 px + })); + } + return ( -
+
{/* Header */}
- + @@ -112,161 +228,252 @@ export default function CollectionList() {
RefBoard
-
+
{(user?.display_name || user?.email || '?')[0].toUpperCase()}
- {user?.display_name || user?.email} + {user?.display_name || user?.email}
- {/* Controls */} -
-
- - - - setSearch(e.target.value)} + {/* Title + Controls */} +
+
+

+ Collections +

+ + {collections.length} collection{collections.length !== 1 ? 's' : ''} + +
+
+
+ + + + setSearch(e.target.value)} + style={{ + width: '100%', padding: '8px 14px 8px 34px', + background: '#141414', border: '1px solid #222', borderRadius: '8px', + color: '#e0e0e0', fontSize: '13px', outline: 'none', boxSizing: 'border-box', + transition: 'border-color 0.15s', + }} + onFocus={(e) => { e.currentTarget.style.borderColor = '#333'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#222'; }} + /> +
+
-
{/* Grid */}
{collections.length === 0 && (
-
+
- No collections yet. Create one to get started. + + + +
No collections yet
+
Create a collection to organize your reference boards
)} - {collections.map((col) => ( -
navigate(`/collection/${col.id}`)} - style={{ - background: '#141414', borderRadius: '12px', border: '1px solid #1e1e1e', - overflow: 'hidden', cursor: 'pointer', transition: 'all 0.2s ease', - }} - onMouseEnter={(e) => { - (e.currentTarget as HTMLElement).style.borderColor = '#333'; - (e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'; - (e.currentTarget as HTMLElement).style.boxShadow = '0 8px 24px rgba(0,0,0,0.3)'; - }} - onMouseLeave={(e) => { - (e.currentTarget as HTMLElement).style.borderColor = '#1e1e1e'; - (e.currentTarget as HTMLElement).style.transform = 'none'; - (e.currentTarget as HTMLElement).style.boxShadow = 'none'; - }} - > -
- {col.name.charAt(0).toUpperCase()} -
-
-

{ + const thumbs = getThumbnails(col); + const offsets = getStackOffsets(col.id, thumbs.length); + const isOwner = col.created_by === user?.id; + + return ( +

navigate(`/collection/${col.id}`)} + style={{ + background: '#131313', borderRadius: '12px', border: '1px solid #1e1e1e', + cursor: 'pointer', transition: 'all 0.2s ease', position: 'relative', + }} + onMouseEnter={(e) => { + const el = e.currentTarget as HTMLElement; + el.style.borderColor = '#2a2a2a'; + el.style.transform = 'translateY(-3px)'; + el.style.boxShadow = '0 12px 32px rgba(0,0,0,0.35)'; + }} + onMouseLeave={(e) => { + const el = e.currentTarget as HTMLElement; + el.style.borderColor = '#1e1e1e'; + el.style.transform = 'none'; + el.style.boxShadow = 'none'; + }} + > + {/* Preview — stacked board thumbnails like scattered photos */} +
- {col.name} -

- {col.description && ( -

- {col.description} -

- )} + {thumbs.length > 0 ? ( + // Stacked/fanned thumbnails + thumbs.map((t, i) => { + const o = offsets[i]; + const z = thumbs.length - i; // first on top + return ( +
+ ); + }) + ) : ( +
+ + + + + {col.board_count === 0 ? 'Empty collection' : `${col.board_count} board${col.board_count !== 1 ? 's' : ''}`} + +
+ )} +
+ + {/* Name + menu */} +
+
+ {renamingId === col.id ? ( + setRenameText(e.target.value)} + onBlur={() => commitRename(col.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(col.id); + if (e.key === 'Escape') setRenamingId(null); + e.stopPropagation(); + }} + onClick={(e) => e.stopPropagation()} + style={{ + width: '100%', fontSize: '14px', fontWeight: 600, color: '#e0e0e0', + background: '#0a0a0a', border: '1px solid #4a9eff', borderRadius: '4px', + padding: '2px 6px', outline: 'none', boxSizing: 'border-box', + }} + autoFocus + /> + ) : ( +

+ {col.name} +

+ )} + {col.description && ( +

+ {col.description} +

+ )} +
+ { setRenameText(col.name); setRenamingId(col.id); }} + onShare={() => setShareColId(col.id)} + onDelete={() => handleDelete(col.id)} + isOwner={isOwner} + /> +
+ + {/* Footer */} +
+ {col.board_count} board{col.board_count !== 1 ? 's' : ''} + | + {col.is_public ? ( + + + + + Public + + ) : ( + + + + + {col.member_count} member{col.member_count !== 1 ? 's' : ''} + + )} + | + {timeAgo(col.updated_at)} +
-
- - {col.board_count} board{col.board_count !== 1 ? 's' : ''} - - - {new Date(col.updated_at).toLocaleDateString()} - - {col.created_by === user?.id && ( - - )} -
-
- ))} + ); + })}
{/* Create modal */} {showModal && (
setShowModal(false)}>
e.stopPropagation()}> -

+

New Collection

setNewName(e.target.value)} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }} style={{ - width: '100%', padding: '10px 14px', marginBottom: '12px', - background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px', + width: '100%', padding: '10px 14px', marginBottom: '10px', + background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: '8px', color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box', }} + onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#2a2a2a'; }} /> 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', + width: '100%', padding: '10px 14px', marginBottom: '20px', + background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: '8px', color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box', }} + onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#2a2a2a'; }} /> -
+
- @@ -309,6 +521,10 @@ export default function CollectionList() {
)} + + {shareColId && ( + { setShareColId(null); load(); }} /> + )}
); } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index eec01c4..320784c 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -67,6 +67,7 @@ export default function Editor({ isPublicView }: EditorProps) { const [strokeWidth, setStrokeWidth] = useState(4); const [fontSize, setFontSize] = useState(24); const [zoom, setZoom] = useState(1); + const [objectCount, setObjectCount] = useState(0); const [saveStatus, setSaveStatus] = useState('saved'); const [onlineUsers, setOnlineUsers] = useState([]); const [canUndo, setCanUndo] = useState(false); @@ -120,7 +121,50 @@ export default function Editor({ isPublicView }: EditorProps) { setSaveStatus('saving'); try { const state = JSON.stringify((canvas as any).toJSON(['id', 'crossOrigin'])); - await saveCanvas(resolvedBoardId, state); + // Generate a small thumbnail preview of the canvas content + let thumbnail: string | undefined; + const objects = canvas.getObjects(); + if (objects.length > 0) { + // Use scene-space coordinates (obj.left/top + scaled dimensions) + // NOT getBoundingRect() which returns viewport/screen-space + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + objects.forEach((obj: any) => { + const l = obj.left ?? 0; + const t = obj.top ?? 0; + const w = (obj.width ?? 0) * (obj.scaleX ?? 1); + const h = (obj.height ?? 0) * (obj.scaleY ?? 1); + minX = Math.min(minX, l); + minY = Math.min(minY, t); + maxX = Math.max(maxX, l + w); + maxY = Math.max(maxY, t + h); + }); + const sceneW = maxX - minX; + const sceneH = maxY - minY; + if (sceneW > 0 && sceneH > 0) { + const origVpt = [...canvas.viewportTransform!]; + try { + const thumbSize = 400; + const scale = Math.min(thumbSize / sceneW, thumbSize / sceneH); + const pw = Math.ceil(sceneW * scale); + const ph = Math.ceil(sceneH * scale); + canvas.setViewportTransform([ + scale, 0, 0, scale, + -minX * scale, -minY * scale, + ]); + const thumbCanvas = canvas.toCanvasElement(1, { + left: 0, top: 0, width: pw, height: ph, + }); + thumbnail = thumbCanvas.toDataURL('image/webp', 0.6); + } catch (thumbErr) { + console.warn('Thumbnail generation failed:', thumbErr); + } finally { + // ALWAYS restore viewport — even if toCanvasElement/toDataURL throws + canvas.setViewportTransform(origVpt as any); + canvas.requestRenderAll(); + } + } + } + await saveCanvas(resolvedBoardId, state, thumbnail); setSaveStatus('saved'); } catch { setSaveStatus('unsaved'); @@ -140,8 +184,11 @@ export default function Editor({ isPublicView }: EditorProps) { const z = canvasRef.current?.getZoom() ?? 1; setZoom(z); const canvas = canvasRef.current?.getCanvas(); - if (canvas?.viewportTransform) { - setCanvasTransform([...canvas.viewportTransform]); + if (canvas) { + if (canvas.viewportTransform) { + setCanvasTransform([...canvas.viewportTransform]); + } + setObjectCount(canvas.getObjects().length); } }, [scheduleSave]); @@ -633,7 +680,6 @@ export default function Editor({ isPublicView }: EditorProps) { const board = boardData?.board; const collection = boardData?.collection; const canvasState = board?.canvas_state; - const imageCount = boardData?.images?.length ?? 0; return (
@@ -763,7 +809,7 @@ export default function Editor({ isPublicView }: EditorProps) { )} {/* Empty canvas guide */} - {imageCount === 0 && !canvasState?.objects?.length && ( + {objectCount === 0 && !canvasState?.objects?.length && (