feat(collab): add server-authoritative sync and preview-only updates

This commit is contained in:
2026-02-13 20:47:05 +01:00
parent fd5470ada5
commit 0ffe410eeb
11 changed files with 792 additions and 273 deletions
+38
View File
@@ -540,6 +540,44 @@ export const updateDrawing = async (id: string, data: Partial<Drawing>) => {
return deserializeDrawing(response.data);
};
export const updateDrawingPreview = async (
id: string,
preview: string | null
): Promise<{ success: true }> => {
const response = await api.put<{ success: true }>(`/drawings/${id}/preview`, { preview });
return response.data;
};
export const getDrawingSceneMeta = async (id: string): Promise<{
drawingId: string;
seq: number;
dbVersion: number;
updatedAt: string | number;
}> => {
const response = await api.get<{
drawingId: string;
seq: number;
dbVersion: number;
updatedAt: string | number;
}>(`/drawings/${id}/scene-meta`);
return response.data;
};
export const flushDrawingScene = async (id: string): Promise<{
success: true;
drawingId: string;
seq: number | null;
dbVersion: number | null;
}> => {
const response = await api.post<{
success: true;
drawingId: string;
seq: number | null;
dbVersion: number | null;
}>(`/drawings/${id}/flush`);
return response.data;
};
export const deleteDrawing = async (id: string) => {
const response = await api.delete<{ success: true }>(`/drawings/${id}`);
return response.data;
+14 -2
View File
@@ -7,7 +7,7 @@ import { formatDistanceToNow } from 'date-fns';
import clsx from 'clsx';
// import { exportToSvg } from "@excalidraw/excalidraw"; // Lazy load this instead
import { exportDrawingToFile } from '../utils/exportUtils';
import { previewHasEmbeddedImages } from '../utils/previewSvg';
import { isPreviewImageDataUrl, previewHasEmbeddedImages } from '../utils/previewSvg';
import * as api from '../api';
@@ -89,6 +89,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
const [exportError, setExportError] = useState<string | null>(null);
const [fullData, setFullData] = useState<HydratedDrawingData | null>(null);
const hasEmbeddedImages = previewHasEmbeddedImages(previewSvg);
const isImagePreview = isPreviewImageDataUrl(previewSvg);
const fullDataRef = React.useRef(fullData);
fullDataRef.current = fullData;
@@ -161,7 +162,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
setPreviewSvg(previewHtml);
// Save to backend and notify parent
api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error);
api.updateDrawingPreview(drawing.id, previewHtml).catch(console.error);
onPreviewGenerated?.(drawing.id, previewHtml);
} catch (e) {
if (!cancelled) {
@@ -277,6 +278,16 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
<div className="absolute inset-0 opacity-[0.3] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] [background-size:24px_24px]"></div>
{previewSvg ? (
isImagePreview ? (
<img
src={previewSvg}
alt=""
className="w-full h-full object-contain p-2 sm:p-3 lg:p-4 relative z-10"
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<div
className={clsx(
"w-full h-full p-3 sm:p-4 lg:p-5 flex items-center justify-center [&>svg]:w-auto [&>svg]:h-auto [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:drop-shadow-sm transition-transform duration-500",
@@ -284,6 +295,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
)}
dangerouslySetInnerHTML={{ __html: previewSvg }}
/>
)
) : (
<div className="w-16 h-16 sm:w-20 sm:h-20 lg:w-24 lg:h-24 bg-white dark:bg-neutral-900 rounded-2xl shadow-sm flex items-center justify-center text-neutral-300 dark:text-neutral-400 border border-slate-100 dark:border-neutral-700 transform group-hover:scale-110 group-hover:rotate-3 transition-all duration-500">
<PenTool size={32} strokeWidth={1.5} className="sm:w-9 sm:h-9 lg:w-10 lg:h-10" />
+16 -4
View File
@@ -11,6 +11,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
import { useUpload } from '../context/UploadContext';
import { DragOverlayPortal, getSelectionBounds, type Point, type SelectionBounds } from './dashboard/shared';
import { useDashboardData } from './dashboard/useDashboardData';
import { isPreviewImageDataUrl } from '../utils/previewSvg';
const PAGE_SIZE = 24;
@@ -699,10 +700,21 @@ export const Dashboard: React.FC = () => {
<div className="absolute inset-0 opacity-[0.3] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] [background-size:24px_24px]"></div>
{d.preview ? (
<div
className="w-full h-full p-2 flex items-center justify-center [&>svg]:w-auto [&>svg]:h-auto [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:drop-shadow-sm relative z-10"
dangerouslySetInnerHTML={{ __html: d.preview }}
/>
isPreviewImageDataUrl(d.preview) ? (
<img
src={d.preview}
alt=""
className="w-full h-full p-2 object-contain relative z-10"
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<div
className="w-full h-full p-2 flex items-center justify-center [&>svg]:w-auto [&>svg]:h-auto [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:drop-shadow-sm relative z-10"
dangerouslySetInnerHTML={{ __html: d.preview }}
/>
)
) : (
<div className="text-slate-300 relative z-10"><Folder size={24} /></div>
)}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -65,11 +65,29 @@ export const previewHasEmbeddedImages = (
preview: string | null | undefined
): boolean => typeof preview === "string" && /<image[\s>]/i.test(preview);
export const isPreviewImageDataUrl = (
preview: string | null | undefined
): boolean =>
typeof preview === "string" &&
/^data:image\/(?:webp|png|jpe?g);base64,[a-z0-9+/=\s]+$/i.test(preview.trim());
export const isPreviewSvgMarkup = (
preview: string | null | undefined
): boolean => typeof preview === "string" && /^\s*<svg[\s>]/i.test(preview);
export const normalizePreviewSvg = (preview: string | null | undefined): string | null => {
if (typeof preview !== "string" || preview.trim().length === 0) {
return preview ?? null;
}
if (isPreviewImageDataUrl(preview)) {
return preview.trim();
}
if (!isPreviewSvgMarkup(preview)) {
return null;
}
if (typeof DOMParser === "undefined") {
return preview;
}