This commit is contained in:
2026-03-16 19:49:33 -04:00
parent 93707ff513
commit fd9afe86b0
22 changed files with 1188 additions and 224 deletions

View File

@@ -35,23 +35,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAuthState(prev => {
if (meLoading) {
// Still checking authentication
console.log('[AuthContext] Checking authentication status...');
return { ...prev, isCheckingAuth: true };
} else if (meData?.data) {
// User is authenticated
} else if (meData?.data && meData.status === 200) {
// User is authenticated - check that response has valid data
console.log('[AuthContext] User authenticated:', meData.data);
return {
isAuthenticated: true,
user: meData.data,
isCheckingAuth: false,
};
} else if (meError) {
} else if (
meError ||
(meData && meData.status === 401) ||
(meData && meData.status === 403)
) {
// User is not authenticated or error occurred
console.log('[AuthContext] User not authenticated:', meError?.message || String(meError));
return {
isAuthenticated: false,
user: null,
isCheckingAuth: false,
};
}
return prev;
console.log('[AuthContext] Unexpected state - checking...');
return { ...prev, isCheckingAuth: false }; // Assume not authenticated if we can't determine
});
}, [meData, meError, meLoading]);
@@ -75,6 +83,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
navigate('/');
} catch (_error) {
console.error('[AuthContext] Login failed:', _error);
throw new Error('Login failed');
}
},

View File

@@ -77,11 +77,11 @@ function ToastContainer({ toasts }: ToastContainerProps) {
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
<div className="pointer-events-auto">
{toasts.map(toast => (
<Toast key={toast.id} {...toast} />
))}
</div>
{toasts.map(toast => (
<div key={toast.id} className="pointer-events-auto">
<Toast {...toast} />
</div>
))}
</div>
);
}

View File

