Fix authenticated media flows and upload preflight handling

This commit is contained in:
2026-02-21 15:53:02 -03:00
parent 1cb6bfee58
commit c3f34b38b4
12 changed files with 619 additions and 35 deletions

View File

@@ -1,12 +1,17 @@
/**
* Card view for displaying document summary, preview, and metadata.
*/
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { JSX } from 'react';
import { Download, FileText, Trash2 } from 'lucide-react';
import type { DmsDocument } from '../types';
import { contentMarkdownUrl, downloadUrl, thumbnailUrl } from '../lib/api';
import {
downloadBlobFile,
downloadDocumentContentMarkdown,
downloadDocumentFile,
getDocumentThumbnailBlob,
} from '../lib/api';
/**
* Defines properties accepted by the document card component.
@@ -79,12 +84,59 @@ export default function DocumentCard({
onFilterTag,
}: DocumentCardProps): JSX.Element {
const [isTrashing, setIsTrashing] = useState<boolean>(false);
const [thumbnailObjectUrl, setThumbnailObjectUrl] = useState<string | null>(null);
const thumbnailObjectUrlRef = useRef<string | null>(null);
const createdDate = new Date(document.created_at).toLocaleString();
const status = statusPresentation(document.status);
const compactPath = compactLogicalPath(document.logical_path, 180);
const trashDisabled = isTrashView || document.status === 'trashed' || isTrashing;
const trashTitle = trashDisabled ? 'Already in trash' : 'Move to trash';
/**
* Loads thumbnail preview through authenticated fetch and revokes replaced object URLs.
*/
useEffect(() => {
const revokeThumbnailObjectUrl = (): void => {
if (!thumbnailObjectUrlRef.current) {
return;
}
URL.revokeObjectURL(thumbnailObjectUrlRef.current);
thumbnailObjectUrlRef.current = null;
};
if (!document.preview_available) {
revokeThumbnailObjectUrl();
setThumbnailObjectUrl(null);
return;
}
let cancelled = false;
const loadThumbnail = async (): Promise<void> => {
try {
const blob = await getDocumentThumbnailBlob(document.id);
if (cancelled) {
return;
}
revokeThumbnailObjectUrl();
const objectUrl = URL.createObjectURL(blob);
thumbnailObjectUrlRef.current = objectUrl;
setThumbnailObjectUrl(objectUrl);
} catch {
if (cancelled) {
return;
}
revokeThumbnailObjectUrl();
setThumbnailObjectUrl(null);
}
};
void loadThumbnail();
return () => {
cancelled = true;
revokeThumbnailObjectUrl();
};
}, [document.id, document.preview_available]);
return (
<article
className={`document-card ${isSelected ? 'selected' : ''}`}
@@ -119,8 +171,8 @@ export default function DocumentCard({
</label>
</header>
<div className="document-preview">
{document.preview_available ? (
<img src={thumbnailUrl(document.id)} alt={document.original_filename} loading="lazy" />
{document.preview_available && thumbnailObjectUrl ? (
<img src={thumbnailObjectUrl} alt={document.original_filename} loading="lazy" />
) : (
<div className="document-preview-fallback">{document.extension || 'file'}</div>
)}
@@ -173,7 +225,13 @@ export default function DocumentCard({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
window.open(downloadUrl(document.id), '_blank', 'noopener,noreferrer');
void (async (): Promise<void> => {
try {
const payload = await downloadDocumentFile(document.id);
downloadBlobFile(payload.blob, payload.filename);
} catch {
}
})();
}}
>
<Download aria-hidden="true" />
@@ -186,7 +244,13 @@ export default function DocumentCard({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
window.open(contentMarkdownUrl(document.id), '_blank', 'noopener,noreferrer');
void (async (): Promise<void> => {
try {
const payload = await downloadDocumentContentMarkdown(document.id);
downloadBlobFile(payload.blob, payload.filename);
} catch {
}
})();
}}
>
<FileText aria-hidden="true" />

View File

@@ -1,14 +1,15 @@
/**
* Embedded document viewer panel for preview, metadata updates, and lifecycle actions.
*/
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { JSX } from 'react';
import {
contentMarkdownUrl,
downloadBlobFile,
downloadDocumentContentMarkdown,
deleteDocument,
getDocumentDetails,
previewUrl,
getDocumentPreviewBlob,
reprocessDocument,
restoreDocument,
trashDocument,
@@ -44,6 +45,8 @@ export default function DocumentViewer({
requestConfirmation,
}: DocumentViewerProps): JSX.Element {
const [documentDetail, setDocumentDetail] = useState<DmsDocumentDetail | null>(null);
const [previewObjectUrl, setPreviewObjectUrl] = useState<string | null>(null);
const [isLoadingPreview, setIsLoadingPreview] = useState<boolean>(false);
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
const [originalFilename, setOriginalFilename] = useState<string>('');
const [logicalPath, setLogicalPath] = useState<string>('');
@@ -55,6 +58,7 @@ export default function DocumentViewer({
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isMetadataDirty, setIsMetadataDirty] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const previewObjectUrlRef = useRef<string | null>(null);
/**
* Syncs editable metadata fields whenever selection changes.
@@ -62,6 +66,12 @@ export default function DocumentViewer({
useEffect(() => {
if (!document) {
setDocumentDetail(null);
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current);
previewObjectUrlRef.current = null;
}
setPreviewObjectUrl(null);
setIsLoadingPreview(false);
setIsMetadataDirty(false);
return;
}
@@ -72,6 +82,57 @@ export default function DocumentViewer({
setError(null);
}, [document?.id]);
/**
* Loads authenticated preview bytes and exposes a temporary object URL for iframe or image rendering.
*/
useEffect(() => {
const revokePreviewObjectUrl = (): void => {
if (!previewObjectUrlRef.current) {
return;
}
URL.revokeObjectURL(previewObjectUrlRef.current);
previewObjectUrlRef.current = null;
};
if (!document) {
revokePreviewObjectUrl();
setPreviewObjectUrl(null);
setIsLoadingPreview(false);
return;
}
let cancelled = false;
setIsLoadingPreview(true);
const loadPreview = async (): Promise<void> => {
try {
const blob = await getDocumentPreviewBlob(document.id);
if (cancelled) {
return;
}
revokePreviewObjectUrl();
const objectUrl = URL.createObjectURL(blob);
previewObjectUrlRef.current = objectUrl;
setPreviewObjectUrl(objectUrl);
} catch {
if (cancelled) {
return;
}
revokePreviewObjectUrl();
setPreviewObjectUrl(null);
} finally {
if (!cancelled) {
setIsLoadingPreview(false);
}
}
};
void loadPreview();
return () => {
cancelled = true;
revokePreviewObjectUrl();
};
}, [document?.id]);
/**
* Refreshes editable metadata from list updates only while form is clean.
*/
@@ -418,10 +479,16 @@ export default function DocumentViewer({
<h2>{document.original_filename}</h2>
<p className="small">Status: {document.status}</p>
<div className="viewer-preview">
{isImageDocument ? (
<img src={previewUrl(document.id)} alt={document.original_filename} />
{previewObjectUrl ? (
isImageDocument ? (
<img src={previewObjectUrl} alt={document.original_filename} />
) : (
<iframe src={previewObjectUrl} title={document.original_filename} />
)
) : isLoadingPreview ? (
<p className="small">Loading preview...</p>
) : (
<iframe src={previewUrl(document.id)} title={document.original_filename} />
<p className="small">Preview unavailable for this document.</p>
)}
</div>
<label>
@@ -561,7 +628,16 @@ export default function DocumentViewer({
<button
type="button"
className="secondary-action"
onClick={() => window.open(contentMarkdownUrl(document.id), '_blank', 'noopener,noreferrer')}
onClick={() => {
void (async (): Promise<void> => {
try {
const payload = await downloadDocumentContentMarkdown(document.id);
downloadBlobFile(payload.blob, payload.filename);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to download markdown');
}
})();
}}
disabled={isDeleting}
title="Downloads recognized/extracted content as markdown for this document."
>