refactor index.ts

This commit is contained in:
Zimeng Xiong
2026-02-07 17:47:41 -08:00
parent 35bbbb9599
commit 6bee0e2ded
32 changed files with 3484 additions and 3143 deletions
+87 -18
View File
@@ -73,6 +73,84 @@ export const clearCsrfToken = (): void => {
csrfToken = null;
};
export interface AuthStatusResponse {
authEnabled?: boolean;
enabled?: boolean;
bootstrapRequired?: boolean;
}
export interface AuthUser {
id: string;
username?: string | null;
email: string;
name: string;
role?: string;
mustResetPassword?: boolean;
}
export const authStatus = async (): Promise<AuthStatusResponse> => {
const response = await axios.get<AuthStatusResponse>(
`${API_URL}/auth/status`,
{ withCredentials: true }
);
return response.data;
};
export const authMe = async (accessToken: string): Promise<{ user: AuthUser }> => {
const response = await axios.get<{ user: AuthUser }>(`${API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
withCredentials: true,
});
return response.data;
};
export const authRefresh = async (
refreshToken: string
): Promise<{ accessToken: string; refreshToken?: string }> => {
const response = await axios.post<{ accessToken: string; refreshToken?: string }>(
`${API_URL}/auth/refresh`,
{ refreshToken },
{ withCredentials: true }
);
return response.data;
};
export const authLogin = async (
email: string,
password: string
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> => {
const response = await axios.post<{ user: AuthUser; accessToken: string; refreshToken: string }>(
`${API_URL}/auth/login`,
{ email, password },
{ withCredentials: true }
);
return response.data;
};
export const authRegister = async (
email: string,
password: string,
name: string
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> => {
const response = await axios.post<{ user: AuthUser; accessToken: string; refreshToken: string }>(
`${API_URL}/auth/register`,
{ email, password, name },
{ withCredentials: true }
);
return response.data;
};
export const authPasswordResetConfirm = async (
token: string,
password: string
): Promise<void> => {
await axios.post(
`${API_URL}/auth/password-reset-confirm`,
{ token, password },
{ withCredentials: true }
);
};
const clearStoredAuth = () => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
@@ -100,15 +178,12 @@ const getAuthEnabledStatus = async (): Promise<boolean | null> => {
}
try {
const response = await axios.get<{ authEnabled?: boolean; enabled?: boolean }>(
`${API_URL}/auth/status`,
{ withCredentials: true }
);
const response = await authStatus();
const enabled =
typeof response.data?.authEnabled === "boolean"
? response.data.authEnabled
: typeof response.data?.enabled === "boolean"
? response.data.enabled
typeof response?.authEnabled === "boolean"
? response.authEnabled
: typeof response?.enabled === "boolean"
? response.enabled
: true;
cacheAuthEnabled(enabled);
return enabled;
@@ -135,22 +210,16 @@ const refreshAccessToken = async (): Promise<string> => {
throw new Error("Missing refresh token");
}
const refreshResponse = await axios.post(
`${API_URL}/auth/refresh`,
{
refreshToken,
},
{ withCredentials: true }
);
const refreshResponse = await authRefresh(refreshToken);
const nextAccessToken = String(refreshResponse.data.accessToken || "");
const nextAccessToken = String(refreshResponse.accessToken || "");
if (!nextAccessToken) {
throw new Error("Missing access token in refresh response");
}
localStorage.setItem(TOKEN_KEY, nextAccessToken);
if (refreshResponse.data.refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, refreshResponse.data.refreshToken);
if (refreshResponse.refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, refreshResponse.refreshToken);
}
return nextAccessToken;
+33 -52
View File
@@ -1,9 +1,14 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || "/api";
import {
authStatus,
authMe,
authRefresh,
authLogin,
authRegister,
isAxiosError,
} from '../api';
interface User {
id: string;
@@ -39,24 +44,21 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [bootstrapRequired, setBootstrapRequired] = useState(false);
const navigate = useNavigate();
// Load user from localStorage on mount
useEffect(() => {
const loadUser = async () => {
try {
// Determine auth mode first (single-user mode vs multi-user auth).
try {
const statusResponse = await axios.get(`${API_URL}/auth/status`);
const statusResponse = await authStatus();
const enabled =
typeof statusResponse.data?.authEnabled === "boolean"
? statusResponse.data.authEnabled
: typeof statusResponse.data?.enabled === "boolean"
? statusResponse.data.enabled
typeof statusResponse?.authEnabled === "boolean"
? statusResponse.authEnabled
: typeof statusResponse?.enabled === "boolean"
? statusResponse.enabled
: true;
setAuthEnabled(enabled);
localStorage.setItem(AUTH_ENABLED_CACHE_KEY, String(enabled));
setBootstrapRequired(Boolean(statusResponse.data?.bootstrapRequired));
setBootstrapRequired(Boolean(statusResponse?.bootstrapRequired));
// In single-user mode, do not require login.
if (!enabled) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
@@ -75,7 +77,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setUser(null);
return;
}
// If status fails and no cached mode exists, default to auth-enabled mode.
setAuthEnabled(true);
setBootstrapRequired(false);
}
@@ -86,39 +87,28 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (storedUser && storedToken) {
const userData = JSON.parse(storedUser);
setUser(userData);
// Verify token is still valid by fetching user info
try {
const response = await axios.get(`${API_URL}/auth/me`, {
headers: {
Authorization: `Bearer ${storedToken}`,
},
});
setUser(response.data.user);
} catch (error) {
// Token invalid, try refresh
const response = await authMe(storedToken);
setUser(response.user);
} catch {
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (refreshToken) {
try {
const refreshResponse = await axios.post(`${API_URL}/auth/refresh`, {
refreshToken,
});
localStorage.setItem(TOKEN_KEY, refreshResponse.data.accessToken);
const userResponse = await axios.get(`${API_URL}/auth/me`, {
headers: {
Authorization: `Bearer ${refreshResponse.data.accessToken}`,
},
});
setUser(userResponse.data.user);
const refreshResponse = await authRefresh(refreshToken);
localStorage.setItem(TOKEN_KEY, refreshResponse.accessToken);
if (refreshResponse.refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, refreshResponse.refreshToken);
}
const userResponse = await authMe(refreshResponse.accessToken);
setUser(userResponse.user);
} catch {
// Refresh failed, clear auth but don't navigate during initial load
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setUser(null);
}
} else {
// No refresh token, clear auth
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
@@ -128,7 +118,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
} catch (error) {
console.error('Failed to load user:', error);
// Clear auth on error
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
@@ -146,12 +135,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (authEnabled === false) {
throw new Error("Authentication is disabled");
}
const response = await axios.post(`${API_URL}/auth/login`, {
email,
password,
});
const response = await authLogin(email, password);
const { user: userData, accessToken, refreshToken } = response.data;
const { user: userData, accessToken, refreshToken } = response;
localStorage.setItem(TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
@@ -159,8 +145,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setUser(userData);
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
const message =
if (isAxiosError(error)) {
const message =
typeof error.response?.data === 'object' &&
error.response.data !== null &&
'message' in error.response.data &&
@@ -178,13 +164,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (authEnabled === false) {
throw new Error("Authentication is disabled");
}
const response = await axios.post(`${API_URL}/auth/register`, {
email,
password,
name,
});
const response = await authRegister(email, password, name);
const { user: userData, accessToken, refreshToken } = response.data;
const { user: userData, accessToken, refreshToken } = response;
localStorage.setItem(TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
@@ -192,8 +174,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setUser(userData);
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
const message =
if (isAxiosError(error)) {
const message =
typeof error.response?.data === 'object' &&
error.response.data !== null &&
'message' in error.response.data &&
@@ -211,7 +193,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setUser(null);
// Navigate to login - use setTimeout to ensure Router is ready
setTimeout(() => {
navigate('/login');
}, 0);
+19 -68
View File
@@ -4,14 +4,13 @@ import { DrawingCard } from '../components/DrawingCard';
import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload, CheckSquare, Square, ArrowUp, ArrowDown, ChevronDown, FileText, Calendar, Clock } from 'lucide-react';
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import * as api from '../api';
import type { DrawingSummary, Collection } from '../types';
import type { DrawingSortField, SortDirection } from '../api';
import { useDebounce } from '../hooks/useDebounce';
import clsx from 'clsx';
import { ConfirmModal } from '../components/ConfirmModal';
import { useUpload } from '../context/UploadContext';
import { DragOverlayPortal, getSelectionBounds, type Point, type SelectionBounds } from './dashboard/shared';
import { isLatestRequest, mergeUniqueDrawings } from './dashboard/pagination';
import { useDashboardData } from './dashboard/useDashboardData';
const PAGE_SIZE = 24;
@@ -19,10 +18,6 @@ export const Dashboard: React.FC = () => {
const [searchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const [drawings, setDrawings] = useState<DrawingSummary[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isFetchingMore, setIsFetchingMore] = useState(false);
const selectedCollectionId = React.useMemo(() => {
if (location.pathname === '/') return undefined;
@@ -73,73 +68,29 @@ export const Dashboard: React.FC = () => {
direction: 'desc'
});
const [isLoading, setIsLoading] = useState(false);
const listRequestVersionRef = useRef(0);
const { uploadFiles } = useUpload();
const hasMore = drawings.length < totalCount;
const refreshData = useCallback(async () => {
const requestVersion = ++listRequestVersionRef.current;
setIsLoading(true);
try {
const [drawingsRes, collectionsData] = await Promise.all([
api.getDrawings(debouncedSearch, selectedCollectionId, {
limit: PAGE_SIZE,
offset: 0,
sortField: sortConfig.field,
sortDirection: sortConfig.direction,
}),
api.getCollections()
]);
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
setDrawings(drawingsRes.drawings);
setTotalCount(drawingsRes.totalCount);
setCollections(collectionsData);
setSelectedIds(new Set());
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
if (isLatestRequest(requestVersion, listRequestVersionRef.current)) {
setIsLoading(false);
}
}
}, [debouncedSearch, selectedCollectionId, sortConfig.field, sortConfig.direction]);
const fetchMore = useCallback(async () => {
if (isFetchingMore || !hasMore || isLoading) return;
const requestVersion = listRequestVersionRef.current;
setIsFetchingMore(true);
try {
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
limit: PAGE_SIZE,
offset: drawings.length,
sortField: sortConfig.field,
sortDirection: sortConfig.direction,
});
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
setDrawings(prev => mergeUniqueDrawings(prev, drawingsRes.drawings));
setTotalCount(drawingsRes.totalCount);
} catch (err) {
console.error('Failed to fetch more data:', err);
} finally {
setIsFetchingMore(false);
}
}, [
const resetSelection = useCallback(() => {
setSelectedIds(new Set());
}, []);
const {
drawings,
setDrawings,
collections,
setCollections,
setTotalCount,
isFetchingMore,
hasMore,
isLoading,
hasMore,
refreshData,
fetchMore,
} = useDashboardData({
debouncedSearch,
selectedCollectionId,
drawings.length,
sortConfig.field,
sortConfig.direction,
]);
useEffect(() => {
refreshData();
}, [refreshData]);
sortField: sortConfig.field,
sortDirection: sortConfig.direction,
pageSize: PAGE_SIZE,
onRefreshSuccess: resetSelection,
});
// Infinite scroll observer
useEffect(() => {
+8 -70
View File
@@ -7,7 +7,7 @@ import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import { Toaster, toast } from 'sonner';
import { io, Socket } from 'socket.io-client';
import { getUserIdentity, type UserIdentity } from '../utils/identity';
import type { UserIdentity } from '../utils/identity';
import { useAuth } from '../context/AuthContext';
import { reconcileElements } from '../utils/sync';
import { exportFromEditor } from '../utils/exportUtils';
@@ -15,9 +15,7 @@ import * as api from '../api';
import { useTheme } from '../context/ThemeContext';
import {
UIOptions,
getColorFromString,
getFilesDelta,
getInitialsFromName,
hasRenderableElements,
haveSameElements,
isSuspiciousEmptySnapshot,
@@ -25,6 +23,8 @@ import {
isStaleNonRenderableSnapshot,
} from './editor/shared';
import type { ElementVersionInfo } from './editor/shared';
import { useEditorChrome } from './editor/useEditorChrome';
import { useEditorIdentity } from './editor/useEditorIdentity';
interface Peer extends UserIdentity {
isActive: boolean;
@@ -49,75 +49,13 @@ export const Editor: React.FC = () => {
const [isSceneLoading, setIsSceneLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isSavingOnLeave, setIsSavingOnLeave] = useState(false);
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
const [autoHideEnabled, setAutoHideEnabled] = useState(true);
useEffect(() => {
document.title = `${drawingName} - ExcaliDash`;
return () => {
document.title = 'ExcaliDash';
};
}, [drawingName]);
// Auto-hide header based on mouse movement
useEffect(() => {
if (!autoHideEnabled || isRenaming) {
setIsHeaderVisible(true);
return;
}
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
let isInTriggerZone = false;
const handleMouseMove = throttle((e: MouseEvent) => {
const wasInTriggerZone = isInTriggerZone;
isInTriggerZone = e.clientY < 5;
if (isInTriggerZone) {
// Mouse is in trigger zone - show header
setIsHeaderVisible(true);
if (hideTimeout !== null) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
} else if (wasInTriggerZone) {
// Mouse just left trigger zone - start hide timer
if (hideTimeout !== null) clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
setIsHeaderVisible(false);
}, 2000);
}
// If mouse is already out of trigger zone and moving, don't reset timer
}, 100);
// Show header initially
setIsHeaderVisible(true);
// Hide after initial delay if mouse doesn't move to top
hideTimeout = setTimeout(() => {
setIsHeaderVisible(false);
}, 3000);
window.addEventListener('mousemove', handleMouseMove, { passive: true });
return () => {
window.removeEventListener('mousemove', handleMouseMove);
if (hideTimeout !== null) clearTimeout(hideTimeout);
};
}, [autoHideEnabled, isRenaming]);
// Use authenticated user identity or fallback to generated identity
const [me] = useState<UserIdentity>(() => {
if (user) {
return {
id: user.id,
name: user.name,
initials: getInitialsFromName(user.name),
color: getColorFromString(user.id),
};
}
return getUserIdentity();
const { isHeaderVisible, setIsHeaderVisible } = useEditorChrome({
drawingName,
autoHideEnabled,
isRenaming,
});
const me: UserIdentity = useEditorIdentity(user);
const [peers, setPeers] = useState<Peer[]>([]);
const [isReady, setIsReady] = useState(false);
+3 -8
View File
@@ -1,9 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
import axios from 'axios';
import { Logo } from '../components/Logo';
const API_URL = import.meta.env.VITE_API_URL || "/api";
import { authPasswordResetConfirm, isAxiosError } from '../api';
export const PasswordResetConfirm: React.FC = () => {
const [searchParams] = useSearchParams();
@@ -44,17 +42,14 @@ export const PasswordResetConfirm: React.FC = () => {
setLoading(true);
try {
await axios.post(`${API_URL}/auth/password-reset-confirm`, {
token,
password,
});
await authPasswordResetConfirm(token, password);
setSuccess(true);
setTimeout(() => {
navigate('/login');
}, 3000);
} catch (err: unknown) {
let message = 'Failed to reset password';
if (axios.isAxiosError(err)) {
if (isAxiosError(err)) {
if (err.response?.status === 404) {
message = 'Password reset feature is not enabled on this server';
} else if (err.response?.data?.message) {
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import * as api from '../../api';
import type { DrawingSortField, SortDirection } from '../../api';
import type { Collection, DrawingSummary } from '../../types';
import { isLatestRequest, mergeUniqueDrawings } from './pagination';
type SelectedCollectionId = string | null | undefined;
type UseDashboardDataOptions = {
debouncedSearch: string;
selectedCollectionId: SelectedCollectionId;
sortField: DrawingSortField;
sortDirection: SortDirection;
pageSize: number;
onRefreshSuccess?: () => void;
};
export const useDashboardData = ({
debouncedSearch,
selectedCollectionId,
sortField,
sortDirection,
pageSize,
onRefreshSuccess,
}: UseDashboardDataOptions) => {
const [drawings, setDrawings] = useState<DrawingSummary[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isFetchingMore, setIsFetchingMore] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const listRequestVersionRef = useRef(0);
const hasMore = drawings.length < totalCount;
const refreshData = useCallback(async () => {
const requestVersion = ++listRequestVersionRef.current;
setIsLoading(true);
try {
const [drawingsRes, collectionsData] = await Promise.all([
api.getDrawings(debouncedSearch, selectedCollectionId, {
limit: pageSize,
offset: 0,
sortField,
sortDirection,
}),
api.getCollections(),
]);
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
setDrawings(drawingsRes.drawings);
setTotalCount(drawingsRes.totalCount);
setCollections(collectionsData);
onRefreshSuccess?.();
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
if (isLatestRequest(requestVersion, listRequestVersionRef.current)) {
setIsLoading(false);
}
}
}, [
debouncedSearch,
selectedCollectionId,
pageSize,
sortField,
sortDirection,
onRefreshSuccess,
]);
const fetchMore = useCallback(async () => {
if (isFetchingMore || !hasMore || isLoading) return;
const requestVersion = listRequestVersionRef.current;
setIsFetchingMore(true);
try {
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
limit: pageSize,
offset: drawings.length,
sortField,
sortDirection,
});
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
setDrawings((prev) => mergeUniqueDrawings(prev, drawingsRes.drawings));
setTotalCount(drawingsRes.totalCount);
} catch (err) {
console.error('Failed to fetch more data:', err);
} finally {
setIsFetchingMore(false);
}
}, [
isFetchingMore,
hasMore,
isLoading,
debouncedSearch,
selectedCollectionId,
pageSize,
drawings.length,
sortField,
sortDirection,
]);
useEffect(() => {
refreshData();
}, [refreshData]);
return {
drawings,
setDrawings,
collections,
setCollections,
totalCount,
setTotalCount,
isFetchingMore,
isLoading,
hasMore,
refreshData,
fetchMore,
};
};
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import throttle from 'lodash/throttle';
type UseEditorChromeOptions = {
drawingName: string;
autoHideEnabled: boolean;
isRenaming: boolean;
};
export const useEditorChrome = ({
drawingName,
autoHideEnabled,
isRenaming,
}: UseEditorChromeOptions) => {
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
useEffect(() => {
document.title = `${drawingName} - ExcaliDash`;
return () => {
document.title = 'ExcaliDash';
};
}, [drawingName]);
useEffect(() => {
if (!autoHideEnabled || isRenaming) {
setIsHeaderVisible(true);
return;
}
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
let isInTriggerZone = false;
const handleMouseMove = throttle((e: MouseEvent) => {
const wasInTriggerZone = isInTriggerZone;
isInTriggerZone = e.clientY < 5;
if (isInTriggerZone) {
setIsHeaderVisible(true);
if (hideTimeout !== null) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
} else if (wasInTriggerZone) {
if (hideTimeout !== null) clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
setIsHeaderVisible(false);
}, 2000);
}
}, 100);
setIsHeaderVisible(true);
hideTimeout = setTimeout(() => {
setIsHeaderVisible(false);
}, 3000);
window.addEventListener('mousemove', handleMouseMove, { passive: true });
return () => {
window.removeEventListener('mousemove', handleMouseMove);
if (hideTimeout !== null) clearTimeout(hideTimeout);
};
}, [autoHideEnabled, isRenaming]);
return {
isHeaderVisible,
setIsHeaderVisible,
};
};
@@ -0,0 +1,25 @@
import { useMemo } from 'react';
import { getUserIdentity, type UserIdentity } from '../../utils/identity';
import {
getColorFromString,
getInitialsFromName,
} from './shared';
type AuthUser = {
id: string;
name: string;
} | null | undefined;
export const useEditorIdentity = (user: AuthUser): UserIdentity => {
return useMemo(() => {
if (user) {
return {
id: user.id,
name: user.name,
initials: getInitialsFromName(user.name),
color: getColorFromString(user.id),
};
}
return getUserIdentity();
}, [user]);
};