221 lines
7.3 KiB
TypeScript
221 lines
7.3 KiB
TypeScript
/**
|
|
* Card view for displaying document summary, preview, and metadata.
|
|
*/
|
|
import { useState } from 'react';
|
|
import { Download, FileText, Trash2 } from 'lucide-react';
|
|
|
|
import type { DmsDocument } from '../types';
|
|
import { contentMarkdownUrl, downloadUrl, thumbnailUrl } from '../lib/api';
|
|
|
|
/**
|
|
* Defines properties accepted by the document card component.
|
|
*/
|
|
interface DocumentCardProps {
|
|
document: DmsDocument;
|
|
isSelected: boolean;
|
|
isChecked: boolean;
|
|
isTrashView: boolean;
|
|
onSelect: (document: DmsDocument) => void;
|
|
onToggleChecked: (documentId: string, checked: boolean) => void;
|
|
onTrashDocument: (documentId: string) => Promise<void>;
|
|
onFilterPath: (path: string) => void;
|
|
onFilterTag: (tag: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Defines visual processing status variants rendered in the card header indicator.
|
|
*/
|
|
type StatusTone = 'success' | 'progress' | 'failed';
|
|
|
|
/**
|
|
* Resolves status tone and tooltip text from backend document status values.
|
|
*/
|
|
function statusPresentation(status: DmsDocument['status']): { tone: StatusTone; tooltip: string } {
|
|
if (status === 'processed') {
|
|
return { tone: 'success', tooltip: 'Processing status: success' };
|
|
}
|
|
if (status === 'queued') {
|
|
return { tone: 'progress', tooltip: 'Processing status: in progress' };
|
|
}
|
|
if (status === 'error') {
|
|
return { tone: 'failed', tooltip: 'Processing status: failed' };
|
|
}
|
|
if (status === 'unsupported') {
|
|
return { tone: 'failed', tooltip: 'Processing status: failed (unsupported type)' };
|
|
}
|
|
return { tone: 'success', tooltip: 'Processing status: success (moved to trash)' };
|
|
}
|
|
|
|
/**
|
|
* Limits logical-path length while preserving start and end context with middle ellipsis.
|
|
*/
|
|
function compactLogicalPath(path: string, maxChars = 180): string {
|
|
const normalized = path.trim();
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
if (normalized.length <= maxChars) {
|
|
return normalized;
|
|
}
|
|
const keepChars = Math.max(12, maxChars - 3);
|
|
const headChars = Math.ceil(keepChars * 0.6);
|
|
const tailChars = keepChars - headChars;
|
|
return `${normalized.slice(0, headChars)}...${normalized.slice(-tailChars)}`;
|
|
}
|
|
|
|
/**
|
|
* Renders one document card with optional image preview and searchable metadata.
|
|
*/
|
|
export default function DocumentCard({
|
|
document,
|
|
isSelected,
|
|
isChecked,
|
|
isTrashView,
|
|
onSelect,
|
|
onToggleChecked,
|
|
onTrashDocument,
|
|
onFilterPath,
|
|
onFilterTag,
|
|
}: DocumentCardProps): JSX.Element {
|
|
const [isTrashing, setIsTrashing] = useState<boolean>(false);
|
|
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';
|
|
|
|
return (
|
|
<article
|
|
className={`document-card ${isSelected ? 'selected' : ''}`}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => onSelect(document)}
|
|
onKeyDown={(event) => {
|
|
if (event.currentTarget !== event.target) {
|
|
return;
|
|
}
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
onSelect(document);
|
|
}
|
|
}}
|
|
>
|
|
<header className="document-card-header">
|
|
<div
|
|
className={`card-status-indicator ${status.tone}`}
|
|
title={status.tooltip}
|
|
aria-label={status.tooltip}
|
|
/>
|
|
<label className="card-checkbox card-checkbox-compact" onClick={(event) => event.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={isChecked}
|
|
onChange={(event) => onToggleChecked(document.id, event.target.checked)}
|
|
onClick={(event) => event.stopPropagation()}
|
|
aria-label={`Select ${document.original_filename}`}
|
|
title="Select document"
|
|
/>
|
|
</label>
|
|
</header>
|
|
<div className="document-preview">
|
|
{document.preview_available ? (
|
|
<img src={thumbnailUrl(document.id)} alt={document.original_filename} loading="lazy" />
|
|
) : (
|
|
<div className="document-preview-fallback">{document.extension || 'file'}</div>
|
|
)}
|
|
</div>
|
|
<div className="document-content document-card-body">
|
|
<h3 title={`${document.logical_path}/${document.original_filename}`}>
|
|
<span className="document-title-path">{compactPath}/</span>
|
|
<span className="document-title-name">{document.original_filename}</span>
|
|
</h3>
|
|
<p className="document-date">{createdDate}</p>
|
|
</div>
|
|
<footer className="document-card-footer">
|
|
<div className="card-footer-discovery">
|
|
<button
|
|
type="button"
|
|
className="card-chip path-chip"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onFilterPath(document.logical_path);
|
|
}}
|
|
title={`Filter by path: ${document.logical_path}`}
|
|
>
|
|
{document.logical_path}
|
|
</button>
|
|
<div className="card-chip-row">
|
|
{document.tags.slice(0, 4).map((tag) => (
|
|
<button
|
|
key={`${document.id}-${tag}`}
|
|
type="button"
|
|
className="card-chip"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onFilterTag(tag);
|
|
}}
|
|
title={`Filter by tag: ${tag}`}
|
|
>
|
|
#{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="card-action-row">
|
|
<button
|
|
type="button"
|
|
className="card-icon-button"
|
|
aria-label="Download original"
|
|
title="Download original"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
window.open(downloadUrl(document.id), '_blank', 'noopener,noreferrer');
|
|
}}
|
|
>
|
|
<Download aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="card-icon-button"
|
|
aria-label="Export recognized text as markdown"
|
|
title="Export recognized text as markdown"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
window.open(contentMarkdownUrl(document.id), '_blank', 'noopener,noreferrer');
|
|
}}
|
|
>
|
|
<FileText aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="card-icon-button danger"
|
|
aria-label={trashTitle}
|
|
title={trashTitle}
|
|
disabled={trashDisabled}
|
|
onClick={async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (trashDisabled) {
|
|
return;
|
|
}
|
|
setIsTrashing(true);
|
|
try {
|
|
await onTrashDocument(document.id);
|
|
} catch {
|
|
} finally {
|
|
setIsTrashing(false);
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</article>
|
|
);
|
|
}
|