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(
-
-
- ,
+
+
+
+
+ ,
);