feat(collab): restore cross-account sharing and reliable realtime sync

This commit is contained in:
2026-02-13 19:02:03 +01:00
parent 12da89b815
commit 75cbe97bc0
18 changed files with 1242 additions and 167 deletions
+8
View File
@@ -52,6 +52,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/shared"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
+62 -3
View File
@@ -2,7 +2,10 @@ import axios from "axios";
import type { Drawing, Collection, DrawingSummary } from "../types";
import { normalizePreviewSvg } from "../utils/previewSvg";
export const API_URL = import.meta.env.VITE_API_URL || "/api";
const DEFAULT_DEV_API_URL = "http://localhost:8000/api";
export const API_URL =
import.meta.env.VITE_API_URL ||
(import.meta.env.DEV ? DEFAULT_DEV_API_URL : "/api");
export const api = axios.create({
baseURL: API_URL,
@@ -416,6 +419,7 @@ export interface PaginatedDrawings<T> {
export type DrawingSortField = "name" | "createdAt" | "updatedAt";
export type SortDirection = "asc" | "desc";
export type ShareLinkRole = "viewer" | "editor";
export function getDrawings(
search?: string,
@@ -475,8 +479,46 @@ export async function getDrawings(
};
}
export const getDrawing = async (id: string) => {
const response = await api.get<Drawing>(`/drawings/${id}`);
export const getSharedDrawings = async (options?: {
search?: string;
includeData?: boolean;
limit?: number;
offset?: number;
sortField?: DrawingSortField;
sortDirection?: SortDirection;
}) => {
const params: Record<string, string | number> = {};
if (options?.search) params.search = options.search;
if (options?.includeData) params.includeData = "true";
if (options?.limit !== undefined) params.limit = options.limit;
if (options?.offset !== undefined) params.offset = options.offset;
if (options?.sortField) params.sortField = options.sortField;
if (options?.sortDirection) params.sortDirection = options.sortDirection;
if (options?.includeData) {
const response = await api.get<PaginatedDrawings<Drawing>>("/drawings/shared", { params });
return {
...response.data,
drawings: response.data.drawings.map(deserializeDrawing),
};
}
const response = await api.get<PaginatedDrawings<DrawingSummary>>("/drawings/shared", { params });
return {
...response.data,
drawings: response.data.drawings.map(deserializeDrawingSummary),
};
};
export const getDrawing = async (
id: string,
options?: { shareToken?: string }
) => {
const headers: Record<string, string> = {};
if (options?.shareToken) {
headers["x-share-token"] = options.shareToken;
}
const response = await api.get<Drawing>(`/drawings/${id}`, { headers });
return deserializeDrawing(response.data);
};
@@ -508,6 +550,23 @@ export const duplicateDrawing = async (id: string) => {
return deserializeDrawing(response.data);
};
export const getDrawingShareLinks = async (
id: string
): Promise<{ drawingId: string; viewerToken: string; editorToken: string }> => {
const response = await api.get<{ drawingId: string; viewerToken: string; editorToken: string }>(`/drawings/${id}/share-links`);
return response.data;
};
export const rotateDrawingShareLink = async (
id: string,
role: ShareLinkRole
): Promise<{ role: ShareLinkRole; drawingId: string; token: string }> => {
const response = await api.post<{ role: ShareLinkRole; drawingId: string; token: string }>(
`/drawings/${id}/share-links/${role}/rotate`
);
return response.data;
};
export const getCollections = async () => {
const response = await api.get<Collection[]>("/collections");
return response.data;
+74 -61
View File
@@ -56,6 +56,7 @@ interface DrawingCardProps {
onDragStart?: (e: React.DragEvent, id: string) => void;
onMouseDown?: (e: React.MouseEvent, id: string) => void;
onPreviewGenerated?: (id: string, preview: string) => void;
canManage?: boolean;
}
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -76,6 +77,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
onDragStart,
onMouseDown,
onPreviewGenerated,
canManage = true,
}) => {
const [isRenaming, setIsRenaming] = useState(false);
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
@@ -207,6 +209,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
}, []);
const handleRenameSubmit = (e: React.FormEvent) => {
if (!canManage) return;
e.preventDefault();
if (newName.trim()) {
onRename(drawing.id, newName);
@@ -313,6 +316,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
className="text-sm sm:text-base font-bold text-slate-800 dark:text-neutral-100 truncate cursor-text select-none group-hover:text-neutral-900 dark:group-hover:text-white transition-colors"
title={drawing.name}
onDoubleClick={(e) => {
if (!canManage) return;
e.stopPropagation();
setIsRenaming(true);
}}
@@ -327,15 +331,17 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
</p>
<div className="relative" onClick={e => e.stopPropagation()}>
<button
onClick={() => setShowCollectionDropdown(!showCollectionDropdown)}
data-testid={`collection-picker-${drawing.id}`}
aria-haspopup="listbox"
aria-expanded={showCollectionDropdown}
className="px-2 py-1 rounded-md bg-slate-50 dark:bg-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-200 text-slate-500 dark:text-neutral-400 text-[10px] font-bold uppercase tracking-wide max-w-[120px] truncate transition-all cursor-pointer border border-slate-100 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600"
>
{drawing.collectionId ? (collections.find(c => c.id === drawing.collectionId)?.name || 'Collection') : 'Unorganized'}
</button>
{canManage && (
<button
onClick={() => setShowCollectionDropdown(!showCollectionDropdown)}
data-testid={`collection-picker-${drawing.id}`}
aria-haspopup="listbox"
aria-expanded={showCollectionDropdown}
className="px-2 py-1 rounded-md bg-slate-50 dark:bg-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-200 text-slate-500 dark:text-neutral-400 text-[10px] font-bold uppercase tracking-wide max-w-[120px] truncate transition-all cursor-pointer border border-slate-100 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600"
>
{drawing.collectionId ? (collections.find(c => c.id === drawing.collectionId)?.name || 'Collection') : 'Unorganized'}
</button>
)}
{showCollectionDropdown && (
<>
@@ -387,56 +393,60 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => {
setIsRenaming(true);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
>
<PenTool size={14} /> Rename
</button>
<div
className="relative group/move"
onMouseEnter={() => setShowMoveSubmenu(true)}
onMouseLeave={() => setShowMoveSubmenu(false)}
>
{canManage && (
<button
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center justify-between"
onClick={() => {
setIsRenaming(true);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
>
<span className="flex items-center gap-2"><FolderInput size={14} /> Move to...</span>
<ArrowRight size={12} />
<PenTool size={14} /> Rename
</button>
)}
{showMoveSubmenu && (
<div className="absolute left-full top-0 ml-1 w-40 bg-white dark:bg-neutral-900 rounded-lg border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] py-1 max-h-64 overflow-y-auto">
<button
onClick={() => { onMoveToCollection(drawing.id, null); setContextMenu(null); }}
className={clsx(
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800",
drawing.collectionId === null ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
)}
>
Unorganized
{drawing.collectionId === null && <Check size={10} />}
</button>
{collections.map(c => (
{canManage && (
<div
className="relative group/move"
onMouseEnter={() => setShowMoveSubmenu(true)}
onMouseLeave={() => setShowMoveSubmenu(false)}
>
<button
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center justify-between"
>
<span className="flex items-center gap-2"><FolderInput size={14} /> Move to...</span>
<ArrowRight size={12} />
</button>
{showMoveSubmenu && (
<div className="absolute left-full top-0 ml-1 w-40 bg-white dark:bg-neutral-900 rounded-lg border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] py-1 max-h-64 overflow-y-auto">
<button
key={c.id}
onClick={() => { onMoveToCollection(drawing.id, c.id); setContextMenu(null); }}
onClick={() => { onMoveToCollection(drawing.id, null); setContextMenu(null); }}
className={clsx(
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 truncate",
drawing.collectionId === c.id ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800",
drawing.collectionId === null ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
)}
>
<span className="truncate">{c.name}</span>
{drawing.collectionId === c.id && <Check size={10} />}
Unorganized
{drawing.collectionId === null && <Check size={10} />}
</button>
))}
</div>
)}
</div>
{collections.map(c => (
<button
key={c.id}
onClick={() => { onMoveToCollection(drawing.id, c.id); setContextMenu(null); }}
className={clsx(
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 truncate",
drawing.collectionId === c.id ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
)}
>
<span className="truncate">{c.name}</span>
{drawing.collectionId === c.id && <Check size={10} />}
</button>
))}
</div>
)}
</div>
)}
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
@@ -468,17 +478,20 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
</div>
)}
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
<button
onClick={() => {
onDelete(drawing.id);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 flex items-center gap-2"
>
<Trash2 size={14} /> Delete
</button>
{canManage && (
<>
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
<button
onClick={() => {
onDelete(drawing.id);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 flex items-center gap-2"
>
<Trash2 size={14} /> Delete
</button>
</>
)}
</div>
</div>
</ContextMenuPortal>
+18 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut, Shield } from 'lucide-react';
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut, Shield, Users } from 'lucide-react';
import type { Collection } from '../types';
import clsx from 'clsx';
import { ConfirmModal } from './ConfirmModal';
@@ -207,6 +207,23 @@ export const Sidebar: React.FC<SidebarProps> = ({
onClick={() => onSelectCollection(null)}
onDrop={onDrop}
/>
{authEnabled && (
<div className="pl-3 pr-2">
<button
onClick={() => onSelectCollection('__shared__')}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2",
selectedCollectionId === '__shared__'
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5"
: "text-slate-600 dark:text-neutral-400 border-transparent hover:bg-slate-50 dark:hover:bg-neutral-800 hover:border-black dark:hover:border-neutral-700 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
)}
>
<Users size={18} className={clsx(selectedCollectionId === '__shared__' ? "text-indigo-900 dark:text-neutral-200" : "text-slate-400 dark:text-neutral-500")} />
<span className="min-w-0 flex-1 text-left">Shared with me</span>
</button>
</div>
)}
</div>
<div className="space-y-1">
+52 -21
View File
@@ -21,6 +21,7 @@ export const Dashboard: React.FC = () => {
const selectedCollectionId = React.useMemo(() => {
if (location.pathname === '/') return undefined;
if (location.pathname === '/shared') return '__shared__';
if (location.pathname === '/collections') {
const id = searchParams.get('id');
if (id === 'unorganized') return null;
@@ -32,6 +33,8 @@ export const Dashboard: React.FC = () => {
const setSelectedCollectionId = (id: string | null | undefined) => {
if (id === undefined) {
navigate('/');
} else if (id === '__shared__') {
navigate('/shared');
} else if (id === null) {
navigate('/collections?id=unorganized');
} else {
@@ -39,6 +42,8 @@ export const Dashboard: React.FC = () => {
}
};
const isSharedView = selectedCollectionId === '__shared__';
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -265,8 +270,13 @@ export const Dashboard: React.FC = () => {
const currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0];
const isTrashView = selectedCollectionId === 'trash';
const canManageDrawing = useCallback(
(id: string) => (drawings.find((d) => d.id === id)?.accessRole ?? 'owner') === 'owner',
[drawings]
);
const handleCreateDrawing = async () => {
if (isTrashView) return;
if (isTrashView || isSharedView) return;
try {
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
const { id } = await api.createDrawing('Untitled Drawing', targetCollectionId);
@@ -277,7 +287,7 @@ export const Dashboard: React.FC = () => {
};
const handleImportDrawings = async (files: FileList | null) => {
if (!files || isTrashView) return;
if (!files || isTrashView || isSharedView) return;
const fileArray = Array.from(files);
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
@@ -290,6 +300,7 @@ export const Dashboard: React.FC = () => {
};
const handleRenameDrawing = async (id: string, name: string) => {
if (!canManageDrawing(id)) return;
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
try {
await api.updateDrawing(id, { name });
@@ -300,6 +311,7 @@ export const Dashboard: React.FC = () => {
};
const handleDeleteDrawing = async (id: string) => {
if (!canManageDrawing(id)) return;
if (isTrashView) {
// Permanent Delete -> Confirm first
setDrawingToDelete(id);
@@ -327,6 +339,7 @@ export const Dashboard: React.FC = () => {
};
const executePermanentDelete = async (id: string) => {
if (!canManageDrawing(id)) return;
setDrawings(prev => {
const next = prev.filter(d => d.id !== id);
if (next.length !== prev.length) {
@@ -378,6 +391,8 @@ export const Dashboard: React.FC = () => {
const handleBulkDeleteClick = () => {
if (selectedIds.size === 0) return;
const ownerSelectedCount = Array.from(selectedIds).filter((id) => canManageDrawing(id)).length;
if (ownerSelectedCount === 0) return;
if (isTrashView) {
setShowBulkDeleteConfirm(true);
} else {
@@ -387,10 +402,12 @@ export const Dashboard: React.FC = () => {
const executeBulkMoveToTrash = async () => {
const trashId = 'trash';
const ids = Array.from(selectedIds);
const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
if (ids.length === 0) return;
setDrawings(prev => {
const next = prev.filter(d => !selectedIds.has(d.id));
const idSet = new Set(ids);
const next = prev.filter(d => !idSet.has(d.id));
setTotalCount(t => t - (prev.length - next.length));
return next;
});
@@ -405,9 +422,11 @@ export const Dashboard: React.FC = () => {
};
const executeBulkPermanentDelete = async () => {
const ids = Array.from(selectedIds);
const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
if (ids.length === 0) return;
setDrawings(prev => {
const next = prev.filter(d => !selectedIds.has(d.id));
const idSet = new Set(ids);
const next = prev.filter(d => !idSet.has(d.id));
setTotalCount(t => t - (prev.length - next.length));
return next;
});
@@ -425,11 +444,13 @@ export const Dashboard: React.FC = () => {
const handleBulkMove = async (collectionId: string | null) => {
if (selectedIds.size === 0) return;
const idsToMove = Array.from(selectedIds);
const idsToMove = Array.from(selectedIds).filter((id) => canManageDrawing(id));
if (idsToMove.length === 0) return;
const idsToMoveSet = new Set(idsToMove);
// Optimistic update
setDrawings(prev => {
const updated = prev.map(d => selectedIds.has(d.id) ? { ...d, collectionId } : d);
const updated = prev.map(d => idsToMoveSet.has(d.id) ? { ...d, collectionId } : d);
if (selectedCollectionId === undefined) return updated;
const next = updated.filter(d => {
if (selectedCollectionId === null) return d.collectionId === null;
@@ -472,6 +493,7 @@ export const Dashboard: React.FC = () => {
};
const handleMoveToCollection = async (id: string, collectionId: string | null) => {
if (!canManageDrawing(id)) return;
setDrawings(prev => {
const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d);
const next = updated.filter(d => {
@@ -530,6 +552,7 @@ export const Dashboard: React.FC = () => {
const viewTitle = React.useMemo(() => {
if (selectedCollectionId === undefined) return "All Drawings";
if (selectedCollectionId === null) return "Unorganized";
if (selectedCollectionId === '__shared__') return "Shared with me";
if (selectedCollectionId === 'trash') return "Trash";
const collection = collections.find(c => c.id === selectedCollectionId);
return collection ? collection.name : "Collection";
@@ -537,6 +560,10 @@ export const Dashboard: React.FC = () => {
const hasSelection = selectedIds.size > 0;
const allSelected = sortedDrawings.length > 0 && selectedIds.size === sortedDrawings.length;
const manageableSelectionCount = React.useMemo(
() => Array.from(selectedIds).filter((id) => canManageDrawing(id)).length,
[selectedIds, canManageDrawing]
);
const handleSelectAll = () => {
if (allSelected) {
@@ -567,7 +594,7 @@ export const Dashboard: React.FC = () => {
}
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
if (drawingFiles.length > 0) {
if (drawingFiles.length > 0 && !isSharedView) {
uploadFiles(drawingFiles, targetCollectionId).finally(() => {
refreshData();
});
@@ -588,6 +615,9 @@ export const Dashboard: React.FC = () => {
idsToMove.add(draggedDrawingId);
}
idsToMove = new Set(Array.from(idsToMove).filter((id) => canManageDrawing(id)));
if (idsToMove.size === 0) return;
// Optimistic Update
setDrawings(prev => {
const updated = prev.map(d => idsToMove.has(d.id) ? { ...d, collectionId: targetCollectionId } : d);
@@ -805,10 +835,10 @@ export const Dashboard: React.FC = () => {
<button
onClick={handleBulkDeleteClick}
disabled={!hasSelection}
disabled={manageableSelectionCount === 0}
className={clsx(
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
hasSelection
manageableSelectionCount > 0
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-rose-600 dark:text-rose-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-rose-50 dark:hover:bg-rose-900/30"
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
)}
@@ -833,11 +863,11 @@ export const Dashboard: React.FC = () => {
<div className="relative">
<button
onClick={() => hasSelection && setShowBulkMoveMenu(!showBulkMoveMenu)}
disabled={!hasSelection}
onClick={() => manageableSelectionCount > 0 && setShowBulkMoveMenu(!showBulkMoveMenu)}
disabled={manageableSelectionCount === 0}
className={clsx(
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
hasSelection
manageableSelectionCount > 0
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-emerald-600 dark:text-emerald-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
)}
@@ -849,12 +879,12 @@ export const Dashboard: React.FC = () => {
</div>
</button>
{showBulkMoveMenu && hasSelection && (
{showBulkMoveMenu && manageableSelectionCount > 0 && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowBulkMoveMenu(false)} />
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-neutral-800 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] z-50 py-1 max-h-64 overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-100">
<div className="px-3 py-2 text-[10px] font-bold uppercase text-slate-400 dark:text-neutral-500 tracking-wider border-b border-slate-100 dark:border-neutral-700 mb-1">
Move {selectedIds.size} items to...
Move {manageableSelectionCount} items to...
</div>
<button
onClick={() => handleBulkMove(null)}
@@ -891,10 +921,10 @@ export const Dashboard: React.FC = () => {
<button
onClick={() => document.getElementById('dashboard-import')?.click()}
disabled={isTrashView}
disabled={isTrashView || isSharedView}
className={clsx(
"h-[42px] w-full sm:w-auto flex items-center justify-center gap-2 px-6 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] transition-all font-bold text-sm whitespace-nowrap",
isTrashView
isTrashView || isSharedView
? "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-600 border-slate-300 dark:border-slate-700 shadow-none cursor-not-allowed"
: "bg-emerald-600 dark:bg-neutral-800 text-white hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:active:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
)}
@@ -905,10 +935,10 @@ export const Dashboard: React.FC = () => {
<button
onClick={handleCreateDrawing}
disabled={isTrashView}
disabled={isTrashView || isSharedView}
className={clsx(
"h-[42px] w-full sm:w-auto flex items-center justify-center gap-2 px-6 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] transition-all font-bold text-sm whitespace-nowrap",
isTrashView
isTrashView || isSharedView
? "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-600 border-slate-300 dark:border-slate-700 shadow-none cursor-not-allowed"
: "bg-indigo-600 dark:bg-neutral-800 text-white hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:active:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
)}
@@ -1006,8 +1036,9 @@ export const Dashboard: React.FC = () => {
}
}}
onMouseDown={handleCardMouseDown}
onDragStart={handleCardDragStart}
onDragStart={(drawing.accessRole ?? 'owner') === 'owner' ? handleCardDragStart : undefined}
onPreviewGenerated={handlePreviewGenerated}
canManage={(drawing.accessRole ?? 'owner') === 'owner'}
/>
))
)}
File diff suppressed because it is too large Load Diff
@@ -36,13 +36,22 @@ export const useDashboardData = ({
const requestVersion = ++listRequestVersionRef.current;
setIsLoading(true);
try {
const isSharedView = selectedCollectionId === '__shared__';
const [drawingsRes, collectionsData] = await Promise.all([
api.getDrawings(debouncedSearch, selectedCollectionId, {
limit: pageSize,
offset: 0,
sortField,
sortDirection,
}),
isSharedView
? api.getSharedDrawings({
search: debouncedSearch || undefined,
limit: pageSize,
offset: 0,
sortField,
sortDirection,
})
: api.getDrawings(debouncedSearch, selectedCollectionId, {
limit: pageSize,
offset: 0,
sortField,
sortDirection,
}),
api.getCollections(),
]);
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
@@ -71,12 +80,21 @@ export const useDashboardData = ({
const requestVersion = listRequestVersionRef.current;
setIsFetchingMore(true);
try {
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
limit: pageSize,
offset: drawings.length,
sortField,
sortDirection,
});
const isSharedView = selectedCollectionId === '__shared__';
const drawingsRes = isSharedView
? await api.getSharedDrawings({
search: debouncedSearch || undefined,
limit: pageSize,
offset: drawings.length,
sortField,
sortDirection,
})
: await api.getDrawings(debouncedSearch, selectedCollectionId, {
limit: pageSize,
offset: drawings.length,
sortField,
sortDirection,
});
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
setDrawings((prev) => mergeUniqueDrawings(prev, drawingsRes.drawings));
setTotalCount(drawingsRes.totalCount);
+27
View File
@@ -1,8 +1,35 @@
export interface ElementVersionInfo {
version: number;
versionNonce: number;
updated?: number;
syncFingerprint?: string;
}
export const buildElementSyncFingerprint = (element: any): string => {
if (!element || typeof element !== "object") return "";
const points = Array.isArray(element.points) ? element.points : null;
const firstPoint = points && points.length > 0 ? points[0] : null;
const lastPoint = points && points.length > 0 ? points[points.length - 1] : null;
return JSON.stringify({
id: element.id ?? null,
type: element.type ?? null,
isDeleted: Boolean(element.isDeleted),
x: element.x ?? null,
y: element.y ?? null,
width: element.width ?? null,
height: element.height ?? null,
angle: element.angle ?? null,
startBinding: element.startBinding?.elementId ?? null,
endBinding: element.endBinding?.elementId ?? null,
pointsLen: points ? points.length : null,
firstPoint,
lastPoint,
text: typeof element.text === "string" ? element.text : null,
});
};
export const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => {
if (!a || !b) return false;
if (a.length !== b.length) return false;
+10
View File
@@ -1,3 +1,11 @@
export type DrawingAccessRole = "owner" | "editor" | "viewer";
export interface DrawingOwner {
id: string;
name: string;
email?: string;
}
export interface DrawingSummary {
id: string;
name: string;
@@ -6,6 +14,8 @@ export interface DrawingSummary {
createdAt: number;
version: number;
preview?: string | null;
accessRole?: DrawingAccessRole;
owner?: DrawingOwner;
}
export interface Drawing extends DrawingSummary {
+18
View File
@@ -17,6 +17,14 @@ export const reconcileElements = (
const value = element?.updated;
return typeof value === "number" ? value : Number(value) || 0;
};
const getComparableContent = (element: any): string => {
if (!element || typeof element !== "object") return "";
const copy = { ...element } as Record<string, unknown>;
delete copy.version;
delete copy.versionNonce;
delete copy.updated;
return JSON.stringify(copy);
};
remoteElements.forEach((remoteEl) => {
const localEl = localMap.get(remoteEl.id);
@@ -51,7 +59,17 @@ export const reconcileElements = (
getVersionNonce(remoteEl) !== getVersionNonce(localEl)
) {
localMap.set(remoteEl.id, remoteEl);
return;
}
if (
remoteUpdated === localUpdated &&
getVersionNonce(remoteEl) === getVersionNonce(localEl) &&
getComparableContent(remoteEl) !== getComparableContent(localEl)
) {
localMap.set(remoteEl.id, remoteEl);
}
});
return Array.from(localMap.values());