feat(collab): restore cross-account sharing and reliable realtime sync
This commit is contained in:
@@ -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");
|
||||||
@@ -27,6 +27,7 @@ model User {
|
|||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
drawingShareGrants DrawingShareGrant[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,8 @@ model Drawing {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
collectionId String?
|
collectionId String?
|
||||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||||
|
shareLinks DrawingShareLink[]
|
||||||
|
shareGrants DrawingShareGrant[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -74,6 +77,38 @@ model Drawing {
|
|||||||
@@index([userId, collectionId, updatedAt])
|
@@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 {
|
model Library {
|
||||||
id String @id // User-specific library ID (e.g., "user_<userId>")
|
id String @id // User-specific library ID (e.g., "user_<userId>")
|
||||||
items String @default("[]") // Stored as JSON string array of library items
|
items String @default("[]") // Stored as JSON string array of library items
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ app.use(
|
|||||||
cors({
|
cors({
|
||||||
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
|
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
|
||||||
credentials: true,
|
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"],
|
exposedHeaders: ["x-csrf-token", "x-request-id"],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -574,6 +574,7 @@ apiApp.get("/health", (req, res) => {
|
|||||||
|
|
||||||
registerDashboardRoutes(apiApp, {
|
registerDashboardRoutes(apiApp, {
|
||||||
prisma,
|
prisma,
|
||||||
|
authModeService,
|
||||||
requireAuth,
|
requireAuth,
|
||||||
asyncHandler,
|
asyncHandler,
|
||||||
parseJsonField,
|
parseJsonField,
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import {
|
|||||||
toInternalTrashCollectionId,
|
toInternalTrashCollectionId,
|
||||||
toPublicTrashCollectionId,
|
toPublicTrashCollectionId,
|
||||||
} from "./trash";
|
} from "./trash";
|
||||||
|
import {
|
||||||
|
ensureShareLinkForRole,
|
||||||
|
isAtLeastRole,
|
||||||
|
isShareRole,
|
||||||
|
rotateShareLinkForRole,
|
||||||
|
resolveDrawingAccess,
|
||||||
|
} from "../../server/drawingAccess";
|
||||||
|
|
||||||
const getRouteIdParam = (value: string | string[] | undefined): string | null => {
|
const getRouteIdParam = (value: string | string[] | undefined): string | null => {
|
||||||
if (typeof value === "string" && value.trim().length > 0) return value;
|
if (typeof value === "string" && value.trim().length > 0) return value;
|
||||||
@@ -16,12 +23,29 @@ const getRouteIdParam = (value: string | string[] | undefined): string | null =>
|
|||||||
return 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 = (
|
export const registerDrawingRoutes = (
|
||||||
app: express.Express,
|
app: express.Express,
|
||||||
deps: DashboardRouteDeps
|
deps: DashboardRouteDeps
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
prisma,
|
prisma,
|
||||||
|
authModeService,
|
||||||
requireAuth,
|
requireAuth,
|
||||||
asyncHandler,
|
asyncHandler,
|
||||||
parseJsonField,
|
parseJsonField,
|
||||||
@@ -152,6 +176,7 @@ export const registerDrawingRoutes = (
|
|||||||
if (shouldIncludeData) {
|
if (shouldIncludeData) {
|
||||||
responsePayload = (drawings as any[]).map((d: any) => ({
|
responsePayload = (drawings as any[]).map((d: any) => ({
|
||||||
...d,
|
...d,
|
||||||
|
accessRole: "owner",
|
||||||
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
|
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
|
||||||
elements: parseJsonField(d.elements, []),
|
elements: parseJsonField(d.elements, []),
|
||||||
appState: parseJsonField(d.appState, {}),
|
appState: parseJsonField(d.appState, {}),
|
||||||
@@ -160,6 +185,7 @@ export const registerDrawingRoutes = (
|
|||||||
} else {
|
} else {
|
||||||
responsePayload = (drawings as any[]).map((d: any) => ({
|
responsePayload = (drawings as any[]).map((d: any) => ({
|
||||||
...d,
|
...d,
|
||||||
|
accessRole: "owner",
|
||||||
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
|
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -177,27 +203,246 @@ export const registerDrawingRoutes = (
|
|||||||
return res.send(body);
|
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) => {
|
app.get("/drawings/:id", requireAuth, asyncHandler(async (req, res) => {
|
||||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
const id = getRouteIdParam(req.params.id);
|
const id = getRouteIdParam(req.params.id);
|
||||||
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
|
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
|
||||||
const drawing = await prisma.drawing.findFirst({
|
|
||||||
where: {
|
const access = await resolveDrawingAccess({
|
||||||
id,
|
prisma,
|
||||||
userId: req.user.id,
|
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" });
|
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({
|
return res.json({
|
||||||
...drawing,
|
...access.drawing,
|
||||||
collectionId: toPublicTrashCollectionId(drawing.collectionId, req.user.id),
|
accessRole: access.role,
|
||||||
elements: parseJsonField(drawing.elements, []),
|
collectionId:
|
||||||
appState: parseJsonField(drawing.appState, {}),
|
access.role === "owner"
|
||||||
files: parseJsonField(drawing.files, {}),
|
? 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({
|
return res.json({
|
||||||
...newDrawing,
|
...newDrawing,
|
||||||
|
accessRole: "owner",
|
||||||
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
|
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
|
||||||
elements: parseJsonField(newDrawing.elements, []),
|
elements: parseJsonField(newDrawing.elements, []),
|
||||||
appState: parseJsonField(newDrawing.appState, {}),
|
appState: parseJsonField(newDrawing.appState, {}),
|
||||||
@@ -266,10 +512,13 @@ export const registerDrawingRoutes = (
|
|||||||
|
|
||||||
const id = getRouteIdParam(req.params.id);
|
const id = getRouteIdParam(req.params.id);
|
||||||
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
|
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);
|
const parsed = drawingUpdateSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -288,6 +537,17 @@ export const registerDrawingRoutes = (
|
|||||||
files?: Record<string, unknown>;
|
files?: Record<string, unknown>;
|
||||||
version?: number;
|
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 trashCollectionId = getUserTrashCollectionId(req.user.id);
|
||||||
const isSceneUpdate =
|
const isSceneUpdate =
|
||||||
payload.elements !== undefined ||
|
payload.elements !== undefined ||
|
||||||
@@ -304,6 +564,9 @@ export const registerDrawingRoutes = (
|
|||||||
if (payload.preview !== undefined) data.preview = payload.preview;
|
if (payload.preview !== undefined) data.preview = payload.preview;
|
||||||
|
|
||||||
if (payload.collectionId !== undefined) {
|
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") {
|
if (payload.collectionId === "trash") {
|
||||||
await ensureTrashCollection(prisma, req.user.id);
|
await ensureTrashCollection(prisma, req.user.id);
|
||||||
(data as Prisma.DrawingUncheckedUpdateInput).collectionId = trashCollectionId;
|
(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) {
|
if (isSceneUpdate && payload.version !== undefined) {
|
||||||
updateWhere.version = payload.version;
|
updateWhere.version = payload.version;
|
||||||
}
|
}
|
||||||
@@ -330,7 +596,7 @@ export const registerDrawingRoutes = (
|
|||||||
if (updateResult.count === 0) {
|
if (updateResult.count === 0) {
|
||||||
if (isSceneUpdate && payload.version !== undefined) {
|
if (isSceneUpdate && payload.version !== undefined) {
|
||||||
const latestDrawing = await prisma.drawing.findFirst({
|
const latestDrawing = await prisma.drawing.findFirst({
|
||||||
where: { id, userId: req.user.id },
|
where: { id },
|
||||||
select: { version: true },
|
select: { version: true },
|
||||||
});
|
});
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
@@ -344,7 +610,7 @@ export const registerDrawingRoutes = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedDrawing = await prisma.drawing.findFirst({
|
const updatedDrawing = await prisma.drawing.findFirst({
|
||||||
where: { id, userId: req.user.id },
|
where: { id },
|
||||||
});
|
});
|
||||||
if (!updatedDrawing) {
|
if (!updatedDrawing) {
|
||||||
return res.status(404).json({ error: "Drawing not found" });
|
return res.status(404).json({ error: "Drawing not found" });
|
||||||
@@ -353,7 +619,11 @@ export const registerDrawingRoutes = (
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
...updatedDrawing,
|
...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, []),
|
elements: parseJsonField(updatedDrawing.elements, []),
|
||||||
appState: parseJsonField(updatedDrawing.appState, {}),
|
appState: parseJsonField(updatedDrawing.appState, {}),
|
||||||
files: parseJsonField(updatedDrawing.files, {}),
|
files: parseJsonField(updatedDrawing.files, {}),
|
||||||
@@ -395,20 +665,26 @@ export const registerDrawingRoutes = (
|
|||||||
|
|
||||||
const id = getRouteIdParam(req.params.id);
|
const id = getRouteIdParam(req.params.id);
|
||||||
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
|
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" });
|
const access = await resolveDrawingAccess({
|
||||||
let duplicatedCollectionId = original.collectionId;
|
prisma,
|
||||||
if (isTrashCollectionId(original.collectionId, req.user.id)) {
|
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);
|
await ensureTrashCollection(prisma, req.user.id);
|
||||||
duplicatedCollectionId = getUserTrashCollectionId(req.user.id);
|
duplicatedCollectionId = getUserTrashCollectionId(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDrawing = await prisma.drawing.create({
|
const newDrawing = await prisma.drawing.create({
|
||||||
data: {
|
data: {
|
||||||
name: `${original.name} (Copy)`,
|
name: `${access.drawing.name} (Copy)`,
|
||||||
elements: original.elements,
|
elements: access.drawing.elements,
|
||||||
appState: original.appState,
|
appState: access.drawing.appState,
|
||||||
files: original.files,
|
files: access.drawing.files,
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
collectionId: duplicatedCollectionId,
|
collectionId: duplicatedCollectionId,
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -418,6 +694,7 @@ export const registerDrawingRoutes = (
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
...newDrawing,
|
...newDrawing,
|
||||||
|
accessRole: "owner",
|
||||||
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
|
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
|
||||||
elements: parseJsonField(newDrawing.elements, []),
|
elements: parseJsonField(newDrawing.elements, []),
|
||||||
appState: parseJsonField(newDrawing.appState, {}),
|
appState: parseJsonField(newDrawing.appState, {}),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Prisma, PrismaClient } from "../../generated/client";
|
import { Prisma, PrismaClient } from "../../generated/client";
|
||||||
|
import { AuthModeService } from "../../auth/authMode";
|
||||||
|
|
||||||
export type SortField = "name" | "createdAt" | "updatedAt";
|
export type SortField = "name" | "createdAt" | "updatedAt";
|
||||||
export type SortDirection = "asc" | "desc";
|
export type SortDirection = "asc" | "desc";
|
||||||
@@ -30,6 +31,7 @@ type LogAuditEvent = (params: {
|
|||||||
|
|
||||||
export type DashboardRouteDeps = {
|
export type DashboardRouteDeps = {
|
||||||
prisma: PrismaClient;
|
prisma: PrismaClient;
|
||||||
|
authModeService: AuthModeService;
|
||||||
requireAuth: express.RequestHandler;
|
requireAuth: express.RequestHandler;
|
||||||
asyncHandler: <T = void>(
|
asyncHandler: <T = void>(
|
||||||
fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<T>
|
fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<T>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { Server } from "socket.io";
|
|||||||
import { PrismaClient } from "../generated/client";
|
import { PrismaClient } from "../generated/client";
|
||||||
import { AuthModeService } from "../auth/authMode";
|
import { AuthModeService } from "../auth/authMode";
|
||||||
import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies";
|
import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies";
|
||||||
|
import { isAtLeastRole, resolveDrawingAccess } from "./drawingAccess";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -110,7 +111,7 @@ export const registerSocketHandlers = ({
|
|||||||
|
|
||||||
io.on("connection", (socket) => {
|
io.on("connection", (socket) => {
|
||||||
const authenticatedUserId = socketUserMap.get(socket.id);
|
const authenticatedUserId = socketUserMap.get(socket.id);
|
||||||
const authorizedDrawingIds = new Set<string>();
|
const authorizedDrawingRoles = new Map<string, "owner" | "editor" | "viewer">();
|
||||||
|
|
||||||
socket.on(
|
socket.on(
|
||||||
"join-room",
|
"join-room",
|
||||||
@@ -123,20 +124,22 @@ export const registerSocketHandlers = ({
|
|||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
if (authenticatedUserId) {
|
if (authenticatedUserId) {
|
||||||
const drawing = await prisma.drawing.findFirst({
|
const drawing = await resolveDrawingAccess({
|
||||||
where: { id: drawingId, userId: authenticatedUserId },
|
prisma,
|
||||||
select: { id: true },
|
drawingId,
|
||||||
|
userId: authenticatedUserId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!drawing) {
|
if (!drawing) {
|
||||||
socket.emit("error", { message: "You do not have access to this drawing" });
|
socket.emit("error", { message: "You do not have access to this drawing" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authorizedDrawingRoles.set(drawingId, drawing.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomId = `drawing_${drawingId}`;
|
const roomId = `drawing_${drawingId}`;
|
||||||
socket.join(roomId);
|
socket.join(roomId);
|
||||||
authorizedDrawingIds.add(drawingId);
|
|
||||||
|
|
||||||
let trustedUserId =
|
let trustedUserId =
|
||||||
typeof user?.id === "string" && user.id.trim().length > 0
|
typeof user?.id === "string" && user.id.trim().length > 0
|
||||||
@@ -179,7 +182,7 @@ export const registerSocketHandlers = ({
|
|||||||
|
|
||||||
socket.on("cursor-move", (data) => {
|
socket.on("cursor-move", (data) => {
|
||||||
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null;
|
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null;
|
||||||
if (!drawingId || !authorizedDrawingIds.has(drawingId)) {
|
if (!drawingId || !authorizedDrawingRoles.has(drawingId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const roomId = `drawing_${drawingId}`;
|
const roomId = `drawing_${drawingId}`;
|
||||||
@@ -188,7 +191,11 @@ export const registerSocketHandlers = ({
|
|||||||
|
|
||||||
socket.on("element-update", (data) => {
|
socket.on("element-update", (data) => {
|
||||||
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const roomId = `drawing_${drawingId}`;
|
const roomId = `drawing_${drawingId}`;
|
||||||
@@ -198,7 +205,7 @@ export const registerSocketHandlers = ({
|
|||||||
socket.on(
|
socket.on(
|
||||||
"user-activity",
|
"user-activity",
|
||||||
({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => {
|
({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => {
|
||||||
if (!authorizedDrawingIds.has(drawingId)) {
|
if (!authorizedDrawingRoles.has(drawingId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const roomId = `drawing_${drawingId}`;
|
const roomId = `drawing_${drawingId}`;
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/shared"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import axios from "axios";
|
|||||||
import type { Drawing, Collection, DrawingSummary } from "../types";
|
import type { Drawing, Collection, DrawingSummary } from "../types";
|
||||||
import { normalizePreviewSvg } from "../utils/previewSvg";
|
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({
|
export const api = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
@@ -416,6 +419,7 @@ export interface PaginatedDrawings<T> {
|
|||||||
|
|
||||||
export type DrawingSortField = "name" | "createdAt" | "updatedAt";
|
export type DrawingSortField = "name" | "createdAt" | "updatedAt";
|
||||||
export type SortDirection = "asc" | "desc";
|
export type SortDirection = "asc" | "desc";
|
||||||
|
export type ShareLinkRole = "viewer" | "editor";
|
||||||
|
|
||||||
export function getDrawings(
|
export function getDrawings(
|
||||||
search?: string,
|
search?: string,
|
||||||
@@ -475,8 +479,46 @@ export async function getDrawings(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDrawing = async (id: string) => {
|
export const getSharedDrawings = async (options?: {
|
||||||
const response = await api.get<Drawing>(`/drawings/${id}`);
|
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);
|
return deserializeDrawing(response.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -508,6 +550,23 @@ export const duplicateDrawing = async (id: string) => {
|
|||||||
return deserializeDrawing(response.data);
|
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 () => {
|
export const getCollections = async () => {
|
||||||
const response = await api.get<Collection[]>("/collections");
|
const response = await api.get<Collection[]>("/collections");
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ interface DrawingCardProps {
|
|||||||
onDragStart?: (e: React.DragEvent, id: string) => void;
|
onDragStart?: (e: React.DragEvent, id: string) => void;
|
||||||
onMouseDown?: (e: React.MouseEvent, id: string) => void;
|
onMouseDown?: (e: React.MouseEvent, id: string) => void;
|
||||||
onPreviewGenerated?: (id: string, preview: string) => void;
|
onPreviewGenerated?: (id: string, preview: string) => void;
|
||||||
|
canManage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
@@ -76,6 +77,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
onDragStart,
|
onDragStart,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
onPreviewGenerated,
|
onPreviewGenerated,
|
||||||
|
canManage = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
|
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
|
||||||
@@ -207,6 +209,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRenameSubmit = (e: React.FormEvent) => {
|
const handleRenameSubmit = (e: React.FormEvent) => {
|
||||||
|
if (!canManage) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (newName.trim()) {
|
if (newName.trim()) {
|
||||||
onRename(drawing.id, newName);
|
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"
|
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}
|
title={drawing.name}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
|
if (!canManage) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsRenaming(true);
|
setIsRenaming(true);
|
||||||
}}
|
}}
|
||||||
@@ -327,15 +331,17 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="relative" onClick={e => e.stopPropagation()}>
|
<div className="relative" onClick={e => e.stopPropagation()}>
|
||||||
<button
|
{canManage && (
|
||||||
onClick={() => setShowCollectionDropdown(!showCollectionDropdown)}
|
<button
|
||||||
data-testid={`collection-picker-${drawing.id}`}
|
onClick={() => setShowCollectionDropdown(!showCollectionDropdown)}
|
||||||
aria-haspopup="listbox"
|
data-testid={`collection-picker-${drawing.id}`}
|
||||||
aria-expanded={showCollectionDropdown}
|
aria-haspopup="listbox"
|
||||||
className="px-2 py-1 rounded-md bg-slate-50 dark:bg-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-200 text-slate-500 dark:text-neutral-400 text-[10px] font-bold uppercase tracking-wide max-w-[120px] truncate transition-all cursor-pointer border border-slate-100 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600"
|
aria-expanded={showCollectionDropdown}
|
||||||
>
|
className="px-2 py-1 rounded-md bg-slate-50 dark:bg-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-200 text-slate-500 dark:text-neutral-400 text-[10px] font-bold uppercase tracking-wide max-w-[120px] truncate transition-all cursor-pointer border border-slate-100 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600"
|
||||||
{drawing.collectionId ? (collections.find(c => c.id === drawing.collectionId)?.name || 'Collection') : 'Unorganized'}
|
>
|
||||||
</button>
|
{drawing.collectionId ? (collections.find(c => c.id === drawing.collectionId)?.name || 'Collection') : 'Unorganized'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCollectionDropdown && (
|
{showCollectionDropdown && (
|
||||||
<>
|
<>
|
||||||
@@ -387,56 +393,60 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
{canManage && (
|
||||||
onClick={() => {
|
|
||||||
setIsRenaming(true);
|
|
||||||
setContextMenu(null);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<PenTool size={14} /> Rename
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="relative group/move"
|
|
||||||
onMouseEnter={() => setShowMoveSubmenu(true)}
|
|
||||||
onMouseLeave={() => setShowMoveSubmenu(false)}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center justify-between"
|
onClick={() => {
|
||||||
|
setIsRenaming(true);
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2"><FolderInput size={14} /> Move to...</span>
|
<PenTool size={14} /> Rename
|
||||||
<ArrowRight size={12} />
|
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{showMoveSubmenu && (
|
{canManage && (
|
||||||
<div className="absolute left-full top-0 ml-1 w-40 bg-white dark:bg-neutral-900 rounded-lg border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] py-1 max-h-64 overflow-y-auto">
|
<div
|
||||||
<button
|
className="relative group/move"
|
||||||
onClick={() => { onMoveToCollection(drawing.id, null); setContextMenu(null); }}
|
onMouseEnter={() => setShowMoveSubmenu(true)}
|
||||||
className={clsx(
|
onMouseLeave={() => setShowMoveSubmenu(false)}
|
||||||
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800",
|
>
|
||||||
drawing.collectionId === null ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
|
<button
|
||||||
)}
|
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center justify-between"
|
||||||
>
|
>
|
||||||
Unorganized
|
<span className="flex items-center gap-2"><FolderInput size={14} /> Move to...</span>
|
||||||
{drawing.collectionId === null && <Check size={10} />}
|
<ArrowRight size={12} />
|
||||||
</button>
|
</button>
|
||||||
{collections.map(c => (
|
|
||||||
|
{showMoveSubmenu && (
|
||||||
|
<div className="absolute left-full top-0 ml-1 w-40 bg-white dark:bg-neutral-900 rounded-lg border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] py-1 max-h-64 overflow-y-auto">
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
onClick={() => { onMoveToCollection(drawing.id, null); setContextMenu(null); }}
|
||||||
onClick={() => { onMoveToCollection(drawing.id, c.id); setContextMenu(null); }}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 truncate",
|
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800",
|
||||||
drawing.collectionId === c.id ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
|
drawing.collectionId === null ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{c.name}</span>
|
Unorganized
|
||||||
{drawing.collectionId === c.id && <Check size={10} />}
|
{drawing.collectionId === null && <Check size={10} />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
{collections.map(c => (
|
||||||
</div>
|
<button
|
||||||
)}
|
key={c.id}
|
||||||
</div>
|
onClick={() => { onMoveToCollection(drawing.id, c.id); setContextMenu(null); }}
|
||||||
|
className={clsx(
|
||||||
|
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 truncate",
|
||||||
|
drawing.collectionId === c.id ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{c.name}</span>
|
||||||
|
{drawing.collectionId === c.id && <Check size={10} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
|
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
|
||||||
|
|
||||||
@@ -468,17 +478,20 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
|
{canManage && (
|
||||||
|
<>
|
||||||
<button
|
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
|
||||||
onClick={() => {
|
<button
|
||||||
onDelete(drawing.id);
|
onClick={() => {
|
||||||
setContextMenu(null);
|
onDelete(drawing.id);
|
||||||
}}
|
setContextMenu(null);
|
||||||
className="w-full px-3 py-2 text-sm text-left text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 flex items-center gap-2"
|
}}
|
||||||
>
|
className="w-full px-3 py-2 text-sm text-left text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 flex items-center gap-2"
|
||||||
<Trash2 size={14} /> Delete
|
>
|
||||||
</button>
|
<Trash2 size={14} /> Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuPortal>
|
</ContextMenuPortal>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 type { Collection } from '../types';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
@@ -207,6 +207,23 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
onClick={() => onSelectCollection(null)}
|
onClick={() => onSelectCollection(null)}
|
||||||
onDrop={onDrop}
|
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>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const selectedCollectionId = React.useMemo(() => {
|
const selectedCollectionId = React.useMemo(() => {
|
||||||
if (location.pathname === '/') return undefined;
|
if (location.pathname === '/') return undefined;
|
||||||
|
if (location.pathname === '/shared') return '__shared__';
|
||||||
if (location.pathname === '/collections') {
|
if (location.pathname === '/collections') {
|
||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
if (id === 'unorganized') return null;
|
if (id === 'unorganized') return null;
|
||||||
@@ -32,6 +33,8 @@ export const Dashboard: React.FC = () => {
|
|||||||
const setSelectedCollectionId = (id: string | null | undefined) => {
|
const setSelectedCollectionId = (id: string | null | undefined) => {
|
||||||
if (id === undefined) {
|
if (id === undefined) {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
|
} else if (id === '__shared__') {
|
||||||
|
navigate('/shared');
|
||||||
} else if (id === null) {
|
} else if (id === null) {
|
||||||
navigate('/collections?id=unorganized');
|
navigate('/collections?id=unorganized');
|
||||||
} else {
|
} else {
|
||||||
@@ -39,6 +42,8 @@ export const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSharedView = selectedCollectionId === '__shared__';
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
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 currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0];
|
||||||
|
|
||||||
const isTrashView = selectedCollectionId === 'trash';
|
const isTrashView = selectedCollectionId === 'trash';
|
||||||
|
const canManageDrawing = useCallback(
|
||||||
|
(id: string) => (drawings.find((d) => d.id === id)?.accessRole ?? 'owner') === 'owner',
|
||||||
|
[drawings]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateDrawing = async () => {
|
const handleCreateDrawing = async () => {
|
||||||
if (isTrashView) return;
|
if (isTrashView || isSharedView) return;
|
||||||
try {
|
try {
|
||||||
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
|
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
|
||||||
const { id } = await api.createDrawing('Untitled Drawing', targetCollectionId);
|
const { id } = await api.createDrawing('Untitled Drawing', targetCollectionId);
|
||||||
@@ -277,7 +287,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImportDrawings = async (files: FileList | null) => {
|
const handleImportDrawings = async (files: FileList | null) => {
|
||||||
if (!files || isTrashView) return;
|
if (!files || isTrashView || isSharedView) return;
|
||||||
|
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
|
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
|
||||||
@@ -290,6 +300,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameDrawing = async (id: string, name: string) => {
|
const handleRenameDrawing = async (id: string, name: string) => {
|
||||||
|
if (!canManageDrawing(id)) return;
|
||||||
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
|
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
|
||||||
try {
|
try {
|
||||||
await api.updateDrawing(id, { name });
|
await api.updateDrawing(id, { name });
|
||||||
@@ -300,6 +311,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteDrawing = async (id: string) => {
|
const handleDeleteDrawing = async (id: string) => {
|
||||||
|
if (!canManageDrawing(id)) return;
|
||||||
if (isTrashView) {
|
if (isTrashView) {
|
||||||
// Permanent Delete -> Confirm first
|
// Permanent Delete -> Confirm first
|
||||||
setDrawingToDelete(id);
|
setDrawingToDelete(id);
|
||||||
@@ -327,6 +339,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const executePermanentDelete = async (id: string) => {
|
const executePermanentDelete = async (id: string) => {
|
||||||
|
if (!canManageDrawing(id)) return;
|
||||||
setDrawings(prev => {
|
setDrawings(prev => {
|
||||||
const next = prev.filter(d => d.id !== id);
|
const next = prev.filter(d => d.id !== id);
|
||||||
if (next.length !== prev.length) {
|
if (next.length !== prev.length) {
|
||||||
@@ -378,6 +391,8 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const handleBulkDeleteClick = () => {
|
const handleBulkDeleteClick = () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
|
const ownerSelectedCount = Array.from(selectedIds).filter((id) => canManageDrawing(id)).length;
|
||||||
|
if (ownerSelectedCount === 0) return;
|
||||||
if (isTrashView) {
|
if (isTrashView) {
|
||||||
setShowBulkDeleteConfirm(true);
|
setShowBulkDeleteConfirm(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -387,10 +402,12 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const executeBulkMoveToTrash = async () => {
|
const executeBulkMoveToTrash = async () => {
|
||||||
const trashId = 'trash';
|
const trashId = 'trash';
|
||||||
const ids = Array.from(selectedIds);
|
const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
setDrawings(prev => {
|
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));
|
setTotalCount(t => t - (prev.length - next.length));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -405,9 +422,11 @@ export const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const executeBulkPermanentDelete = async () => {
|
const executeBulkPermanentDelete = async () => {
|
||||||
const ids = Array.from(selectedIds);
|
const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
|
||||||
|
if (ids.length === 0) return;
|
||||||
setDrawings(prev => {
|
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));
|
setTotalCount(t => t - (prev.length - next.length));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -425,11 +444,13 @@ export const Dashboard: React.FC = () => {
|
|||||||
const handleBulkMove = async (collectionId: string | null) => {
|
const handleBulkMove = async (collectionId: string | null) => {
|
||||||
if (selectedIds.size === 0) return;
|
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
|
// Optimistic update
|
||||||
setDrawings(prev => {
|
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;
|
if (selectedCollectionId === undefined) return updated;
|
||||||
const next = updated.filter(d => {
|
const next = updated.filter(d => {
|
||||||
if (selectedCollectionId === null) return d.collectionId === null;
|
if (selectedCollectionId === null) return d.collectionId === null;
|
||||||
@@ -472,6 +493,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveToCollection = async (id: string, collectionId: string | null) => {
|
const handleMoveToCollection = async (id: string, collectionId: string | null) => {
|
||||||
|
if (!canManageDrawing(id)) return;
|
||||||
setDrawings(prev => {
|
setDrawings(prev => {
|
||||||
const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d);
|
const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d);
|
||||||
const next = updated.filter(d => {
|
const next = updated.filter(d => {
|
||||||
@@ -530,6 +552,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
const viewTitle = React.useMemo(() => {
|
const viewTitle = React.useMemo(() => {
|
||||||
if (selectedCollectionId === undefined) return "All Drawings";
|
if (selectedCollectionId === undefined) return "All Drawings";
|
||||||
if (selectedCollectionId === null) return "Unorganized";
|
if (selectedCollectionId === null) return "Unorganized";
|
||||||
|
if (selectedCollectionId === '__shared__') return "Shared with me";
|
||||||
if (selectedCollectionId === 'trash') return "Trash";
|
if (selectedCollectionId === 'trash') return "Trash";
|
||||||
const collection = collections.find(c => c.id === selectedCollectionId);
|
const collection = collections.find(c => c.id === selectedCollectionId);
|
||||||
return collection ? collection.name : "Collection";
|
return collection ? collection.name : "Collection";
|
||||||
@@ -537,6 +560,10 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const hasSelection = selectedIds.size > 0;
|
const hasSelection = selectedIds.size > 0;
|
||||||
const allSelected = sortedDrawings.length > 0 && selectedIds.size === sortedDrawings.length;
|
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 = () => {
|
const handleSelectAll = () => {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
@@ -567,7 +594,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
|
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
|
||||||
if (drawingFiles.length > 0) {
|
if (drawingFiles.length > 0 && !isSharedView) {
|
||||||
uploadFiles(drawingFiles, targetCollectionId).finally(() => {
|
uploadFiles(drawingFiles, targetCollectionId).finally(() => {
|
||||||
refreshData();
|
refreshData();
|
||||||
});
|
});
|
||||||
@@ -588,6 +615,9 @@ export const Dashboard: React.FC = () => {
|
|||||||
idsToMove.add(draggedDrawingId);
|
idsToMove.add(draggedDrawingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
idsToMove = new Set(Array.from(idsToMove).filter((id) => canManageDrawing(id)));
|
||||||
|
if (idsToMove.size === 0) return;
|
||||||
|
|
||||||
// Optimistic Update
|
// Optimistic Update
|
||||||
setDrawings(prev => {
|
setDrawings(prev => {
|
||||||
const updated = prev.map(d => idsToMove.has(d.id) ? { ...d, collectionId: targetCollectionId } : d);
|
const updated = prev.map(d => idsToMove.has(d.id) ? { ...d, collectionId: targetCollectionId } : d);
|
||||||
@@ -805,10 +835,10 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleBulkDeleteClick}
|
onClick={handleBulkDeleteClick}
|
||||||
disabled={!hasSelection}
|
disabled={manageableSelectionCount === 0}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
|
"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-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"
|
: "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">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => hasSelection && setShowBulkMoveMenu(!showBulkMoveMenu)}
|
onClick={() => manageableSelectionCount > 0 && setShowBulkMoveMenu(!showBulkMoveMenu)}
|
||||||
disabled={!hasSelection}
|
disabled={manageableSelectionCount === 0}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
|
"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-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"
|
: "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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showBulkMoveMenu && hasSelection && (
|
{showBulkMoveMenu && manageableSelectionCount > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setShowBulkMoveMenu(false)} />
|
<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="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">
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBulkMove(null)}
|
onClick={() => handleBulkMove(null)}
|
||||||
@@ -891,10 +921,10 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => document.getElementById('dashboard-import')?.click()}
|
onClick={() => document.getElementById('dashboard-import')?.click()}
|
||||||
disabled={isTrashView}
|
disabled={isTrashView || isSharedView}
|
||||||
className={clsx(
|
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",
|
"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-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)]"
|
: "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
|
<button
|
||||||
onClick={handleCreateDrawing}
|
onClick={handleCreateDrawing}
|
||||||
disabled={isTrashView}
|
disabled={isTrashView || isSharedView}
|
||||||
className={clsx(
|
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",
|
"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-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)]"
|
: "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}
|
onMouseDown={handleCardMouseDown}
|
||||||
onDragStart={handleCardDragStart}
|
onDragStart={(drawing.accessRole ?? 'owner') === 'owner' ? handleCardDragStart : undefined}
|
||||||
onPreviewGenerated={handlePreviewGenerated}
|
onPreviewGenerated={handlePreviewGenerated}
|
||||||
|
canManage={(drawing.accessRole ?? 'owner') === 'owner'}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
+291
-28
File diff suppressed because it is too large
Load Diff
@@ -36,13 +36,22 @@ export const useDashboardData = ({
|
|||||||
const requestVersion = ++listRequestVersionRef.current;
|
const requestVersion = ++listRequestVersionRef.current;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const isSharedView = selectedCollectionId === '__shared__';
|
||||||
const [drawingsRes, collectionsData] = await Promise.all([
|
const [drawingsRes, collectionsData] = await Promise.all([
|
||||||
api.getDrawings(debouncedSearch, selectedCollectionId, {
|
isSharedView
|
||||||
limit: pageSize,
|
? api.getSharedDrawings({
|
||||||
offset: 0,
|
search: debouncedSearch || undefined,
|
||||||
sortField,
|
limit: pageSize,
|
||||||
sortDirection,
|
offset: 0,
|
||||||
}),
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
})
|
||||||
|
: api.getDrawings(debouncedSearch, selectedCollectionId, {
|
||||||
|
limit: pageSize,
|
||||||
|
offset: 0,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
}),
|
||||||
api.getCollections(),
|
api.getCollections(),
|
||||||
]);
|
]);
|
||||||
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
|
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
|
||||||
@@ -71,12 +80,21 @@ export const useDashboardData = ({
|
|||||||
const requestVersion = listRequestVersionRef.current;
|
const requestVersion = listRequestVersionRef.current;
|
||||||
setIsFetchingMore(true);
|
setIsFetchingMore(true);
|
||||||
try {
|
try {
|
||||||
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
|
const isSharedView = selectedCollectionId === '__shared__';
|
||||||
limit: pageSize,
|
const drawingsRes = isSharedView
|
||||||
offset: drawings.length,
|
? await api.getSharedDrawings({
|
||||||
sortField,
|
search: debouncedSearch || undefined,
|
||||||
sortDirection,
|
limit: pageSize,
|
||||||
});
|
offset: drawings.length,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
})
|
||||||
|
: await api.getDrawings(debouncedSearch, selectedCollectionId, {
|
||||||
|
limit: pageSize,
|
||||||
|
offset: drawings.length,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
});
|
||||||
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
|
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
|
||||||
setDrawings((prev) => mergeUniqueDrawings(prev, drawingsRes.drawings));
|
setDrawings((prev) => mergeUniqueDrawings(prev, drawingsRes.drawings));
|
||||||
setTotalCount(drawingsRes.totalCount);
|
setTotalCount(drawingsRes.totalCount);
|
||||||
|
|||||||
@@ -1,8 +1,35 @@
|
|||||||
export interface ElementVersionInfo {
|
export interface ElementVersionInfo {
|
||||||
version: number;
|
version: number;
|
||||||
versionNonce: 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[] = []) => {
|
export const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => {
|
||||||
if (!a || !b) return false;
|
if (!a || !b) return false;
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
export type DrawingAccessRole = "owner" | "editor" | "viewer";
|
||||||
|
|
||||||
|
export interface DrawingOwner {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DrawingSummary {
|
export interface DrawingSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -6,6 +14,8 @@ export interface DrawingSummary {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
version: number;
|
version: number;
|
||||||
preview?: string | null;
|
preview?: string | null;
|
||||||
|
accessRole?: DrawingAccessRole;
|
||||||
|
owner?: DrawingOwner;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Drawing extends DrawingSummary {
|
export interface Drawing extends DrawingSummary {
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ export const reconcileElements = (
|
|||||||
const value = element?.updated;
|
const value = element?.updated;
|
||||||
return typeof value === "number" ? value : Number(value) || 0;
|
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) => {
|
remoteElements.forEach((remoteEl) => {
|
||||||
const localEl = localMap.get(remoteEl.id);
|
const localEl = localMap.get(remoteEl.id);
|
||||||
@@ -51,7 +59,17 @@ export const reconcileElements = (
|
|||||||
getVersionNonce(remoteEl) !== getVersionNonce(localEl)
|
getVersionNonce(remoteEl) !== getVersionNonce(localEl)
|
||||||
) {
|
) {
|
||||||
localMap.set(remoteEl.id, remoteEl);
|
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());
|
return Array.from(localMap.values());
|
||||||
|
|||||||
@@ -16,22 +16,25 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appVersion = process.env.VITE_APP_VERSION?.trim() || versionFromFile;
|
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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(({ command }) => {
|
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 = {
|
const processEnvDefines = {
|
||||||
'process.env.IS_PREACT': JSON.stringify("false"),
|
"process.env.IS_PREACT": JSON.stringify("false"),
|
||||||
'process.env.NODE_ENV': JSON.stringify(nodeEnv),
|
"process.env.NODE_ENV": JSON.stringify(nodeEnv),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
define: {
|
define: {
|
||||||
...processEnvDefines,
|
...processEnvDefines,
|
||||||
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
|
"import.meta.env.VITE_APP_VERSION": JSON.stringify(appVersion),
|
||||||
'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel),
|
"import.meta.env.VITE_APP_BUILD_LABEL": JSON.stringify(buildLabel),
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
esbuildOptions: {
|
esbuildOptions: {
|
||||||
|
|||||||
Reference in New Issue
Block a user