Initial commit
This commit is contained in:
220
frontend/src/components/DocumentCard.tsx
Normal file
220
frontend/src/components/DocumentCard.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user