@@ -44,6 +44,7 @@ import type {
LoginRequest,
LoginResponse,
LogsResponse,
MessageResponse,
PostAdminActionBody,
PostImportBody,
PostSearchBody,
@@ -440,6 +441,279 @@ export function useGetDocument<TData = Awaited<ReturnType<typeof getDocument>>,
/**
* @summary Get document cover image
*/
export type getDocumentCoverResponse200ImageJpeg = {
data: Blob
status: 200
}
export type getDocumentCoverResponse200ImagePng = {
data: Blob
status: 200
}
export type getDocumentCoverResponse401 = {
data: ErrorResponse
status: 401
}
export type getDocumentCoverResponse404 = {
data: ErrorResponse
status: 404
}
export type getDocumentCoverResponse500 = {
data: ErrorResponse
status: 500
}
export type getDocumentCoverResponseSuccess = (getDocumentCoverResponse200ImageJpeg | getDocumentCoverResponse200ImagePng) & {
headers: Headers;
};
export type getDocumentCoverResponseError = (getDocumentCoverResponse401 | getDocumentCoverResponse404 | getDocumentCoverResponse500) & {
headers: Headers;
};
export type getDocumentCoverResponse = (getDocumentCoverResponseSuccess | getDocumentCoverResponseError)
export const getGetDocumentCoverUrl = (id: string,) => {
return `/api/v1/documents/${id}/cover`
}
export const getDocumentCover = async (id: string, options?: RequestInit): Promise<getDocumentCoverResponse> => {
const res = await fetch(getGetDocumentCoverUrl(id),
{
...options,
method: 'GET'
}
)
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
const data: getDocumentCoverResponse['data'] = body ? JSON.parse(body) : {}
return { data, status: res.status, headers: res.headers } as getDocumentCoverResponse
}
export const getGetDocumentCoverQueryKey = (id: string,) => {
return [
`/api/v1/documents/${id}/cover`
] as const;
}
export const getGetDocumentCoverQueryOptions = <TData = Awaited<ReturnType<typeof getDocumentCover>>, TError = ErrorResponse>(id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData>>, fetch?: RequestInit}
) => {
const {query: queryOptions, fetch: fetchOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetDocumentCoverQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getDocumentCover>>> = ({ signal }) => getDocumentCover(id, { signal, ...fetchOptions });
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetDocumentCoverQueryResult = NonNullable<Awaited<ReturnType<typeof getDocumentCover>>>
export type GetDocumentCoverQueryError = ErrorResponse
export function useGetDocumentCover<TData = Awaited<ReturnType<typeof getDocumentCover>>, TError = ErrorResponse>(
id: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getDocumentCover>>,
TError,
Awaited<ReturnType<typeof getDocumentCover>>
> , 'initialData'
>, fetch?: RequestInit}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetDocumentCover<TData = Awaited<ReturnType<typeof getDocumentCover>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getDocumentCover>>,
TError,
Awaited<ReturnType<typeof getDocumentCover>>
> , 'initialData'
>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetDocumentCover<TData = Awaited<ReturnType<typeof getDocumentCover>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData>>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get document cover image
*/
export function useGetDocumentCover<TData = Awaited<ReturnType<typeof getDocumentCover>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData>>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetDocumentCoverQueryOptions(id,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Download document file
*/
export type getDocumentFileResponse200 = {
data: Blob
status: 200
}
export type getDocumentFileResponse401 = {
data: ErrorResponse
status: 401
}
export type getDocumentFileResponse404 = {
data: ErrorResponse
status: 404
}
export type getDocumentFileResponse500 = {
data: ErrorResponse
status: 500
}
export type getDocumentFileResponseSuccess = (getDocumentFileResponse200) & {
headers: Headers;
};
export type getDocumentFileResponseError = (getDocumentFileResponse401 | getDocumentFileResponse404 | getDocumentFileResponse500) & {
headers: Headers;
};
export type getDocumentFileResponse = (getDocumentFileResponseSuccess | getDocumentFileResponseError)
export const getGetDocumentFileUrl = (id: string,) => {
return `/api/v1/documents/${id}/file`
}
export const getDocumentFile = async (id: string, options?: RequestInit): Promise<getDocumentFileResponse> => {
const res = await fetch(getGetDocumentFileUrl(id),
{
...options,
method: 'GET'
}
)
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
const data: getDocumentFileResponse['data'] = body ? JSON.parse(body) : {}
return { data, status: res.status, headers: res.headers } as getDocumentFileResponse
}
export const getGetDocumentFileQueryKey = (id: string,) => {
return [
`/api/v1/documents/${id}/file`
] as const;
}
export const getGetDocumentFileQueryOptions = <TData = Awaited<ReturnType<typeof getDocumentFile>>, TError = ErrorResponse>(id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData>>, fetch?: RequestInit}
) => {
const {query: queryOptions, fetch: fetchOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetDocumentFileQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getDocumentFile>>> = ({ signal }) => getDocumentFile(id, { signal, ...fetchOptions });
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetDocumentFileQueryResult = NonNullable<Awaited<ReturnType<typeof getDocumentFile>>>
export type GetDocumentFileQueryError = ErrorResponse
export function useGetDocumentFile<TData = Awaited<ReturnType<typeof getDocumentFile>>, TError = ErrorResponse>(
id: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getDocumentFile>>,
TError,
Awaited<ReturnType<typeof getDocumentFile>>
> , 'initialData'
>, fetch?: RequestInit}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetDocumentFile<TData = Awaited<ReturnType<typeof getDocumentFile>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getDocumentFile>>,
TError,
Awaited<ReturnType<typeof getDocumentFile>>
> , 'initialData'
>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetDocumentFile<TData = Awaited<ReturnType<typeof getDocumentFile>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData>>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Download document file
*/
export function useGetDocumentFile<TData = Awaited<ReturnType<typeof getDocumentFile>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData>>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetDocumentFileQueryOptions(id,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List progress records
*/
@@ -2296,7 +2570,12 @@ export function useGetAdmin<TData = Awaited<ReturnType<typeof getAdmin>>, TError
/**
* @summary Perform admin action (backup, restore, etc.)
*/
export type postAdminActionResponse200 = {
export type postAdminActionResponse200ApplicationJson = {
data: MessageResponse
status: 200
}
export type postAdminActionResponse200ApplicationOctetStream = {
data: Blob
status: 200
}
@@ -2316,7 +2595,7 @@ export type postAdminActionResponse500 = {
status: 500
}
export type postAdminActionResponseSuccess = (postAdminActionResponse200) & {
export type postAdminActionResponseSuccess = (postAdminActionResponse200ApplicationJson | postAdminActionResponse200ApplicationOctetStream) & {
headers: Headers;
};
export type postAdminActionResponseError = (postAdminActionResponse400 | postAdminActionResponse401 | postAdminActionResponse500) & {
@@ -2334,22 +2613,22 @@ export const getPostAdminActionUrl = () => {
}
export const postAdminAction = async (postAdminActionBody: PostAdminActionBody, options?: RequestInit): Promise<postAdminActionResponse> => {
const formUrlEncoded = new URLSearchParams();
formUrlEncoded.append(`action`, postAdminActionBody.action);
const formData = new FormData();
formData.append(`action`, postAdminActionBody.action);
if(postAdminActionBody.backup_types !== undefined) {
postAdminActionBody.backup_types.forEach(value => formUrlEncoded.append(`backup_types`, value));
postAdminActionBody.backup_types.forEach(value => formData.append(`backup_types`, value));
}
if(postAdminActionBody.restore_file !== undefined) {
formUrlEncoded.append(`restore_file`, postAdminActionBody.restore_file);
formData.append(`restore_file`, postAdminActionBody.restore_file);
}
const res = await fetch(getPostAdminActionUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...options?.headers },
method: 'POST'
,
body:
formUrlEncoded,
formData,
}
)

View File

@@ -39,6 +39,7 @@ export * from './logEntry';
export * from './loginRequest';
export * from './loginResponse';
export * from './logsResponse';
export * from './messageResponse';
export * from './operationType';
export * from './postAdminActionBody';
export * from './postAdminActionBodyAction';

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface MessageResponse {
message: string;
}

View File

@@ -19,52 +19,79 @@ export default function AdminPage() {
});
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const handleBackupSubmit = (e: FormEvent) => {
const handleBackupSubmit = async (e: FormEvent) => {
e.preventDefault();
const backupTypesList: string[] = [];
if (backupTypes.covers) backupTypesList.push('COVERS');
if (backupTypes.documents) backupTypesList.push('DOCUMENTS');
postAdminAction.mutate(
{
data: {
action: 'BACKUP',
backup_types: backupTypesList as any,
},
},
{
onSuccess: response => {
// Handle file download
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute(
'download',
`AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`
);
document.body.appendChild(link);
link.click();
link.remove();
showInfo('Backup completed successfully');
},
onError: error => {
showError('Backup failed: ' + (error as any).message);
},
try {
const formData = new FormData();
formData.append('action', 'BACKUP');
backupTypesList.forEach(value => formData.append('backup_types', value));
const response = await fetch('/api/v1/admin', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Backup failed: ' + response.statusText);
}
);
const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
// Stream the response directly to disk using File System Access API
// This avoids loading multi-GB files into browser memory
if (typeof (window as any).showSaveFilePicker === 'function') {
try {
// Modern browsers: Use File System Access API for direct disk writes
const handle = await (window as any).showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
});
const writable = await handle.createWritable();
// Stream response body directly to file without buffering
const reader = response.body?.getReader();
if (!reader) throw new Error('Unable to read response');
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writable.write(value);
}
await writable.close();
showInfo('Backup completed successfully');
} catch (err) {
// User cancelled or error
if ((err as Error).name !== 'AbortError') {
showError('Backup failed: ' + (err as Error).message);
}
}
} else {
// Fallback for older browsers
showError(
'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.'
);
}
} catch (error) {
showError('Backup failed: ' + (error as any).message);
}
};
const handleRestoreSubmit = (e: FormEvent) => {
e.preventDefault();
if (!restoreFile) return;
const formData = new FormData();
formData.append('restore_file', restoreFile);
formData.append('action', 'RESTORE');
postAdminAction.mutate(
{
data: formData as any,
data: {
action: 'RESTORE',
restore_file: restoreFile,
},
},
{
onSuccess: () => {

View File

@@ -76,9 +76,9 @@ function DocumentCard({ doc }: DocumentCardProps) {
<Activity size={20} />
</Link>
{doc.filepath ? (
<Link to={`/documents/${doc.id}/file`}>
<a href={`/api/v1/documents/${doc.id}/file`}>
<Download size={20} />
</Link>
</a>
) : (
<Download size={20} className="text-gray-400" />
)}