feat(collab): restore cross-account sharing and reliable realtime sync
This commit is contained in:
@@ -52,6 +52,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shared"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
+291
-28
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user