diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index a372f95..2363e34 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -5,6 +5,7 @@ import React, { useRef, useState, useContext, + useReducer, } from 'react'; import { createRoot } from 'react-dom/client'; import axios from 'axios'; @@ -33,6 +34,91 @@ if (STORED_TOKEN) { api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; } +const initialAppState = { + status: STORED_TOKEN ? 'authenticated' : 'logged-out', + token: STORED_TOKEN, + error: null, + isRefreshing: false, +}; + +const AppStateContext = React.createContext(null); +const AppDispatchContext = React.createContext(null); + +const appStateReducer = (state, action) => { + switch (action.type) { + case 'LOGIN_REQUEST': + return { ...state, status: 'authenticating', error: null }; + case 'LOGIN_SUCCESS': + return { ...state, status: 'authenticated', token: action.token, error: null }; + case 'LOGIN_FAILURE': + return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false }; + case 'BOOTSTRAP_START': + return { ...state, status: 'bootstrapping', error: null }; + case 'BOOTSTRAP_SUCCESS': + return { ...state, status: 'ready', error: null }; + case 'BOOTSTRAP_FAILURE': + return { ...state, status: 'authenticated', error: action.error || null }; + case 'TOKEN_REFRESH_START': + return { ...state, isRefreshing: true, error: null }; + case 'TOKEN_REFRESH_SUCCESS': + return { + ...state, + token: action.token, + isRefreshing: false, + status: state.status === 'logged-out' ? 'authenticated' : state.status, + }; + case 'TOKEN_REFRESH_FAILURE': + return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false }; + case 'LOGOUT': + return { status: 'logged-out', token: '', error: null, isRefreshing: false }; + case 'RESET_ERROR': + return { ...state, error: null }; + default: + return state; + } +}; + +const AppStateProvider = ({ children }) => { + const [state, dispatch] = useReducer(appStateReducer, initialAppState); + + useEffect(() => { + const token = state.token || ''; + if (token) { + api.defaults.headers.common.Authorization = `Bearer ${token}`; + window.localStorage.setItem('papercrate_token', token); + } else { + delete api.defaults.headers.common.Authorization; + window.localStorage.removeItem('papercrate_token'); + } + }, [state.token]); + + const stateValue = useMemo(() => state, [state]); + + return ( + + + {children} + + + ); +}; + +const useAppState = () => { + const context = useContext(AppStateContext); + if (!context) { + throw new Error('useAppState must be used within an AppStateProvider.'); + } + return context; +}; + +const useAppDispatch = () => { + const context = useContext(AppDispatchContext); + if (!context) { + throw new Error('useAppDispatch must be used within an AppStateProvider.'); + } + return context; +}; + const DEFAULT_FOLDER_NAME = 'All Documents'; const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path); @@ -1495,13 +1581,15 @@ const AppShellContext = React.createContext(null); const AppLayout = () => { const navigate = useNavigate(); const location = useLocation(); + const appState = useAppState(); + const appDispatch = useAppDispatch(); const folderMatch = matchPath('/folders/:folderId', location.pathname); const nestedDocMatch = matchPath('/folders/:folderId/documents/:documentId', location.pathname); const docMatch = nestedDocMatch || matchPath('/documents/:documentId', location.pathname); const routeFolderId = folderMatch?.params?.folderId || nestedDocMatch?.params?.folderId || null; const routeDocumentId = docMatch?.params?.documentId || null; - const [token, setToken] = useState(() => window.localStorage.getItem('papercrate_token') || ''); + const { status: appStatus, token } = appState; const [status, setStatus] = useState(null); const setStatusMessage = useCallback((message, variant = 'info') => { setStatus(message ? { message, variant } : null); @@ -1527,19 +1615,21 @@ const AppLayout = () => { const breadcrumbFetchRef = useRef(new Set()); const refreshAccessToken = useCallback(async () => { console.log('[Auth] Attempting to refresh access token…'); + appDispatch({ type: 'TOKEN_REFRESH_START' }); try { const { data } = await api.post('/auth/refresh'); if (data?.access_token) { - setToken(data.access_token); + appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', token: data.access_token }); console.log('[Auth] Access token refreshed'); return data.access_token; } throw new Error('Missing access token in refresh response'); } catch (error) { console.warn('[Auth] Failed to refresh access token', error); + appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null }); throw error; } - }, []); + }, [appDispatch]); const [searchResults, setSearchResults] = useState(null); const [tags, setTags] = useState([]); const [searchQuery, setSearchQuery] = useState(''); @@ -1554,11 +1644,46 @@ const AppLayout = () => { const [previewDocumentId, setPreviewDocumentId] = useState(null); const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false); - const initializedRef = useRef(false); + const bootstrapInitializedRef = useRef(false); + const selectionInitializedRef = useRef(false); const dragCounterRef = useRef(0); const selectionAnchorRef = useRef(routeDocumentId); const selectionOrderRef = useRef(initialSelection); + const resetWorkspaceState = useCallback(() => { + const rootNode = createRootNode(); + setFolderNodes(new Map([[rootNode.id, rootNode]])); + setFolderContents(new Map()); + setSelectedFolder('root'); + setCurrentFolder(null); + setCurrentSubfolders([]); + setDocuments([]); + setSelectedDocumentIds([]); + setSelectionOrder([]); + selectionOrderRef.current = []; + setFocusedDocumentId(null); + selectionAnchorRef.current = null; + setDraggedDocumentIds([]); + setDraggedFolderId(null); + setDocumentDetails(() => { + const next = new Map(); + documentDetailsRef.current = next; + return next; + }); + setSearchResults(null); + setTags([]); + setSearchQuery(''); + setActiveTagFilters([]); + setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); + setActivePreviewId(null); + setPreviewDocumentId(null); + setPreviewDocumentLoading(false); + dragCounterRef.current = 0; + breadcrumbFetchRef.current = new Set(); + bootstrapInitializedRef.current = false; + selectionInitializedRef.current = false; + }, []); + const tagLookupById = useMemo(() => { const map = new Map(); tags.forEach((tag) => { @@ -1610,6 +1735,12 @@ const AppLayout = () => { } }, []); + useEffect(() => { + if (appStatus === 'logged-out') { + resetWorkspaceState(); + } + }, [appStatus, resetWorkspaceState]); + useEffect(() => { documentDetailsRef.current = documentDetails; }, [documentDetails]); @@ -1618,16 +1749,6 @@ const AppLayout = () => { tokenRef.current = token; }, [token]); - useEffect(() => { - if (token) { - api.defaults.headers.common.Authorization = `Bearer ${token}`; - window.localStorage.setItem('papercrate_token', token); - } else { - delete api.defaults.headers.common.Authorization; - window.localStorage.removeItem('papercrate_token'); - } - }, [token]); - useEffect(() => { const requestInterceptor = api.interceptors.request.use((config) => { const currentToken = tokenRef.current; @@ -1677,7 +1798,6 @@ const AppLayout = () => { return api(config); } catch (refreshError) { console.warn('[Auth] Refresh failed, clearing session'); - setToken(''); setStatusMessage('Session expired. Please log in again.', 'error'); return Promise.reject(refreshError); } @@ -1703,7 +1823,7 @@ const AppLayout = () => { if (!selectedDocumentIds.includes(activePreviewId)) { setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]); } - initializedRef.current = true; + selectionInitializedRef.current = true; }, [selectedDocumentIds, activePreviewId]); const currentFolderName = useMemo(() => { @@ -1730,7 +1850,7 @@ const AppLayout = () => { let nextSelection = []; setSelectedDocumentIds((previous) => { - if (initializedRef.current) { + if (selectionInitializedRef.current) { nextSelection = previous.filter((id) => availableIds.has(id)); return nextSelection; } @@ -2301,12 +2421,20 @@ const AppLayout = () => { } catch (error) { console.error(error); setStatusMessage('Failed to initialize data.', 'error'); + throw error; } finally { setLoading(false); } }, [refreshTags, routeFolderId, loadFolder, setStatusMessage]); useEffect(() => { + if (!token) { + return; + } + if (appStatus !== 'ready' && appStatus !== 'bootstrapping') { + return; + } + const targetParam = routeFolderId ?? 'root'; if (targetParam === 'root' && routeDocumentId) { @@ -2317,7 +2445,15 @@ const AppLayout = () => { if (targetParam !== selectedFolder || !hasData) { loadFolder(targetParam, { showLoading: !routeDocumentId }); } - }, [routeFolderId, routeDocumentId, selectedFolder, loadFolder, folderContents]); + }, [ + token, + appStatus, + routeFolderId, + routeDocumentId, + selectedFolder, + loadFolder, + folderContents, + ]); const refreshCurrentFolder = useCallback(async () => { setLoading(true); @@ -2336,10 +2472,39 @@ const AppLayout = () => { }, [selectedFolder, ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage]); useEffect(() => { - if (!token || initializedRef.current) return; - initializedRef.current = true; - initializeAfterLogin(); - }, [token, initializeAfterLogin]); + if (appStatus !== 'authenticated') { + return; + } + if (bootstrapInitializedRef.current) { + return; + } + + let cancelled = false; + const bootstrap = async () => { + bootstrapInitializedRef.current = true; + appDispatch({ type: 'BOOTSTRAP_START' }); + try { + await initializeAfterLogin(); + if (!cancelled) { + appDispatch({ type: 'BOOTSTRAP_SUCCESS' }); + } + } catch (error) { + if (!cancelled) { + appDispatch({ + type: 'BOOTSTRAP_FAILURE', + error: error?.message || 'Failed to initialize data.', + }); + bootstrapInitializedRef.current = false; + } + } + }; + + bootstrap(); + + return () => { + cancelled = true; + }; + }, [appStatus, appDispatch, initializeAfterLogin]); const handleBulkMoveSubmit = useCallback( async (event) => { @@ -3374,17 +3539,22 @@ const AppLayout = () => { } try { setLoading(true); + appDispatch({ type: 'LOGIN_REQUEST' }); const { data } = await api.post('/auth/login', payload); - setToken(data.access_token); + appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); setStatusMessage('Login successful.', 'success'); } catch (error) { console.error(error); + appDispatch({ + type: 'LOGIN_FAILURE', + error: error?.response?.data?.error || 'Login failed. Check credentials.', + }); setStatusMessage('Login failed. Check credentials.', 'error'); } finally { setLoading(false); } }, - [setStatusMessage], + [appDispatch, setStatusMessage], ); const handleLogout = useCallback(async () => { @@ -3395,26 +3565,10 @@ const AppLayout = () => { console.warn('[Auth] Failed to revoke refresh token during logout', error); } finally { setLoading(false); - setToken(''); + appDispatch({ type: 'LOGOUT' }); setStatusMessage('Logged out.', 'info'); - setFolderNodes(new Map([[createRootNode().id, createRootNode()]])); - setFolderContents(new Map()); - setSelectedFolder('root'); - setCurrentFolder(null); - setCurrentSubfolders([]); - setDocuments([]); - setSelectedDocumentIds([]); - setFocusedDocumentId(null); - selectionAnchorRef.current = null; - setDraggedDocumentIds([]); - setDocumentDetails(new Map()); - setSearchResults(null); - setTags([]); - setSearchQuery(''); - setActiveTagFilters([]); - initializedRef.current = false; } - }, [setStatusMessage]); + }, [appDispatch, setStatusMessage]); const handleBulkReanalyze = useCallback(async () => { try { @@ -3845,6 +3999,7 @@ const AppLayout = () => { const contextValue = useMemo( () => ({ token, + appStatus, status, dropOverlayState, handleBulkReanalyze, @@ -3864,6 +4019,7 @@ const AppLayout = () => { }), [ token, + appStatus, status, dropOverlayState, handleBulkReanalyze, @@ -3883,11 +4039,13 @@ const AppLayout = () => { ], ); - if (!token) { + if (appStatus === 'logged-out' || appStatus === 'authenticating') { return ( -
- -
+ ); } @@ -3903,7 +4061,9 @@ const AppLayout = () => {

Papercrate

- {previewActive + {appStatus === 'bootstrapping' + ? 'Loading your library…' + : previewActive ? 'Viewing document preview. Press ← Back to return to the library.' : 'Drag files here to upload.'} @@ -3976,6 +4136,64 @@ const DocumentsRoute = () => { ); }; +const LoginRoute = () => { + const { status: appStatus } = useAppState(); + const appDispatch = useAppDispatch(); + const location = useLocation(); + const [status, setStatus] = useState(null); + + const setStatusMessage = useCallback((message, variant = 'info') => { + setStatus(message ? { message, variant } : null); + }, []); + + const handleLogin = useCallback( + async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const payload = { + username: form.get('username')?.toString().trim(), + password: form.get('password')?.toString() || '', + }; + + if (!payload.username || !payload.password) { + setStatusMessage('Username and password are required.', 'error'); + return; + } + + try { + appDispatch({ type: 'LOGIN_REQUEST' }); + const { data } = await api.post('/auth/login', payload); + appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); + setStatusMessage('Login successful.', 'success'); + } catch (error) { + console.error(error); + const message = error?.response?.data?.error || 'Login failed. Check credentials.'; + appDispatch({ type: 'LOGIN_FAILURE', error: message }); + setStatusMessage(message, 'error'); + } + }, + [appDispatch, setStatusMessage], + ); + + const redirectTarget = useMemo(() => { + const target = location.state?.from; + if (typeof target === 'string' && target.startsWith('/')) { + return target; + } + return '/folders'; + }, [location.state]); + + if (appStatus !== 'logged-out' && appStatus !== 'authenticating') { + return ; + } + + return ( +
+ +
+ ); +}; + const TagsRoute = () => { const { sidebarProps, tags, refreshTags, handleTagUpdate } = useAppShell(); return ( @@ -3987,6 +4205,7 @@ const TagsRoute = () => { const AppRouter = () => ( + } /> }> } /> } /> @@ -4005,7 +4224,9 @@ const AppRouter = () => ( const container = document.getElementById('app'); const root = createRoot(container); root.render( - - - , + + + + + , );