refactor: Reorganize frontend by moving UI components, hooks, and utilities to new components, logic, features, and lib directories
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user