feat: board thumbnails, inline rename, UX overhaul + zoom/counter fixes
- Auto-generate board thumbnail from canvas content on save (webp, 400px) - Collection cards show stacked/fanned board thumbnails with random offsets - Three-dot menu on all cards with Rename, Share, Delete actions - Inline rename for collections (header + card) and boards - Collection cards show public/private status and member count - Share button on collection cards for quick access - Add updateCollection/updateBoard API functions - Add thumbnail + object_count columns to boards table with migration - Fix zoom drift: use element-relative coords (offsetX/Y) not getScenePoint - Fix thumbnail generation breaking viewport: use finally block for restore - Fix thumbnail bounds: use scene-space obj coords not screen-space getBoundingRect - Fix object counter: live count from canvas instead of stale DB images table - Fix three-dot menu clipping by removing overflow:hidden from card containers - Status bar shows "objects" instead of "images", updates on add/delete
This commit is contained in:
+37
-6
@@ -57,6 +57,8 @@ db.exec(`
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT DEFAULT '',
|
description TEXT DEFAULT '',
|
||||||
canvas_state 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_by TEXT NOT NULL REFERENCES users(id),
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_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);
|
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
|
// User helpers
|
||||||
// ---------------------
|
// ---------------------
|
||||||
@@ -128,7 +142,10 @@ function getUserCount() {
|
|||||||
function getCollections(userId, search, limit = 50, offset = 0) {
|
function getCollections(userId, search, limit = 50, offset = 0) {
|
||||||
let query = `
|
let query = `
|
||||||
SELECT DISTINCT c.*, cm.role as member_role,
|
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
|
FROM collections c
|
||||||
LEFT JOIN collection_members cm ON c.id = cm.collection_id AND cm.user_id = ?
|
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)
|
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) {
|
function getCollectionBoards(collectionId, search, limit = 50, offset = 0) {
|
||||||
let query = `
|
let query = `
|
||||||
SELECT b.*,
|
SELECT b.id, b.collection_id, b.name, b.description, b.thumbnail,
|
||||||
(SELECT COUNT(*) FROM images WHERE board_id = b.id) as image_count
|
b.created_by, b.created_at, b.updated_at,
|
||||||
|
b.object_count as image_count
|
||||||
FROM boards b
|
FROM boards b
|
||||||
WHERE b.collection_id = ?
|
WHERE b.collection_id = ?
|
||||||
`;
|
`;
|
||||||
@@ -281,9 +299,22 @@ function deleteBoard(boardId) {
|
|||||||
db.prepare('DELETE FROM boards WHERE id = ?').run(boardId);
|
db.prepare('DELETE FROM boards WHERE id = ?').run(boardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveBoardCanvas(boardId, canvasState) {
|
function saveBoardCanvas(boardId, canvasState, thumbnail) {
|
||||||
db.prepare("UPDATE boards SET canvas_state = ?, updated_at = datetime('now') WHERE id = ?")
|
const stateStr = typeof canvasState === 'string' ? canvasState : JSON.stringify(canvasState);
|
||||||
.run(typeof canvasState === 'string' ? canvasState : JSON.stringify(canvasState), boardId);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------
|
// ---------------------
|
||||||
|
|||||||
@@ -169,12 +169,12 @@ router.post('/:boardId/save', (req, res) => {
|
|||||||
const result = resolveBoard(req, res, 'editor');
|
const result = resolveBoard(req, res, 'editor');
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
const { canvas_state } = req.body;
|
const { canvas_state, thumbnail } = req.body;
|
||||||
if (canvas_state === undefined) {
|
if (canvas_state === undefined) {
|
||||||
return res.status(400).json({ error: 'canvas_state is required' });
|
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' });
|
return res.json({ message: 'Canvas saved' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+10
-2
@@ -54,6 +54,10 @@ export function getCollectionDetail(collectionId: string) {
|
|||||||
return api.get(`/api/collections/${collectionId}`);
|
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) {
|
export function deleteCollection(collectionId: string) {
|
||||||
return api.delete(`/api/collections/${collectionId}`);
|
return api.delete(`/api/collections/${collectionId}`);
|
||||||
}
|
}
|
||||||
@@ -91,12 +95,16 @@ export function getBoard(boardId: string) {
|
|||||||
return api.get(`/api/boards/${boardId}`);
|
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) {
|
export function deleteBoard(boardId: string) {
|
||||||
return api.delete(`/api/boards/${boardId}`);
|
return api.delete(`/api/boards/${boardId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveCanvas(boardId: string, canvasState: string) {
|
export function saveCanvas(boardId: string, canvasState: string, thumbnail?: string) {
|
||||||
return api.post(`/api/boards/${boardId}/save`, { canvas_state: canvasState });
|
return api.post(`/api/boards/${boardId}/save`, { canvas_state: canvasState, thumbnail });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadImage(boardId: string, file: File) {
|
export function uploadImage(boardId: string, file: File) {
|
||||||
|
|||||||
@@ -138,9 +138,9 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
|
|||||||
zoom *= 1 - direction * ZOOM_STEP;
|
zoom *= 1 - direction * ZOOM_STEP;
|
||||||
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
||||||
|
|
||||||
// Zoom toward cursor position (industry standard)
|
// Zoom toward cursor — must use element-relative coords (offsetX/Y),
|
||||||
const point = canvas.getScenePoint(e);
|
// NOT getScenePoint() which returns scene-space and causes zoom drift
|
||||||
canvas.zoomToPoint(point, zoom);
|
canvas.zoomToPoint({ x: e.offsetX, y: e.offsetY } as any, zoom);
|
||||||
canvas.requestRenderAll();
|
canvas.requestRenderAll();
|
||||||
persistViewport();
|
persistViewport();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function StatusBar({ boardName, imageCount, saveStatus }: StatusB
|
|||||||
<div style={styles.bar}>
|
<div style={styles.bar}>
|
||||||
<div style={styles.left}>
|
<div style={styles.left}>
|
||||||
<span style={styles.name}>{boardName}</span>
|
<span style={styles.name}>{boardName}</span>
|
||||||
<span>{imageCount} image{imageCount !== 1 ? 's' : ''}</span>
|
<span>{imageCount} object{imageCount !== 1 ? 's' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.right}>
|
<div style={styles.right}>
|
||||||
<span style={{ ...styles.dot, background: status.color }} />
|
<span style={{ ...styles.dot, background: status.color }} />
|
||||||
|
|||||||
@@ -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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth';
|
import { useAuth } from '../auth';
|
||||||
import {
|
import {
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
getCollectionByShareToken,
|
getCollectionByShareToken,
|
||||||
createBoard,
|
createBoard,
|
||||||
deleteBoard as apiDeleteBoard,
|
deleteBoard as apiDeleteBoard,
|
||||||
|
updateBoard,
|
||||||
|
updateCollection,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import ShareDialog from '../components/ShareDialog';
|
import ShareDialog from '../components/ShareDialog';
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ interface Board {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
image_count: number;
|
image_count: number;
|
||||||
|
thumbnail: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -22,22 +25,93 @@ interface CollectionDetailProps {
|
|||||||
isPublicView?: boolean;
|
isPublicView?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gradients = [
|
function timeAgo(dateStr: string): string {
|
||||||
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
const d = new Date(dateStr);
|
||||||
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
const now = new Date();
|
||||||
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
const diff = now.getTime() - d.getTime();
|
||||||
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
|
const mins = Math.floor(diff / 60000);
|
||||||
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
if (mins < 1) return 'just now';
|
||||||
'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)',
|
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 {
|
function CardMenu({ onRename, onDelete, canEdit, canDelete }: {
|
||||||
let hash = 0;
|
onRename: () => void;
|
||||||
for (let i = 0; i < str.length; i++) {
|
onDelete?: () => void;
|
||||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
canEdit: boolean;
|
||||||
hash |= 0;
|
canDelete: boolean;
|
||||||
}
|
}) {
|
||||||
return Math.abs(hash);
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(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 (
|
||||||
|
<div ref={ref} style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||||
|
style={{
|
||||||
|
width: '24px', height: '24px', background: 'transparent', border: 'none',
|
||||||
|
color: '#666', cursor: 'pointer', borderRadius: '4px', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', transition: 'all 0.15s', padding: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#222'; e.currentTarget.style.color = '#ccc'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#666'; }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<circle cx="8" cy="3" r="1.5" /><circle cx="8" cy="8" r="1.5" /><circle cx="8" cy="13" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', right: 0, top: '100%', marginTop: '4px',
|
||||||
|
background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: '8px',
|
||||||
|
padding: '4px', minWidth: '110px', zIndex: 100,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||||
|
}} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setOpen(false); onRename(); }}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', padding: '7px 12px', background: 'transparent',
|
||||||
|
border: 'none', color: '#ccc', fontSize: '12px', cursor: 'pointer',
|
||||||
|
textAlign: 'left', borderRadius: '5px', transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#222'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canDelete && onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setOpen(false); onDelete(); }}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', padding: '7px 12px', background: 'transparent',
|
||||||
|
border: 'none', color: '#ff6b6b', fontSize: '12px', cursor: 'pointer',
|
||||||
|
textAlign: 'left', borderRadius: '5px', transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#1f1111'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CollectionDetail({ isPublicView }: CollectionDetailProps) {
|
export default function CollectionDetail({ isPublicView }: CollectionDetailProps) {
|
||||||
@@ -55,6 +129,12 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps
|
|||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [newDesc, setNewDesc] = useState('');
|
const [newDesc, setNewDesc] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
|
const [titleText, setTitleText] = useState('');
|
||||||
|
const [renamingBoardId, setRenamingBoardId] = useState<string | null>(null);
|
||||||
|
const [renameBoardText, setRenameBoardText] = useState('');
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
const boardRenameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
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 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'));
|
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() {
|
async function handleCreateBoard() {
|
||||||
if (!newName.trim() || !resolvedId) return;
|
if (!newName.trim() || !resolvedId) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
@@ -100,8 +183,7 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteBoard(e: React.MouseEvent, boardId: string) {
|
async function handleDeleteBoard(boardId: string) {
|
||||||
e.stopPropagation();
|
|
||||||
if (!confirm('Delete this board? This cannot be undone.')) return;
|
if (!confirm('Delete this board? This cannot be undone.')) return;
|
||||||
try {
|
try {
|
||||||
await apiDeleteBoard(boardId);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#0d0d0d', color: '#555', fontSize: '14px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#0a0a0a', color: '#555', fontSize: '14px' }}>
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#0d0d0d', gap: '16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#0a0a0a', gap: '16px' }}>
|
||||||
<div style={{ color: '#ff6b6b', fontSize: '14px' }}>{error}</div>
|
<div style={{ color: '#ff6b6b', fontSize: '14px' }}>{error}</div>
|
||||||
<button onClick={() => navigate('/')} style={{
|
<button onClick={() => navigate('/')} style={{
|
||||||
padding: '8px 20px', background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
padding: '8px 20px', background: '#4a9eff',
|
||||||
border: 'none', borderRadius: '8px', color: '#fff', cursor: 'pointer', fontSize: '13px',
|
border: 'none', borderRadius: '8px', color: '#fff', cursor: 'pointer', fontSize: '13px',
|
||||||
}}>
|
}}>
|
||||||
Back
|
Back
|
||||||
@@ -133,37 +239,92 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: '#0d0d0d' }}>
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: '#0a0a0a' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '12px 28px', background: '#111', borderBottom: '1px solid #1a1a1a',
|
padding: '12px 32px', background: '#0f0f0f', borderBottom: '1px solid #1a1a1a',
|
||||||
flexShrink: 0, gap: '16px',
|
flexShrink: 0, gap: '16px',
|
||||||
}}>
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1, minWidth: 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
style={{
|
style={{
|
||||||
padding: '5px 12px', background: 'transparent', border: '1px solid #222',
|
padding: '5px 12px', background: 'transparent', border: '1px solid #252525',
|
||||||
borderRadius: '6px', color: '#666', fontSize: '12px', cursor: 'pointer',
|
borderRadius: '6px', color: '#666', fontSize: '12px', cursor: 'pointer',
|
||||||
whiteSpace: 'nowrap', transition: 'all 0.15s',
|
whiteSpace: 'nowrap', transition: 'all 0.15s', flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#444'; e.currentTarget.style.color = '#aaa'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#444'; e.currentTarget.style.color = '#aaa'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#222'; e.currentTarget.style.color = '#666'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#252525'; e.currentTarget.style.color = '#666'; }}
|
||||||
>
|
>
|
||||||
← Back
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ marginRight: '4px', verticalAlign: 'middle' }}>
|
||||||
|
<path d="M8 1L3 6l5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
Collections
|
||||||
</button>
|
</button>
|
||||||
<div style={{
|
<span style={{ color: '#333', flexShrink: 0 }}>/</span>
|
||||||
fontSize: '16px', fontWeight: 600, color: '#e0e0e0', flex: 1,
|
{editingTitle ? (
|
||||||
|
<input
|
||||||
|
ref={titleRef}
|
||||||
|
value={titleText}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '15px', fontWeight: 600, color: '#e0e0e0',
|
||||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
letterSpacing: '-0.2px',
|
}}
|
||||||
}}>
|
>
|
||||||
{collection?.name || 'Collection'}
|
{collection?.name || 'Collection'}
|
||||||
|
</span>
|
||||||
|
{isEditor && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setTitleText(collection?.name || '');
|
||||||
|
setEditingTitle(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '22px', height: '22px', background: 'transparent', border: 'none',
|
||||||
|
color: '#555', cursor: 'pointer', borderRadius: '4px', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', transition: 'all 0.15s',
|
||||||
|
flexShrink: 0, padding: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#222'; e.currentTarget.style.color = '#ccc'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#555'; }}
|
||||||
|
title="Rename collection"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||||
{user && !isPublicView && (
|
{user && !isPublicView && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowShare(true)}
|
onClick={() => setShowShare(true)}
|
||||||
style={{
|
style={{
|
||||||
padding: '5px 16px', background: 'transparent',
|
padding: '5px 14px', background: 'transparent',
|
||||||
border: '1px solid rgba(74,158,255,0.3)', borderRadius: '6px',
|
border: '1px solid rgba(74,158,255,0.3)', borderRadius: '6px',
|
||||||
color: '#4a9eff', fontSize: '12px', fontWeight: 600, cursor: 'pointer',
|
color: '#4a9eff', fontSize: '12px', fontWeight: 600, cursor: 'pointer',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
@@ -181,20 +342,22 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '16px 28px', flexShrink: 0 }}>
|
<div style={{ padding: '16px 32px', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(true)}
|
onClick={() => setShowModal(true)}
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 20px', background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
padding: '8px 18px', background: '#4a9eff',
|
||||||
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
||||||
fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
|
fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
boxShadow: '0 2px 12px rgba(74,158,255,0.25)', transition: 'opacity 0.15s',
|
transition: 'background 0.15s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.85'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#3d8be0'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = '#4a9eff'; }}
|
||||||
>
|
>
|
||||||
+ New Board
|
+ New Board
|
||||||
</button>
|
</button>
|
||||||
@@ -203,19 +366,28 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps
|
|||||||
<span style={{ fontSize: '13px', color: '#555' }}>{collection.description}</span>
|
<span style={{ fontSize: '13px', color: '#555' }}>{collection.description}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span style={{ fontSize: '12px', color: '#444' }}>
|
||||||
|
{boards.length} board{boards.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Board Grid */}
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1, overflow: 'auto', padding: '0 28px 28px',
|
flex: 1, overflow: 'auto', padding: '0 32px 32px',
|
||||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||||
gap: '16px', alignContent: 'start',
|
gap: '16px', alignContent: 'start',
|
||||||
}}>
|
}}>
|
||||||
{boards.length === 0 && (
|
{boards.length === 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
gridColumn: '1 / -1', textAlign: 'center', padding: '80px 20px', color: '#444', fontSize: '14px',
|
gridColumn: '1 / -1', textAlign: 'center', padding: '100px 20px', color: '#444',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: '40px', marginBottom: '12px', opacity: 0.2 }}>+</div>
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1" style={{ marginBottom: '14px' }}>
|
||||||
No boards in this collection yet.
|
<rect x="2" y="3" width="20" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="10.5" r="2" />
|
||||||
|
<path d="M21 15l-5-5L5 21" />
|
||||||
|
</svg>
|
||||||
|
<div style={{ fontSize: '15px', fontWeight: 500, color: '#555', marginBottom: '6px' }}>No boards yet</div>
|
||||||
|
<div style={{ fontSize: '13px', color: '#444' }}>Create a board to start collecting references</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{boards.map((board) => (
|
{boards.map((board) => (
|
||||||
@@ -223,60 +395,101 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps
|
|||||||
key={board.id}
|
key={board.id}
|
||||||
onClick={() => navigate(`/board/${board.id}`)}
|
onClick={() => navigate(`/board/${board.id}`)}
|
||||||
style={{
|
style={{
|
||||||
background: '#141414', borderRadius: '12px', border: '1px solid #1e1e1e',
|
background: '#131313', borderRadius: '10px', border: '1px solid #1c1c1c',
|
||||||
overflow: 'hidden', cursor: 'pointer', transition: 'all 0.2s ease',
|
cursor: 'pointer', transition: 'all 0.2s ease', position: 'relative',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = '#333';
|
const el = e.currentTarget as HTMLElement;
|
||||||
(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)';
|
el.style.borderColor = '#2a2a2a';
|
||||||
(e.currentTarget as HTMLElement).style.boxShadow = '0 8px 24px rgba(0,0,0,0.3)';
|
el.style.transform = 'translateY(-2px)';
|
||||||
|
el.style.boxShadow = '0 8px 24px rgba(0,0,0,0.3)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = '#1e1e1e';
|
const el = e.currentTarget as HTMLElement;
|
||||||
(e.currentTarget as HTMLElement).style.transform = 'none';
|
el.style.borderColor = '#1c1c1c';
|
||||||
(e.currentTarget as HTMLElement).style.boxShadow = 'none';
|
el.style.transform = 'none';
|
||||||
|
el.style.boxShadow = 'none';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Preview — auto-generated from board content */}
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '90px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
height: '140px',
|
||||||
background: gradients[hashCode(board.id) % gradients.length],
|
background: '#0c0c0c',
|
||||||
fontSize: '28px', color: 'rgba(255,255,255,0.4)', fontWeight: 700,
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden', borderRadius: '10px 10px 0 0',
|
||||||
}}>
|
}}>
|
||||||
{board.name.charAt(0).toUpperCase()}
|
{board.thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={board.thumbnail}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '6px', color: '#2a2a2a',
|
||||||
|
}}>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||||
|
<rect x="2" y="3" width="20" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="10.5" r="2" />
|
||||||
|
<path d="M21 15l-5-5L5 21" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: '10px', color: '#333' }}>
|
||||||
|
{board.image_count > 0 ? `${board.image_count} image${board.image_count !== 1 ? 's' : ''}` : 'Empty board'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px 14px 8px' }}>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Board name + menu */}
|
||||||
|
<div style={{ padding: '10px 12px 6px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{renamingBoardId === board.id ? (
|
||||||
|
<input
|
||||||
|
ref={boardRenameRef}
|
||||||
|
value={renameBoardText}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<p style={{
|
<p style={{
|
||||||
fontSize: '14px', fontWeight: 600, color: '#e0e0e0', margin: 0,
|
fontSize: '13px', fontWeight: 600, color: '#e0e0e0', margin: 0,
|
||||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
}}>
|
}}>
|
||||||
{board.name}
|
{board.name}
|
||||||
</p>
|
</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>
|
||||||
|
<CardMenu
|
||||||
|
onRename={() => { setRenameBoardText(board.name); setRenamingBoardId(board.id); }}
|
||||||
|
onDelete={() => handleDeleteBoard(board.id)}
|
||||||
|
canEdit={isEditor}
|
||||||
|
canDelete={isOwner}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
|
padding: '6px 12px', borderTop: '1px solid #191919',
|
||||||
|
fontSize: '10px', color: '#555',
|
||||||
|
}}>
|
||||||
|
<span>{board.image_count} img{board.image_count !== 1 ? 's' : ''}</span>
|
||||||
|
<span style={{ color: '#2a2a2a' }}>|</span>
|
||||||
|
<span style={{ color: '#444' }}>{timeAgo(board.updated_at)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -284,49 +497,54 @@ export default function CollectionDetail({ isPublicView }: CollectionDetailProps
|
|||||||
{/* Create board modal */}
|
{/* Create board modal */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(8px)',
|
||||||
}} onClick={() => setShowModal(false)}>
|
}} onClick={() => setShowModal(false)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#161616', borderRadius: '16px', padding: '32px',
|
background: '#151515', borderRadius: '14px', padding: '28px',
|
||||||
width: '100%', maxWidth: '420px', border: '1px solid #222',
|
width: '100%', maxWidth: '400px', border: '1px solid #252525',
|
||||||
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
|
||||||
}} onClick={(e) => e.stopPropagation()}>
|
}} onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 style={{ margin: '0 0 24px', fontSize: '18px', fontWeight: 600, color: '#e0e0e0' }}>New Board</h2>
|
<h2 style={{ margin: '0 0 20px', fontSize: '17px', fontWeight: 600, color: '#e0e0e0' }}>New Board</h2>
|
||||||
<input
|
<input
|
||||||
type="text" placeholder="Board name" value={newName}
|
type="text" placeholder="Board name" value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)} autoFocus
|
onChange={(e) => setNewName(e.target.value)} autoFocus
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateBoard(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateBoard(); }}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '10px 14px', marginBottom: '12px',
|
width: '100%', padding: '10px 14px', marginBottom: '10px',
|
||||||
background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px',
|
background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: '8px',
|
||||||
color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box',
|
color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = '#2a2a2a'; }}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text" placeholder="Description (optional)" value={newDesc}
|
type="text" placeholder="Description (optional)" value={newDesc}
|
||||||
onChange={(e) => setNewDesc(e.target.value)}
|
onChange={(e) => setNewDesc(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateBoard(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateBoard(); }}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '10px 14px', marginBottom: '16px',
|
width: '100%', padding: '10px 14px', marginBottom: '20px',
|
||||||
background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px',
|
background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: '8px',
|
||||||
color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box',
|
color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = '#2a2a2a'; }}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||||
<button onClick={() => setShowModal(false)} style={{
|
<button onClick={() => setShowModal(false)} style={{
|
||||||
padding: '8px 18px', background: 'transparent', border: '1px solid #222',
|
padding: '8px 16px', background: 'transparent', border: '1px solid #252525',
|
||||||
borderRadius: '8px', color: '#888', fontSize: '13px', cursor: 'pointer',
|
borderRadius: '8px', color: '#888', fontSize: '13px', cursor: 'pointer',
|
||||||
}}>
|
}}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleCreateBoard} disabled={creating} style={{
|
<button onClick={handleCreateBoard} disabled={creating || !newName.trim()} style={{
|
||||||
padding: '8px 20px',
|
padding: '8px 20px',
|
||||||
background: creating ? '#333' : 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
background: creating || !newName.trim() ? '#333' : '#4a9eff',
|
||||||
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
||||||
fontWeight: 600, cursor: creating ? 'default' : 'pointer',
|
fontWeight: 600, cursor: creating || !newName.trim() ? 'default' : 'pointer',
|
||||||
opacity: creating ? 0.6 : 1,
|
opacity: creating || !newName.trim() ? 0.5 : 1,
|
||||||
|
transition: 'all 0.15s',
|
||||||
}}>
|
}}>
|
||||||
{creating ? 'Creating...' : 'Create'}
|
{creating ? 'Creating...' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth';
|
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 {
|
interface Collection {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,22 +11,14 @@ interface Collection {
|
|||||||
is_public: number;
|
is_public: number;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
board_count: number;
|
board_count: number;
|
||||||
|
member_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
member_role: string | null;
|
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 {
|
function hashCode(str: string): number {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
@@ -35,6 +28,93 @@ function hashCode(str: string): number {
|
|||||||
return Math.abs(hash);
|
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<HTMLDivElement>(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 (
|
||||||
|
<div ref={ref} style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||||
|
style={{
|
||||||
|
width: '24px', height: '24px', background: 'transparent', border: 'none',
|
||||||
|
color: '#666', cursor: 'pointer', borderRadius: '4px', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', transition: 'all 0.15s',
|
||||||
|
fontSize: '16px', padding: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#222'; e.currentTarget.style.color = '#ccc'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#666'; }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<circle cx="8" cy="3" r="1.5" /><circle cx="8" cy="8" r="1.5" /><circle cx="8" cy="13" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', right: 0, top: '100%', marginTop: '4px',
|
||||||
|
background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: '8px',
|
||||||
|
padding: '4px', minWidth: '120px', zIndex: 100,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||||
|
}} onClick={(e) => 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) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => { setOpen(false); item.onClick?.(); }}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', padding: '7px 12px', background: 'transparent',
|
||||||
|
border: 'none', color: item.danger ? '#ff6b6b' : '#ccc', fontSize: '12px',
|
||||||
|
cursor: 'pointer', textAlign: 'left', borderRadius: '5px',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = item.danger ? '#1f1111' : '#222'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CollectionList() {
|
export default function CollectionList() {
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -42,6 +122,10 @@ export default function CollectionList() {
|
|||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [newDesc, setNewDesc] = useState('');
|
const [newDesc, setNewDesc] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [shareColId, setShareColId] = useState<string | null>(null);
|
||||||
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
|
const [renameText, setRenameText] = useState('');
|
||||||
|
const renameRef = useRef<HTMLInputElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
@@ -61,6 +145,10 @@ export default function CollectionList() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [search, load]);
|
}, [search, load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renamingId) renameRef.current?.select();
|
||||||
|
}, [renamingId]);
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
@@ -78,8 +166,7 @@ export default function CollectionList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(e: React.MouseEvent, id: string) {
|
async function handleDelete(id: string) {
|
||||||
e.stopPropagation();
|
|
||||||
if (!confirm('Delete this collection and ALL its boards? This cannot be undone.')) return;
|
if (!confirm('Delete this collection and ALL its boards? This cannot be undone.')) return;
|
||||||
try {
|
try {
|
||||||
await apiDeleteCollection(id);
|
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 (
|
return (
|
||||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: '#0d0d0d' }}>
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: '#0a0a0a' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '14px 28px', background: '#111', borderBottom: '1px solid #1a1a1a', flexShrink: 0,
|
padding: '12px 32px', background: '#0f0f0f', borderBottom: '1px solid #1a1a1a', flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '28px', height: '28px', borderRadius: '7px',
|
width: '30px', height: '30px', borderRadius: '8px',
|
||||||
background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
boxShadow: '0 2px 8px rgba(74,158,255,0.2)',
|
boxShadow: '0 2px 10px rgba(74,158,255,0.25)',
|
||||||
}}>
|
}}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5">
|
<svg width="15" height="15" 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="3" y="3" width="7" height="7" rx="1.5" />
|
||||||
<rect x="14" 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="3" y="14" width="7" height="7" rx="1.5" />
|
||||||
@@ -112,161 +228,252 @@ export default function CollectionList() {
|
|||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: '18px', fontWeight: 700, color: '#e8e8e8', letterSpacing: '-0.3px' }}>RefBoard</span>
|
<span style={{ fontSize: '18px', fontWeight: 700, color: '#e8e8e8', letterSpacing: '-0.3px' }}>RefBoard</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '28px', height: '28px', borderRadius: '50%',
|
width: '30px', height: '30px', borderRadius: '50%',
|
||||||
background: 'linear-gradient(135deg, #4a9eff, #667eea)',
|
background: 'linear-gradient(135deg, #4a9eff, #667eea)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontSize: '12px', fontWeight: 700, color: '#fff',
|
fontSize: '13px', fontWeight: 700, color: '#fff',
|
||||||
}}>
|
}}>
|
||||||
{(user?.display_name || user?.email || '?')[0].toUpperCase()}
|
{(user?.display_name || user?.email || '?')[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: '13px', color: '#777' }}>{user?.display_name || user?.email}</span>
|
<span style={{ fontSize: '13px', color: '#888' }}>{user?.display_name || user?.email}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => { logout(); navigate('/login', { replace: true }); }}
|
onClick={() => { logout(); navigate('/login', { replace: true }); }}
|
||||||
style={{
|
style={{
|
||||||
padding: '5px 14px', background: 'transparent', border: '1px solid #222',
|
padding: '6px 14px', background: 'transparent', border: '1px solid #252525',
|
||||||
borderRadius: '6px', color: '#666', fontSize: '12px', cursor: 'pointer',
|
borderRadius: '6px', color: '#666', fontSize: '12px', cursor: 'pointer',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#444'; e.currentTarget.style.color = '#aaa'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#444'; e.currentTarget.style.color = '#aaa'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#222'; e.currentTarget.style.color = '#666'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#252525'; e.currentTarget.style.color = '#666'; }}
|
||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Title + Controls */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '20px 28px', flexShrink: 0 }}>
|
<div style={{ padding: '24px 32px 16px', flexShrink: 0 }}>
|
||||||
<div style={{ position: 'relative', flex: 1, maxWidth: '400px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: '22px', fontWeight: 700, color: '#e8e8e8', letterSpacing: '-0.4px' }}>
|
||||||
|
Collections
|
||||||
|
</h1>
|
||||||
|
<span style={{ fontSize: '12px', color: '#555' }}>
|
||||||
|
{collections.length} collection{collections.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ position: 'relative', flex: 1, maxWidth: '360px' }}>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="#555" strokeWidth="1.5"
|
<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%)' }}>
|
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" />
|
<circle cx="6" cy="6" r="4.5" /><line x1="9.5" y1="9.5" x2="13" y2="13" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="text" placeholder="Search collections..."
|
type="text" placeholder="Search..."
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '9px 14px 9px 34px',
|
width: '100%', padding: '8px 14px 8px 34px',
|
||||||
background: '#161616', border: '1px solid #222', borderRadius: '8px',
|
background: '#141414', border: '1px solid #222', borderRadius: '8px',
|
||||||
color: '#e0e0e0', fontSize: '13px', outline: 'none', boxSizing: 'border-box',
|
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'; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(true)}
|
onClick={() => setShowModal(true)}
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 20px', background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
padding: '8px 18px', background: '#4a9eff',
|
||||||
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
||||||
fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
|
fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
boxShadow: '0 2px 12px rgba(74,158,255,0.25)', transition: 'opacity 0.15s',
|
transition: 'background 0.15s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.85'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#3d8be0'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = '#4a9eff'; }}
|
||||||
>
|
>
|
||||||
+ New Collection
|
+ New Collection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1, overflow: 'auto', padding: '0 28px 28px',
|
flex: 1, overflow: 'auto', padding: '0 32px 32px',
|
||||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
gap: '16px', alignContent: 'start',
|
gap: '20px', alignContent: 'start',
|
||||||
}}>
|
}}>
|
||||||
{collections.length === 0 && (
|
{collections.length === 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
gridColumn: '1 / -1', textAlign: 'center', padding: '80px 20px', color: '#444', fontSize: '14px',
|
gridColumn: '1 / -1', textAlign: 'center', padding: '100px 20px', color: '#444',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: '40px', marginBottom: '12px', opacity: 0.2 }}>+</div>
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1" style={{ marginBottom: '16px' }}>
|
||||||
No collections yet. Create one to get started.
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
||||||
|
</svg>
|
||||||
|
<div style={{ fontSize: '15px', fontWeight: 500, color: '#555', marginBottom: '6px' }}>No collections yet</div>
|
||||||
|
<div style={{ fontSize: '13px', color: '#444' }}>Create a collection to organize your reference boards</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{collections.map((col) => (
|
{collections.map((col) => {
|
||||||
|
const thumbs = getThumbnails(col);
|
||||||
|
const offsets = getStackOffsets(col.id, thumbs.length);
|
||||||
|
const isOwner = col.created_by === user?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={col.id}
|
key={col.id}
|
||||||
onClick={() => navigate(`/collection/${col.id}`)}
|
onClick={() => navigate(`/collection/${col.id}`)}
|
||||||
style={{
|
style={{
|
||||||
background: '#141414', borderRadius: '12px', border: '1px solid #1e1e1e',
|
background: '#131313', borderRadius: '12px', border: '1px solid #1e1e1e',
|
||||||
overflow: 'hidden', cursor: 'pointer', transition: 'all 0.2s ease',
|
cursor: 'pointer', transition: 'all 0.2s ease', position: 'relative',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = '#333';
|
const el = e.currentTarget as HTMLElement;
|
||||||
(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)';
|
el.style.borderColor = '#2a2a2a';
|
||||||
(e.currentTarget as HTMLElement).style.boxShadow = '0 8px 24px rgba(0,0,0,0.3)';
|
el.style.transform = 'translateY(-3px)';
|
||||||
|
el.style.boxShadow = '0 12px 32px rgba(0,0,0,0.35)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = '#1e1e1e';
|
const el = e.currentTarget as HTMLElement;
|
||||||
(e.currentTarget as HTMLElement).style.transform = 'none';
|
el.style.borderColor = '#1e1e1e';
|
||||||
(e.currentTarget as HTMLElement).style.boxShadow = 'none';
|
el.style.transform = 'none';
|
||||||
|
el.style.boxShadow = 'none';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Preview — stacked board thumbnails like scattered photos */}
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '100px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
height: '160px',
|
||||||
background: gradients[hashCode(col.id) % gradients.length],
|
background: '#0c0c0c',
|
||||||
fontSize: '32px', color: 'rgba(255,255,255,0.4)', fontWeight: 700,
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden', borderRadius: '12px 12px 0 0',
|
||||||
|
position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
{col.name.charAt(0).toUpperCase()}
|
{thumbs.length > 0 ? (
|
||||||
|
// Stacked/fanned thumbnails
|
||||||
|
thumbs.map((t, i) => {
|
||||||
|
const o = offsets[i];
|
||||||
|
const z = thumbs.length - i; // first on top
|
||||||
|
return (
|
||||||
|
<div key={i} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: thumbs.length === 1 ? '75%' : '60%',
|
||||||
|
aspectRatio: '4/3',
|
||||||
|
borderRadius: '6px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '2px solid #1a1a1a',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||||
|
transform: `rotate(${o.rotate}deg) translate(${o.offsetX}px, ${o.offsetY}px)`,
|
||||||
|
zIndex: z,
|
||||||
|
background: `url(${t}) center/cover #111`,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px', color: '#2a2a2a',
|
||||||
|
}}>
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
|
||||||
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: '11px', color: '#333' }}>
|
||||||
|
{col.board_count === 0 ? 'Empty collection' : `${col.board_count} board${col.board_count !== 1 ? 's' : ''}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '14px 16px 10px' }}>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + menu */}
|
||||||
|
<div style={{ padding: '12px 14px 6px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{renamingId === col.id ? (
|
||||||
|
<input
|
||||||
|
ref={renameRef}
|
||||||
|
value={renameText}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<p style={{
|
<p style={{
|
||||||
fontSize: '15px', fontWeight: 600, color: '#e0e0e0', margin: 0,
|
fontSize: '14px', fontWeight: 600, color: '#e0e0e0', margin: 0,
|
||||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
}}>
|
}}>
|
||||||
{col.name}
|
{col.name}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
{col.description && (
|
{col.description && (
|
||||||
<p style={{
|
<p style={{
|
||||||
fontSize: '12px', color: '#666', margin: '4px 0 0',
|
fontSize: '11px', color: '#555', margin: '3px 0 0',
|
||||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
}}>
|
}}>
|
||||||
{col.description}
|
{col.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<CardMenu
|
||||||
|
onRename={() => { setRenameText(col.name); setRenamingId(col.id); }}
|
||||||
|
onShare={() => setShareColId(col.id)}
|
||||||
|
onDelete={() => handleDelete(col.id)}
|
||||||
|
isOwner={isOwner}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
padding: '8px 16px', borderTop: '1px solid #1a1a1a',
|
padding: '8px 14px', borderTop: '1px solid #191919',
|
||||||
|
fontSize: '10px', color: '#555',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: '11px', color: '#555' }}>
|
<span>{col.board_count} board{col.board_count !== 1 ? 's' : ''}</span>
|
||||||
{col.board_count} board{col.board_count !== 1 ? 's' : ''}
|
<span style={{ color: '#2a2a2a' }}>|</span>
|
||||||
|
{col.is_public ? (
|
||||||
|
<span style={{ color: '#43e97b', display: 'flex', alignItems: 'center', gap: '3px' }}>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<circle cx="12" cy="12" r="10" /><path d="M2 12h20" />
|
||||||
|
</svg>
|
||||||
|
Public
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: '11px', color: '#444' }}>
|
) : (
|
||||||
{new Date(col.updated_at).toLocaleDateString()}
|
<span style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4-4v2" /><circle cx="9" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
{col.member_count} member{col.member_count !== 1 ? 's' : ''}
|
||||||
</span>
|
</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>
|
|
||||||
)}
|
)}
|
||||||
|
<span style={{ color: '#2a2a2a' }}>|</span>
|
||||||
|
<span style={{ color: '#444' }}>{timeAgo(col.updated_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create modal */}
|
{/* Create modal */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(8px)',
|
||||||
}} onClick={() => setShowModal(false)}>
|
}} onClick={() => setShowModal(false)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#161616', borderRadius: '16px', padding: '32px',
|
background: '#151515', borderRadius: '14px', padding: '28px',
|
||||||
width: '100%', maxWidth: '420px', border: '1px solid #222',
|
width: '100%', maxWidth: '400px', border: '1px solid #252525',
|
||||||
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
|
||||||
}} onClick={(e) => e.stopPropagation()}>
|
}} onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 style={{ margin: '0 0 24px', fontSize: '18px', fontWeight: 600, color: '#e0e0e0' }}>
|
<h2 style={{ margin: '0 0 20px', fontSize: '17px', fontWeight: 600, color: '#e0e0e0' }}>
|
||||||
New Collection
|
New Collection
|
||||||
</h2>
|
</h2>
|
||||||
<input
|
<input
|
||||||
@@ -274,34 +481,39 @@ export default function CollectionList() {
|
|||||||
onChange={(e) => setNewName(e.target.value)} autoFocus
|
onChange={(e) => setNewName(e.target.value)} autoFocus
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '10px 14px', marginBottom: '12px',
|
width: '100%', padding: '10px 14px', marginBottom: '10px',
|
||||||
background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px',
|
background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: '8px',
|
||||||
color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box',
|
color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = '#2a2a2a'; }}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text" placeholder="Description (optional)" value={newDesc}
|
type="text" placeholder="Description (optional)" value={newDesc}
|
||||||
onChange={(e) => setNewDesc(e.target.value)}
|
onChange={(e) => setNewDesc(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '10px 14px', marginBottom: '16px',
|
width: '100%', padding: '10px 14px', marginBottom: '20px',
|
||||||
background: '#0d0d0d', border: '1px solid #2a2a2a', borderRadius: '8px',
|
background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: '8px',
|
||||||
color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box',
|
color: '#e0e0e0', fontSize: '14px', outline: 'none', boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = '#2a2a2a'; }}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||||
<button onClick={() => setShowModal(false)} style={{
|
<button onClick={() => setShowModal(false)} style={{
|
||||||
padding: '8px 18px', background: 'transparent', border: '1px solid #222',
|
padding: '8px 16px', background: 'transparent', border: '1px solid #252525',
|
||||||
borderRadius: '8px', color: '#888', fontSize: '13px', cursor: 'pointer',
|
borderRadius: '8px', color: '#888', fontSize: '13px', cursor: 'pointer',
|
||||||
}}>
|
}}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleCreate} disabled={creating} style={{
|
<button onClick={handleCreate} disabled={creating || !newName.trim()} style={{
|
||||||
padding: '8px 20px',
|
padding: '8px 20px',
|
||||||
background: creating ? '#333' : 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
background: creating || !newName.trim() ? '#333' : '#4a9eff',
|
||||||
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px',
|
||||||
fontWeight: 600, cursor: creating ? 'default' : 'pointer',
|
fontWeight: 600, cursor: creating || !newName.trim() ? 'default' : 'pointer',
|
||||||
opacity: creating ? 0.6 : 1,
|
opacity: creating || !newName.trim() ? 0.5 : 1,
|
||||||
|
transition: 'all 0.15s',
|
||||||
}}>
|
}}>
|
||||||
{creating ? 'Creating...' : 'Create'}
|
{creating ? 'Creating...' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
@@ -309,6 +521,10 @@ export default function CollectionList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{shareColId && (
|
||||||
|
<ShareDialog collectionId={shareColId} onClose={() => { setShareColId(null); load(); }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [strokeWidth, setStrokeWidth] = useState(4);
|
const [strokeWidth, setStrokeWidth] = useState(4);
|
||||||
const [fontSize, setFontSize] = useState(24);
|
const [fontSize, setFontSize] = useState(24);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [objectCount, setObjectCount] = useState(0);
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('saved');
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('saved');
|
||||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||||
const [canUndo, setCanUndo] = useState(false);
|
const [canUndo, setCanUndo] = useState(false);
|
||||||
@@ -120,7 +121,50 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
try {
|
try {
|
||||||
const state = JSON.stringify((canvas as any).toJSON(['id', 'crossOrigin']));
|
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');
|
setSaveStatus('saved');
|
||||||
} catch {
|
} catch {
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
@@ -140,9 +184,12 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const z = canvasRef.current?.getZoom() ?? 1;
|
const z = canvasRef.current?.getZoom() ?? 1;
|
||||||
setZoom(z);
|
setZoom(z);
|
||||||
const canvas = canvasRef.current?.getCanvas();
|
const canvas = canvasRef.current?.getCanvas();
|
||||||
if (canvas?.viewportTransform) {
|
if (canvas) {
|
||||||
|
if (canvas.viewportTransform) {
|
||||||
setCanvasTransform([...canvas.viewportTransform]);
|
setCanvasTransform([...canvas.viewportTransform]);
|
||||||
}
|
}
|
||||||
|
setObjectCount(canvas.getObjects().length);
|
||||||
|
}
|
||||||
}, [scheduleSave]);
|
}, [scheduleSave]);
|
||||||
|
|
||||||
// Toast helper
|
// Toast helper
|
||||||
@@ -633,7 +680,6 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const board = boardData?.board;
|
const board = boardData?.board;
|
||||||
const collection = boardData?.collection;
|
const collection = boardData?.collection;
|
||||||
const canvasState = board?.canvas_state;
|
const canvasState = board?.canvas_state;
|
||||||
const imageCount = boardData?.images?.length ?? 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: '#1a1a1a' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: '#1a1a1a' }}>
|
||||||
@@ -763,7 +809,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty canvas guide */}
|
{/* Empty canvas guide */}
|
||||||
{imageCount === 0 && !canvasState?.objects?.length && (
|
{objectCount === 0 && !canvasState?.objects?.length && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
||||||
pointerEvents: 'none', textAlign: 'center', color: '#555', userSelect: 'none',
|
pointerEvents: 'none', textAlign: 'center', color: '#555', userSelect: 'none',
|
||||||
@@ -884,7 +930,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
<StatusBar
|
<StatusBar
|
||||||
boardName={board?.name || 'Untitled'}
|
boardName={board?.name || 'Untitled'}
|
||||||
imageCount={imageCount}
|
imageCount={objectCount}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user