minor UI fixes
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
-- Improve dashboard query performance for user-scoped collection and drawing listings.
|
||||||
|
CREATE INDEX IF NOT EXISTS "Collection_userId_updatedAt_idx"
|
||||||
|
ON "Collection" ("userId", "updatedAt");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Drawing_userId_updatedAt_idx"
|
||||||
|
ON "Drawing" ("userId", "updatedAt");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Drawing_userId_collectionId_updatedAt_idx"
|
||||||
|
ON "Drawing" ("userId", "collectionId", "updatedAt");
|
||||||
@@ -49,6 +49,8 @@ model Collection {
|
|||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId, updatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Drawing {
|
model Drawing {
|
||||||
@@ -65,6 +67,9 @@ model Drawing {
|
|||||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId, updatedAt])
|
||||||
|
@@index([userId, collectionId, updatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Library {
|
model Library {
|
||||||
|
|||||||
+43
-2
@@ -9,7 +9,7 @@ import { z } from "zod";
|
|||||||
import { PrismaClient, Prisma } from "./generated/client";
|
import { PrismaClient, Prisma } from "./generated/client";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import { requireAuth, optionalAuth } from "./middleware/auth";
|
import { requireAuth, optionalAuth } from "./middleware/auth";
|
||||||
import { sanitizeText } from "./security";
|
import { sanitizeText, getCsrfTokenHeader, validateCsrfToken } from "./security";
|
||||||
import rateLimit, { MemoryStore } from "express-rate-limit";
|
import rateLimit, { MemoryStore } from "express-rate-limit";
|
||||||
import { logAuditEvent } from "./utils/audit";
|
import { logAuditEvent } from "./utils/audit";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
@@ -281,6 +281,36 @@ const requireAdmin = (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getClientId = (req: Request): string => {
|
||||||
|
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
|
const userAgent = req.headers["user-agent"] || "unknown";
|
||||||
|
return `${ip}:${userAgent}`.slice(0, 256);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireCsrf = (req: Request, res: Response): boolean => {
|
||||||
|
const headerName = getCsrfTokenHeader();
|
||||||
|
const tokenHeader = req.headers[headerName];
|
||||||
|
const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: "CSRF token missing",
|
||||||
|
message: `Missing ${headerName} header`,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateCsrfToken(getClientId(req), token)) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: "CSRF token invalid",
|
||||||
|
message: "Invalid or expired CSRF token. Please refresh and try again.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const countActiveAdmins = async () => {
|
const countActiveAdmins = async () => {
|
||||||
return prisma.user.count({
|
return prisma.user.count({
|
||||||
where: { role: "ADMIN", isActive: true },
|
where: { role: "ADMIN", isActive: true },
|
||||||
@@ -968,6 +998,8 @@ router.get("/status", optionalAuth, async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
router.post("/auth-enabled", optionalAuth, async (req: Request, res: Response) => {
|
router.post("/auth-enabled", optionalAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
if (!requireCsrf(req, res)) return;
|
||||||
|
|
||||||
const parsed = authEnabledToggleSchema.safeParse(req.body);
|
const parsed = authEnabledToggleSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res
|
return res
|
||||||
@@ -1477,6 +1509,15 @@ router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
res.json({ user: updated });
|
res.json({ user: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === "P2002"
|
||||||
|
) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: "Conflict",
|
||||||
|
message: "User with this username already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
console.error("Update user error:", error);
|
console.error("Update user error:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: "Internal server error",
|
error: "Internal server error",
|
||||||
@@ -1745,7 +1786,7 @@ router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Requ
|
|||||||
: `http://${baseUrlRaw}`
|
: `http://${baseUrlRaw}`
|
||||||
: "http://localhost:6767";
|
: "http://localhost:6767";
|
||||||
const baseUrl = baseUrlWithProtocol.replace(/\/$/, "");
|
const baseUrl = baseUrlWithProtocol.replace(/\/$/, "");
|
||||||
console.log(`[DEV] Reset URL: ${baseUrl}/reset-password?token=${resetToken}`);
|
console.log(`[DEV] Reset URL: ${baseUrl}/reset-password-confirm?token=${resetToken}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+558
-294
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ services:
|
|||||||
- DATABASE_URL=file:/app/prisma/dev.db
|
- DATABASE_URL=file:/app/prisma/dev.db
|
||||||
- PORT=8000
|
- PORT=8000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
# Required for authentication: must be explicitly set to a strong secret (min 32 chars)
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
|
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
|
||||||
# - CSRF_SECRET=${CSRF_SECRET}
|
# - CSRF_SECRET=${CSRF_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+2
-2
@@ -8,8 +8,8 @@ services:
|
|||||||
- DATABASE_URL=file:/app/prisma/dev.db
|
- DATABASE_URL=file:/app/prisma/dev.db
|
||||||
- PORT=8000
|
- PORT=8000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
# Required for authentication: set a strong secret (min 32 chars)
|
# Required for authentication: must be explicitly set to a strong secret (min 32 chars)
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-this-secret-in-production-min-32-chars}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
|
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
|
||||||
# - CSRF_SECRET=${CSRF_SECRET}
|
# - CSRF_SECRET=${CSRF_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+75
-64
@@ -1,17 +1,26 @@
|
|||||||
|
import { Suspense, lazy } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { Dashboard } from './pages/Dashboard';
|
|
||||||
import { Editor } from './pages/Editor';
|
|
||||||
import { Settings } from './pages/Settings';
|
|
||||||
import { Profile } from './pages/Profile';
|
|
||||||
import { Admin } from './pages/Admin';
|
|
||||||
import { Login } from './pages/Login';
|
|
||||||
import { Register } from './pages/Register';
|
|
||||||
import { PasswordResetRequest } from './pages/PasswordResetRequest';
|
|
||||||
import { PasswordResetConfirm } from './pages/PasswordResetConfirm';
|
|
||||||
import { ThemeProvider } from './context/ThemeContext';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
import { UploadProvider } from './context/UploadContext';
|
import { UploadProvider } from './context/UploadContext';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })));
|
||||||
|
const Editor = lazy(() => import('./pages/Editor').then(m => ({ default: m.Editor })));
|
||||||
|
const Settings = lazy(() => import('./pages/Settings').then(m => ({ default: m.Settings })));
|
||||||
|
const Profile = lazy(() => import('./pages/Profile').then(m => ({ default: m.Profile })));
|
||||||
|
const Admin = lazy(() => import('./pages/Admin').then(m => ({ default: m.Admin })));
|
||||||
|
const Login = lazy(() => import('./pages/Login').then(m => ({ default: m.Login })));
|
||||||
|
const Register = lazy(() => import('./pages/Register').then(m => ({ default: m.Register })));
|
||||||
|
const PasswordResetRequest = lazy(() => import('./pages/PasswordResetRequest').then(m => ({ default: m.PasswordResetRequest })));
|
||||||
|
const PasswordResetConfirm = lazy(() => import('./pages/PasswordResetConfirm').then(m => ({ default: m.PasswordResetConfirm })));
|
||||||
|
|
||||||
|
const PageLoader = () => (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-neutral-950 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -19,61 +28,63 @@ function App() {
|
|||||||
<Router>
|
<Router>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<UploadProvider>
|
<UploadProvider>
|
||||||
<Routes>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<Route path="/login" element={<Login />} />
|
<Routes>
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/reset-password" element={<PasswordResetRequest />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/reset-password-confirm" element={<PasswordResetConfirm />} />
|
<Route path="/reset-password" element={<PasswordResetRequest />} />
|
||||||
<Route
|
<Route path="/reset-password-confirm" element={<PasswordResetConfirm />} />
|
||||||
path="/"
|
<Route
|
||||||
element={
|
path="/"
|
||||||
<ProtectedRoute>
|
element={
|
||||||
<Dashboard />
|
<ProtectedRoute>
|
||||||
</ProtectedRoute>
|
<Dashboard />
|
||||||
}
|
</ProtectedRoute>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/collections"
|
<Route
|
||||||
element={
|
path="/collections"
|
||||||
<ProtectedRoute>
|
element={
|
||||||
<Dashboard />
|
<ProtectedRoute>
|
||||||
</ProtectedRoute>
|
<Dashboard />
|
||||||
}
|
</ProtectedRoute>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/settings"
|
<Route
|
||||||
element={
|
path="/settings"
|
||||||
<ProtectedRoute>
|
element={
|
||||||
<Settings />
|
<ProtectedRoute>
|
||||||
</ProtectedRoute>
|
<Settings />
|
||||||
}
|
</ProtectedRoute>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/profile"
|
<Route
|
||||||
element={
|
path="/profile"
|
||||||
<ProtectedRoute>
|
element={
|
||||||
<Profile />
|
<ProtectedRoute>
|
||||||
</ProtectedRoute>
|
<Profile />
|
||||||
}
|
</ProtectedRoute>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/admin"
|
<Route
|
||||||
element={
|
path="/admin"
|
||||||
<ProtectedRoute>
|
element={
|
||||||
<Admin />
|
<ProtectedRoute>
|
||||||
</ProtectedRoute>
|
<Admin />
|
||||||
}
|
</ProtectedRoute>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/editor/:id"
|
<Route
|
||||||
element={
|
path="/editor/:id"
|
||||||
<ProtectedRoute>
|
element={
|
||||||
<Editor />
|
<ProtectedRoute>
|
||||||
</ProtectedRoute>
|
<Editor />
|
||||||
}
|
</ProtectedRoute>
|
||||||
/>
|
}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
/>
|
||||||
</Routes>
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</UploadProvider>
|
</UploadProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
+27
-10
@@ -218,33 +218,50 @@ const deserializeDrawing = (drawing: unknown): Drawing => {
|
|||||||
return deserializeTimestamps(drawing as HasTimestamps & Drawing);
|
return deserializeTimestamps(drawing as HasTimestamps & Drawing);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface PaginatedDrawings<T> {
|
||||||
|
drawings: T[];
|
||||||
|
totalCount: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDrawings(
|
export function getDrawings(
|
||||||
search?: string,
|
search?: string,
|
||||||
collectionId?: string | null
|
collectionId?: string | null,
|
||||||
): Promise<DrawingSummary[]>;
|
options?: { limit?: number; offset?: number }
|
||||||
|
): Promise<PaginatedDrawings<DrawingSummary>>;
|
||||||
|
|
||||||
export function getDrawings(
|
export function getDrawings(
|
||||||
search: string | undefined,
|
search: string | undefined,
|
||||||
collectionId: string | null | undefined,
|
collectionId: string | null | undefined,
|
||||||
options: { includeData: true }
|
options: { includeData: true; limit?: number; offset?: number }
|
||||||
): Promise<Drawing[]>;
|
): Promise<PaginatedDrawings<Drawing>>;
|
||||||
|
|
||||||
export async function getDrawings(
|
export async function getDrawings(
|
||||||
search?: string,
|
search?: string,
|
||||||
collectionId?: string | null,
|
collectionId?: string | null,
|
||||||
options?: { includeData?: boolean }
|
options?: { includeData?: boolean; limit?: number; offset?: number }
|
||||||
) {
|
) {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string | number> = {};
|
||||||
if (search) params.search = search;
|
if (search) params.search = search;
|
||||||
if (collectionId !== undefined)
|
if (collectionId !== undefined)
|
||||||
params.collectionId = collectionId === null ? "null" : collectionId;
|
params.collectionId = collectionId === null ? "null" : collectionId;
|
||||||
|
if (options?.limit !== undefined) params.limit = options.limit;
|
||||||
|
if (options?.offset !== undefined) params.offset = options.offset;
|
||||||
|
|
||||||
if (options?.includeData) {
|
if (options?.includeData) {
|
||||||
params.includeData = "true";
|
params.includeData = "true";
|
||||||
const response = await api.get<Drawing[]>("/drawings", { params });
|
const response = await api.get<PaginatedDrawings<Drawing>>("/drawings", { params });
|
||||||
return response.data.map(deserializeDrawing);
|
return {
|
||||||
|
...response.data,
|
||||||
|
drawings: response.data.drawings.map(deserializeDrawing)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const response = await api.get<DrawingSummary[]>("/drawings", { params });
|
const response = await api.get<PaginatedDrawings<DrawingSummary>>("/drawings", { params });
|
||||||
return response.data.map(deserializeDrawingSummary);
|
return {
|
||||||
|
...response.data,
|
||||||
|
drawings: response.data.drawings.map(deserializeDrawingSummary)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDrawing = async (id: string) => {
|
export const getDrawing = async (id: string) => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download,
|
|||||||
import type { DrawingSummary, Collection, Drawing } from '../types';
|
import type { DrawingSummary, Collection, Drawing } from '../types';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { exportToSvg } from "@excalidraw/excalidraw";
|
// import { exportToSvg } from "@excalidraw/excalidraw"; // Lazy load this instead
|
||||||
import { exportDrawingToFile } from '../utils/exportUtils';
|
import { exportDrawingToFile } from '../utils/exportUtils';
|
||||||
|
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
@@ -112,6 +112,11 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!data?.elements || !data?.appState) return;
|
if (!data?.elements || !data?.appState) return;
|
||||||
|
|
||||||
|
// Lazy load exportToSvg to keep the main bundle small
|
||||||
|
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
const svg = await exportToSvg({
|
const svg = await exportToSvg({
|
||||||
elements: data.elements,
|
elements: data.elements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -243,18 +248,18 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
|
|
||||||
{previewSvg ? (
|
{previewSvg ? (
|
||||||
<div
|
<div
|
||||||
className="w-full h-full p-6 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:object-contain [&>svg]:drop-shadow-sm dark:[&>svg]:invert dark:[&>svg_rect[fill='white']]:opacity-0 dark:[&>svg_rect[fill='#ffffff']]:opacity-0 transition-transform duration-500 group-hover:scale-105"
|
className="w-full h-full p-3 sm:p-4 lg:p-5 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:object-contain [&>svg]:drop-shadow-sm dark:[&>svg]:invert dark:[&>svg_rect[fill='white']]:opacity-0 dark:[&>svg_rect[fill='#ffffff']]:opacity-0 transition-transform duration-500 group-hover:scale-105"
|
||||||
dangerouslySetInnerHTML={{ __html: previewSvg }}
|
dangerouslySetInnerHTML={{ __html: previewSvg }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-24 h-24 bg-white dark:bg-neutral-900 rounded-2xl shadow-sm flex items-center justify-center text-neutral-300 dark:text-neutral-400 border border-slate-100 dark:border-neutral-700 transform group-hover:scale-110 group-hover:rotate-3 transition-all duration-500">
|
<div className="w-16 h-16 sm:w-20 sm:h-20 lg:w-24 lg:h-24 bg-white dark:bg-neutral-900 rounded-2xl shadow-sm flex items-center justify-center text-neutral-300 dark:text-neutral-400 border border-slate-100 dark:border-neutral-700 transform group-hover:scale-110 group-hover:rotate-3 transition-all duration-500">
|
||||||
<PenTool size={40} strokeWidth={1.5} />
|
<PenTool size={32} strokeWidth={1.5} className="sm:w-9 sm:h-9 lg:w-10 lg:h-10" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-5 bg-white dark:bg-neutral-900 rounded-b-2xl relative z-10">
|
<div className="p-3 sm:p-4 lg:p-5 bg-white dark:bg-neutral-900 rounded-b-2xl relative z-10">
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleRenameSubmit}
|
onSubmit={handleRenameSubmit}
|
||||||
@@ -270,12 +275,12 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
onBlur={() => setIsRenaming(false)}
|
onBlur={() => setIsRenaming(false)}
|
||||||
onDragStart={(e) => e.stopPropagation()}
|
onDragStart={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
className="w-full px-2 py-1 -ml-2 text-base font-bold text-slate-900 dark:text-white border-2 border-black dark:border-neutral-600 rounded-lg focus:outline-none shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] bg-white dark:bg-neutral-800"
|
className="w-full px-2 py-1 -ml-2 text-sm sm:text-base font-bold text-slate-900 dark:text-white border-2 border-black dark:border-neutral-600 rounded-lg focus:outline-none shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] bg-white dark:bg-neutral-800"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<h3
|
<h3
|
||||||
className="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) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -285,9 +290,9 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
{drawing.name}
|
{drawing.name}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between mt-3 relative">
|
<div className="flex items-center justify-between mt-2.5 sm:mt-3 relative">
|
||||||
<p className="text-[11px] font-medium text-slate-400 dark:text-neutral-500 flex items-center gap-1.5">
|
<p className="text-[10px] sm:text-[11px] font-medium text-slate-400 dark:text-neutral-500 flex items-center gap-1 sm:gap-1.5">
|
||||||
<Clock size={11} />
|
<Clock size={10} className="sm:w-[11px] sm:h-[11px]" />
|
||||||
{formatDistanceToNow(drawing.updatedAt)} ago
|
{formatDistanceToNow(drawing.updatedAt)} ago
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -451,4 +456,3 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { getOrCreateBrowserFingerprint, getFingerprintInitials } from '../utils/identity';
|
||||||
const DEVICE_ID_KEY = 'excalidash-device-id';
|
|
||||||
|
|
||||||
const getOrCreateDeviceId = (): string => {
|
|
||||||
if (typeof window === 'undefined') return 'server';
|
|
||||||
const existing = localStorage.getItem(DEVICE_ID_KEY);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const generated =
|
|
||||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
|
|
||||||
localStorage.setItem(DEVICE_ID_KEY, generated);
|
|
||||||
return generated;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fnv1a = (input: string): number => {
|
const fnv1a = (input: string): number => {
|
||||||
let hash = 0x811c9dc5;
|
let hash = 0x811c9dc5;
|
||||||
@@ -27,99 +12,38 @@ const fnv1a = (input: string): number => {
|
|||||||
|
|
||||||
const toHsl = (n: number) => {
|
const toHsl = (n: number) => {
|
||||||
const hue = n % 360;
|
const hue = n % 360;
|
||||||
const sat = 60 + (n % 20);
|
const sat = 55 + (n % 20);
|
||||||
const light = 45 + (n % 10);
|
const light = 42 + (n % 12);
|
||||||
return `hsl(${hue} ${sat}% ${light}%)`;
|
return `hsl(${hue} ${sat}% ${light}%)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildPattern = (seed: string) => {
|
|
||||||
let x = fnv1a(seed);
|
|
||||||
const nextBit = () => {
|
|
||||||
// xorshift32
|
|
||||||
x ^= x << 13;
|
|
||||||
x ^= x >>> 17;
|
|
||||||
x ^= x << 5;
|
|
||||||
return (x >>> 0) & 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cells: boolean[][] = Array.from({ length: 5 }, () => Array.from({ length: 5 }, () => false));
|
|
||||||
|
|
||||||
// Generate left 3 columns, mirror to 5.
|
|
||||||
for (let row = 0; row < 5; row += 1) {
|
|
||||||
for (let col = 0; col < 3; col += 1) {
|
|
||||||
const on = nextBit() === 1;
|
|
||||||
cells[row][col] = on;
|
|
||||||
cells[row][4 - col] = on;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const foreground = toHsl(x);
|
|
||||||
const background = 'hsl(0 0% 98%)';
|
|
||||||
const backgroundDark = 'hsl(0 0% 12%)';
|
|
||||||
|
|
||||||
return { cells, foreground, background, backgroundDark };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FingerprintAvatar: React.FC<{
|
export const FingerprintAvatar: React.FC<{
|
||||||
size?: number;
|
size?: number;
|
||||||
seed?: string;
|
seed?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ size = 32, seed, title = 'Device fingerprint', className }) => {
|
}> = ({ size = 32, seed, title = 'Browser fingerprint avatar', className }) => {
|
||||||
const [deviceId] = useState(() => getOrCreateDeviceId());
|
const [deviceId] = useState(() => getOrCreateBrowserFingerprint());
|
||||||
const effectiveSeed = seed || deviceId;
|
const effectiveSeed = seed || deviceId;
|
||||||
|
|
||||||
const { cells, foreground, background, backgroundDark } = useMemo(
|
const initials = useMemo(() => getFingerprintInitials(effectiveSeed), [effectiveSeed]);
|
||||||
() => buildPattern(effectiveSeed),
|
const background = useMemo(() => toHsl(fnv1a(effectiveSeed)), [effectiveSeed]);
|
||||||
[effectiveSeed]
|
|
||||||
);
|
|
||||||
|
|
||||||
const padding = 0.5;
|
|
||||||
const viewBox = `${-padding} ${-padding} ${5 + padding * 2} ${5 + padding * 2}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<div
|
||||||
width={size}
|
title={title}
|
||||||
height={size}
|
|
||||||
viewBox={viewBox}
|
|
||||||
role="img"
|
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
className={className}
|
className={className}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: 10,
|
||||||
|
background,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<title>{title}</title>
|
<div className="w-full h-full flex items-center justify-center font-bold text-white text-xs select-none">
|
||||||
<rect
|
{initials}
|
||||||
x={-padding}
|
</div>
|
||||||
y={-padding}
|
</div>
|
||||||
width={5 + padding * 2}
|
|
||||||
height={5 + padding * 2}
|
|
||||||
rx={1.4}
|
|
||||||
fill={background}
|
|
||||||
className="dark:hidden"
|
|
||||||
/>
|
|
||||||
<rect
|
|
||||||
x={-padding}
|
|
||||||
y={-padding}
|
|
||||||
width={5 + padding * 2}
|
|
||||||
height={5 + padding * 2}
|
|
||||||
rx={1.4}
|
|
||||||
fill={backgroundDark}
|
|
||||||
className="hidden dark:block"
|
|
||||||
/>
|
|
||||||
{cells.map((row, r) =>
|
|
||||||
row.map((on, c) =>
|
|
||||||
on ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} rx={0.2} fill={foreground} /> : null
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<rect
|
|
||||||
x={-padding}
|
|
||||||
y={-padding}
|
|
||||||
width={5 + padding * 2}
|
|
||||||
height={5 + padding * 2}
|
|
||||||
rx={1.4}
|
|
||||||
fill="none"
|
|
||||||
stroke="rgba(0,0,0,0.25)"
|
|
||||||
className="dark:stroke-neutral-700"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import clsx from 'clsx';
|
|||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
import { Logo } from './Logo';
|
import { Logo } from './Logo';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { FingerprintAvatar } from './FingerprintAvatar';
|
|
||||||
import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation';
|
import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -112,6 +111,16 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getInitialsFromName = (name: string): string => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return 'U';
|
||||||
|
const parts = trimmed.split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return trimmed.slice(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
@@ -374,14 +383,16 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<div className="px-3 py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
|
<div className="py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-3">
|
||||||
<FingerprintAvatar size={28} className="flex-shrink-0 sm:hidden" title="Browser profile" />
|
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-indigo-600 text-white font-bold flex items-center justify-center">
|
||||||
<FingerprintAvatar size={32} className="flex-shrink-0 hidden sm:block" title="Browser profile" />
|
{getInitialsFromName(user.name)}
|
||||||
<div className="min-w-0">
|
</div>
|
||||||
|
<div className="min-w-0 text-left">
|
||||||
<div className="font-semibold text-slate-700 dark:text-neutral-300 truncate leading-tight">{user.name}</div>
|
<div className="font-semibold text-slate-700 dark:text-neutral-300 truncate leading-tight">{user.name}</div>
|
||||||
<div className="truncate leading-tight">{user.email}</div>
|
<div className="truncate leading-tight">{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-7 h-7 sm:w-8 sm:h-8 invisible" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,12 +41,16 @@ const DragOverlayPortal: React.FC<{ children: React.ReactNode }> = ({ children }
|
|||||||
return createPortal(children, document.body);
|
return createPortal(children, document.body);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
export const Dashboard: React.FC = () => {
|
export const Dashboard: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [drawings, setDrawings] = useState<DrawingSummary[]>([]);
|
const [drawings, setDrawings] = useState<DrawingSummary[]>([]);
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [isFetchingMore, setIsFetchingMore] = useState(false);
|
||||||
|
|
||||||
const selectedCollectionId = React.useMemo(() => {
|
const selectedCollectionId = React.useMemo(() => {
|
||||||
if (location.pathname === '/') return undefined;
|
if (location.pathname === '/') return undefined;
|
||||||
@@ -85,6 +89,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
const [dragCurrent, setDragCurrent] = useState<Point | null>(null);
|
const [dragCurrent, setDragCurrent] = useState<Point | null>(null);
|
||||||
const [potentialDragId, setPotentialDragId] = useState<string | null>(null);
|
const [potentialDragId, setPotentialDragId] = useState<string | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loaderRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
type SortField = 'name' | 'createdAt' | 'updatedAt';
|
type SortField = 'name' | 'createdAt' | 'updatedAt';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
@@ -101,14 +106,17 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const { uploadFiles } = useUpload();
|
const { uploadFiles } = useUpload();
|
||||||
|
|
||||||
|
const hasMore = drawings.length < totalCount;
|
||||||
|
|
||||||
const refreshData = useCallback(async () => {
|
const refreshData = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [drawingsData, collectionsData] = await Promise.all([
|
const [drawingsRes, collectionsData] = await Promise.all([
|
||||||
api.getDrawings(debouncedSearch, selectedCollectionId),
|
api.getDrawings(debouncedSearch, selectedCollectionId, { limit: PAGE_SIZE, offset: 0 }),
|
||||||
api.getCollections()
|
api.getCollections()
|
||||||
]);
|
]);
|
||||||
setDrawings(drawingsData);
|
setDrawings(drawingsRes.drawings);
|
||||||
|
setTotalCount(drawingsRes.totalCount);
|
||||||
setCollections(collectionsData);
|
setCollections(collectionsData);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -118,10 +126,45 @@ export const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [debouncedSearch, selectedCollectionId]);
|
}, [debouncedSearch, selectedCollectionId]);
|
||||||
|
|
||||||
|
const fetchMore = useCallback(async () => {
|
||||||
|
if (isFetchingMore || !hasMore || isLoading) return;
|
||||||
|
setIsFetchingMore(true);
|
||||||
|
try {
|
||||||
|
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: drawings.length
|
||||||
|
});
|
||||||
|
setDrawings(prev => [...prev, ...drawingsRes.drawings]);
|
||||||
|
setTotalCount(drawingsRes.totalCount);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch more data:', err);
|
||||||
|
} finally {
|
||||||
|
setIsFetchingMore(false);
|
||||||
|
}
|
||||||
|
}, [isFetchingMore, hasMore, isLoading, debouncedSearch, selectedCollectionId, drawings.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshData();
|
refreshData();
|
||||||
}, [refreshData]);
|
}, [refreshData]);
|
||||||
|
|
||||||
|
// Infinite scroll observer
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMore) {
|
||||||
|
fetchMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loaderRef.current) {
|
||||||
|
observer.observe(loaderRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [fetchMore, hasMore]);
|
||||||
|
|
||||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
@@ -324,7 +367,13 @@ export const Dashboard: React.FC = () => {
|
|||||||
const trashId = 'trash';
|
const trashId = 'trash';
|
||||||
|
|
||||||
// Optimistic Remove from current view
|
// Optimistic Remove from current view
|
||||||
setDrawings(prev => prev.filter(d => d.id !== id));
|
setDrawings(prev => {
|
||||||
|
const next = prev.filter(d => d.id !== id);
|
||||||
|
if (next.length !== prev.length) {
|
||||||
|
setTotalCount(t => t - 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setSelectedIds(prev => { const s = new Set(prev); s.delete(id); return s; });
|
setSelectedIds(prev => { const s = new Set(prev); s.delete(id); return s; });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -337,7 +386,13 @@ export const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const executePermanentDelete = async (id: string) => {
|
const executePermanentDelete = async (id: string) => {
|
||||||
setDrawings(prev => prev.filter(d => d.id !== id));
|
setDrawings(prev => {
|
||||||
|
const next = prev.filter(d => d.id !== id);
|
||||||
|
if (next.length !== prev.length) {
|
||||||
|
setTotalCount(t => t - 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setSelectedIds(prev => { const s = new Set(prev); s.delete(id); return s; });
|
setSelectedIds(prev => { const s = new Set(prev); s.delete(id); return s; });
|
||||||
setDrawingToDelete(null); // Close modal immediately
|
setDrawingToDelete(null); // Close modal immediately
|
||||||
|
|
||||||
@@ -393,7 +448,11 @@ export const Dashboard: React.FC = () => {
|
|||||||
const trashId = 'trash';
|
const trashId = 'trash';
|
||||||
const ids = Array.from(selectedIds);
|
const ids = Array.from(selectedIds);
|
||||||
|
|
||||||
setDrawings(prev => prev.filter(d => !selectedIds.has(d.id)));
|
setDrawings(prev => {
|
||||||
|
const next = prev.filter(d => !selectedIds.has(d.id));
|
||||||
|
setTotalCount(t => t - (prev.length - next.length));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -406,7 +465,11 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const executeBulkPermanentDelete = async () => {
|
const executeBulkPermanentDelete = async () => {
|
||||||
const ids = Array.from(selectedIds);
|
const ids = Array.from(selectedIds);
|
||||||
setDrawings(prev => prev.filter(d => !selectedIds.has(d.id)));
|
setDrawings(prev => {
|
||||||
|
const next = prev.filter(d => !selectedIds.has(d.id));
|
||||||
|
setTotalCount(t => t - (prev.length - next.length));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setShowBulkDeleteConfirm(false);
|
setShowBulkDeleteConfirm(false);
|
||||||
|
|
||||||
@@ -427,10 +490,12 @@ export const Dashboard: React.FC = () => {
|
|||||||
setDrawings(prev => {
|
setDrawings(prev => {
|
||||||
const updated = prev.map(d => selectedIds.has(d.id) ? { ...d, collectionId } : d);
|
const updated = prev.map(d => selectedIds.has(d.id) ? { ...d, collectionId } : d);
|
||||||
if (selectedCollectionId === undefined) return updated;
|
if (selectedCollectionId === undefined) return updated;
|
||||||
return updated.filter(d => {
|
const next = updated.filter(d => {
|
||||||
if (selectedCollectionId === null) return d.collectionId === null;
|
if (selectedCollectionId === null) return d.collectionId === null;
|
||||||
return d.collectionId === selectedCollectionId;
|
return d.collectionId === selectedCollectionId;
|
||||||
});
|
});
|
||||||
|
setTotalCount(t => t - (prev.length - next.length));
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
setSelectedIds(new Set()); // Clear selection after move
|
setSelectedIds(new Set()); // Clear selection after move
|
||||||
setShowBulkMoveMenu(false);
|
setShowBulkMoveMenu(false);
|
||||||
@@ -467,12 +532,16 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const handleMoveToCollection = async (id: string, collectionId: string | null) => {
|
const handleMoveToCollection = async (id: string, collectionId: string | null) => {
|
||||||
setDrawings(prev => {
|
setDrawings(prev => {
|
||||||
return prev.map(d => d.id === id ? { ...d, collectionId } : d)
|
const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d);
|
||||||
.filter(d => {
|
const next = updated.filter(d => {
|
||||||
if (selectedCollectionId === undefined) return true;
|
if (selectedCollectionId === undefined) return true;
|
||||||
if (selectedCollectionId === null) return d.collectionId === null;
|
if (selectedCollectionId === null) return d.collectionId === null;
|
||||||
return d.collectionId === selectedCollectionId;
|
return d.collectionId === selectedCollectionId;
|
||||||
});
|
});
|
||||||
|
if (next.length !== prev.length) {
|
||||||
|
setTotalCount(t => t - 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await api.updateDrawing(id, { collectionId });
|
await api.updateDrawing(id, { collectionId });
|
||||||
@@ -567,10 +636,12 @@ export const Dashboard: React.FC = () => {
|
|||||||
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);
|
||||||
if (selectedCollectionId === undefined) return updated;
|
if (selectedCollectionId === undefined) return updated;
|
||||||
return updated.filter(d => {
|
const next = updated.filter(d => {
|
||||||
if (selectedCollectionId === null) return d.collectionId === null;
|
if (selectedCollectionId === null) return d.collectionId === null;
|
||||||
return d.collectionId === selectedCollectionId;
|
return d.collectionId === selectedCollectionId;
|
||||||
});
|
});
|
||||||
|
setTotalCount(t => t - (prev.length - next.length));
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear selection if we moved selected items
|
// Clear selection if we moved selected items
|
||||||
@@ -678,8 +749,8 @@ export const Dashboard: React.FC = () => {
|
|||||||
{viewTitle}
|
{viewTitle}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mb-8 flex flex-col xl:flex-row items-center justify-between gap-4">
|
<div className="mb-8 flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||||
<div className="flex flex-1 w-full gap-3 items-center flex-wrap">
|
<div className="flex flex-1 w-full lg:w-auto gap-3 items-center flex-wrap">
|
||||||
<div className="relative flex-1 group max-w-md transition-all duration-200 focus-within:-translate-y-0.5">
|
<div className="relative flex-1 group max-w-md transition-all duration-200 focus-within:-translate-y-0.5">
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
@@ -760,7 +831,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 w-full sm:w-auto justify-end">
|
<div className="flex items-center gap-3 w-full lg:w-auto justify-start lg:justify-end flex-wrap">
|
||||||
<div className="flex items-center gap-2 mr-2">
|
<div className="flex items-center gap-2 mr-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSelectAll}
|
onClick={handleSelectAll}
|
||||||
@@ -835,7 +906,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Inbox size={14} /> Unorganized
|
<Inbox size={14} /> Unorganized
|
||||||
</button>
|
</button>
|
||||||
{collections.filter(c => c.name !== 'Trash').map(c => (
|
{collections.filter(c => c.id !== 'trash').map(c => (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => handleBulkMove(c.id)}
|
onClick={() => handleBulkMove(c.id)}
|
||||||
@@ -933,7 +1004,10 @@ export const Dashboard: React.FC = () => {
|
|||||||
<Loader2 size={32} className="animate-spin" />
|
<Loader2 size={32} className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={clsx("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6 pb-16 sm:pb-24 transition-all duration-300", isDraggingFile && "opacity-20 blur-sm")}>
|
<div
|
||||||
|
className={clsx("grid gap-3 sm:gap-4 pb-16 sm:pb-24 transition-all duration-300", isDraggingFile && "opacity-20 blur-sm")}
|
||||||
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))' }}
|
||||||
|
>
|
||||||
{sortedDrawings.length === 0 ? (
|
{sortedDrawings.length === 0 ? (
|
||||||
<div className="col-span-full flex flex-col items-center justify-center py-16 sm:py-32 text-slate-400 dark:text-neutral-500 border-2 border-dashed border-slate-200 dark:border-neutral-700 rounded-3xl bg-slate-50/50 dark:bg-neutral-800/50">
|
<div className="col-span-full flex flex-col items-center justify-center py-16 sm:py-32 text-slate-400 dark:text-neutral-500 border-2 border-dashed border-slate-200 dark:border-neutral-700 rounded-3xl bg-slate-50/50 dark:bg-neutral-800/50">
|
||||||
<div className="w-20 h-20 bg-white dark:bg-slate-800 rounded-full shadow-sm border border-slate-100 dark:border-slate-700 flex items-center justify-center mb-6">
|
<div className="w-20 h-20 bg-white dark:bg-slate-800 rounded-full shadow-sm border border-slate-100 dark:border-slate-700 flex items-center justify-center mb-6">
|
||||||
@@ -983,6 +1057,16 @@ export const Dashboard: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Infinite Scroll Trigger */}
|
||||||
|
<div ref={loaderRef} className="py-8 flex justify-center items-center h-20">
|
||||||
|
{isFetchingMore && (
|
||||||
|
<div className="flex items-center gap-2 text-indigo-600 font-bold animate-in fade-in slide-in-from-bottom-2">
|
||||||
|
<Loader2 size={24} className="animate-spin" />
|
||||||
|
<span>Loading more...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
|
|||||||
@@ -85,13 +85,14 @@ const UIOptions = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to generate initials from a name
|
|
||||||
const getInitialsFromName = (name: string): string => {
|
const getInitialsFromName = (name: string): string => {
|
||||||
const parts = name.trim().split(/\s+/);
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return 'U';
|
||||||
|
const parts = trimmed.split(/\s+/).filter(Boolean);
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
||||||
}
|
}
|
||||||
return name.substring(0, 2).toUpperCase().padEnd(2, name[0] || 'U');
|
return trimmed.slice(0, 2).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to generate a color from a string (consistent hash)
|
// Helper function to generate a color from a string (consistent hash)
|
||||||
@@ -118,6 +119,7 @@ export const Editor: React.FC = () => {
|
|||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [initialData, setInitialData] = useState<any>(null);
|
const [initialData, setInitialData] = useState<any>(null);
|
||||||
const [isSceneLoading, setIsSceneLoading] = useState(true);
|
const [isSceneLoading, setIsSceneLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [isSavingOnLeave, setIsSavingOnLeave] = useState(false);
|
const [isSavingOnLeave, setIsSavingOnLeave] = useState(false);
|
||||||
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
|
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
|
||||||
const [autoHideEnabled, setAutoHideEnabled] = useState(true);
|
const [autoHideEnabled, setAutoHideEnabled] = useState(true);
|
||||||
@@ -326,7 +328,6 @@ export const Editor: React.FC = () => {
|
|||||||
button: data.button || 'up',
|
button: data.button || 'up',
|
||||||
selectedElementIds: data.selectedElementIds || {},
|
selectedElementIds: data.selectedElementIds || {},
|
||||||
username: data.username,
|
username: data.username,
|
||||||
avatarUrl: data.avatarUrl,
|
|
||||||
color: { background: data.color, stroke: data.color },
|
color: { background: data.color, stroke: data.color },
|
||||||
id: data.userId,
|
id: data.userId,
|
||||||
});
|
});
|
||||||
@@ -664,6 +665,7 @@ export const Editor: React.FC = () => {
|
|||||||
excalidrawAPI.current = null;
|
excalidrawAPI.current = null;
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setIsSceneLoading(true);
|
setIsSceneLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
setInitialData(null);
|
setInitialData(null);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -712,11 +714,26 @@ export const Editor: React.FC = () => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load drawing', err);
|
console.error('Failed to load drawing', err);
|
||||||
toast.error("Failed to load drawing");
|
let message = "Failed to load drawing";
|
||||||
|
if (api.isAxiosError(err)) {
|
||||||
|
const responseMessage =
|
||||||
|
typeof err.response?.data?.message === "string"
|
||||||
|
? err.response.data.message
|
||||||
|
: null;
|
||||||
|
if (responseMessage) {
|
||||||
|
message = responseMessage;
|
||||||
|
} else if (err.response?.status === 403) {
|
||||||
|
message = "You do not have access to this drawing";
|
||||||
|
} else if (err.response?.status === 404) {
|
||||||
|
message = "Drawing not found";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.error(message);
|
||||||
latestElementsRef.current = [];
|
latestElementsRef.current = [];
|
||||||
latestFilesRef.current = {};
|
latestFilesRef.current = {};
|
||||||
lastSyncedFilesRef.current = {};
|
lastSyncedFilesRef.current = {};
|
||||||
setInitialData(buildEmptyScene());
|
setLoadError(message);
|
||||||
|
setInitialData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSceneLoading(false);
|
setIsSceneLoading(false);
|
||||||
}
|
}
|
||||||
@@ -1014,7 +1031,24 @@ export const Editor: React.FC = () => {
|
|||||||
marginTop: isHeaderVisible ? '3.5rem' : '0'
|
marginTop: isHeaderVisible ? '3.5rem' : '0'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{initialData ? (
|
{loadError ? (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-white dark:bg-neutral-950 px-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Unable to open drawing
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{loadError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="px-4 py-2 rounded-lg border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100 font-semibold hover:bg-gray-50 dark:hover:bg-neutral-800 transition-colors"
|
||||||
|
>
|
||||||
|
Back to dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : initialData ? (
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
key={id}
|
key={id}
|
||||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
theme={theme === 'dark' ? 'dark' : 'light'}
|
||||||
|
|||||||
@@ -255,17 +255,17 @@ export const Settings: React.FC = () => {
|
|||||||
Exports an `.excalidash` archive (zip) organized by collections
|
Exports an `.excalidash` archive (zip) organized by collections
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex items-center gap-2 pt-2">
|
<div className="w-full flex flex-col items-stretch gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={exportBackup}
|
onClick={exportBackup}
|
||||||
className="flex-1 px-4 py-2 text-sm font-bold rounded-xl border-2 border-black dark:border-neutral-700 bg-indigo-600 text-white shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-0.5 transition-all"
|
className="w-full px-4 py-2 text-sm font-bold rounded-xl border-2 border-black dark:border-neutral-700 bg-indigo-600 text-white shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-0.5 transition-all"
|
||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
<select
|
<select
|
||||||
value={backupExportExt}
|
value={backupExportExt}
|
||||||
onChange={(e) => setBackupExportExt(e.target.value as any)}
|
onChange={(e) => setBackupExportExt(e.target.value as any)}
|
||||||
className="px-3 py-2 text-sm font-bold rounded-xl border-2 border-slate-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-slate-900 dark:text-white"
|
className="w-full px-3 py-2 text-sm font-bold rounded-xl border-2 border-slate-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-slate-900 dark:text-white"
|
||||||
title="Download name"
|
title="Download name"
|
||||||
>
|
>
|
||||||
<option value="excalidash">.excalidash</option>
|
<option value="excalidash">.excalidash</option>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface UserIdentity {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEVICE_ID_KEY = "excalidash-device-id";
|
||||||
|
|
||||||
const TRANSFORMERS = [
|
const TRANSFORMERS = [
|
||||||
{ name: "Optimus Prime", initials: "OP" },
|
{ name: "Optimus Prime", initials: "OP" },
|
||||||
{ name: "Megatron", initials: "ME" },
|
{ name: "Megatron", initials: "ME" },
|
||||||
@@ -80,6 +82,17 @@ const COLORS = [
|
|||||||
"#f43f5e", // rose-500
|
"#f43f5e", // rose-500
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
|
||||||
|
const hashString = (input: string): number => {
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
hash ^= input.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
const generateClientId = (): string => {
|
const generateClientId = (): string => {
|
||||||
const cryptoObj: Crypto | undefined =
|
const cryptoObj: Crypto | undefined =
|
||||||
typeof globalThis !== "undefined"
|
typeof globalThis !== "undefined"
|
||||||
@@ -105,20 +118,42 @@ const generateClientId = (): string => {
|
|||||||
return `id-${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
|
return `id-${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getOrCreateBrowserFingerprint = (): string => {
|
||||||
|
const existing = localStorage.getItem(DEVICE_ID_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const generated = generateClientId();
|
||||||
|
localStorage.setItem(DEVICE_ID_KEY, generated);
|
||||||
|
return generated;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFingerprintInitials = (seed?: string): string => {
|
||||||
|
const fingerprint = seed || getOrCreateBrowserFingerprint();
|
||||||
|
const hash = hashString(fingerprint);
|
||||||
|
const first = ALPHABET[hash % ALPHABET.length];
|
||||||
|
const second = ALPHABET[Math.floor(hash / ALPHABET.length) % ALPHABET.length];
|
||||||
|
return `${first}${second}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const getUserIdentity = (): UserIdentity => {
|
export const getUserIdentity = (): UserIdentity => {
|
||||||
const stored = localStorage.getItem("excalidash-user-id");
|
const stored = localStorage.getItem("excalidash-user-id");
|
||||||
if (stored) {
|
if (stored) {
|
||||||
return JSON.parse(stored);
|
const parsed = JSON.parse(stored) as UserIdentity;
|
||||||
|
if (!parsed.initials || parsed.initials.length !== 2) {
|
||||||
|
parsed.initials = getFingerprintInitials(parsed.id);
|
||||||
|
localStorage.setItem("excalidash-user-id", JSON.stringify(parsed));
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deviceId = getOrCreateBrowserFingerprint();
|
||||||
const randomTransformer =
|
const randomTransformer =
|
||||||
TRANSFORMERS[Math.floor(Math.random() * TRANSFORMERS.length)];
|
TRANSFORMERS[Math.floor(Math.random() * TRANSFORMERS.length)];
|
||||||
const randomColor = COLORS[Math.floor(Math.random() * COLORS.length)];
|
const randomColor = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||||
|
|
||||||
const identity: UserIdentity = {
|
const identity: UserIdentity = {
|
||||||
id: generateClientId(),
|
id: deviceId,
|
||||||
name: randomTransformer.name,
|
name: randomTransformer.name,
|
||||||
initials: randomTransformer.initials,
|
initials: getFingerprintInitials(deviceId),
|
||||||
color: randomColor,
|
color: randomColor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user