Files
Stirling-PDF/frontend/src/core/components/shared/FileSidebarFileItem.tsx
T
EthanHealy01 a42fdfad00 UI redesign: multi-tool mode, workbench bar animation, PDFium rendering fixes
- Add multi-tool mode support across FileSidebar, FilesModalContext, PageEditorContext,
  WorkbenchBar, and ToolWorkflowContext — adding files stays in pageEditor view and
  auto-selects files; left panel hides/shows on workbench switch
- Animate WorkbenchBar in/out via CSS grid-template-rows trick when files are added/removed;
  suppress initial transition with rAF flag
- Fix PDFium pixel format (RGBA not BGRA) and rotation logic (effective dims already
  post-rotation; counter-rotate when applyRotation=false)
- Fix PageEditor flash of "No PDF files loaded" by checking file context directly
- Redesign AddFileCard: solid border, rounded corners, shadow hover, focus-visible ring
- Show AddFileCard in FileEditor empty state instead of text stack
- Add date grouping helpers (today/yesterday/thisWeek/thisMonth/older) to FileSidebar
- Redesign FilePdfIcon: solid filled with folded corner, red colour
- Simplify NonPdfBanner: remove file-type badge, keep only convert-to-PDF button
- Fix thumbnail precedence in useToolOperation (prefer metadata.thumbnailUrl)
- Fix fileActions stub dispatch to trigger selector re-render after ADD_FILES
- Switch Annotate to useAllFiles and Automate/AutomationRun to useViewScopedFiles
2026-04-30 21:16:58 +01:00

