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
@@ -1,5 +1,5 @@
// Types aligned with OpenAPI schemas for common endpoints.
import type { Identifier } from '../types/identifiers';
import type { Identifier } from '../../types/identifiers';
export type { Identifier };
+175
View File
@@ -0,0 +1,175 @@
import type { Identifier } from '../../types/identifiers';
import type { Asset } from '../../types/assets';
import type { DocumentVersion, Document } from '../../types/documents';
type Nullable<T> = T | null;
export type { Asset };
const resolveAssetExpiresAt = (asset?: { download?: { expires_at: number } | null } | null): number | null =>
asset?.download?.expires_at ?? null;
export const resolveAssetUrl = (asset?: { download?: { url: string } | null } | null): string | null =>
asset?.download?.url ?? null;
export type EnsureAssetUrl = (
documentId: Identifier,
asset: Asset,
options?: { force?: boolean;[key: string]: unknown },
) => Promise<unknown>;
export type GetAsset = (document: Document, assetType: string) => Nullable<Asset>;
const getAssetFromGroup = (
assets?: Asset[] | Record<string, Asset> | null,
assetType: string = '',
): Nullable<Asset> => {
if (!assetType || !assets) {
return null;
}
if (Array.isArray(assets)) {
return assets.find((entry) => entry?.asset_type === assetType) || null;
}
return assets?.[assetType] || null;
};
export const getAssetFromVersion = (currentVersion: Nullable<DocumentVersion>, assetType: string) => {
if (!currentVersion) {
return null;
}
return getAssetFromGroup(currentVersion.assets, assetType);
};
export const resolveDocumentAssetUrl = (
doc: Nullable<Document>,
type: string,
{
ensureAssetUrl,
getAsset,
ensureOptions,
}: {
ensureAssetUrl?: EnsureAssetUrl;
getAsset?: GetAsset;
ensureOptions?: { force?: boolean;[key: string]: unknown };
} = {},
): Nullable<string> => {
if (!doc || !type) {
return null;
}
const asset = getAsset ? getAsset(doc, type) : null;
if (!asset) {
return null;
}
const url = resolveAssetUrl(asset);
const expiresAt = resolveAssetExpiresAt(asset);
const now = Date.now();
if (url && (!expiresAt || expiresAt > now)) {
return url;
}
if (doc.id && asset.id && ensureAssetUrl) {
const force = Boolean(url && expiresAt && expiresAt <= now);
const options: { force: boolean;[key: string]: unknown } = {
force,
...(ensureOptions || {}),
};
ensureAssetUrl(doc.id, asset, options).catch(() => { });
}
return null;
};
class AssetManager {
fetchAsset: ((id: Identifier) => Promise<Asset | null>) | null;
assetCache: Map<Identifier, Asset>;
assetInflight: Map<string, Promise<Asset | null>>;
constructor({ fetchAsset }: { fetchAsset: ((id: Identifier) => Promise<Asset | null>) | null }) {
this.fetchAsset = fetchAsset;
this.assetCache = new Map();
this.assetInflight = new Map();
}
setFetchAsset(fetchAsset: ((id: Identifier) => Promise<Asset | null>) | null) {
this.fetchAsset = fetchAsset;
}
rememberAsset(entry?: Nullable<Asset>) {
if (entry?.id) {
this.assetCache.set(entry.id, entry);
}
}
ensureAsset(
documentId?: Identifier | null,
asset?: Nullable<Asset>,
{ force = false }: { force?: boolean } = {},
): Promise<Nullable<Asset>> {
if (!documentId || !asset?.id) {
return Promise.resolve(asset);
}
const baseAsset = this.assetCache.get(asset.id) || asset;
const assetExpiresAt = resolveAssetExpiresAt(baseAsset);
const now = Date.now();
const isPrimarySatisfied = () => {
const assetUrl = resolveAssetUrl(baseAsset);
if (assetUrl && (!assetExpiresAt || assetExpiresAt > now)) {
return true;
}
return false;
};
let needsFetch = force;
if (!needsFetch) {
needsFetch = !isPrimarySatisfied();
}
if (!needsFetch) {
this.rememberAsset(baseAsset);
return Promise.resolve(baseAsset);
}
const inflightKey = `${documentId}:${asset.id}`;
if (!force && this.assetInflight.has(inflightKey)) {
return this.assetInflight.get(inflightKey);
}
if (!this.fetchAsset) {
return Promise.reject(new Error('AssetManager fetcher is not configured.'));
}
const request: Promise<Asset | null> = this.fetchAsset(asset.id)
.then((data) => {
if (!data) return null;
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
const combined = { ...cachedEntry, ...asset, ...data };
const expires_at = resolveAssetExpiresAt(combined);
const entry = {
...combined,
url: resolveAssetUrl(combined),
expires_at,
};
this.rememberAsset(entry);
return entry;
})
.finally(() => {
this.assetInflight.delete(inflightKey);
});
this.assetInflight.set(inflightKey, request);
return request;
}
reset() {
this.assetCache.clear();
this.assetInflight.clear();
}
}
export default AssetManager;
+38
View File
@@ -0,0 +1,38 @@
import { generateRandomTagColor } from '../../utils/colors';
type ColorGenerator = () => string;
interface TagManagerOptions {
colorGenerator?: ColorGenerator;
}
interface TagPayload {
label: string;
color: string;
}
class TagManager {
private readonly colorGenerator: ColorGenerator;
constructor({ colorGenerator = generateRandomTagColor }: TagManagerOptions = {}) {
this.colorGenerator = colorGenerator;
}
normalizeLabel(label?: string | null): string {
return label?.trim?.() || '';
}
buildPayload({ label, color }: { label?: string | null; color?: string | null } = {}): TagPayload {
const normalizedLabel = this.normalizeLabel(label);
if (!normalizedLabel) {
throw new Error('Tag label is required.');
}
const trimmedColor = color?.trim?.() || null;
return {
label: normalizedLabel,
color: trimmedColor || this.colorGenerator(),
};
}
}
export default TagManager;
+38
View File
@@ -0,0 +1,38 @@
import React, { useEffect, useMemo } from 'react';
import type { PropsWithChildren } from 'react';
import { httpClient, setAuthToken, clearAuthToken } from '../api/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 };
@@ -0,0 +1,5 @@
import { createSafeContext } from '../../utils/createSafeContext';
type AppShellContextValue = Record<string, unknown>;
export const [AppShellContext, useAppShell] = createSafeContext<AppShellContextValue>('AppShell');
@@ -0,0 +1,56 @@
import React, { useCallback } from 'react';
import type { Document } from '../../types/documents';
import type { Identifier } from '../../types/identifiers';
import { createSafeContext } from '../../utils/createSafeContext';
type DocumentOpenTarget = 'preview' | 'sidepanel' | 'viewer';
interface DocumentOpenContextValue {
openDocument: (doc: Document, target?: DocumentOpenTarget) => void;
}
const [DocumentOpenContext, useDocumentOpen] = createSafeContext<DocumentOpenContextValue>('DocumentOpen');
interface DocumentOpenProviderProps {
children: React.ReactNode;
onOpenViewer?: (docId: Identifier) => void;
onOpenPreview?: (doc: Document) => void;
onOpenSidepanel?: (docId: Identifier) => void;
}
export const DocumentOpenProvider: React.FC<DocumentOpenProviderProps> = ({
children,
onOpenViewer,
onOpenPreview,
onOpenSidepanel,
}) => {
const openDocument = useCallback((doc: Document, target: DocumentOpenTarget = 'preview') => {
if (!doc) return;
switch (target) {
case 'preview':
if (onOpenPreview) {
onOpenPreview(doc);
}
break;
case 'sidepanel':
if (onOpenSidepanel) {
onOpenSidepanel(doc.id);
}
break;
case 'viewer':
if (onOpenViewer) {
onOpenViewer(doc.id);
}
break;
}
}, [onOpenPreview, onOpenSidepanel, onOpenViewer]);
return (
<DocumentOpenContext.Provider value={{ openDocument }}>
{children}
</DocumentOpenContext.Provider>
);
};
export { useDocumentOpen };
@@ -0,0 +1,106 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { createSafeContext } from '../../utils/createSafeContext';
export type ToastVariant = 'info' | 'success' | 'error';
interface ToastMessage {
id: string;
message: string;
variant: ToastVariant;
timestamp: number;
duration: number;
}
interface StatusToastContextValue {
toasts: ToastMessage[];
showToast: (message: string, variant?: ToastVariant, duration?: number) => void;
removeToast: (id: string) => void;
}
const [StatusToastContext, useStatusToast] = createSafeContext<StatusToastContextValue>('StatusToast');
const DEFAULT_DURATIONS: Record<ToastVariant, number> = {
success: 3000,
info: 5000,
error: 8000,
};
const MAX_TOASTS = 3;
export const StatusToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const timeoutRefs = useRef<Map<string, number>>(new Map());
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
// Clear timeout if it exists
const timeout = timeoutRefs.current.get(id);
if (timeout) {
clearTimeout(timeout);
timeoutRefs.current.delete(id);
}
}, []);
const showToast = useCallback((message: string, variant: ToastVariant = 'info', duration?: number) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const finalDuration = duration ?? DEFAULT_DURATIONS[variant];
const newToast: ToastMessage = {
id,
message,
variant,
timestamp: Date.now(),
duration: finalDuration,
};
setToasts((prev) => {
const updated = [...prev, newToast];
// If we exceed max toasts, remove the oldest ones
if (updated.length > MAX_TOASTS) {
const removed = updated.slice(0, updated.length - MAX_TOASTS);
removed.forEach((toast) => {
const timeout = timeoutRefs.current.get(toast.id);
if (timeout) {
clearTimeout(timeout);
timeoutRefs.current.delete(toast.id);
}
});
return updated.slice(-MAX_TOASTS);
}
return updated;
});
// Set auto-dismiss timeout - trigger fade then remove
const timeout = setTimeout(() => {
removeToast(id);
}, finalDuration);
timeoutRefs.current.set(id, timeout);
}, [removeToast]);
// Cleanup all timeouts on unmount
useEffect(() => {
const timeouts = timeoutRefs.current;
return () => {
timeouts.forEach((timeout) => clearTimeout(timeout));
timeouts.clear();
};
}, []);
const value: StatusToastContextValue = {
toasts,
showToast,
removeToast,
};
return (
<StatusToastContext.Provider value={value}>
{children}
</StatusToastContext.Provider>
);
};
export { useStatusToast };
+273
View File
@@ -0,0 +1,273 @@
import React, { useEffect, useMemo, useReducer } from 'react';
import { createSafeContext } from '../../utils/createSafeContext';
import { clearAuthToken, setAuthToken, setAuthRefreshHandlers } from '../api/apiClient';
import { ApiProvider } from '../context/ApiContext';
import { listTenants } from '../api/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 };