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

This commit is contained in:
2026-02-13 19:02:03 +01:00
parent 12da89b815
commit 75cbe97bc0
18 changed files with 1242 additions and 167 deletions
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "DrawingShareLink" (
"id" TEXT NOT NULL PRIMARY KEY,
"drawingId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"token" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "DrawingShareLink_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "DrawingShareGrant" (
"id" TEXT NOT NULL PRIMARY KEY,
"drawingId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"shareLinkId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "DrawingShareGrant_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DrawingShareGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DrawingShareGrant_shareLinkId_fkey" FOREIGN KEY ("shareLinkId") REFERENCES "DrawingShareLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "DrawingShareLink_token_key" ON "DrawingShareLink"("token");
-- CreateIndex
CREATE INDEX "DrawingShareLink_drawingId_idx" ON "DrawingShareLink"("drawingId");
-- CreateIndex
CREATE UNIQUE INDEX "DrawingShareLink_drawingId_role_key" ON "DrawingShareLink"("drawingId", "role");
-- CreateIndex
CREATE INDEX "DrawingShareGrant_drawingId_userId_idx" ON "DrawingShareGrant"("drawingId", "userId");
-- CreateIndex
CREATE INDEX "DrawingShareGrant_userId_createdAt_idx" ON "DrawingShareGrant"("userId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "DrawingShareGrant_drawingId_userId_shareLinkId_key" ON "DrawingShareGrant"("drawingId", "userId", "shareLinkId");
+35
View File
@@ -27,6 +27,7 @@ model User {
passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
drawingShareGrants DrawingShareGrant[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -67,6 +68,8 @@ model Drawing {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionId String?
collection Collection? @relation(fields: [collectionId], references: [id])
shareLinks DrawingShareLink[]
shareGrants DrawingShareGrant[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -74,6 +77,38 @@ model Drawing {
@@index([userId, collectionId, updatedAt])
}
model DrawingShareLink {
id String @id @default(uuid())
drawingId String
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
role String
token String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
grants DrawingShareGrant[]
@@unique([drawingId, role])
@@index([drawingId])
}
model DrawingShareGrant {
id String @id @default(uuid())
drawingId String
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
shareLinkId String
shareLink DrawingShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
role String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([drawingId, userId, shareLinkId])
@@index([drawingId, userId])
@@index([userId, createdAt])
}
model Library {
id String @id // User-specific library ID (e.g., "user_<userId>")
items String @default("[]") // Stored as JSON string array of library items
+2 -1
View File
@@ -282,7 +282,7 @@ app.use(
cors({
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
credentials: true,
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token", "x-imported-file"],
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token", "x-imported-file", "x-share-token"],
exposedHeaders: ["x-csrf-token", "x-request-id"],
})
);
@@ -574,6 +574,7 @@ apiApp.get("/health", (req, res) => {
registerDashboardRoutes(apiApp, {
prisma,
authModeService,
requireAuth,
asyncHandler,
parseJsonField,
+302 -25
View File
@@ -7,6 +7,13 @@ import {
toInternalTrashCollectionId,
toPublicTrashCollectionId,
} from "./trash";
import {
ensureShareLinkForRole,
isAtLeastRole,
isShareRole,
rotateShareLinkForRole,
resolveDrawingAccess,
} from "../../server/drawingAccess";
const getRouteIdParam = (value: string | string[] | undefined): string | null => {
if (typeof value === "string" && value.trim().length > 0) return value;
@@ -16,12 +23,29 @@ const getRouteIdParam = (value: string | string[] | undefined): string | null =>
return null;
};
const getShareTokenFromRequest = (req: express.Request): string | undefined => {
const value = req.headers["x-share-token"];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
if (Array.isArray(value) && typeof value[0] === "string" && value[0].trim().length > 0) {
return value[0].trim();
}
return undefined;
};
const payloadHasOnlySceneFields = (payload: Record<string, unknown>): boolean => {
const allowed = new Set(["elements", "appState", "files", "preview", "version"]);
return Object.keys(payload).every((key) => allowed.has(key));
};
export const registerDrawingRoutes = (
app: express.Express,
deps: DashboardRouteDeps
) => {
const {
prisma,
authModeService,
requireAuth,
asyncHandler,
parseJsonField,
@@ -152,6 +176,7 @@ export const registerDrawingRoutes = (
if (shouldIncludeData) {
responsePayload = (drawings as any[]).map((d: any) => ({
...d,
accessRole: "owner",
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
elements: parseJsonField(d.elements, []),
appState: parseJsonField(d.appState, {}),
@@ -160,6 +185,7 @@ export const registerDrawingRoutes = (
} else {
responsePayload = (drawings as any[]).map((d: any) => ({
...d,
accessRole: "owner",
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
}));
}
@@ -177,27 +203,246 @@ export const registerDrawingRoutes = (
return res.send(body);
}));
app.get("/drawings/shared", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const authEnabled = await authModeService.getAuthEnabled();
if (!authEnabled) {
return res.status(404).json({ error: "Not found" });
}
const { search, includeData, limit, offset, sortField, sortDirection } = req.query;
const searchTerm =
typeof search === "string" && search.trim().length > 0 ? search.trim() : undefined;
const shouldIncludeData =
typeof includeData === "string"
? includeData.toLowerCase() === "true" || includeData === "1"
: false;
const parsedSortField: SortField =
sortField === "name" || sortField === "createdAt" || sortField === "updatedAt"
? sortField
: "updatedAt";
const parsedSortDirection: SortDirection =
sortDirection === "asc" || sortDirection === "desc"
? sortDirection
: parsedSortField === "name"
? "asc"
: "desc";
const rawLimit = limit ? Number.parseInt(limit as string, 10) : undefined;
const rawOffset = offset ? Number.parseInt(offset as string, 10) : undefined;
const parsedLimit =
rawLimit !== undefined && Number.isFinite(rawLimit)
? Math.min(Math.max(rawLimit, 1), MAX_PAGE_SIZE)
: undefined;
const parsedOffset =
rawOffset !== undefined && Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : undefined;
const baseRows = await prisma.$queryRaw<Array<{
id: string;
name: string;
collectionId: string | null;
preview: string | null;
version: number;
createdAt: string;
updatedAt: string;
elements: string;
appState: string;
files: string;
ownerId: string;
ownerName: string;
ownerEmail: string;
roleRank: number;
}>>(Prisma.sql`
SELECT
d."id" AS id,
d."name" AS name,
d."collectionId" AS "collectionId",
d."preview" AS preview,
d."version" AS version,
d."createdAt" AS "createdAt",
d."updatedAt" AS "updatedAt",
d."elements" AS elements,
d."appState" AS "appState",
d."files" AS files,
u."id" AS "ownerId",
u."name" AS "ownerName",
u."email" AS "ownerEmail",
MAX(CASE g."role" WHEN 'editor' THEN 2 WHEN 'viewer' THEN 1 ELSE 0 END) AS "roleRank"
FROM "DrawingShareGrant" g
JOIN "Drawing" d ON d."id" = g."drawingId"
JOIN "User" u ON u."id" = d."userId"
WHERE g."userId" = ${req.user.id}
AND d."userId" <> ${req.user.id}
AND g."role" IN ('viewer', 'editor')
${searchTerm ? Prisma.sql`AND d."name" LIKE ${`%${searchTerm}%`}` : Prisma.empty}
GROUP BY d."id", u."id"
`);
const sortedRows = [...baseRows].sort((a, b) => {
const direction = parsedSortDirection === "asc" ? 1 : -1;
if (parsedSortField === "name") {
return a.name.localeCompare(b.name) * direction;
}
if (parsedSortField === "createdAt") {
return (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) * direction;
}
return (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * direction;
});
const totalCount = sortedRows.length;
const start = parsedOffset ?? 0;
const end = parsedLimit !== undefined ? start + parsedLimit : undefined;
const pageRows = sortedRows.slice(start, end);
const payload = pageRows.map((row) => {
const accessRole = Number(row.roleRank) >= 2 ? "editor" : "viewer";
const base = {
id: row.id,
name: row.name,
preview: row.preview,
version: row.version,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
collectionId: null,
accessRole,
owner: {
id: row.ownerId,
name: row.ownerName,
email: row.ownerEmail,
},
} as Record<string, unknown>;
if (shouldIncludeData) {
base.elements = parseJsonField(row.elements, []);
base.appState = parseJsonField(row.appState, {});
base.files = parseJsonField(row.files, {});
}
return base;
});
return res.json({
drawings: payload,
totalCount,
limit: parsedLimit,
offset: parsedOffset,
});
}));
app.get("/drawings/:id/share-links", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const authEnabled = await authModeService.getAuthEnabled();
if (!authEnabled) {
return res.status(404).json({ error: "Not found" });
}
const id = getRouteIdParam(req.params.id);
if (!id) {
return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
}
const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id }, select: { id: true } });
if (!drawing) {
return res.status(404).json({ error: "Drawing not found" });
}
const [viewer, editor] = await Promise.all([
ensureShareLinkForRole(prisma, id, "viewer"),
ensureShareLinkForRole(prisma, id, "editor"),
]);
return res.json({
drawingId: id,
viewerToken: viewer.token,
editorToken: editor.token,
});
}));
app.post("/drawings/:id/share-links/:role/rotate", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const authEnabled = await authModeService.getAuthEnabled();
if (!authEnabled) {
return res.status(404).json({ error: "Not found" });
}
const id = getRouteIdParam(req.params.id);
if (!id) {
return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
}
const roleParam = getRouteIdParam(req.params.role);
if (!isShareRole(roleParam)) {
return res.status(400).json({ error: "Validation error", message: "Invalid share role" });
}
const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id }, select: { id: true } });
if (!drawing) {
return res.status(404).json({ error: "Drawing not found" });
}
const rotated = await rotateShareLinkForRole(prisma, id, roleParam);
if (config.enableAuditLogging) {
await logAuditEvent({
userId: req.user.id,
action: "drawing_share_link_rotated",
resource: `drawing:${id}`,
ipAddress: req.ip || req.connection.remoteAddress || undefined,
userAgent: req.headers["user-agent"] || undefined,
details: { drawingId: id, role: roleParam },
});
}
return res.json({
role: roleParam,
drawingId: id,
token: rotated.token,
});
}));
app.get("/drawings/:id", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
const drawing = await prisma.drawing.findFirst({
where: {
id,
const access = await resolveDrawingAccess({
prisma,
drawingId: id,
userId: req.user.id,
},
shareToken: getShareTokenFromRequest(req),
});
if (!drawing) {
if (!access) {
return res.status(404).json({ error: "Drawing not found", message: "Drawing does not exist" });
}
if (access.tokenRedeemedRole && config.enableAuditLogging) {
await logAuditEvent({
userId: req.user.id,
action: "drawing_share_token_redeemed",
resource: `drawing:${id}`,
ipAddress: req.ip || req.connection.remoteAddress || undefined,
userAgent: req.headers["user-agent"] || undefined,
details: { drawingId: id, role: access.tokenRedeemedRole },
});
}
return res.json({
...drawing,
collectionId: toPublicTrashCollectionId(drawing.collectionId, req.user.id),
elements: parseJsonField(drawing.elements, []),
appState: parseJsonField(drawing.appState, {}),
files: parseJsonField(drawing.files, {}),
...access.drawing,
accessRole: access.role,
collectionId:
access.role === "owner"
? toPublicTrashCollectionId(access.drawing.collectionId, req.user.id)
: null,
elements: parseJsonField(access.drawing.elements, []),
appState: parseJsonField(access.drawing.appState, {}),
files: parseJsonField(access.drawing.files, {}),
});
}));
@@ -254,6 +499,7 @@ export const registerDrawingRoutes = (
return res.json({
...newDrawing,
accessRole: "owner",
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
elements: parseJsonField(newDrawing.elements, []),
appState: parseJsonField(newDrawing.appState, {}),
@@ -266,10 +512,13 @@ export const registerDrawingRoutes = (
const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
const existingDrawing = await prisma.drawing.findFirst({
where: { id, userId: req.user.id },
const access = await resolveDrawingAccess({
prisma,
drawingId: id,
userId: req.user.id,
});
if (!existingDrawing) return res.status(404).json({ error: "Drawing not found" });
if (!access) return res.status(404).json({ error: "Drawing not found" });
const parsed = drawingUpdateSchema.safeParse(req.body);
if (!parsed.success) {
@@ -288,6 +537,17 @@ export const registerDrawingRoutes = (
files?: Record<string, unknown>;
version?: number;
};
const payloadRecord = payload as unknown as Record<string, unknown>;
if (!isAtLeastRole(access.role, "editor")) {
return res.status(403).json({ error: "Forbidden", message: "You do not have edit access" });
}
if (access.role === "editor" && !payloadHasOnlySceneFields(payloadRecord)) {
return res.status(403).json({ error: "Forbidden", message: "Editors can only update scene content" });
}
const trashCollectionId = getUserTrashCollectionId(req.user.id);
const isSceneUpdate =
payload.elements !== undefined ||
@@ -304,6 +564,9 @@ export const registerDrawingRoutes = (
if (payload.preview !== undefined) data.preview = payload.preview;
if (payload.collectionId !== undefined) {
if (access.role !== "owner") {
return res.status(403).json({ error: "Forbidden", message: "Only the owner can move drawings" });
}
if (payload.collectionId === "trash") {
await ensureTrashCollection(prisma, req.user.id);
(data as Prisma.DrawingUncheckedUpdateInput).collectionId = trashCollectionId;
@@ -318,7 +581,10 @@ export const registerDrawingRoutes = (
}
}
const updateWhere: Prisma.DrawingWhereInput = { id, userId: req.user.id };
const updateWhere: Prisma.DrawingWhereInput = { id };
if (access.role === "owner") {
updateWhere.userId = req.user.id;
}
if (isSceneUpdate && payload.version !== undefined) {
updateWhere.version = payload.version;
}
@@ -330,7 +596,7 @@ export const registerDrawingRoutes = (
if (updateResult.count === 0) {
if (isSceneUpdate && payload.version !== undefined) {
const latestDrawing = await prisma.drawing.findFirst({
where: { id, userId: req.user.id },
where: { id },
select: { version: true },
});
return res.status(409).json({
@@ -344,7 +610,7 @@ export const registerDrawingRoutes = (
}
const updatedDrawing = await prisma.drawing.findFirst({
where: { id, userId: req.user.id },
where: { id },
});
if (!updatedDrawing) {
return res.status(404).json({ error: "Drawing not found" });
@@ -353,7 +619,11 @@ export const registerDrawingRoutes = (
return res.json({
...updatedDrawing,
collectionId: toPublicTrashCollectionId(updatedDrawing.collectionId, req.user.id),
accessRole: access.role,
collectionId:
access.role === "owner"
? toPublicTrashCollectionId(updatedDrawing.collectionId, req.user.id)
: null,
elements: parseJsonField(updatedDrawing.elements, []),
appState: parseJsonField(updatedDrawing.appState, {}),
files: parseJsonField(updatedDrawing.files, {}),
@@ -395,20 +665,26 @@ export const registerDrawingRoutes = (
const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
if (!original) return res.status(404).json({ error: "Original drawing not found" });
let duplicatedCollectionId = original.collectionId;
if (isTrashCollectionId(original.collectionId, req.user.id)) {
const access = await resolveDrawingAccess({
prisma,
drawingId: id,
userId: req.user.id,
});
if (!access) return res.status(404).json({ error: "Original drawing not found" });
let duplicatedCollectionId = access.role === "owner" ? access.drawing.collectionId : null;
if (access.role === "owner" && isTrashCollectionId(access.drawing.collectionId, req.user.id)) {
await ensureTrashCollection(prisma, req.user.id);
duplicatedCollectionId = getUserTrashCollectionId(req.user.id);
}
const newDrawing = await prisma.drawing.create({
data: {
name: `${original.name} (Copy)`,
elements: original.elements,
appState: original.appState,
files: original.files,
name: `${access.drawing.name} (Copy)`,
elements: access.drawing.elements,
appState: access.drawing.appState,
files: access.drawing.files,
userId: req.user.id,
collectionId: duplicatedCollectionId,
version: 1,
@@ -418,6 +694,7 @@ export const registerDrawingRoutes = (
return res.json({
...newDrawing,
accessRole: "owner",
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
elements: parseJsonField(newDrawing.elements, []),
appState: parseJsonField(newDrawing.appState, {}),
+2
View File
@@ -1,6 +1,7 @@
import express from "express";
import { z } from "zod";
import { Prisma, PrismaClient } from "../../generated/client";
import { AuthModeService } from "../../auth/authMode";
export type SortField = "name" | "createdAt" | "updatedAt";
export type SortDirection = "asc" | "desc";
@@ -30,6 +31,7 @@ type LogAuditEvent = (params: {
export type DashboardRouteDeps = {
prisma: PrismaClient;
authModeService: AuthModeService;
requireAuth: express.RequestHandler;
asyncHandler: <T = void>(
fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<T>
+244
View File
@@ -0,0 +1,244 @@
import { randomBytes, randomUUID } from "crypto";
import { Prisma, PrismaClient } from "../generated/client";
export const SHARE_ROLES = ["viewer", "editor"] as const;
export type ShareRole = (typeof SHARE_ROLES)[number];
export type DrawingAccessRole = "owner" | ShareRole;
const SHARE_ROLE_SET = new Set<string>(SHARE_ROLES);
export const isShareRole = (value: unknown): value is ShareRole =>
typeof value === "string" && SHARE_ROLE_SET.has(value);
const roleRank = (role: DrawingAccessRole): number => {
if (role === "owner") return 3;
if (role === "editor") return 2;
return 1;
};
export const isAtLeastRole = (
grantedRole: DrawingAccessRole,
requiredRole: DrawingAccessRole
): boolean => roleRank(grantedRole) >= roleRank(requiredRole);
export const generateShareToken = (): string => randomBytes(24).toString("hex");
export type DrawingAccessResult = {
drawing: {
id: string;
userId: string;
name: string;
elements: string;
appState: string;
files: string;
preview: string | null;
version: number;
collectionId: string | null;
createdAt: Date;
updatedAt: Date;
};
role: DrawingAccessRole;
tokenRedeemedRole?: ShareRole;
};
type ResolveDrawingAccessParams = {
prisma: PrismaClient;
drawingId: string;
userId: string;
shareToken?: string;
};
type RawShareLink = {
id: string;
drawingId: string;
role: string;
};
type RawShareGrant = {
role: string;
};
const upsertShareGrant = async (
prisma: PrismaClient,
params: {
drawingId: string;
userId: string;
shareLinkId: string;
role: ShareRole;
}
): Promise<void> => {
const now = new Date().toISOString();
await prisma.$executeRaw`
INSERT OR IGNORE INTO "DrawingShareGrant"
("id", "drawingId", "userId", "shareLinkId", "role", "createdAt", "updatedAt")
VALUES
(${randomUUID()}, ${params.drawingId}, ${params.userId}, ${params.shareLinkId}, ${params.role}, ${now}, ${now})
`;
await prisma.$executeRaw`
UPDATE "DrawingShareGrant"
SET "role" = ${params.role}, "updatedAt" = ${now}
WHERE "drawingId" = ${params.drawingId}
AND "userId" = ${params.userId}
AND "shareLinkId" = ${params.shareLinkId}
`;
};
export const resolveDrawingAccess = async ({
prisma,
drawingId,
userId,
shareToken,
}: ResolveDrawingAccessParams): Promise<DrawingAccessResult | null> => {
const drawing = await prisma.drawing.findUnique({
where: { id: drawingId },
});
if (!drawing) return null;
if (drawing.userId === userId) {
return { drawing, role: "owner" };
}
let tokenRedeemedRole: ShareRole | undefined;
const token = typeof shareToken === "string" && shareToken.trim().length > 0
? shareToken.trim()
: undefined;
if (token) {
const links = await prisma.$queryRaw<RawShareLink[]>`
SELECT "id", "drawingId", "role"
FROM "DrawingShareLink"
WHERE "token" = ${token}
LIMIT 1
`;
const link = links[0];
if (link && link.drawingId === drawingId && isShareRole(link.role)) {
await upsertShareGrant(prisma, {
drawingId,
userId,
shareLinkId: link.id,
role: link.role,
});
tokenRedeemedRole = link.role;
}
}
const grants = await prisma.$queryRaw<RawShareGrant[]>`
SELECT "role"
FROM "DrawingShareGrant"
WHERE "drawingId" = ${drawingId}
AND "userId" = ${userId}
`;
const hasEditor = grants.some((grant) => grant.role === "editor");
if (hasEditor) return { drawing, role: "editor", tokenRedeemedRole };
const hasViewer = grants.some((grant) => grant.role === "viewer");
if (hasViewer) return { drawing, role: "viewer", tokenRedeemedRole };
return null;
};
export const ensureShareLinkForRole = async (
prisma: PrismaClient,
drawingId: string,
role: ShareRole
): Promise<{ id: string; token: string }> => {
const rows = await prisma.$queryRaw<Array<{ id: string; token: string }>>`
SELECT "id", "token"
FROM "DrawingShareLink"
WHERE "drawingId" = ${drawingId}
AND "role" = ${role}
LIMIT 1
`;
const existing = rows[0];
if (existing) return existing;
const token = generateShareToken();
const now = new Date().toISOString();
const id = randomUUID();
await prisma.$executeRaw`
INSERT INTO "DrawingShareLink"
("id", "drawingId", "role", "token", "createdAt", "updatedAt")
VALUES
(${id}, ${drawingId}, ${role}, ${token}, ${now}, ${now})
`;
return { id, token };
};
export const rotateShareLinkForRole = async (
prisma: PrismaClient,
drawingId: string,
role: ShareRole
): Promise<{ token: string }> => {
return prisma.$transaction(async (tx) => {
const existing = await tx.$queryRaw<Array<{ id: string }>>`
SELECT "id"
FROM "DrawingShareLink"
WHERE "drawingId" = ${drawingId}
AND "role" = ${role}
LIMIT 1
`;
const now = new Date().toISOString();
const token = generateShareToken();
if (!existing[0]) {
await tx.$executeRaw`
INSERT INTO "DrawingShareLink"
("id", "drawingId", "role", "token", "createdAt", "updatedAt")
VALUES
(${randomUUID()}, ${drawingId}, ${role}, ${token}, ${now}, ${now})
`;
return { token };
}
const linkId = existing[0].id;
await tx.$executeRaw`
DELETE FROM "DrawingShareGrant"
WHERE "drawingId" = ${drawingId}
AND "shareLinkId" = ${linkId}
`;
await tx.$executeRaw`
UPDATE "DrawingShareLink"
SET "token" = ${token}, "updatedAt" = ${now}
WHERE "id" = ${linkId}
`;
return { token };
});
};
export const getSharedRoleMap = async (
prisma: PrismaClient,
userId: string,
drawingIds: string[]
): Promise<Map<string, ShareRole>> => {
const roleByDrawingId = new Map<string, ShareRole>();
if (drawingIds.length === 0) return roleByDrawingId;
const idsSql = Prisma.join(drawingIds);
const rows = await prisma.$queryRaw<Array<{ drawingId: string; role: string }>>(
Prisma.sql`
SELECT "drawingId", "role"
FROM "DrawingShareGrant"
WHERE "userId" = ${userId}
AND "drawingId" IN (${idsSql})
`
);
for (const row of rows) {
if (!isShareRole(row.role)) continue;
const current = roleByDrawingId.get(row.drawingId);
if (!current || (current === "viewer" && row.role === "editor")) {
roleByDrawingId.set(row.drawingId, row.role);
}
}
return roleByDrawingId;
};
+15 -8
View File
@@ -3,6 +3,7 @@ import { Server } from "socket.io";
import { PrismaClient } from "../generated/client";
import { AuthModeService } from "../auth/authMode";
import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies";
import { isAtLeastRole, resolveDrawingAccess } from "./drawingAccess";
interface User {
id: string;
@@ -110,7 +111,7 @@ export const registerSocketHandlers = ({
io.on("connection", (socket) => {
const authenticatedUserId = socketUserMap.get(socket.id);
const authorizedDrawingIds = new Set<string>();
const authorizedDrawingRoles = new Map<string, "owner" | "editor" | "viewer">();
socket.on(
"join-room",
@@ -123,20 +124,22 @@ export const registerSocketHandlers = ({
}) => {
try {
if (authenticatedUserId) {
const drawing = await prisma.drawing.findFirst({
where: { id: drawingId, userId: authenticatedUserId },
select: { id: true },
const drawing = await resolveDrawingAccess({
prisma,
drawingId,
userId: authenticatedUserId,
});
if (!drawing) {
socket.emit("error", { message: "You do not have access to this drawing" });
return;
}
authorizedDrawingRoles.set(drawingId, drawing.role);
}
const roomId = `drawing_${drawingId}`;
socket.join(roomId);
authorizedDrawingIds.add(drawingId);
let trustedUserId =
typeof user?.id === "string" && user.id.trim().length > 0
@@ -179,7 +182,7 @@ export const registerSocketHandlers = ({
socket.on("cursor-move", (data) => {
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null;
if (!drawingId || !authorizedDrawingIds.has(drawingId)) {
if (!drawingId || !authorizedDrawingRoles.has(drawingId)) {
return;
}
const roomId = `drawing_${drawingId}`;
@@ -188,7 +191,11 @@ export const registerSocketHandlers = ({
socket.on("element-update", (data) => {
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null;
if (!drawingId || !authorizedDrawingIds.has(drawingId)) {
if (!drawingId) {
return;
}
const role = authorizedDrawingRoles.get(drawingId);
if (!role || !isAtLeastRole(role, "editor")) {
return;
}
const roomId = `drawing_${drawingId}`;
@@ -198,7 +205,7 @@ export const registerSocketHandlers = ({
socket.on(
"user-activity",
({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => {
if (!authorizedDrawingIds.has(drawingId)) {
if (!authorizedDrawingRoles.has(drawingId)) {
return;
}
const roomId = `drawing_${drawingId}`;
+8
View File
@@ -52,6 +52,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/shared"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
+62 -3
View File
@@ -2,7 +2,10 @@ import axios from "axios";
import type { Drawing, Collection, DrawingSummary } from "../types";
import { normalizePreviewSvg } from "../utils/previewSvg";
export const API_URL = import.meta.env.VITE_API_URL || "/api";
const DEFAULT_DEV_API_URL = "http://localhost:8000/api";
export const API_URL =
import.meta.env.VITE_API_URL ||
(import.meta.env.DEV ? DEFAULT_DEV_API_URL : "/api");
export const api = axios.create({
baseURL: API_URL,
@@ -416,6 +419,7 @@ export interface PaginatedDrawings<T> {
export type DrawingSortField = "name" | "createdAt" | "updatedAt";
export type SortDirection = "asc" | "desc";
export type ShareLinkRole = "viewer" | "editor";
export function getDrawings(
search?: string,
@@ -475,8 +479,46 @@ export async function getDrawings(
};
}
export const getDrawing = async (id: string) => {
const response = await api.get<Drawing>(`/drawings/${id}`);
export const getSharedDrawings = async (options?: {
search?: string;
includeData?: boolean;
limit?: number;
offset?: number;
sortField?: DrawingSortField;
sortDirection?: SortDirection;
}) => {
const params: Record<string, string | number> = {};
if (options?.search) params.search = options.search;
if (options?.includeData) params.includeData = "true";
if (options?.limit !== undefined) params.limit = options.limit;
if (options?.offset !== undefined) params.offset = options.offset;
if (options?.sortField) params.sortField = options.sortField;
if (options?.sortDirection) params.sortDirection = options.sortDirection;
if (options?.includeData) {
const response = await api.get<PaginatedDrawings<Drawing>>("/drawings/shared", { params });
return {
...response.data,
drawings: response.data.drawings.map(deserializeDrawing),
};
}
const response = await api.get<PaginatedDrawings<DrawingSummary>>("/drawings/shared", { params });
return {
...response.data,
drawings: response.data.drawings.map(deserializeDrawingSummary),
};
};
export const getDrawing = async (
id: string,
options?: { shareToken?: string }
) => {
const headers: Record<string, string> = {};
if (options?.shareToken) {
headers["x-share-token"] = options.shareToken;
}
const response = await api.get<Drawing>(`/drawings/${id}`, { headers });
return deserializeDrawing(response.data);
};
@@ -508,6 +550,23 @@ export const duplicateDrawing = async (id: string) => {
return deserializeDrawing(response.data);
};
export const getDrawingShareLinks = async (
id: string
): Promise<{ drawingId: string; viewerToken: string; editorToken: string }> => {
const response = await api.get<{ drawingId: string; viewerToken: string; editorToken: string }>(`/drawings/${id}/share-links`);
return response.data;
};
export const rotateDrawingShareLink = async (
id: string,
role: ShareLinkRole
): Promise<{ role: ShareLinkRole; drawingId: string; token: string }> => {
const response = await api.post<{ role: ShareLinkRole; drawingId: string; token: string }>(
`/drawings/${id}/share-links/${role}/rotate`
);
return response.data;
};
export const getCollections = async () => {
const response = await api.get<Collection[]>("/collections");
return response.data;
+14 -1
View File
@@ -56,6 +56,7 @@ interface DrawingCardProps {
onDragStart?: (e: React.DragEvent, id: string) => void;
onMouseDown?: (e: React.MouseEvent, id: string) => void;
onPreviewGenerated?: (id: string, preview: string) => void;
canManage?: boolean;
}
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -76,6 +77,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
onDragStart,
onMouseDown,
onPreviewGenerated,
canManage = true,
}) => {
const [isRenaming, setIsRenaming] = useState(false);
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
@@ -207,6 +209,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
}, []);
const handleRenameSubmit = (e: React.FormEvent) => {
if (!canManage) return;
e.preventDefault();
if (newName.trim()) {
onRename(drawing.id, newName);
@@ -313,6 +316,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
className="text-sm sm:text-base font-bold text-slate-800 dark:text-neutral-100 truncate cursor-text select-none group-hover:text-neutral-900 dark:group-hover:text-white transition-colors"
title={drawing.name}
onDoubleClick={(e) => {
if (!canManage) return;
e.stopPropagation();
setIsRenaming(true);
}}
@@ -327,6 +331,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
</p>
<div className="relative" onClick={e => e.stopPropagation()}>
{canManage && (
<button
onClick={() => setShowCollectionDropdown(!showCollectionDropdown)}
data-testid={`collection-picker-${drawing.id}`}
@@ -336,6 +341,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
>
{drawing.collectionId ? (collections.find(c => c.id === drawing.collectionId)?.name || 'Collection') : 'Unorganized'}
</button>
)}
{showCollectionDropdown && (
<>
@@ -387,6 +393,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{canManage && (
<button
onClick={() => {
setIsRenaming(true);
@@ -396,7 +403,9 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
>
<PenTool size={14} /> Rename
</button>
)}
{canManage && (
<div
className="relative group/move"
onMouseEnter={() => setShowMoveSubmenu(true)}
@@ -437,6 +446,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
</div>
)}
</div>
)}
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
@@ -468,8 +478,9 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
</div>
)}
{canManage && (
<>
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
<button
onClick={() => {
onDelete(drawing.id);
@@ -479,6 +490,8 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
>
<Trash2 size={14} /> Delete
</button>
</>
)}
</div>
</div>
</ContextMenuPortal>
+18 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut, Shield } from 'lucide-react';
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut, Shield, Users } from 'lucide-react';
import type { Collection } from '../types';
import clsx from 'clsx';
import { ConfirmModal } from './ConfirmModal';
@@ -207,6 +207,23 @@ export const Sidebar: React.FC<SidebarProps> = ({
onClick={() => onSelectCollection(null)}
onDrop={onDrop}
/>
{authEnabled && (
<div className="pl-3 pr-2">
<button
onClick={() => onSelectCollection('__shared__')}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2",
selectedCollectionId === '__shared__'
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5"
: "text-slate-600 dark:text-neutral-400 border-transparent hover:bg-slate-50 dark:hover:bg-neutral-800 hover:border-black dark:hover:border-neutral-700 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
)}
>
<Users size={18} className={clsx(selectedCollectionId === '__shared__' ? "text-indigo-900 dark:text-neutral-200" : "text-slate-400 dark:text-neutral-500")} />
<span className="min-w-0 flex-1 text-left">Shared with me</span>
</button>
</div>
)}
</div>
<div className="space-y-1">
+52 -21
View File
@@ -21,6 +21,7 @@ export const Dashboard: React.FC = () => {
const selectedCollectionId = React.useMemo(() => {
if (location.pathname === '/') return undefined;
if (location.pathname === '/shared') return '__shared__';
if (location.pathname === '/collections') {
const id = searchParams.get('id');
if (id === 'unorganized') return null;
@@ -32,6 +33,8 @@ export const Dashboard: React.FC = () => {
const setSelectedCollectionId = (id: string | null | undefined) => {
if (id === undefined) {
navigate('/');
} else if (id === '__shared__') {
navigate('/shared');
} else if (id === null) {
navigate('/collections?id=unorganized');
} else {
@@ -39,6 +42,8 @@ export const Dashboard: React.FC = () => {
}
};
const isSharedView = selectedCollectionId === '__shared__';
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -265,8 +270,13 @@ export const Dashboard: React.FC = () => {
const currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0];
const isTrashView = selectedCollectionId === 'trash';
const canManageDrawing = useCallback(
(id: string) => (drawings.find((d) => d.id === id)?.accessRole ?? 'owner') === 'owner',
[drawings]
);
const handleCreateDrawing = async () => {
if (isTrashView) return;
if (isTrashView || isSharedView) return;
try {
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
const { id } = await api.createDrawing('Untitled Drawing', targetCollectionId);
@@ -277,7 +287,7 @@ export const Dashboard: React.FC = () => {
};
const handleImportDrawings = async (files: FileList | null) => {
if (!files || isTrashView) return;
if (!files || isTrashView || isSharedView) return;
const fileArray = Array.from(files);
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
@@ -290,6 +300,7 @@ export const Dashboard: React.FC = () => {
};
const handleRenameDrawing = async (id: string, name: string) => {
if (!canManageDrawing(id)) return;
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
try {
await api.updateDrawing(id, { name });
@@ -300,6 +311,7 @@ export const Dashboard: React.FC = () => {
};
const handleDeleteDrawing = async (id: string) => {
if (!canManageDrawing(id)) return;
if (isTrashView) {
// Permanent Delete -> Confirm first
setDrawingToDelete(id);
@@ -327,6 +339,7 @@ export const Dashboard: React.FC = () => {
};
const executePermanentDelete = async (id: string) => {
if (!canManageDrawing(id)) return;
setDrawings(prev => {
const next = prev.filter(d => d.id !== id);
if (next.length !== prev.length) {
@@ -378,6 +391,8 @@ export const Dashboard: React.FC = () => {
const handleBulkDeleteClick = () => {
if (selectedIds.size === 0) return;
const ownerSelectedCount = Array.from(selectedIds).filter((id) => canManageDrawing(id)).length;
if (ownerSelectedCount === 0) return;
if (isTrashView) {
setShowBulkDeleteConfirm(true);
} else {
@@ -387,10 +402,12 @@ export const Dashboard: React.FC = () => {
const executeBulkMoveToTrash = async () => {
const trashId = 'trash';
const ids = Array.from(selectedIds);
const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
if (ids.length === 0) return;
setDrawings(prev => {
const next = prev.filter(d => !selectedIds.has(d.id));
const idSet = new Set(ids);
const next = prev.filter(d => !idSet.has(d.id));
setTotalCount(t => t - (prev.length - next.length));
return next;
});
@@ -405,9 +422,11 @@ export const Dashboard: React.FC = () => {
};
const executeBulkPermanentDelete = async () => {
const ids = Array.from(selectedIds);
const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
if (ids.length === 0) return;
setDrawings(prev => {
const next = prev.filter(d => !selectedIds.has(d.id));
const idSet = new Set(ids);
const next = prev.filter(d => !idSet.has(d.id));
setTotalCount(t => t - (prev.length - next.length));
return next;
});
@@ -425,11 +444,13 @@ export const Dashboard: React.FC = () => {
const handleBulkMove = async (collectionId: string | null) => {
if (selectedIds.size === 0) return;
const idsToMove = Array.from(selectedIds);
const idsToMove = Array.from(selectedIds).filter((id) => canManageDrawing(id));
if (idsToMove.length === 0) return;
const idsToMoveSet = new Set(idsToMove);
// Optimistic update
setDrawings(prev => {
const updated = prev.map(d => selectedIds.has(d.id) ? { ...d, collectionId } : d);
const updated = prev.map(d => idsToMoveSet.has(d.id) ? { ...d, collectionId } : d);
if (selectedCollectionId === undefined) return updated;
const next = updated.filter(d => {
if (selectedCollectionId === null) return d.collectionId === null;
@@ -472,6 +493,7 @@ export const Dashboard: React.FC = () => {
};
const handleMoveToCollection = async (id: string, collectionId: string | null) => {
if (!canManageDrawing(id)) return;
setDrawings(prev => {
const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d);
const next = updated.filter(d => {
@@ -530,6 +552,7 @@ export const Dashboard: React.FC = () => {
const viewTitle = React.useMemo(() => {
if (selectedCollectionId === undefined) return "All Drawings";
if (selectedCollectionId === null) return "Unorganized";
if (selectedCollectionId === '__shared__') return "Shared with me";
if (selectedCollectionId === 'trash') return "Trash";
const collection = collections.find(c => c.id === selectedCollectionId);
return collection ? collection.name : "Collection";
@@ -537,6 +560,10 @@ export const Dashboard: React.FC = () => {
const hasSelection = selectedIds.size > 0;
const allSelected = sortedDrawings.length > 0 && selectedIds.size === sortedDrawings.length;
const manageableSelectionCount = React.useMemo(
() => Array.from(selectedIds).filter((id) => canManageDrawing(id)).length,
[selectedIds, canManageDrawing]
);
const handleSelectAll = () => {
if (allSelected) {
@@ -567,7 +594,7 @@ export const Dashboard: React.FC = () => {
}
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
if (drawingFiles.length > 0) {
if (drawingFiles.length > 0 && !isSharedView) {
uploadFiles(drawingFiles, targetCollectionId).finally(() => {
refreshData();
});
@@ -588,6 +615,9 @@ export const Dashboard: React.FC = () => {
idsToMove.add(draggedDrawingId);
}
idsToMove = new Set(Array.from(idsToMove).filter((id) => canManageDrawing(id)));
if (idsToMove.size === 0) return;
// Optimistic Update
setDrawings(prev => {
const updated = prev.map(d => idsToMove.has(d.id) ? { ...d, collectionId: targetCollectionId } : d);
@@ -805,10 +835,10 @@ export const Dashboard: React.FC = () => {
<button
onClick={handleBulkDeleteClick}
disabled={!hasSelection}
disabled={manageableSelectionCount === 0}
className={clsx(
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
hasSelection
manageableSelectionCount > 0
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-rose-600 dark:text-rose-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-rose-50 dark:hover:bg-rose-900/30"
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
)}
@@ -833,11 +863,11 @@ export const Dashboard: React.FC = () => {
<div className="relative">
<button
onClick={() => hasSelection && setShowBulkMoveMenu(!showBulkMoveMenu)}
disabled={!hasSelection}
onClick={() => manageableSelectionCount > 0 && setShowBulkMoveMenu(!showBulkMoveMenu)}
disabled={manageableSelectionCount === 0}
className={clsx(
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
hasSelection
manageableSelectionCount > 0
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-emerald-600 dark:text-emerald-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
)}
@@ -849,12 +879,12 @@ export const Dashboard: React.FC = () => {
</div>
</button>
{showBulkMoveMenu && hasSelection && (
{showBulkMoveMenu && manageableSelectionCount > 0 && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowBulkMoveMenu(false)} />
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-neutral-800 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] z-50 py-1 max-h-64 overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-100">
<div className="px-3 py-2 text-[10px] font-bold uppercase text-slate-400 dark:text-neutral-500 tracking-wider border-b border-slate-100 dark:border-neutral-700 mb-1">
Move {selectedIds.size} items to...
Move {manageableSelectionCount} items to...
</div>
<button
onClick={() => handleBulkMove(null)}
@@ -891,10 +921,10 @@ export const Dashboard: React.FC = () => {
<button
onClick={() => document.getElementById('dashboard-import')?.click()}
disabled={isTrashView}
disabled={isTrashView || isSharedView}
className={clsx(
"h-[42px] w-full sm:w-auto flex items-center justify-center gap-2 px-6 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] transition-all font-bold text-sm whitespace-nowrap",
isTrashView
isTrashView || isSharedView
? "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-600 border-slate-300 dark:border-slate-700 shadow-none cursor-not-allowed"
: "bg-emerald-600 dark:bg-neutral-800 text-white hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:active:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
)}
@@ -905,10 +935,10 @@ export const Dashboard: React.FC = () => {
<button
onClick={handleCreateDrawing}
disabled={isTrashView}
disabled={isTrashView || isSharedView}
className={clsx(
"h-[42px] w-full sm:w-auto flex items-center justify-center gap-2 px-6 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] transition-all font-bold text-sm whitespace-nowrap",
isTrashView
isTrashView || isSharedView
? "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-600 border-slate-300 dark:border-slate-700 shadow-none cursor-not-allowed"
: "bg-indigo-600 dark:bg-neutral-800 text-white hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:active:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
)}
@@ -1006,8 +1036,9 @@ export const Dashboard: React.FC = () => {
}
}}
onMouseDown={handleCardMouseDown}
onDragStart={handleCardDragStart}
onDragStart={(drawing.accessRole ?? 'owner') === 'owner' ? handleCardDragStart : undefined}
onPreviewGenerated={handlePreviewGenerated}
canManage={(drawing.accessRole ?? 'owner') === 'owner'}
/>
))
)}
File diff suppressed because it is too large Load Diff
@@ -36,8 +36,17 @@ export const useDashboardData = ({
const requestVersion = ++listRequestVersionRef.current;
setIsLoading(true);
try {
const isSharedView = selectedCollectionId === '__shared__';
const [drawingsRes, collectionsData] = await Promise.all([
api.getDrawings(debouncedSearch, selectedCollectionId, {
isSharedView
? api.getSharedDrawings({
search: debouncedSearch || undefined,
limit: pageSize,
offset: 0,
sortField,
sortDirection,
})
: api.getDrawings(debouncedSearch, selectedCollectionId, {
limit: pageSize,
offset: 0,
sortField,
@@ -71,7 +80,16 @@ export const useDashboardData = ({
const requestVersion = listRequestVersionRef.current;
setIsFetchingMore(true);
try {
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
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,
+27
View File
@@ -1,8 +1,35 @@
export interface ElementVersionInfo {
version: number;
versionNonce: number;
updated?: number;
syncFingerprint?: string;
}
export const buildElementSyncFingerprint = (element: any): string => {
if (!element || typeof element !== "object") return "";
const points = Array.isArray(element.points) ? element.points : null;
const firstPoint = points && points.length > 0 ? points[0] : null;
const lastPoint = points && points.length > 0 ? points[points.length - 1] : null;
return JSON.stringify({
id: element.id ?? null,
type: element.type ?? null,
isDeleted: Boolean(element.isDeleted),
x: element.x ?? null,
y: element.y ?? null,
width: element.width ?? null,
height: element.height ?? null,
angle: element.angle ?? null,
startBinding: element.startBinding?.elementId ?? null,
endBinding: element.endBinding?.elementId ?? null,
pointsLen: points ? points.length : null,
firstPoint,
lastPoint,
text: typeof element.text === "string" ? element.text : null,
});
};
export const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => {
if (!a || !b) return false;
if (a.length !== b.length) return false;
+10
View File
@@ -1,3 +1,11 @@
export type DrawingAccessRole = "owner" | "editor" | "viewer";
export interface DrawingOwner {
id: string;
name: string;
email?: string;
}
export interface DrawingSummary {
id: string;
name: string;
@@ -6,6 +14,8 @@ export interface DrawingSummary {
createdAt: number;
version: number;
preview?: string | null;
accessRole?: DrawingAccessRole;
owner?: DrawingOwner;
}
export interface Drawing extends DrawingSummary {
+18
View File
@@ -17,6 +17,14 @@ export const reconcileElements = (
const value = element?.updated;
return typeof value === "number" ? value : Number(value) || 0;
};
const getComparableContent = (element: any): string => {
if (!element || typeof element !== "object") return "";
const copy = { ...element } as Record<string, unknown>;
delete copy.version;
delete copy.versionNonce;
delete copy.updated;
return JSON.stringify(copy);
};
remoteElements.forEach((remoteEl) => {
const localEl = localMap.get(remoteEl.id);
@@ -51,7 +59,17 @@ export const reconcileElements = (
getVersionNonce(remoteEl) !== getVersionNonce(localEl)
) {
localMap.set(remoteEl.id, remoteEl);
return;
}
if (
remoteUpdated === localUpdated &&
getVersionNonce(remoteEl) === getVersionNonce(localEl) &&
getComparableContent(remoteEl) !== getComparableContent(localEl)
) {
localMap.set(remoteEl.id, remoteEl);
}
});
return Array.from(localMap.values());
+9 -6
View File
@@ -16,22 +16,25 @@ try {
}
const appVersion = process.env.VITE_APP_VERSION?.trim() || versionFromFile;
const buildLabel = process.env.VITE_APP_BUILD_LABEL?.trim() || "local development build";
const buildLabel =
process.env.VITE_APP_BUILD_LABEL?.trim() || "local development build";
// https://vite.dev/config/
export default defineConfig(({ command }) => {
const nodeEnv = process.env.NODE_ENV || (command === "build" ? "production" : "development");
const nodeEnv =
process.env.NODE_ENV ||
(command === "build" ? "production" : "development");
const processEnvDefines = {
'process.env.IS_PREACT': JSON.stringify("false"),
'process.env.NODE_ENV': JSON.stringify(nodeEnv),
"process.env.IS_PREACT": JSON.stringify("false"),
"process.env.NODE_ENV": JSON.stringify(nodeEnv),
};
return {
plugins: [react()],
define: {
...processEnvDefines,
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel),
"import.meta.env.VITE_APP_VERSION": JSON.stringify(appVersion),
"import.meta.env.VITE_APP_BUILD_LABEL": JSON.stringify(buildLabel),
},
optimizeDeps: {
esbuildOptions: {