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[]
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { 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}`;
|
||||
|
||||
@@ -52,6 +52,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shared"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
|
||||
@@ -2,7 +2,10 @@ import axios from "axios";
|
||||
import type { Drawing, Collection, DrawingSummary } from "../types";
|
||||
import { normalizePreviewSvg } from "../utils/previewSvg";
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL || "/api";
|
||||
const DEFAULT_DEV_API_URL = "http://localhost:8000/api";
|
||||
export const API_URL =
|
||||
import.meta.env.VITE_API_URL ||
|
||||
(import.meta.env.DEV ? DEFAULT_DEV_API_URL : "/api");
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
@@ -416,6 +419,7 @@ export interface PaginatedDrawings<T> {
|
||||
|
||||
export type DrawingSortField = "name" | "createdAt" | "updatedAt";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
export type ShareLinkRole = "viewer" | "editor";
|
||||
|
||||
export function getDrawings(
|
||||
search?: string,
|
||||
@@ -475,8 +479,46 @@ export async function getDrawings(
|
||||
};
|
||||
}
|
||||
|
||||
export const getDrawing = async (id: string) => {
|
||||
const response = await api.get<Drawing>(`/drawings/${id}`);
|
||||
export const getSharedDrawings = async (options?: {
|
||||
search?: string;
|
||||
includeData?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortField?: DrawingSortField;
|
||||
sortDirection?: SortDirection;
|
||||
}) => {
|
||||
const params: Record<string, string | number> = {};
|
||||
if (options?.search) params.search = options.search;
|
||||
if (options?.includeData) params.includeData = "true";
|
||||
if (options?.limit !== undefined) params.limit = options.limit;
|
||||
if (options?.offset !== undefined) params.offset = options.offset;
|
||||
if (options?.sortField) params.sortField = options.sortField;
|
||||
if (options?.sortDirection) params.sortDirection = options.sortDirection;
|
||||
|
||||
if (options?.includeData) {
|
||||
const response = await api.get<PaginatedDrawings<Drawing>>("/drawings/shared", { params });
|
||||
return {
|
||||
...response.data,
|
||||
drawings: response.data.drawings.map(deserializeDrawing),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await api.get<PaginatedDrawings<DrawingSummary>>("/drawings/shared", { params });
|
||||
return {
|
||||
...response.data,
|
||||
drawings: response.data.drawings.map(deserializeDrawingSummary),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDrawing = async (
|
||||
id: string,
|
||||
options?: { shareToken?: string }
|
||||
) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (options?.shareToken) {
|
||||
headers["x-share-token"] = options.shareToken;
|
||||
}
|
||||
const response = await api.get<Drawing>(`/drawings/${id}`, { headers });
|
||||
return deserializeDrawing(response.data);
|
||||
};
|
||||
|
||||
@@ -508,6 +550,23 @@ export const duplicateDrawing = async (id: string) => {
|
||||
return deserializeDrawing(response.data);
|
||||
};
|
||||
|
||||
export const getDrawingShareLinks = async (
|
||||
id: string
|
||||
): Promise<{ drawingId: string; viewerToken: string; editorToken: string }> => {
|
||||
const response = await api.get<{ drawingId: string; viewerToken: string; editorToken: string }>(`/drawings/${id}/share-links`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const rotateDrawingShareLink = async (
|
||||
id: string,
|
||||
role: ShareLinkRole
|
||||
): Promise<{ role: ShareLinkRole; drawingId: string; token: string }> => {
|
||||
const response = await api.post<{ role: ShareLinkRole; drawingId: string; token: string }>(
|
||||
`/drawings/${id}/share-links/${role}/rotate`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getCollections = async () => {
|
||||
const response = await api.get<Collection[]>("/collections");
|
||||
return response.data;
|
||||
|
||||
@@ -56,6 +56,7 @@ interface DrawingCardProps {
|
||||
onDragStart?: (e: React.DragEvent, id: string) => void;
|
||||
onMouseDown?: (e: React.MouseEvent, id: string) => void;
|
||||
onPreviewGenerated?: (id: string, preview: string) => void;
|
||||
canManage?: boolean;
|
||||
}
|
||||
|
||||
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@@ -76,6 +77,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
onDragStart,
|
||||
onMouseDown,
|
||||
onPreviewGenerated,
|
||||
canManage = true,
|
||||
}) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
|
||||
@@ -207,6 +209,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleRenameSubmit = (e: React.FormEvent) => {
|
||||
if (!canManage) return;
|
||||
e.preventDefault();
|
||||
if (newName.trim()) {
|
||||
onRename(drawing.id, newName);
|
||||
@@ -313,6 +316,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
className="text-sm sm:text-base font-bold text-slate-800 dark:text-neutral-100 truncate cursor-text select-none group-hover:text-neutral-900 dark:group-hover:text-white transition-colors"
|
||||
title={drawing.name}
|
||||
onDoubleClick={(e) => {
|
||||
if (!canManage) return;
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
}}
|
||||
@@ -327,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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut, Shield } from 'lucide-react';
|
||||
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut, Shield, Users } from 'lucide-react';
|
||||
import type { Collection } from '../types';
|
||||
import clsx from 'clsx';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
@@ -207,6 +207,23 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
onClick={() => onSelectCollection(null)}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
|
||||
{authEnabled && (
|
||||
<div className="pl-3 pr-2">
|
||||
<button
|
||||
onClick={() => onSelectCollection('__shared__')}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2",
|
||||
selectedCollectionId === '__shared__'
|
||||
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5"
|
||||
: "text-slate-600 dark:text-neutral-400 border-transparent hover:bg-slate-50 dark:hover:bg-neutral-800 hover:border-black dark:hover:border-neutral-700 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<Users size={18} className={clsx(selectedCollectionId === '__shared__' ? "text-indigo-900 dark:text-neutral-200" : "text-slate-400 dark:text-neutral-500")} />
|
||||
<span className="min-w-0 flex-1 text-left">Shared with me</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -21,6 +21,7 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
const selectedCollectionId = React.useMemo(() => {
|
||||
if (location.pathname === '/') return undefined;
|
||||
if (location.pathname === '/shared') return '__shared__';
|
||||
if (location.pathname === '/collections') {
|
||||
const id = searchParams.get('id');
|
||||
if (id === 'unorganized') return null;
|
||||
@@ -32,6 +33,8 @@ export const Dashboard: React.FC = () => {
|
||||
const setSelectedCollectionId = (id: string | null | undefined) => {
|
||||
if (id === undefined) {
|
||||
navigate('/');
|
||||
} else if (id === '__shared__') {
|
||||
navigate('/shared');
|
||||
} else if (id === null) {
|
||||
navigate('/collections?id=unorganized');
|
||||
} else {
|
||||
@@ -39,6 +42,8 @@ export const Dashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isSharedView = selectedCollectionId === '__shared__';
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -265,8 +270,13 @@ export const Dashboard: React.FC = () => {
|
||||
const currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0];
|
||||
|
||||
const isTrashView = selectedCollectionId === 'trash';
|
||||
const canManageDrawing = useCallback(
|
||||
(id: string) => (drawings.find((d) => d.id === id)?.accessRole ?? 'owner') === 'owner',
|
||||
[drawings]
|
||||
);
|
||||
|
||||
const handleCreateDrawing = async () => {
|
||||
if (isTrashView) return;
|
||||
if (isTrashView || isSharedView) return;
|
||||
try {
|
||||
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
|
||||
const { id } = await api.createDrawing('Untitled Drawing', targetCollectionId);
|
||||
@@ -277,7 +287,7 @@ export const Dashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleImportDrawings = async (files: FileList | null) => {
|
||||
if (!files || isTrashView) return;
|
||||
if (!files || isTrashView || isSharedView) return;
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
|
||||
@@ -290,6 +300,7 @@ export const Dashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleRenameDrawing = async (id: string, name: string) => {
|
||||
if (!canManageDrawing(id)) return;
|
||||
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
|
||||
try {
|
||||
await api.updateDrawing(id, { name });
|
||||
@@ -300,6 +311,7 @@ export const Dashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeleteDrawing = async (id: string) => {
|
||||
if (!canManageDrawing(id)) return;
|
||||
if (isTrashView) {
|
||||
// Permanent Delete -> Confirm first
|
||||
setDrawingToDelete(id);
|
||||
@@ -327,6 +339,7 @@ export const Dashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
const executePermanentDelete = async (id: string) => {
|
||||
if (!canManageDrawing(id)) return;
|
||||
setDrawings(prev => {
|
||||
const next = prev.filter(d => d.id !== id);
|
||||
if (next.length !== prev.length) {
|
||||
@@ -378,6 +391,8 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
const handleBulkDeleteClick = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const ownerSelectedCount = Array.from(selectedIds).filter((id) => canManageDrawing(id)).length;
|
||||
if (ownerSelectedCount === 0) return;
|
||||
if (isTrashView) {
|
||||
setShowBulkDeleteConfirm(true);
|
||||
} else {
|
||||
@@ -387,10 +402,12 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
const executeBulkMoveToTrash = async () => {
|
||||
const trashId = 'trash';
|
||||
const ids = Array.from(selectedIds);
|
||||
const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
|
||||
if (ids.length === 0) return;
|
||||
|
||||
setDrawings(prev => {
|
||||
const next = prev.filter(d => !selectedIds.has(d.id));
|
||||
const idSet = new Set(ids);
|
||||
const next = prev.filter(d => !idSet.has(d.id));
|
||||
setTotalCount(t => t - (prev.length - next.length));
|
||||
return next;
|
||||
});
|
||||
@@ -405,9 +422,11 @@ export const Dashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
const executeBulkPermanentDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
const ids = Array.from(selectedIds).filter((id) => canManageDrawing(id));
|
||||
if (ids.length === 0) return;
|
||||
setDrawings(prev => {
|
||||
const next = prev.filter(d => !selectedIds.has(d.id));
|
||||
const idSet = new Set(ids);
|
||||
const next = prev.filter(d => !idSet.has(d.id));
|
||||
setTotalCount(t => t - (prev.length - next.length));
|
||||
return next;
|
||||
});
|
||||
@@ -425,11 +444,13 @@ export const Dashboard: React.FC = () => {
|
||||
const handleBulkMove = async (collectionId: string | null) => {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
const idsToMove = Array.from(selectedIds);
|
||||
const idsToMove = Array.from(selectedIds).filter((id) => canManageDrawing(id));
|
||||
if (idsToMove.length === 0) return;
|
||||
const idsToMoveSet = new Set(idsToMove);
|
||||
|
||||
// Optimistic update
|
||||
setDrawings(prev => {
|
||||
const updated = prev.map(d => selectedIds.has(d.id) ? { ...d, collectionId } : d);
|
||||
const updated = prev.map(d => idsToMoveSet.has(d.id) ? { ...d, collectionId } : d);
|
||||
if (selectedCollectionId === undefined) return updated;
|
||||
const next = updated.filter(d => {
|
||||
if (selectedCollectionId === null) return d.collectionId === null;
|
||||
@@ -472,6 +493,7 @@ export const Dashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleMoveToCollection = async (id: string, collectionId: string | null) => {
|
||||
if (!canManageDrawing(id)) return;
|
||||
setDrawings(prev => {
|
||||
const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d);
|
||||
const next = updated.filter(d => {
|
||||
@@ -530,6 +552,7 @@ export const Dashboard: React.FC = () => {
|
||||
const viewTitle = React.useMemo(() => {
|
||||
if (selectedCollectionId === undefined) return "All Drawings";
|
||||
if (selectedCollectionId === null) return "Unorganized";
|
||||
if (selectedCollectionId === '__shared__') return "Shared with me";
|
||||
if (selectedCollectionId === 'trash') return "Trash";
|
||||
const collection = collections.find(c => c.id === selectedCollectionId);
|
||||
return collection ? collection.name : "Collection";
|
||||
@@ -537,6 +560,10 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
const hasSelection = selectedIds.size > 0;
|
||||
const allSelected = sortedDrawings.length > 0 && selectedIds.size === sortedDrawings.length;
|
||||
const manageableSelectionCount = React.useMemo(
|
||||
() => Array.from(selectedIds).filter((id) => canManageDrawing(id)).length,
|
||||
[selectedIds, canManageDrawing]
|
||||
);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
@@ -567,7 +594,7 @@ export const Dashboard: React.FC = () => {
|
||||
}
|
||||
|
||||
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
|
||||
if (drawingFiles.length > 0) {
|
||||
if (drawingFiles.length > 0 && !isSharedView) {
|
||||
uploadFiles(drawingFiles, targetCollectionId).finally(() => {
|
||||
refreshData();
|
||||
});
|
||||
@@ -588,6 +615,9 @@ export const Dashboard: React.FC = () => {
|
||||
idsToMove.add(draggedDrawingId);
|
||||
}
|
||||
|
||||
idsToMove = new Set(Array.from(idsToMove).filter((id) => canManageDrawing(id)));
|
||||
if (idsToMove.size === 0) return;
|
||||
|
||||
// Optimistic Update
|
||||
setDrawings(prev => {
|
||||
const updated = prev.map(d => idsToMove.has(d.id) ? { ...d, collectionId: targetCollectionId } : d);
|
||||
@@ -805,10 +835,10 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
<button
|
||||
onClick={handleBulkDeleteClick}
|
||||
disabled={!hasSelection}
|
||||
disabled={manageableSelectionCount === 0}
|
||||
className={clsx(
|
||||
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
|
||||
hasSelection
|
||||
manageableSelectionCount > 0
|
||||
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-rose-600 dark:text-rose-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-rose-50 dark:hover:bg-rose-900/30"
|
||||
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
|
||||
)}
|
||||
@@ -833,11 +863,11 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => hasSelection && setShowBulkMoveMenu(!showBulkMoveMenu)}
|
||||
disabled={!hasSelection}
|
||||
onClick={() => manageableSelectionCount > 0 && setShowBulkMoveMenu(!showBulkMoveMenu)}
|
||||
disabled={manageableSelectionCount === 0}
|
||||
className={clsx(
|
||||
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
|
||||
hasSelection
|
||||
manageableSelectionCount > 0
|
||||
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-emerald-600 dark:text-emerald-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
|
||||
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
|
||||
)}
|
||||
@@ -849,12 +879,12 @@ export const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showBulkMoveMenu && hasSelection && (
|
||||
{showBulkMoveMenu && manageableSelectionCount > 0 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowBulkMoveMenu(false)} />
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-neutral-800 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] z-50 py-1 max-h-64 overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-100">
|
||||
<div className="px-3 py-2 text-[10px] font-bold uppercase text-slate-400 dark:text-neutral-500 tracking-wider border-b border-slate-100 dark:border-neutral-700 mb-1">
|
||||
Move {selectedIds.size} items to...
|
||||
Move {manageableSelectionCount} items to...
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleBulkMove(null)}
|
||||
@@ -891,10 +921,10 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
<button
|
||||
onClick={() => document.getElementById('dashboard-import')?.click()}
|
||||
disabled={isTrashView}
|
||||
disabled={isTrashView || isSharedView}
|
||||
className={clsx(
|
||||
"h-[42px] w-full sm:w-auto flex items-center justify-center gap-2 px-6 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] transition-all font-bold text-sm whitespace-nowrap",
|
||||
isTrashView
|
||||
isTrashView || isSharedView
|
||||
? "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-600 border-slate-300 dark:border-slate-700 shadow-none cursor-not-allowed"
|
||||
: "bg-emerald-600 dark:bg-neutral-800 text-white hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:active:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
|
||||
)}
|
||||
@@ -905,10 +935,10 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
<button
|
||||
onClick={handleCreateDrawing}
|
||||
disabled={isTrashView}
|
||||
disabled={isTrashView || isSharedView}
|
||||
className={clsx(
|
||||
"h-[42px] w-full sm:w-auto flex items-center justify-center gap-2 px-6 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] transition-all font-bold text-sm whitespace-nowrap",
|
||||
isTrashView
|
||||
isTrashView || isSharedView
|
||||
? "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-600 border-slate-300 dark:border-slate-700 shadow-none cursor-not-allowed"
|
||||
: "bg-indigo-600 dark:bg-neutral-800 text-white hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:active:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
|
||||
)}
|
||||
@@ -1006,8 +1036,9 @@ export const Dashboard: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
onMouseDown={handleCardMouseDown}
|
||||
onDragStart={handleCardDragStart}
|
||||
onDragStart={(drawing.accessRole ?? 'owner') === 'owner' ? handleCardDragStart : undefined}
|
||||
onPreviewGenerated={handlePreviewGenerated}
|
||||
canManage={(drawing.accessRole ?? 'owner') === 'owner'}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
+291
-28
File diff suppressed because it is too large
Load Diff
@@ -36,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,
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
export interface ElementVersionInfo {
|
||||
version: number;
|
||||
versionNonce: number;
|
||||
updated?: number;
|
||||
syncFingerprint?: string;
|
||||
}
|
||||
|
||||
export const buildElementSyncFingerprint = (element: any): string => {
|
||||
if (!element || typeof element !== "object") return "";
|
||||
|
||||
const points = Array.isArray(element.points) ? element.points : null;
|
||||
const firstPoint = points && points.length > 0 ? points[0] : null;
|
||||
const lastPoint = points && points.length > 0 ? points[points.length - 1] : null;
|
||||
|
||||
return JSON.stringify({
|
||||
id: element.id ?? null,
|
||||
type: element.type ?? null,
|
||||
isDeleted: Boolean(element.isDeleted),
|
||||
x: element.x ?? null,
|
||||
y: element.y ?? null,
|
||||
width: element.width ?? null,
|
||||
height: element.height ?? null,
|
||||
angle: element.angle ?? null,
|
||||
startBinding: element.startBinding?.elementId ?? null,
|
||||
endBinding: element.endBinding?.elementId ?? null,
|
||||
pointsLen: points ? points.length : null,
|
||||
firstPoint,
|
||||
lastPoint,
|
||||
text: typeof element.text === "string" ? element.text : null,
|
||||
});
|
||||
};
|
||||
|
||||
export const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => {
|
||||
if (!a || !b) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export type DrawingAccessRole = "owner" | "editor" | "viewer";
|
||||
|
||||
export interface DrawingOwner {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface DrawingSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -6,6 +14,8 @@ export interface DrawingSummary {
|
||||
createdAt: number;
|
||||
version: number;
|
||||
preview?: string | null;
|
||||
accessRole?: DrawingAccessRole;
|
||||
owner?: DrawingOwner;
|
||||
}
|
||||
|
||||
export interface Drawing extends DrawingSummary {
|
||||
|
||||
@@ -17,6 +17,14 @@ export const reconcileElements = (
|
||||
const value = element?.updated;
|
||||
return typeof value === "number" ? value : Number(value) || 0;
|
||||
};
|
||||
const getComparableContent = (element: any): string => {
|
||||
if (!element || typeof element !== "object") return "";
|
||||
const copy = { ...element } as Record<string, unknown>;
|
||||
delete copy.version;
|
||||
delete copy.versionNonce;
|
||||
delete copy.updated;
|
||||
return JSON.stringify(copy);
|
||||
};
|
||||
|
||||
remoteElements.forEach((remoteEl) => {
|
||||
const localEl = localMap.get(remoteEl.id);
|
||||
@@ -51,7 +59,17 @@ export const reconcileElements = (
|
||||
getVersionNonce(remoteEl) !== getVersionNonce(localEl)
|
||||
) {
|
||||
localMap.set(remoteEl.id, remoteEl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
remoteUpdated === localUpdated &&
|
||||
getVersionNonce(remoteEl) === getVersionNonce(localEl) &&
|
||||
getComparableContent(remoteEl) !== getComparableContent(localEl)
|
||||
) {
|
||||
localMap.set(remoteEl.id, remoteEl);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return Array.from(localMap.values());
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user