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
+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]);
};