Initial commit

This commit is contained in:
2026-02-21 09:44:18 -03:00
commit 5dfc2cbd85
65 changed files with 11989 additions and 0 deletions

View 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>
);
}