2026-04-21 15:48:02 +01:00
|
|
|
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. */
|
2026-04-21 18:47:23 +01:00
|
|
|
function useLazyThumbnail(
|
|
|
|
|
fileId: FileId,
|
|
|
|
|
size: number,
|
|
|
|
|
thumbnailUrl?: string,
|
|
|
|
|
): string | undefined {
|
2026-04-21 15:48:02 +01:00
|
|
|
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(() => {
|
2026-04-21 18:47:23 +01:00
|
|
|
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
|
|
|
|
|
return;
|
2026-04-21 15:48:02 +01:00
|
|
|
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() : "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 21:16:58 +01:00
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 15:48:02 +01:00
|
|
|
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);
|
2026-04-21 18:47:23 +01:00
|
|
|
const fileDay = new Date(
|
|
|
|
|
lastModified.getFullYear(),
|
|
|
|
|
lastModified.getMonth(),
|
|
|
|
|
lastModified.getDate(),
|
|
|
|
|
);
|
2026-04-21 15:48:02 +01:00
|
|
|
|
|
|
|
|
if (fileDay.getTime() === today.getTime()) {
|
2026-04-21 18:47:23 +01:00
|
|
|
return lastModified
|
|
|
|
|
.toLocaleTimeString("en-US", {
|
|
|
|
|
hour: "numeric",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
hour12: true,
|
|
|
|
|
})
|
|
|
|
|
.toLowerCase();
|
2026-04-21 15:48:02 +01:00
|
|
|
}
|
|
|
|
|
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" });
|
|
|
|
|
}
|
2026-04-21 18:47:23 +01:00
|
|
|
return lastModified.toLocaleDateString("en-US", {
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
});
|
2026-04-21 15:48:02 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 18:47:23 +01:00
|
|
|
function FilePdfIcon({
|
|
|
|
|
className,
|
|
|
|
|
style,
|
|
|
|
|
}: {
|
|
|
|
|
className?: string;
|
|
|
|
|
style?: React.CSSProperties;
|
|
|
|
|
}) {
|
2026-04-21 15:48:02 +01:00
|
|
|
return (
|
|
|
|
|
<svg
|
|
|
|
|
className={className}
|
|
|
|
|
style={style}
|
2026-04-30 21:16:58 +01:00
|
|
|
width="16"
|
2026-04-21 15:48:02 +01:00
|
|
|
height="20"
|
2026-04-30 21:16:58 +01:00
|
|
|
viewBox="0 0 16 20"
|
2026-04-21 15:48:02 +01:00
|
|
|
fill="none"
|
2026-04-30 21:16:58 +01:00
|
|
|
aria-hidden="true"
|
2026-04-21 15:48:02 +01:00
|
|
|
>
|
2026-04-30 21:16:58 +01:00
|
|
|
<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)" />
|
2026-04-21 15:48:02 +01:00
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 18:47:23 +01:00
|
|
|
function FileGenericIcon({
|
|
|
|
|
className,
|
|
|
|
|
style,
|
|
|
|
|
}: {
|
|
|
|
|
className?: string;
|
|
|
|
|
style?: React.CSSProperties;
|
|
|
|
|
}) {
|
2026-04-21 15:48:02 +01:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 18:47:23 +01:00
|
|
|
function CheckIcon({
|
|
|
|
|
className,
|
|
|
|
|
style,
|
|
|
|
|
}: {
|
|
|
|
|
className?: string;
|
|
|
|
|
style?: React.CSSProperties;
|
|
|
|
|
}) {
|
2026-04-21 15:48:02 +01:00
|
|
|
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) : "";
|
2026-04-30 21:16:58 +01:00
|
|
|
const typeLabel = isPdf ? "" : ext ? ext.toUpperCase() : "File";
|
2026-04-21 15:48:02 +01:00
|
|
|
|
|
|
|
|
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 =
|
2026-04-21 18:47:23 +01:00
|
|
|
hoverRect && resolvedThumbnail
|
|
|
|
|
? {
|
|
|
|
|
top: hoverRect.top + hoverRect.height / 2,
|
|
|
|
|
left: hoverRect.right + 10,
|
|
|
|
|
}
|
|
|
|
|
: null;
|
2026-04-21 15:48:02 +01:00
|
|
|
|
|
|
|
|
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"
|
2026-04-30 21:16:58 +01:00
|
|
|
style={{ color: "#DC2626" }}
|
2026-04-21 15:48:02 +01:00
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<FileGenericIcon
|
|
|
|
|
className="file-sidebar-file-icon file-sidebar-file-icon-hover-hide"
|
|
|
|
|
style={{ color: "#71717A" }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="file-sidebar-file-info">
|
2026-04-21 18:47:23 +01:00
|
|
|
<span
|
|
|
|
|
className={`file-sidebar-file-name${isSelected ? " selected" : ""}`}
|
|
|
|
|
>
|
|
|
|
|
{name}
|
|
|
|
|
</span>
|
2026-04-21 15:48:02 +01:00
|
|
|
<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"}
|
|
|
|
|
>
|
2026-04-21 18:47:23 +01:00
|
|
|
<VisibilityOutlinedIcon
|
|
|
|
|
className="file-sidebar-eye-open"
|
|
|
|
|
sx={{ fontSize: "1.1rem" }}
|
|
|
|
|
/>
|
|
|
|
|
<VisibilityOffOutlinedIcon
|
|
|
|
|
className="file-sidebar-eye-closed"
|
|
|
|
|
sx={{ fontSize: "1.1rem" }}
|
|
|
|
|
/>
|
2026-04-21 15:48:02 +01:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{thumbPos &&
|
|
|
|
|
resolvedThumbnail &&
|
|
|
|
|
createPortal(
|
2026-04-21 18:47:23 +01:00
|
|
|
<div
|
|
|
|
|
className="file-sidebar-thumb-tooltip"
|
|
|
|
|
style={{ top: thumbPos.top, left: thumbPos.left }}
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src={resolvedThumbnail}
|
|
|
|
|
alt=""
|
|
|
|
|
className="file-sidebar-thumb-img"
|
|
|
|
|
/>
|
2026-04-21 15:48:02 +01:00
|
|
|
</div>,
|
|
|
|
|
document.body,
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|