Compare commits

..

11 Commits

Author SHA1 Message Date
Zimeng Xiong 02736d663a chore: pre-release v0.4.6-dev 2026-02-07 12:46:00 -08:00
Zimeng Xiong de254d46f2 concurrency 2026-02-07 12:45:33 -08:00
Zimeng Xiong dd0f381ed1 chore: pre-release v0.4.5-dev 2026-02-07 12:09:21 -08:00
Zimeng Xiong c40a5f46a0 fix colliding drawing IDs 2026-02-07 12:09:02 -08:00
Zimeng Xiong 8fcca43b0d chore: pre-release v0.4.4-dev 2026-02-07 11:58:09 -08:00
Zimeng Xiong f20412cdfb separate debounced autosave 2026-02-07 11:57:32 -08:00
Zimeng Xiong a366acfedc chore: pre-release v0.4.3-dev 2026-02-07 11:08:03 -08:00
Zimeng Xiong 154dcbb151 update resopnsiveness hamburger 2026-02-07 11:07:15 -08:00
Zimeng Xiong 2e74d2ad1a chore: pre-release v0.4.2-dev 2026-02-07 10:34:36 -08:00
Zimeng Xiong 173c050f58 fix HTTPS reuqirement when frontend URL is nto HTTPS 2026-02-07 10:31:08 -08:00
Zimeng Xiong 8161a563f0 chore: pre-release v0.4.1-dev 2026-02-07 10:08:27 -08:00
20 changed files with 663 additions and 90 deletions
+1 -1
View File
@@ -1 +1 @@
0.4.1 0.4.6
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "backend", "name": "backend",
"version": "0.4.0", "version": "0.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "backend", "name": "backend",
"version": "0.4.0", "version": "0.4.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "0.4.1", "version": "0.4.6",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -3,6 +3,7 @@ import request from "supertest";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import JSZip from "jszip";
import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils"; import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils";
type LegacyDbOptions = { type LegacyDbOptions = {
@@ -156,6 +157,111 @@ const createLegacySqliteDb = (opts: LegacyDbOptions): string => {
return filePath; return filePath;
}; };
const createExcalidashArchiveWithDuplicateDrawingIds = async (): Promise<string> => {
const dir = createTempDir();
const filePath = path.join(dir, "duplicate-drawing-ids.excalidash");
const zip = new JSZip();
const manifest = {
format: "excalidash",
formatVersion: 1,
exportedAt: new Date().toISOString(),
unorganizedFolder: "Unorganized",
collections: [] as any[],
drawings: [
{
id: "duplicate-drawing-id",
name: "Drawing One",
filePath: "Unorganized/drawing-1.excalidraw",
collectionId: null,
},
{
id: "duplicate-drawing-id",
name: "Drawing Two",
filePath: "Unorganized/drawing-2.excalidraw",
collectionId: null,
},
],
};
zip.file("excalidash.manifest.json", JSON.stringify(manifest));
zip.file(
"Unorganized/drawing-1.excalidraw",
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
);
zip.file(
"Unorganized/drawing-2.excalidraw",
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
);
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(filePath, buffer);
return filePath;
};
const createLegacySqliteDbWithDuplicateDrawingIds = (): string => {
const dir = createTempDir();
const filePath = path.join(dir, "legacy-duplicate-ids.db");
const db = openWritableDb(filePath);
try {
db.exec(`
CREATE TABLE "Drawing" (
id TEXT,
name TEXT NOT NULL,
elements TEXT NOT NULL,
appState TEXT NOT NULL,
files TEXT,
preview TEXT,
version INTEGER,
collectionId TEXT,
collectionName TEXT,
createdAt TEXT,
updatedAt TEXT
);
`);
const now = new Date("2024-01-03T00:00:00.000Z").toISOString();
const insertDrawing = db.prepare(
`INSERT INTO "Drawing"
(id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
insertDrawing.run(
"legacy-duplicate-id",
"Legacy Drawing A",
JSON.stringify([]),
JSON.stringify({}),
JSON.stringify({}),
null,
1,
null,
null,
now,
now,
);
insertDrawing.run(
"legacy-duplicate-id",
"Legacy Drawing B",
JSON.stringify([]),
JSON.stringify({}),
JSON.stringify({}),
null,
1,
null,
null,
now,
now,
);
} finally {
db.close();
}
return filePath;
};
describe("Import compatibility (legacy exports)", () => { describe("Import compatibility (legacy exports)", () => {
const uploadsDir = path.resolve(__dirname, "../../uploads"); const uploadsDir = path.resolve(__dirname, "../../uploads");
const userAgent = "vitest-import-compat"; const userAgent = "vitest-import-compat";
@@ -287,4 +393,52 @@ describe("Import compatibility (legacy exports)", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.error).toBe("Invalid legacy DB"); expect(res.body.error).toBe("Invalid legacy DB");
}); });
it("rejects .excalidash verify when manifest has duplicate drawing IDs", async () => {
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
const res = await request(app)
.post("/import/excalidash/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("archive", archive);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
it("rejects .excalidash import when manifest has duplicate drawing IDs", async () => {
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
const res = await request(app)
.post("/import/excalidash")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("archive", archive);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
it("rejects legacy verify when DB has duplicate drawing IDs", async () => {
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
const res = await request(app)
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
it("rejects legacy import when DB has duplicate drawing IDs", async () => {
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
const res = await request(app)
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(400);
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
});
}); });
+7 -2
View File
@@ -259,8 +259,12 @@ app.use((req, res, next) => {
next(); next();
}); });
// HTTPS enforcement in production // HTTPS enforcement in production only when configured frontend origins use HTTPS.
if (config.nodeEnv === "production") { const shouldEnforceHttps =
config.nodeEnv === "production" &&
allowedOrigins.some((origin) => origin.toLowerCase().startsWith("https://"));
if (shouldEnforceHttps) {
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.header("x-forwarded-proto") !== "https") { if (req.header("x-forwarded-proto") !== "https") {
res.redirect(`https://${req.header("host")}${req.url}`); res.redirect(`https://${req.header("host")}${req.url}`);
@@ -556,6 +560,7 @@ const drawingUpdateSchema = drawingBaseSchema
elements: elementSchema.array().optional(), elements: elementSchema.array().optional(),
appState: appStateSchema.optional(), appState: appStateSchema.optional(),
files: filesFieldSchema, files: filesFieldSchema,
version: z.number().int().positive().optional(),
}) })
.refine( .refine(
(data) => { (data) => {
+57 -5
View File
@@ -310,7 +310,12 @@ export const registerDashboardRoutes = (
appState?: Record<string, unknown>; appState?: Record<string, unknown>;
preview?: string | null; preview?: string | null;
files?: Record<string, unknown>; files?: Record<string, unknown>;
version?: number;
}; };
const isSceneUpdate =
payload.elements !== undefined ||
payload.appState !== undefined ||
payload.files !== undefined;
const data: Prisma.DrawingUpdateInput = { version: { increment: 1 } }; const data: Prisma.DrawingUpdateInput = { version: { increment: 1 } };
if (payload.name !== undefined) data.name = payload.name; if (payload.name !== undefined) data.name = payload.name;
@@ -334,7 +339,37 @@ export const registerDashboardRoutes = (
} }
} }
const updatedDrawing = await prisma.drawing.update({ where: { id }, data }); const updateWhere: Prisma.DrawingWhereInput = { id, userId: req.user.id };
if (isSceneUpdate && payload.version !== undefined) {
updateWhere.version = payload.version;
}
const updateResult = await prisma.drawing.updateMany({
where: updateWhere,
data,
});
if (updateResult.count === 0) {
if (isSceneUpdate && payload.version !== undefined) {
const latestDrawing = await prisma.drawing.findFirst({
where: { id, userId: req.user.id },
select: { version: true },
});
return res.status(409).json({
error: "Conflict",
code: "VERSION_CONFLICT",
message: "Drawing has changed since this editor state was loaded.",
currentVersion: latestDrawing?.version ?? null,
});
}
return res.status(404).json({ error: "Drawing not found" });
}
const updatedDrawing = await prisma.drawing.findFirst({
where: { id, userId: req.user.id },
});
if (!updatedDrawing) {
return res.status(404).json({ error: "Drawing not found" });
}
invalidateDrawingsCache(); invalidateDrawingsCache();
return res.json({ return res.json({
@@ -352,7 +387,12 @@ export const registerDashboardRoutes = (
const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } }); const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
if (!drawing) return res.status(404).json({ error: "Drawing not found" }); if (!drawing) return res.status(404).json({ error: "Drawing not found" });
await prisma.drawing.delete({ where: { id } }); const deleteResult = await prisma.drawing.deleteMany({
where: { id, userId: req.user.id },
});
if (deleteResult.count === 0) {
return res.status(404).json({ error: "Drawing not found" });
}
invalidateDrawingsCache(); invalidateDrawingsCache();
if (config.enableAuditLogging) { if (config.enableAuditLogging) {
@@ -375,6 +415,9 @@ export const registerDashboardRoutes = (
const { id } = req.params; const { id } = req.params;
const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } }); const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
if (!original) return res.status(404).json({ error: "Original drawing not found" }); if (!original) return res.status(404).json({ error: "Original drawing not found" });
if (original.collectionId === "trash") {
await ensureTrashCollection(prisma, req.user.id);
}
const newDrawing = await prisma.drawing.create({ const newDrawing = await prisma.drawing.create({
data: { data: {
@@ -443,10 +486,19 @@ export const registerDashboardRoutes = (
} }
const sanitizedName = sanitizeText(parsed.data, 100); const sanitizedName = sanitizeText(parsed.data, 100);
const updatedCollection = await prisma.collection.update({ const updateResult = await prisma.collection.updateMany({
where: { id }, where: { id, userId: req.user.id },
data: { name: sanitizedName }, data: { name: sanitizedName },
}); });
if (updateResult.count === 0) {
return res.status(404).json({ error: "Collection not found" });
}
const updatedCollection = await prisma.collection.findFirst({
where: { id, userId: req.user.id },
});
if (!updatedCollection) {
return res.status(404).json({ error: "Collection not found" });
}
return res.json(updatedCollection); return res.json(updatedCollection);
})); }));
@@ -464,7 +516,7 @@ export const registerDashboardRoutes = (
where: { collectionId: id, userId: req.user.id }, where: { collectionId: id, userId: req.user.id },
data: { collectionId: null }, data: { collectionId: null },
}), }),
prisma.collection.delete({ where: { id } }), prisma.collection.deleteMany({ where: { id, userId: req.user.id } }),
]); ]);
invalidateDrawingsCache(); invalidateDrawingsCache();
+106
View File
@@ -131,6 +131,21 @@ const makeUniqueName = (base: string, used: Set<string>): string => {
return candidate; return candidate;
}; };
const findFirstDuplicate = (values: string[]): string | null => {
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value)) return value;
seen.add(value);
}
return null;
};
const normalizeNonEmptyId = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const findSqliteTable = (tables: string[], candidates: string[]): string | null => { const findSqliteTable = (tables: string[], candidates: string[]): string | null => {
const byLower = new Map(tables.map((t) => [t.toLowerCase(), t])); const byLower = new Map(tables.map((t) => [t.toLowerCase(), t]));
for (const candidate of candidates) { for (const candidate of candidates) {
@@ -439,6 +454,28 @@ Drawings: ${drawings.length}
message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`, message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`,
}); });
} }
const duplicateCollectionId = findFirstDuplicate(manifest.collections.map((c) => c.id));
if (duplicateCollectionId) {
return res.status(400).json({
error: "Invalid backup manifest",
message: `Duplicate collection id in manifest: ${duplicateCollectionId}`,
});
}
const duplicateDrawingId = findFirstDuplicate(manifest.drawings.map((d) => d.id));
if (duplicateDrawingId) {
return res.status(400).json({
error: "Invalid backup manifest",
message: `Duplicate drawing id in manifest: ${duplicateDrawingId}`,
});
}
const duplicateDrawingPath = findFirstDuplicate(manifest.drawings.map((d) => d.filePath));
if (duplicateDrawingPath) {
return res.status(400).json({
error: "Invalid backup manifest",
message: `Duplicate drawing file path in manifest: ${duplicateDrawingPath}`,
});
}
for (const drawing of manifest.drawings) { for (const drawing of manifest.drawings) {
if (!getSafeZipEntry(zip, drawing.filePath)) { if (!getSafeZipEntry(zip, drawing.filePath)) {
return res.status(400).json({ return res.status(400).json({
@@ -532,6 +569,28 @@ Drawings: ${drawings.length}
}); });
} }
const duplicateCollectionId = findFirstDuplicate(manifest.collections.map((c) => c.id));
if (duplicateCollectionId) {
return res.status(400).json({
error: "Invalid backup manifest",
message: `Duplicate collection id in manifest: ${duplicateCollectionId}`,
});
}
const duplicateDrawingId = findFirstDuplicate(manifest.drawings.map((d) => d.id));
if (duplicateDrawingId) {
return res.status(400).json({
error: "Invalid backup manifest",
message: `Duplicate drawing id in manifest: ${duplicateDrawingId}`,
});
}
const duplicateDrawingPath = findFirstDuplicate(manifest.drawings.map((d) => d.filePath));
if (duplicateDrawingPath) {
return res.status(400).json({
error: "Invalid backup manifest",
message: `Duplicate drawing file path in manifest: ${duplicateDrawingPath}`,
});
}
type PreparedImportDrawing = { type PreparedImportDrawing = {
id: string; id: string;
name: string; name: string;
@@ -772,6 +831,31 @@ Drawings: ${drawings.length}
}); });
} }
const duplicateDrawingIdRow = db
.prepare(
`SELECT id FROM "${drawingTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1`
)
.get();
if (duplicateDrawingIdRow?.id) {
return res.status(400).json({
error: "Invalid legacy DB",
message: `Duplicate drawing id in legacy DB: ${String(duplicateDrawingIdRow.id)}`,
});
}
if (collectionTable) {
const duplicateCollectionIdRow = db
.prepare(
`SELECT id FROM "${collectionTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1`
)
.get();
if (duplicateCollectionIdRow?.id) {
return res.status(400).json({
error: "Invalid legacy DB",
message: `Duplicate collection id in legacy DB: ${String(duplicateCollectionIdRow.id)}`,
});
}
}
let latestMigration: string | null = null; let latestMigration: string | null = null;
const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]); const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]);
if (migrationsTable) { if (migrationsTable) {
@@ -862,6 +946,28 @@ Drawings: ${drawings.length}
}); });
} }
const importedCollectionIds = importedCollections
.map((c) => normalizeNonEmptyId(c?.id))
.filter((id): id is string => id !== null);
const duplicateCollectionId = findFirstDuplicate(importedCollectionIds);
if (duplicateCollectionId) {
return res.status(400).json({
error: "Invalid legacy DB",
message: `Duplicate collection id in legacy DB: ${duplicateCollectionId}`,
});
}
const importedDrawingIds = importedDrawings
.map((d) => normalizeNonEmptyId(d?.id))
.filter((id): id is string => id !== null);
const duplicateDrawingId = findFirstDuplicate(importedDrawingIds);
if (duplicateDrawingId) {
return res.status(400).json({
error: "Invalid legacy DB",
message: `Duplicate drawing id in legacy DB: ${duplicateDrawingId}`,
});
}
type PreparedLegacyDrawing = { type PreparedLegacyDrawing = {
importedId: string | null; importedId: string | null;
name: string; name: string;
+12 -25
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.3.2", "version": "0.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.3.2", "version": "0.4.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -162,7 +162,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -517,7 +516,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -561,7 +559,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2612,7 +2609,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -2777,7 +2775,6 @@
"integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -2789,7 +2786,6 @@
"integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/react": "*" "@types/react": "*"
} }
@@ -2860,7 +2856,6 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0", "@typescript-eslint/types": "8.47.0",
@@ -3224,7 +3219,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3275,6 +3269,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -3504,7 +3499,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -3840,7 +3834,6 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10" "node": ">=0.10"
} }
@@ -4214,7 +4207,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -4454,7 +4446,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.1.6", "version": "3.1.6",
@@ -4673,7 +4666,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5507,7 +5499,6 @@
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz",
"integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12.20.0" "node": ">=12.20.0"
}, },
@@ -5855,6 +5846,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -6884,7 +6876,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
@@ -7050,6 +7041,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -7065,6 +7057,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -7120,7 +7113,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -7133,7 +7125,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -7147,7 +7138,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
@@ -7862,7 +7854,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -8001,7 +7992,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -8191,7 +8181,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -8285,7 +8274,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -8612,7 +8600,6 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.4.1", "version": "0.4.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port 6767", "dev": "vite --port 6767",
+2 -2
View File
@@ -340,8 +340,8 @@ export const createDrawing = async (
}; };
export const updateDrawing = async (id: string, data: Partial<Drawing>) => { export const updateDrawing = async (id: string, data: Partial<Drawing>) => {
const response = await api.put<{ success: true }>(`/drawings/${id}`, data); const response = await api.put<Drawing>(`/drawings/${id}`, data);
return response.data; return deserializeDrawing(response.data);
}; };
export const deleteDrawing = async (id: string) => { export const deleteDrawing = async (id: string) => {
+60
View File
@@ -0,0 +1,60 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
vi.mock("./Sidebar", () => ({
Sidebar: () => <div data-testid="sidebar">sidebar</div>,
}));
vi.mock("./Logo", () => ({
Logo: () => <div data-testid="logo">logo</div>,
}));
vi.mock("./UploadStatus", () => ({
UploadStatus: () => <div data-testid="upload-status">upload-status</div>,
}));
import { Layout } from "./Layout";
describe("Layout", () => {
it("removes active resize listeners on unmount", () => {
const addSpy = vi.spyOn(document, "addEventListener");
const removeSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = render(
<MemoryRouter>
<Layout
collections={[]}
selectedCollectionId={undefined}
onSelectCollection={() => {}}
onCreateCollection={() => {}}
onEditCollection={() => {}}
onDeleteCollection={() => {}}
>
<div>content</div>
</Layout>
</MemoryRouter>
);
fireEvent.mouseDown(screen.getByTitle("Drag to resize sidebar"));
const mouseMoveAdd = addSpy.mock.calls.find(([event]) => event === "mousemove");
const mouseUpAdd = addSpy.mock.calls.find(([event]) => event === "mouseup");
expect(mouseMoveAdd?.[1]).toBeTypeOf("function");
expect(mouseUpAdd?.[1]).toBeTypeOf("function");
unmount();
expect(
removeSpy.mock.calls.some(
([event, handler]) => event === "mousemove" && handler === mouseMoveAdd?.[1]
)
).toBe(true);
expect(
removeSpy.mock.calls.some(
([event, handler]) => event === "mouseup" && handler === mouseUpAdd?.[1]
)
).toBe(true);
});
});
+30 -4
View File
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { Menu, X } from 'lucide-react'; import { Menu, X } from 'lucide-react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { Logo } from './Logo';
import { UploadStatus } from './UploadStatus'; import { UploadStatus } from './UploadStatus';
import type { Collection } from '../types'; import type { Collection } from '../types';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -35,6 +36,8 @@ export const Layout: React.FC<LayoutProps> = ({
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
const startXRef = useRef(0); const startXRef = useRef(0);
const startWidthRef = useRef(0); const startWidthRef = useRef(0);
const resizeMouseMoveHandlerRef = useRef<((e: MouseEvent) => void) | null>(null);
const resizeMouseUpHandlerRef = useRef<(() => void) | null>(null);
// Handle mouse down on resize handle // Handle mouse down on resize handle
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
@@ -43,6 +46,13 @@ export const Layout: React.FC<LayoutProps> = ({
startXRef.current = e.clientX; startXRef.current = e.clientX;
startWidthRef.current = sidebarWidth; startWidthRef.current = sidebarWidth;
if (resizeMouseMoveHandlerRef.current) {
document.removeEventListener('mousemove', resizeMouseMoveHandlerRef.current);
}
if (resizeMouseUpHandlerRef.current) {
document.removeEventListener('mouseup', resizeMouseUpHandlerRef.current);
}
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const diff = e.clientX - startXRef.current; const diff = e.clientX - startXRef.current;
const newWidth = Math.max(200, Math.min(600, startWidthRef.current + diff)); const newWidth = Math.max(200, Math.min(600, startWidthRef.current + diff));
@@ -53,8 +63,12 @@ export const Layout: React.FC<LayoutProps> = ({
setIsResizing(false); setIsResizing(false);
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
resizeMouseMoveHandlerRef.current = null;
resizeMouseUpHandlerRef.current = null;
}; };
resizeMouseMoveHandlerRef.current = handleMouseMove;
resizeMouseUpHandlerRef.current = handleMouseUp;
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
}; };
@@ -62,8 +76,14 @@ export const Layout: React.FC<LayoutProps> = ({
// Cleanup event listeners on unmount // Cleanup event listeners on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
document.removeEventListener('mousemove', () => {}); if (resizeMouseMoveHandlerRef.current) {
document.removeEventListener('mouseup', () => {}); document.removeEventListener('mousemove', resizeMouseMoveHandlerRef.current);
resizeMouseMoveHandlerRef.current = null;
}
if (resizeMouseUpHandlerRef.current) {
document.removeEventListener('mouseup', resizeMouseUpHandlerRef.current);
resizeMouseUpHandlerRef.current = null;
}
}; };
}, []); }, []);
@@ -89,16 +109,22 @@ export const Layout: React.FC<LayoutProps> = ({
{isMobile ? ( {isMobile ? (
<div className="relative h-full min-w-0"> <div className="relative h-full min-w-0">
<main className="h-full min-w-0 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm transition-colors duration-200 overflow-hidden flex flex-col"> <main className="h-full min-w-0 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm transition-colors duration-200 overflow-hidden flex flex-col">
<div className="px-3 pt-3 flex-shrink-0"> <div className="h-16 flex-shrink-0 flex items-center px-4 border-b border-black/5 dark:border-white/5 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-md">
<button <button
type="button" type="button"
onClick={() => setIsSidebarOpen(v => !v)} onClick={() => setIsSidebarOpen(v => !v)}
className="inline-flex items-center justify-center h-11 w-11 rounded-xl border-2 border-black dark:border-neutral-700 bg-white/90 dark:bg-neutral-900/90 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] backdrop-blur-sm text-slate-900 dark:text-neutral-200 hover:-translate-y-0.5 transition-all" className="inline-flex items-center justify-center h-11 w-11 rounded-xl border-2 border-black dark:border-neutral-700 bg-white/90 dark:bg-neutral-900/90 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] text-slate-900 dark:text-neutral-200 hover:-translate-y-0.5 transition-all active:translate-y-0 active:shadow-none"
title={isSidebarOpen ? 'Close menu' : 'Open menu'} title={isSidebarOpen ? 'Close menu' : 'Open menu'}
aria-label={isSidebarOpen ? 'Close menu' : 'Open menu'} aria-label={isSidebarOpen ? 'Close menu' : 'Open menu'}
> >
{isSidebarOpen ? <X size={20} /> : <Menu size={20} />} {isSidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button> </button>
<div className="ml-auto flex items-center gap-2">
<Logo className="w-8 h-8" />
<span className="text-xl text-slate-900 dark:text-white mt-1" style={{ fontFamily: 'Excalifont' }}>ExcaliDash</span>
<span className="text-[10px] font-bold text-red-500 mt-2" style={{ fontFamily: 'sans-serif' }}>BETA</span>
</div>
</div> </div>
<div className="flex-1 min-w-0 overflow-y-auto"> <div className="flex-1 min-w-0 overflow-y-auto">
+48
View File
@@ -0,0 +1,48 @@
import { render, screen, waitFor } from "@testing-library/react";
import axios from "axios";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { AuthProvider, useAuth } from "./AuthContext";
const Probe = () => {
const { loading, authEnabled } = useAuth();
return (
<div>
<span data-testid="loading">{String(loading)}</span>
<span data-testid="auth-enabled">{String(authEnabled)}</span>
</div>
);
};
describe("AuthProvider", () => {
it("defaults to auth-enabled mode if /auth/status fails", async () => {
const storage = new Map<string, string>();
Object.defineProperty(window, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
},
});
vi.spyOn(axios, "get").mockRejectedValueOnce(new Error("network down"));
render(
<MemoryRouter>
<AuthProvider>
<Probe />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByTestId("loading").textContent).toBe("false");
});
expect(screen.getByTestId("auth-enabled").textContent).toBe("true");
});
});
+3 -5
View File
@@ -60,12 +60,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
return; return;
} }
} catch { } catch {
// If status fails (backend down / schema mismatch), avoid locking the UI // If status fails, default to auth-enabled mode to avoid exposing
// behind login. Backend still enforces auth when enabled. // single-user UI paths accidentally. Backend remains the source of truth.
setAuthEnabled(false); setAuthEnabled(true);
setBootstrapRequired(false); setBootstrapRequired(false);
setUser(null);
return;
} }
const storedUser = localStorage.getItem(USER_KEY); const storedUser = localStorage.getItem(USER_KEY);
+10 -2
View File
@@ -11,6 +11,7 @@ import clsx from 'clsx';
import { ConfirmModal } from '../components/ConfirmModal'; import { ConfirmModal } from '../components/ConfirmModal';
import { useUpload } from '../context/UploadContext'; import { useUpload } from '../context/UploadContext';
import { DragOverlayPortal, getSelectionBounds, type Point, type SelectionBounds } from './dashboard/shared'; import { DragOverlayPortal, getSelectionBounds, type Point, type SelectionBounds } from './dashboard/shared';
import { isLatestRequest, mergeUniqueDrawings } from './dashboard/pagination';
const PAGE_SIZE = 24; const PAGE_SIZE = 24;
@@ -73,12 +74,14 @@ export const Dashboard: React.FC = () => {
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const listRequestVersionRef = useRef(0);
const { uploadFiles } = useUpload(); const { uploadFiles } = useUpload();
const hasMore = drawings.length < totalCount; const hasMore = drawings.length < totalCount;
const refreshData = useCallback(async () => { const refreshData = useCallback(async () => {
const requestVersion = ++listRequestVersionRef.current;
setIsLoading(true); setIsLoading(true);
try { try {
const [drawingsRes, collectionsData] = await Promise.all([ const [drawingsRes, collectionsData] = await Promise.all([
@@ -90,6 +93,7 @@ export const Dashboard: React.FC = () => {
}), }),
api.getCollections() api.getCollections()
]); ]);
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
setDrawings(drawingsRes.drawings); setDrawings(drawingsRes.drawings);
setTotalCount(drawingsRes.totalCount); setTotalCount(drawingsRes.totalCount);
setCollections(collectionsData); setCollections(collectionsData);
@@ -97,12 +101,15 @@ export const Dashboard: React.FC = () => {
} catch (err) { } catch (err) {
console.error('Failed to fetch data:', err); console.error('Failed to fetch data:', err);
} finally { } finally {
setIsLoading(false); if (isLatestRequest(requestVersion, listRequestVersionRef.current)) {
setIsLoading(false);
}
} }
}, [debouncedSearch, selectedCollectionId, sortConfig.field, sortConfig.direction]); }, [debouncedSearch, selectedCollectionId, sortConfig.field, sortConfig.direction]);
const fetchMore = useCallback(async () => { const fetchMore = useCallback(async () => {
if (isFetchingMore || !hasMore || isLoading) return; if (isFetchingMore || !hasMore || isLoading) return;
const requestVersion = listRequestVersionRef.current;
setIsFetchingMore(true); setIsFetchingMore(true);
try { try {
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, { const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
@@ -111,7 +118,8 @@ export const Dashboard: React.FC = () => {
sortField: sortConfig.field, sortField: sortConfig.field,
sortDirection: sortConfig.direction, sortDirection: sortConfig.direction,
}); });
setDrawings(prev => [...prev, ...drawingsRes.drawings]); if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
setDrawings(prev => mergeUniqueDrawings(prev, drawingsRes.drawings));
setTotalCount(drawingsRes.totalCount); setTotalCount(drawingsRes.totalCount);
} catch (err) { } catch (err) {
console.error('Failed to fetch more data:', err); console.error('Failed to fetch more data:', err);
+78 -40
View File
@@ -19,7 +19,9 @@ import {
getColorFromString, getColorFromString,
getFilesDelta, getFilesDelta,
getInitialsFromName, getInitialsFromName,
hasRenderableElements,
haveSameElements, haveSameElements,
isSuspiciousEmptySnapshot,
} from './editor/shared'; } from './editor/shared';
import type { ElementVersionInfo } from './editor/shared'; import type { ElementVersionInfo } from './editor/shared';
@@ -124,7 +126,9 @@ export const Editor: React.FC = () => {
const latestFilesRef = useRef<any>(null); const latestFilesRef = useRef<any>(null);
const lastSyncedFilesRef = useRef<Record<string, any>>({}); const lastSyncedFilesRef = useRef<Record<string, any>>({});
const latestAppStateRef = useRef<any>(null); const latestAppStateRef = useRef<any>(null);
const debouncedSaveRef = useRef<((elements: readonly any[], appState: any) => void) | null>(null); const debouncedSaveRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => void) | null>(null);
const currentDrawingVersionRef = useRef<number | null>(null);
const lastPersistedElementsRef = useRef<readonly any[]>([]);
const emitFilesDeltaIfNeeded = useCallback( const emitFilesDeltaIfNeeded = useCallback(
(nextFiles: Record<string, any>) => { (nextFiles: Record<string, any>) => {
@@ -361,13 +365,13 @@ export const Editor: React.FC = () => {
const didEmit = emitFilesDeltaIfNeeded(nextFiles); const didEmit = emitFilesDeltaIfNeeded(nextFiles);
// Persist after file data becomes available so new tabs (tab3) load correctly. // Persist after file data becomes available so new tabs (tab3) load correctly.
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) { if (didEmit && id && latestAppStateRef.current && debouncedSaveRef.current) {
debouncedSaveRef.current(latestElementsRef.current, latestAppStateRef.current); debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, latestFilesRef.current || {});
} }
}; };
} }
setIsReady(true); setIsReady(true);
}, [emitFilesDeltaIfNeeded]); }, [emitFilesDeltaIfNeeded, id]);
// Handle #addLibrary URL hash parameter for importing libraries from links // Handle #addLibrary URL hash parameter for importing libraries from links
useEffect(() => { useEffect(() => {
@@ -428,12 +432,12 @@ export const Editor: React.FC = () => {
scrollToContent: true, scrollToContent: true,
}), []); }), []);
const saveDataRef = useRef<((elements: readonly any[], appState: any) => Promise<void>) | null>(null); const saveDataRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => Promise<void>) | null>(null);
const savePreviewRef = useRef<((elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null); const savePreviewRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
const saveLibraryRef = useRef<((items: any[]) => Promise<void>) | null>(null); const saveLibraryRef = useRef<((items: any[]) => Promise<void>) | null>(null);
saveDataRef.current = async (elements: readonly any[], appState: any) => { saveDataRef.current = async (drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => {
if (!id) return; if (!drawingId) return;
try { try {
const persistableAppState = { const persistableAppState = {
@@ -442,31 +446,45 @@ export const Editor: React.FC = () => {
gridSize: appState?.gridSize || null, gridSize: appState?.gridSize || null,
}; };
const snapshot = latestElementsRef.current ?? elements ?? []; const persistableElements = Array.isArray(elements) ? elements : [];
const persistableElements = Array.isArray(snapshot) ? snapshot : []; if (isSuspiciousEmptySnapshot(lastPersistedElementsRef.current, persistableElements)) {
console.warn("[Editor] Skipping suspicious empty snapshot save", { drawingId });
return;
}
const persistableFiles = files ?? latestFilesRef.current ?? {};
console.log("[Editor] Saving drawing", { console.log("[Editor] Saving drawing", {
drawingId: id, drawingId,
elementCount: persistableElements.length, elementCount: persistableElements.length,
hasRenderableElements: persistableElements.some((el: any) => !el?.isDeleted), hasRenderableElements: hasRenderableElements(persistableElements),
appState: persistableAppState, appState: persistableAppState,
}); });
await api.updateDrawing(id, { const updated = await api.updateDrawing(drawingId, {
elements: persistableElements, elements: persistableElements,
appState: persistableAppState, appState: persistableAppState,
files: latestFilesRef.current || {}, files: persistableFiles,
version: currentDrawingVersionRef.current ?? undefined,
}); });
if (typeof updated.version === "number") {
currentDrawingVersionRef.current = updated.version;
}
lastPersistedElementsRef.current = persistableElements;
console.log("[Editor] Save complete", { drawingId: id }); console.log("[Editor] Save complete", { drawingId });
} catch (err) { } catch (err) {
if (api.isAxiosError(err) && err.response?.status === 409) {
console.warn("[Editor] Version conflict while saving drawing", { drawingId });
toast.error("Drawing changed in another tab. Refresh to load latest.");
return;
}
console.error('Failed to save drawing', err); console.error('Failed to save drawing', err);
toast.error("Failed to save changes"); toast.error("Failed to save changes");
} }
}; };
savePreviewRef.current = async (elements: readonly any[], appState: any, files: any) => { savePreviewRef.current = async (drawingId: string, elements: readonly any[], appState: any, files: any) => {
if (!id) return; if (!drawingId) return;
try { try {
const currentSnapshot = latestElementsRef.current ?? elements; const currentSnapshot = latestElementsRef.current ?? elements;
@@ -484,13 +502,13 @@ export const Editor: React.FC = () => {
const preview = svg.outerHTML; const preview = svg.outerHTML;
console.log("[Editor] Saving preview", { console.log("[Editor] Saving preview", {
drawingId: id, drawingId,
elementCount: currentSnapshot.length, elementCount: currentSnapshot.length,
}); });
await api.updateDrawing(id, { preview }); await api.updateDrawing(drawingId, { preview });
console.log("[Editor] Preview save complete", { drawingId: id }); console.log("[Editor] Preview save complete", { drawingId });
} catch (err) { } catch (err) {
console.error('Failed to save preview', err); console.error('Failed to save preview', err);
} }
@@ -509,9 +527,9 @@ export const Editor: React.FC = () => {
const debouncedSave = useCallback( const debouncedSave = useCallback(
debounce((elements, appState) => { debounce((drawingId, elements, appState, files) => {
if (saveDataRef.current) { if (saveDataRef.current) {
saveDataRef.current(elements, appState); saveDataRef.current(drawingId, elements, appState, files);
} }
}, 1000), }, 1000),
[] // Empty dependency array = Stable across renders [] // Empty dependency array = Stable across renders
@@ -519,9 +537,9 @@ export const Editor: React.FC = () => {
// Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves. // Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves.
debouncedSaveRef.current = debouncedSave; debouncedSaveRef.current = debouncedSave;
const debouncedSavePreview = useCallback( const debouncedSavePreview = useCallback(
debounce((elements, appState, files) => { debounce((drawingId, elements, appState, files) => {
if (savePreviewRef.current) { if (savePreviewRef.current) {
savePreviewRef.current(elements, appState, files); savePreviewRef.current(drawingId, elements, appState, files);
} }
}, 10000), }, 10000),
[] []
@@ -536,6 +554,13 @@ export const Editor: React.FC = () => {
[] []
); );
useEffect(() => {
return () => {
debouncedSave.cancel();
debouncedSavePreview.cancel();
};
}, [debouncedSave, debouncedSavePreview]);
const broadcastChanges = useCallback( const broadcastChanges = useCallback(
throttle((elements: readonly any[], currentFiles?: Record<string, any>) => { throttle((elements: readonly any[], currentFiles?: Record<string, any>) => {
if (!socketRef.current || !id) return; if (!socketRef.current || !id) return;
@@ -580,6 +605,8 @@ export const Editor: React.FC = () => {
latestElementsRef.current = []; latestElementsRef.current = [];
latestFilesRef.current = {}; latestFilesRef.current = {};
lastSyncedFilesRef.current = {}; lastSyncedFilesRef.current = {};
currentDrawingVersionRef.current = null;
lastPersistedElementsRef.current = [];
excalidrawAPI.current = null; excalidrawAPI.current = null;
setIsReady(false); setIsReady(false);
setIsSceneLoading(true); setIsSceneLoading(true);
@@ -607,6 +634,8 @@ export const Editor: React.FC = () => {
latestElementsRef.current = elements; latestElementsRef.current = elements;
latestFilesRef.current = files; latestFilesRef.current = files;
lastSyncedFilesRef.current = files; lastSyncedFilesRef.current = files;
currentDrawingVersionRef.current = typeof data.version === "number" ? data.version : null;
lastPersistedElementsRef.current = elements;
elements.forEach((el: any) => { elements.forEach((el: any) => {
recordElementVersion(el); recordElementVersion(el);
@@ -650,6 +679,8 @@ export const Editor: React.FC = () => {
latestElementsRef.current = []; latestElementsRef.current = [];
latestFilesRef.current = {}; latestFilesRef.current = {};
lastSyncedFilesRef.current = {}; lastSyncedFilesRef.current = {};
currentDrawingVersionRef.current = null;
lastPersistedElementsRef.current = [];
setLoadError(message); setLoadError(message);
setInitialData(null); setInitialData(null);
} finally { } finally {
@@ -670,8 +701,9 @@ export const Editor: React.FC = () => {
const files = excalidrawAPI.current.getFiles() || {}; const files = excalidrawAPI.current.getFiles() || {};
latestElementsRef.current = elements; latestElementsRef.current = elements;
latestFilesRef.current = files; latestFilesRef.current = files;
await saveDataRef.current(elements, appState); if (!id) return;
savePreviewRef.current(elements, appState, files); await saveDataRef.current(id, elements, appState, files);
savePreviewRef.current(id, elements, appState, files);
toast.success("Saved changes to server"); toast.success("Saved changes to server");
} }
} }
@@ -721,8 +753,8 @@ export const Editor: React.FC = () => {
latestElementsRef.current = allElements; latestElementsRef.current = allElements;
const hasRenderableElements = allElements.some((el: any) => !el?.isDeleted); const hasRenderable = hasRenderableElements(allElements);
if (isBootstrappingScene.current && !hasRenderableElements) { if (isBootstrappingScene.current && !hasRenderable) {
console.log("[Editor] Bootstrapping guard active", { console.log("[Editor] Bootstrapping guard active", {
drawingId: id, drawingId: id,
elementCount: allElements.length, elementCount: allElements.length,
@@ -733,23 +765,28 @@ export const Editor: React.FC = () => {
// Trigger Sync (Throttled) // Trigger Sync (Throttled)
broadcastChanges(allElements, currentFiles); broadcastChanges(allElements, currentFiles);
const filesSnapshot = currentFiles;
latestFilesRef.current = filesSnapshot;
// Trigger Fast Save // Trigger Fast Save
console.log("[Editor] Queueing save", { console.log("[Editor] Queueing save", {
drawingId: id, drawingId: id,
elementCount: allElements.length, elementCount: allElements.length,
hasRenderableElements, hasRenderableElements: hasRenderable,
}); });
debouncedSave(allElements, appState); if (id) {
debouncedSave(id, allElements, appState, filesSnapshot);
}
// Trigger Slow Preview Gen // Trigger Slow Preview Gen
const filesSnapshot = currentFiles;
latestFilesRef.current = filesSnapshot;
console.log("[Editor] Queueing preview save", { console.log("[Editor] Queueing preview save", {
drawingId: id, drawingId: id,
fileCount: Object.keys(filesSnapshot).length, fileCount: Object.keys(filesSnapshot).length,
}); });
debouncedSavePreview(allElements, appState, filesSnapshot); if (id) {
}, [debouncedSave, debouncedSavePreview, broadcastChanges]); debouncedSavePreview(id, allElements, appState, filesSnapshot);
}
}, [debouncedSave, debouncedSavePreview, broadcastChanges, id]);
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously) // Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
// are still broadcast to collaborators AND persisted to the server. // are still broadcast to collaborators AND persisted to the server.
@@ -767,7 +804,7 @@ export const Editor: React.FC = () => {
// Persist after file data becomes available (covers the "tab 3" case). // Persist after file data becomes available (covers the "tab 3" case).
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) { if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
debouncedSaveRef.current(latestElementsRef.current, latestAppStateRef.current); debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles);
} }
}, 1000); }, 1000);
@@ -803,6 +840,7 @@ export const Editor: React.FC = () => {
// Save drawing and generate preview before navigating // Save drawing and generate preview before navigating
try { try {
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
if (!id) return;
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
const appState = excalidrawAPI.current.getAppState(); const appState = excalidrawAPI.current.getAppState();
const files = excalidrawAPI.current.getFiles() || {}; const files = excalidrawAPI.current.getFiles() || {};
@@ -810,8 +848,8 @@ export const Editor: React.FC = () => {
latestFilesRef.current = files; latestFilesRef.current = files;
await Promise.all([ await Promise.all([
saveDataRef.current(elements, appState), saveDataRef.current(id, elements, appState, files),
savePreviewRef.current(elements, appState, files) savePreviewRef.current(id, elements, appState, files)
]); ]);
console.log("[Editor] Saved on back navigation", { drawingId: id }); console.log("[Editor] Saved on back navigation", { drawingId: id });
} }
@@ -827,7 +865,7 @@ export const Editor: React.FC = () => {
<div className="h-screen flex flex-col bg-white dark:bg-neutral-950 overflow-hidden"> <div className="h-screen flex flex-col bg-white dark:bg-neutral-950 overflow-hidden">
<header <header
className={clsx( className={clsx(
"h-14 bg-white dark:bg-neutral-900 border-b border-gray-200 dark:border-neutral-800 flex items-center px-4 justify-between z-10 fixed top-0 left-0 right-0 transition-transform duration-300", "h-16 bg-white dark:bg-neutral-900 border-b border-gray-200 dark:border-neutral-800 flex items-center px-4 justify-between z-10 fixed top-0 left-0 right-0 transition-transform duration-300",
isHeaderVisible ? "translate-y-0" : "-translate-y-full" isHeaderVisible ? "translate-y-0" : "-translate-y-full"
)} )}
> >
@@ -945,8 +983,8 @@ export const Editor: React.FC = () => {
<div <div
className="flex-1 w-full relative transition-all duration-300" className="flex-1 w-full relative transition-all duration-300"
style={{ style={{
height: isHeaderVisible ? 'calc(100vh - 3.5rem)' : '100vh', height: isHeaderVisible ? 'calc(100vh - 4rem)' : '100vh',
marginTop: isHeaderVisible ? '3.5rem' : '0' marginTop: isHeaderVisible ? '4rem' : '0'
}} }}
> >
{loadError ? ( {loadError ? (
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { isLatestRequest, mergeUniqueDrawings } from "./pagination";
import type { DrawingSummary } from "../../types";
const drawing = (id: string, name = id): DrawingSummary => ({
id,
name,
collectionId: null,
preview: null,
version: 1,
createdAt: Date.now(),
updatedAt: Date.now(),
});
describe("dashboard pagination helpers", () => {
it("accepts only latest request version", () => {
expect(isLatestRequest(3, 3)).toBe(true);
expect(isLatestRequest(2, 3)).toBe(false);
expect(isLatestRequest(4, 3)).toBe(false);
});
it("merges pages without duplicating IDs", () => {
const existing = [drawing("a"), drawing("b")];
const incoming = [drawing("b", "b-new"), drawing("c")];
const merged = mergeUniqueDrawings(existing, incoming);
expect(merged.map((d) => d.id)).toEqual(["a", "b", "c"]);
expect(merged[1].name).toBe("b");
});
});
@@ -0,0 +1,13 @@
import type { DrawingSummary } from "../../types";
export const isLatestRequest = (requestVersion: number, currentVersion: number): boolean =>
requestVersion === currentVersion;
export const mergeUniqueDrawings = (
existing: DrawingSummary[],
incoming: DrawingSummary[]
): DrawingSummary[] => {
const seen = new Set(existing.map((d) => d.id));
const nextPage = incoming.filter((d) => !seen.has(d.id));
return [...existing, ...nextPage];
};
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
hasRenderableElements,
isSuspiciousEmptySnapshot,
} from "./shared";
describe("editor/shared scene guards", () => {
it("detects renderable elements", () => {
expect(hasRenderableElements([{ id: "a", isDeleted: false }])).toBe(true);
expect(
hasRenderableElements([
{ id: "a", isDeleted: true },
{ id: "b", isDeleted: true },
])
).toBe(false);
});
it("flags empty snapshot after a previously non-empty persisted scene", () => {
const previous = [{ id: "a", isDeleted: false }];
expect(isSuspiciousEmptySnapshot(previous, [])).toBe(true);
});
it("does not flag empty snapshot for already-empty drawings", () => {
expect(isSuspiciousEmptySnapshot([], [])).toBe(false);
});
it("does not flag non-empty snapshots", () => {
const previous = [{ id: "a", isDeleted: false }];
const next = [{ id: "a", isDeleted: true }];
expect(isSuspiciousEmptySnapshot(previous, next)).toBe(false);
});
});
+15
View File
@@ -17,6 +17,21 @@ export const haveSameElements = (a: readonly any[] = [], b: readonly any[] = [])
return true; return true;
}; };
export const hasRenderableElements = (elements: readonly any[] = []): boolean =>
elements.some((element: any) => !element?.isDeleted);
/**
* Guard against transient empty snapshots (e.g. hydration/reload races) from
* overwriting a previously persisted non-empty drawing.
*/
export const isSuspiciousEmptySnapshot = (
previousPersisted: readonly any[] = [],
nextSnapshot: readonly any[] = []
): boolean => {
if (!Array.isArray(nextSnapshot) || nextSnapshot.length > 0) return false;
return hasRenderableElements(previousPersisted);
};
const buildFileSignature = (file: any): string => { const buildFileSignature = (file: any): string => {
const mimeType = typeof file?.mimeType === "string" ? file.mimeType : ""; const mimeType = typeof file?.mimeType === "string" ? file.mimeType : "";
const id = typeof file?.id === "string" ? file.id : ""; const id = typeof file?.id === "string" ? file.id : "";