refactor index.ts
This commit is contained in:
+87
-18
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
Reference in New Issue
Block a user