349 lines
9.3 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import type { FileId } from "@app/types/file";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
import "@app/components/shared/FileSidebarFileItem.css";
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
/** Generate + persist a thumbnail for a sidebar file that doesn't have one yet. */
function useLazyThumbnail(
fileId: FileId,
size: number,
thumbnailUrl?: string,
): string | undefined {
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
const attempted = useRef(false);
const indexedDB = useIndexedDB();
// Sync prop changes (e.g. thumbnail arrives after TTL bump)
useEffect(() => {
if (thumbnailUrl) setThumb(thumbnailUrl);
}, [thumbnailUrl]);
useEffect(() => {
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
return;
attempted.current = true;
let cancelled = false;
(async () => {
try {
const file = await indexedDB.loadFile(fileId);
if (!file || cancelled) return;
const thumbnail = await generateThumbnailForFile(file);
if (cancelled || !thumbnail) return;
setThumb(thumbnail);
void indexedDB.updateThumbnail(fileId, thumbnail);
} catch {
// non-critical
}
})();
return () => {
cancelled = true;
};
}, [fileId, size, thumbnailUrl, indexedDB]);
return thumb;
}
export function getFileExtension(name: string): string {
const parts = name.split(".");
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
}
export type DateGroup =
| "today"
| "yesterday"
| "thisWeek"
| "thisMonth"
| "older";
export const DATE_GROUP_ORDER: DateGroup[] = [
"today",
"yesterday",
"thisWeek",
"thisMonth",
"older",
];
export function getDateGroup(lastModified: number | undefined): DateGroup {
if (!lastModified) return "older";
const now = new Date();
const today = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const d = new Date(lastModified);
const fileDay = new Date(
d.getFullYear(),
d.getMonth(),
d.getDate(),
).getTime();
const daysAgo = (today - fileDay) / 86400000;
if (daysAgo < 1) return "today";
if (daysAgo < 2) return "yesterday";
if (daysAgo < 7) return "thisWeek";
if (daysAgo < 30) return "thisMonth";
return "older";
}
export function formatFileDate(lastModifiedTs: number): string {
const lastModified = lastModifiedTs ? new Date(lastModifiedTs) : new Date();
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const fileDay = new Date(
lastModified.getFullYear(),
lastModified.getMonth(),
lastModified.getDate(),
);
if (fileDay.getTime() === today.getTime()) {
return lastModified
.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.toLowerCase();
}
if (fileDay.getTime() === yesterday.getTime()) {
return "Yesterday";
}
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
if (fileDay >= weekAgo) {
return lastModified.toLocaleDateString("en-US", { weekday: "long" });
}
return lastModified.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function FilePdfIcon({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
return (
<svg
className={className}
style={style}
width="16"
height="20"
viewBox="0 0 16 20"
fill="none"
aria-hidden="true"
>
<path
d="M1 2C1 .895 1.895 0 3 0H9.5L15 5.5V18C15 19.105 14.105 20 13 20H3C1.895 20 1 19.105 1 18V2Z"
fill="currentColor"
/>
<path
d="M9.5 0L15 5.5H11C10.172 5.5 9.5 4.828 9.5 4V0Z"
fill="rgba(0,0,0,0.2)"
/>
<rect x="3.5" y="8.5" width="7" height="1.2" rx=".6" fill="rgba(255,255,255,.85)" />
<rect x="3.5" y="11" width="9" height="1.2" rx=".6" fill="rgba(255,255,255,.85)" />
<rect x="3.5" y="13.5" width="5.5" height="1.2" rx=".6" fill="rgba(255,255,255,.85)" />
</svg>
);
}
function FileGenericIcon({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
return (
<svg
className={className}
style={style}
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" />
</svg>
);
}
function CheckIcon({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
return (
<svg
className={className}
style={style}
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
export interface FileItemProps {
fileId: FileId;
name: string;
size?: number;
lastModified?: number;
isSelected: boolean;
isActive: boolean;
isViewedInViewer: boolean;
thumbnailUrl?: string;
onClick: (fileId: FileId) => void;
onEyeClick: (fileId: FileId, e: React.MouseEvent) => void;
}
export function FileItem({
fileId,
name,
size,
lastModified,
isSelected,
isActive,
isViewedInViewer,
thumbnailUrl,
onClick,
onEyeClick,
}: FileItemProps) {
const ext = getFileExtension(name);
const isPdf = ext === "pdf";
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
const typeLabel = isPdf ? "" : ext ? ext.toUpperCase() : "File";
const resolvedThumbnail = useLazyThumbnail(fileId, size ?? 0, thumbnailUrl);
const itemRef = useRef<HTMLDivElement>(null);
const [hoverRect, setHoverRect] = useState<DOMRect | null>(null);
const handleMouseEnter = useCallback(() => {
setHoverRect(itemRef.current?.getBoundingClientRect() ?? null);
}, []);
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
const thumbPos =
hoverRect && resolvedThumbnail
? {
top: hoverRect.top + hoverRect.height / 2,
left: hoverRect.right + 10,
}
: null;
return (
<>
<div
ref={itemRef}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
onClick={() => onClick(fileId)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onClick(fileId)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="file-sidebar-file-icon-wrapper">
{isSelected ? (
<div className="file-sidebar-file-check">
<CheckIcon className="file-sidebar-check-svg" />
</div>
) : (
<>
<div className="file-sidebar-file-checkbox-hover" />
{isPdf ? (
<FilePdfIcon
className="file-sidebar-file-icon file-sidebar-file-icon-hover-hide"
style={{ color: "#DC2626" }}
/>
) : (
<FileGenericIcon
className="file-sidebar-file-icon file-sidebar-file-icon-hover-hide"
style={{ color: "#71717A" }}
/>
)}
</>
)}
</div>
<div className="file-sidebar-file-info">
<span
className={`file-sidebar-file-name${isSelected ? " selected" : ""}`}
>
{name}
</span>
<span className="file-sidebar-file-meta">
{dateLabel}
{dateLabel && typeLabel ? " · " : ""}
{typeLabel}
</span>
</div>
<button
className="file-sidebar-eye-btn"
onClick={(e) => {
e.stopPropagation();
onEyeClick(fileId, e);
}}
tabIndex={-1}
type="button"
aria-label={isViewedInViewer ? "Close viewer" : "Open in viewer"}
>
<VisibilityOutlinedIcon
className="file-sidebar-eye-open"
sx={{ fontSize: "1.1rem" }}
/>
<VisibilityOffOutlinedIcon
className="file-sidebar-eye-closed"
sx={{ fontSize: "1.1rem" }}
/>
</button>
</div>
{thumbPos &&
resolvedThumbnail &&
createPortal(
<div
className="file-sidebar-thumb-tooltip"
style={{ top: thumbPos.top, left: thumbPos.left }}
>
<img
src={resolvedThumbnail}
alt=""
className="file-sidebar-thumb-img"
/>
</div>,
document.body,
)}
</>
);
}