refactor: Reorganize frontend by moving UI components, hooks, and utilities to new components, logic, features, and lib directories

This commit is contained in:
2025-12-04 23:05:35 +01:00
parent 128714a1e0
commit 8aa1872ea5
114 changed files with 199 additions and 199 deletions
-38
View File
@@ -1,38 +0,0 @@
import React, { useEffect, useMemo } from 'react';
import type { PropsWithChildren } from 'react';
import { httpClient, setAuthToken, clearAuthToken } from '../lib/apiClient';
import { createSafeContext } from '../utils/createSafeContext';
type HttpClient = typeof httpClient;
interface ApiContextValue {
client: HttpClient;
setAuthToken: (token: string) => void;
clearAuthToken: () => void;
}
const [ApiContext, useApi] = createSafeContext<ApiContextValue>('Api');
export const ApiProvider: React.FC<PropsWithChildren<{ initialToken?: string | null }>> = ({
initialToken = null,
children,
}) => {
useEffect(() => {
if (initialToken) {
setAuthToken(initialToken);
}
}, [initialToken]);
const value = useMemo<ApiContextValue>(
() => ({
client: httpClient,
setAuthToken,
clearAuthToken,
}),
[],
);
return <ApiContext.Provider value={value}>{children}</ApiContext.Provider>;
};
export { useApi };
+1 -1
View File
@@ -5,7 +5,7 @@ import {
DocumentsFilterProvider,
} from '../documents/context/DocumentsFilterContext';
import { PreviewProvider, usePreviewContext } from '../preview/PreviewContext';
import { DocumentOpenProvider } from '../contexts/DocumentOpenContext';
import { DocumentOpenProvider } from '../lib/context/DocumentOpenContext';
import { useWorkspaceSurface } from './useWorkspaceSurface';
import { DocumentsHeaderBreadcrumb } from '../documents/panel/DocumentsPanelHeader';
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
+2 -2
View File
@@ -10,7 +10,7 @@ import {
serializeAuthenticationCredential,
serializeRegistrationCredential,
} from '../utils/webauthn';
import { useAppDispatch, useAppState } from './appState';
import { useAppDispatch, useAppState } from '../lib/store/appState';
import {
finishPasskeyLogin,
finishSignup,
@@ -18,7 +18,7 @@ import {
selectTenant,
startPasskeyLogin,
startSignup,
} from '../lib/apiClient';
} from '../lib/api/apiClient';
type StatusVariant = 'info' | 'success' | 'error';
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useCallback } from 'react';
import SettingsModal from '../settings/SettingsModal';
import { useAppShell } from '../appShellContext';
import { useAppShell } from '../lib/context/AppShellContext';
import useApiTokens from '../settings/useApiTokens';
import useCapabilitySets from '../settings/useCapabilitySets';
import useCapabilities from '../settings/useCapabilities';
+2 -2
View File
@@ -9,8 +9,8 @@ import {
WarningIcon,
BottombarCollapseIcon,
BottombarExpandIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
} from '../components/icons';
import PanelHeader from '../components/PanelHeader';
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error' | (string & {});
-273
View File
@@ -1,273 +0,0 @@
import React, { useEffect, useMemo, useReducer } from 'react';
import { createSafeContext } from '../utils/createSafeContext';
import { clearAuthToken, setAuthToken, setAuthRefreshHandlers } from '../lib/apiClient';
import { ApiProvider } from './ApiContext';
import { listTenants } from '../lib/apiClient';
import { STORED_TOKEN_KEY } from '../constants/app';
type Tenant = Record<string, unknown> | null;
interface TenantSelection {
selectionToken: string;
tenants: Tenant[];
}
type AppStatus =
| 'logged-out'
| 'authenticating'
| 'authenticated'
| 'selecting-tenant'
| 'bootstrapping'
| 'ready';
interface AppState {
status: AppStatus;
token: string;
error: string | null;
isRefreshing: boolean;
tenantSelection: TenantSelection | null;
tenant: Tenant;
tenants: Tenant[];
}
type AppAction =
| { type: 'LOGIN_REQUEST' }
| { type: 'LOGIN_SUCCESS'; token: string; tenant?: Tenant }
| { type: 'LOGIN_FAILURE'; error?: string | null }
| { type: 'TENANT_SELECTION_REQUIRED'; selectionToken: string; tenants: Tenant[] }
| { type: 'CLEAR_TENANT_SELECTION' }
| { type: 'LOGOUT_SUCCESS' }
| { type: 'BOOTSTRAP_START' }
| { type: 'BOOTSTRAP_SUCCESS' }
| { type: 'BOOTSTRAP_FAILURE'; error?: string | null }
| { type: 'TOKEN_REFRESH_START' }
| { type: 'TOKEN_REFRESH_SUCCESS'; token: string; tenant?: Tenant }
| { type: 'TOKEN_REFRESH_FAILURE'; error?: string | null }
| { type: 'LOGOUT' }
| { type: 'RESET_ERROR' }
| { type: 'SET_TENANTS'; tenants: Tenant[] };
const storage = window.sessionStorage;
const storedToken = storage?.getItem(STORED_TOKEN_KEY) ?? '';
let STORED_TENANT: Tenant = null;
if (storage) {
try {
const rawTenant = storage.getItem('papercrate_tenant');
if (rawTenant) {
STORED_TENANT = JSON.parse(rawTenant);
}
} catch (error) {
console.warn('[app] Failed to parse stored tenant metadata', error);
}
}
if (storedToken) {
setAuthToken(storedToken);
}
const initialAppState: AppState = {
status: storedToken ? 'authenticated' : 'logged-out',
token: storedToken,
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: STORED_TENANT,
tenants: [],
};
const [AppStateContext, useAppState] = createSafeContext<AppState>('AppState');
const [AppDispatchContext, useAppDispatch] = createSafeContext<React.Dispatch<AppAction>>('AppDispatch');
const appStateReducer = (state: AppState, action: AppAction): AppState => {
switch (action.type) {
case 'LOGIN_REQUEST':
return {
...state,
status: 'authenticating',
error: null,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGIN_SUCCESS':
return {
...state,
status: 'authenticated',
token: action.token,
error: null,
tenantSelection: null,
tenant: action.tenant ?? null,
tenants: state.tenants,
};
case 'LOGIN_FAILURE':
return {
status: 'logged-out',
token: '',
error: action.error ?? null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'TENANT_SELECTION_REQUIRED':
return {
status: 'selecting-tenant',
token: '',
error: null,
isRefreshing: false,
tenantSelection: {
selectionToken: action.selectionToken,
tenants: action.tenants,
},
tenant: null,
tenants: [],
};
case 'CLEAR_TENANT_SELECTION':
return {
status: 'logged-out',
token: '',
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGOUT_SUCCESS':
case 'LOGOUT':
return {
status: 'logged-out',
token: '',
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
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,
tenantSelection: null,
tenant: action.tenant ?? state.tenant ?? null,
tenants: state.tenants,
};
case 'TOKEN_REFRESH_FAILURE':
return {
status: 'logged-out',
token: '',
error: action.error ?? null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'RESET_ERROR':
return { ...state, error: null };
case 'SET_TENANTS':
return {
...state,
tenants: Array.isArray(action.tenants) ? action.tenants : [],
};
default:
return state;
}
};
const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(appStateReducer, initialAppState);
useEffect(() => {
const token = state.token ?? '';
if (token) {
setAuthToken(token);
storage?.setItem('papercrate_token', token);
} else {
clearAuthToken();
storage?.removeItem('papercrate_token');
}
}, [state.token]);
useEffect(() => {
if (state.tenant) {
try {
storage?.setItem('papercrate_tenant', JSON.stringify(state.tenant));
} catch (error) {
console.warn('[app] Failed to persist tenant info', error);
}
} else {
storage?.removeItem('papercrate_tenant');
}
}, [state.tenant]);
useEffect(() => {
setAuthRefreshHandlers({
onRefreshSuccess: (token, payload) => {
dispatch({ type: 'TOKEN_REFRESH_SUCCESS', token, tenant: payload?.tenant ?? null });
},
onRefreshFailure: (error) => {
dispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
},
});
return () => {
setAuthRefreshHandlers({});
};
}, [dispatch]);
useEffect(() => {
let abort = false;
const loadTenants = async () => {
if (state.status !== 'authenticated' || !state.token) {
dispatch({ type: 'SET_TENANTS', tenants: [] });
return;
}
try {
if (!abort) {
const tenants = await listTenants();
dispatch({
type: 'SET_TENANTS',
tenants,
});
}
} catch (error) {
if (!abort) {
console.warn('Failed to load tenant list', error);
}
}
};
loadTenants();
return () => {
abort = true;
};
}, [state.status, state.token, dispatch]);
const stateValue = useMemo(() => state, [state]);
return (
<ApiProvider initialToken={state.token}>
<AppStateContext.Provider value={stateValue}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
</ApiProvider>
);
};
export { AppStateProvider, useAppState, useAppDispatch };
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { TAG_FILTER_UNTAGGED } from './workspaceUtils';
import { listDocuments } from '../lib/apiClient';
import { listDocuments } from '../lib/api/apiClient';
import type { Identifier } from '../types/identifiers';
import type { Document } from '../types/documents';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import type { ComponentProps } from 'react';
import { useAppShell } from '../appShellContext';
import { useAppShell } from '../lib/context/AppShellContext';
import type { DocumentsFilterValue } from '../documents/context/DocumentsFilterContext';
import type Sidebar from '../sidebar/Sidebar';
import type { UseWorkspaceSurfaceArgs } from './useWorkspaceSurface';
+2 -2
View File
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import TagsPanel from '../tags/TagsPanel';
import CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents/CorrespondentsPanel';
import PanelHeader from '../ui/PanelHeader';
import { CloseIcon } from '../ui/icons';
import PanelHeader from '../components/PanelHeader';
import { CloseIcon } from '../components/icons';
import { CORRESPONDENTS_MODAL, TAGS_MODAL } from '../constants/app';
interface TagRecord {
+1 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react';
import type { ComponentProps, ReactNode } from 'react';
import { SidebarExpandIcon } from '../ui/icons';
import { SidebarExpandIcon } from '../components/icons';
import DocumentsPanel from '../documents/panel/DocumentsPanel';
import DocumentViewerPanel from '../preview/DocumentViewerPanel';
import { usePanelManager } from './PanelManagerContext';