These adress the change requests along with some other issues I found with our UI

This commit is contained in:
EthanHealy01
2026-05-05 17:25:31 +01:00
parent 93ab2b4805
commit 18fd86f5bf
31 changed files with 634 additions and 962 deletions
@@ -6814,7 +6814,7 @@ defaultPdfEditorChecking = "Checking..."
defaultPdfEditorInactive = "Another application is set as default"
defaultPdfEditorSet = "Already Default"
defaultStartupView = "Default view on launch"
defaultStartupViewDescription = "Choose which tab is active in the left column when the app starts"
defaultStartupViewDescription = "Choose which view is active when the app starts"
defaultToolPickerMode = "Default tool picker mode"
defaultToolPickerModeDescription = "Choose whether the tool picker opens in fullscreen or sidebar by default"
defaultViewerZoom = "Default reader zoom"
@@ -7855,8 +7855,9 @@ singleFileScope = "Only applying to: {{fileName}}"
viewerMode = "Switch to the file editor to add multiple files."
[toolPanel]
allTools = "All tools"
alpha = "Alpha"
backToTools = "Back to tools"
backToTools = "Back to all tools"
collapse = "Collapse panel"
comingSoon = "Coming soon:"
expand = "Expand panel"
@@ -33,6 +33,7 @@
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-height: 100%;
}
@@ -80,11 +81,6 @@
transform 0.2s ease;
}
.thumbContainer[data-supported="false"] {
filter: grayscale(80%);
opacity: 0.65;
}
.thumbContainer:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
@@ -93,7 +89,7 @@
.thumbImage {
width: 100%;
height: 100%;
object-fit: fill;
object-fit: contain;
border-radius: 12px;
background: var(--bg-surface);
display: block;
@@ -5,7 +5,6 @@ import {
Button,
Group,
Stack,
Loader,
ActionIcon,
Tooltip,
} from "@mantine/core";
@@ -28,6 +27,7 @@ import {
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { StirlingFileStub } from "@app/types/fileContext";
import { zipFileService } from "@app/services/zipFileService";
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
import { useFileContext } from "@app/contexts/FileContext";
@@ -43,6 +43,7 @@ import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { truncateCenter } from "@app/utils/textUtils";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
interface FileEditorThumbnailProps {
file: StirlingFileStub;
@@ -85,6 +86,11 @@ const FileEditorThumbnail = ({
} = useFileContext();
const { state, selectors } = useFileState();
const isMobile = useIsMobile();
const {
isEncrypted,
thumbnail: displayThumbnail,
isGenerating: isThumbGenerating,
} = useFileThumbnail(file);
const actualFile = useMemo(
() => activeFiles.find((f) => f.fileId === file.id),
@@ -96,7 +102,6 @@ const FileEditorThumbnail = ({
const hasError = state.ui.errorFileIds.includes(file.id);
const pageCount = file.processedFile?.totalPages || 0;
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
// Aspect ratio from page dimensions, falling back to letter size
const firstPage = file.processedFile?.pages?.[0];
@@ -481,33 +486,23 @@ const FileEditorThumbnail = ({
)}
{/* Thumbnail image or loading state */}
{file.thumbnailUrl ? (
<PrivateContent>
<img
src={file.thumbnailUrl}
alt={file.name}
className={styles.thumbImage}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
</PrivateContent>
) : file.type?.startsWith("application/pdf") ? (
<Stack
align="center"
justify="center"
gap="xs"
style={{ height: "100%" }}
>
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading thumbnail...
</Text>
</Stack>
) : null}
<DocumentThumbnail
file={file}
thumbnail={displayThumbnail || undefined}
isEncrypted={isEncrypted}
isLoading={
!isEncrypted &&
!displayThumbnail &&
(isThumbGenerating ||
file.type?.startsWith("application/pdf") ||
file.type?.startsWith("image/"))
}
iconSize="6rem"
imgClassName={styles.thumbImage}
onImageError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
{/* Badges — visible on hover via CSS */}
<div className={styles.thumbBadges}>
@@ -11,7 +11,6 @@ interface FileHistoryGroupProps {
onDownloadSingle: (file: StirlingFileStub) => void;
onFileDoubleClick: (file: StirlingFileStub) => void;
onHistoryFileRemove: (file: StirlingFileStub) => void;
isFileSupported: (fileName: string) => boolean;
}
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
@@ -21,7 +20,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
onDownloadSingle,
onFileDoubleClick,
onHistoryFileRemove,
isFileSupported,
}) => {
const { t } = useTranslation();
@@ -50,7 +48,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
file={historyFile}
isSelected={false} // History files are not selectable
isSupported={isFileSupported(historyFile.name)}
onSelect={() => {}} // No selection for history files
onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file
onDownload={() => onDownloadSingle(historyFile)}
@@ -28,7 +28,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onHistoryFileRemove,
onFileDoubleClick,
onDownloadSingle,
isFileSupported,
isLoading,
activeFileIds,
} = useFileManagerContext();
@@ -65,7 +64,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
<FileListItem
file={file}
isSelected={selectedFilesSet.has(file.id)}
isSupported={isFileSupported(file.name)}
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
onRemove={() => onFileRemove(index)}
onDownload={() => onDownloadSingle(file)}
@@ -82,7 +80,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onDownloadSingle={onDownloadSingle}
onFileDoubleClick={onFileDoubleClick}
onHistoryFileRemove={onHistoryFileRemove}
isFileSupported={isFileSupported}
/>
</React.Fragment>
);
@@ -39,7 +39,6 @@ import { alert } from "@app/components/toast";
interface FileListItemProps {
file: StirlingFileStub;
isSelected: boolean;
isSupported: boolean;
onSelect: (shiftKey?: boolean) => void;
onRemove: () => void;
onDownload?: () => void;
@@ -53,7 +52,6 @@ interface FileListItemProps {
const FileListItem: React.FC<FileListItemProps> = ({
file,
isSelected,
isSupported,
onSelect,
onRemove,
onDownload,
@@ -192,7 +190,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
isSelected || shouldShowHovered
? "var(--mantine-color-gray-1)"
: "var(--bg-file-list)",
opacity: isSupported ? 1 : 0.5,
opacity: 1,
transition: "background-color 0.15s ease",
userSelect: "none",
WebkitUserSelect: "none",
@@ -9,6 +9,7 @@ import {
useNavigationActions,
} from "@app/contexts/NavigationContext";
import { isBaseWorkbench } from "@app/types/workbench";
import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import styles from "@app/components/layout/Workbench.module.css";
@@ -107,7 +108,9 @@ export default function Workbench() {
return (
<FileEditor
toolMode={!!selectedToolId}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
supportedExtensions={
selectedTool?.supportedFormats || VIEWER_SUPPORTED_EXTENSIONS
}
{...(!selectedToolId && {
onOpenPageEditor: () => {
setCurrentView("pageEditor");
@@ -7,21 +7,18 @@ import {
Badge,
Button,
Box,
Image,
ThemeIcon,
ActionIcon,
Tooltip,
Loader,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { StirlingFileStub } from "@app/types/fileContext";
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
interface FileCardProps {
file: File;
@@ -47,15 +44,15 @@ const FileCard = ({
isSupported = true,
}: FileCardProps) => {
const { t } = useTranslation();
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
const { thumbnail: indexedDBThumb, isGenerating } =
useIndexedDBThumbnail(fileStub);
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
const {
isEncrypted,
thumbnail: thumb,
isGenerating,
} = useFileThumbnail(fileStub);
const [isHovered, setIsHovered] = useState(false);
// Show loading state during hydration: PDF file without thumbnail yet
const isPdf = file.type === "application/pdf";
const isHydrating = isPdf && !thumb && !isGenerating;
const isHydrating = isPdf && !isEncrypted && !thumb && !isGenerating;
return (
<Card
@@ -73,8 +70,7 @@ const FileCard = ({
? "2px solid var(--mantine-color-blue-6)"
: undefined,
backgroundColor: isSelected ? "var(--mantine-color-blue-0)" : undefined,
opacity: isSupported ? 1 : 0.5,
filter: isSupported ? "none" : "grayscale(50%)",
opacity: 1,
}}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
@@ -146,51 +142,13 @@ const FileCard = ({
)}
</div>
)}
{thumb ? (
<Image
src={thumb}
alt="PDF thumbnail"
height={110}
width={80}
fit="contain"
radius="sm"
/>
) : isGenerating || isHydrating ? (
<Stack align="center" justify="center" gap="xs">
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading...
</Text>
</Stack>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>
Large File
</Text>
)}
</div>
)}
<DocumentThumbnail
file={file}
thumbnail={thumb || undefined}
isEncrypted={isEncrypted}
isLoading={isGenerating || isHydrating}
iconSize="3rem"
/>
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
@@ -0,0 +1,204 @@
export type FileDocVariant =
| "pdf"
| "spreadsheet"
| "doc"
| "image"
| "archive"
| "code"
| "generic";
export const VARIANT_COLORS: Record<FileDocVariant, string> = {
pdf: "#DC2626",
spreadsheet: "#16a34a",
doc: "#2563eb",
image: "#7c3aed",
archive: "#ea580c",
code: "#0891b2",
generic: "#71717a",
};
export function FileDocIcon({
color,
variant,
className,
style,
}: {
color?: string;
variant: FileDocVariant;
className?: string;
style?: React.CSSProperties;
}) {
color ??= VARIANT_COLORS[variant];
const foldX = 10.5;
const foldY = 5.5;
let interior: React.ReactNode;
if (variant === "spreadsheet") {
// Outer border of the grid
const gx1 = 3,
gy1 = 8,
gx2 = 13,
gy2 = 17;
// Column divider x, row divider y positions
const colX = 8;
const row1Y = 11,
row2Y = 14;
interior = (
<g opacity="0.7" stroke={color} strokeWidth="1" fill="none">
<rect x={gx1} y={gy1} width={gx2 - gx1} height={gy2 - gy1} rx="0.5" />
<line x1={colX} y1={gy1} x2={colX} y2={gy2} />
<line x1={gx1} y1={row1Y} x2={gx2} y2={row1Y} />
<line x1={gx1} y1={row2Y} x2={gx2} y2={row2Y} />
</g>
);
} else if (variant === "image") {
interior = (
<>
<polyline
points="2.5,16 5.5,11.5 8,14 10,12 13.5,16"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
fill="none"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle
cx="11"
cy="9.5"
r="1.3"
stroke={color}
strokeWidth="1"
opacity="0.6"
fill="none"
/>
</>
);
} else if (variant === "code") {
interior = (
<>
<polyline
points="6,9.5 3.5,12.5 6,15.5"
stroke={color}
strokeWidth="1.3"
opacity="0.7"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="10,9.5 12.5,12.5 10,15.5"
stroke={color}
strokeWidth="1.3"
opacity="0.7"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
);
} else if (variant === "archive") {
interior = (
<>
<line
x1="5"
y1="9.5"
x2="11"
y2="9.5"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeDasharray="2,1.5"
strokeLinecap="round"
/>
<line
x1="5"
y1="12"
x2="11"
y2="12"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeDasharray="2,1.5"
strokeLinecap="round"
/>
<line
x1="5"
y1="14.5"
x2="11"
y2="14.5"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeDasharray="2,1.5"
strokeLinecap="round"
/>
</>
);
} else {
// pdf, doc, generic — text lines
interior = (
<>
<line
x1="3.5"
y1="9.5"
x2="12.5"
y2="9.5"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeLinecap="round"
/>
<line
x1="3.5"
y1="12"
x2="12.5"
y2="12"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeLinecap="round"
/>
<line
x1="3.5"
y1="14.5"
x2="8.5"
y2="14.5"
stroke={color}
strokeWidth="1.1"
opacity="0.6"
strokeLinecap="round"
/>
</>
);
}
return (
<svg
className={className}
style={{ width: 16, height: 20, ...style }}
viewBox="0 0 16 20"
fill="none"
aria-hidden="true"
>
{/* Document outline with top-right page fold */}
<path
d={`M 1.5,1 H ${foldX} L 15,${foldY} V 18.5 Q 15,19 14.5,19 H 1.5 Q 1,19 1,18.5 V 1.5 Q 1,1 1.5,1 Z`}
stroke={color}
strokeWidth="1.3"
fill="none"
strokeLinejoin="round"
/>
{/* Fold corner triangle */}
<path
d={`M ${foldX},1 L ${foldX},${foldY} L 15,${foldY} Z`}
stroke={color}
strokeWidth="1.3"
fill={color}
fillOpacity="0.2"
strokeLinejoin="round"
/>
{interior}
</svg>
);
}
@@ -8,13 +8,11 @@ import {
Checkbox,
ScrollArea,
Box,
Image,
Badge,
ThemeIcon,
SimpleGrid,
} from "@mantine/core";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { useTranslation } from "react-i18next";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
import { FileId } from "@app/types/file";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
@@ -200,26 +198,23 @@ const FilePickerModal = ({
height: 80,
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "var(--mantine-color-gray-0)",
backgroundColor: "white",
flexShrink: 0,
overflow: "hidden",
}}
>
{file.thumbnail ? (
<Image
src={file.thumbnail}
alt="PDF thumbnail"
height={70}
width={50}
fit="contain"
/>
) : (
<ThemeIcon variant="light" color="red" size={40}>
<PictureAsPdfIcon style={{ fontSize: 24 }} />
</ThemeIcon>
)}
<DocumentThumbnail
file={file}
thumbnail={
file.processedFile?.isEncrypted
? undefined
: file.thumbnail
}
isEncrypted={Boolean(
file.processedFile?.isEncrypted,
)}
iconSize="2rem"
/>
</Box>
{/* File info */}
@@ -124,7 +124,7 @@
background: transparent;
}
.file-sidebar-scroll::-webkit-scrollbar-thumb {
background: var(--border);
background: rgb(var(--border));
border-radius: 2px;
}
@@ -220,6 +220,26 @@
margin-top: 4px;
}
.file-sidebar-file-list {
flex: 1;
min-height: 0;
overflow-y: scroll;
}
.file-sidebar-file-list::-webkit-scrollbar {
width: 4px;
}
.file-sidebar-file-list::-webkit-scrollbar-track {
background: transparent;
}
.file-sidebar-file-list::-webkit-scrollbar-thumb {
background: rgb(var(--border));
border-radius: 2px;
}
.file-sidebar-file-list::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.file-sidebar-section-divider {
border-top: 1px solid var(--border-subtle);
margin: 10px 14px 6px 14px;
@@ -500,10 +500,15 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
);
const isActive = isViewedInViewer;
// In-memory thumbnail may be fresher than the IndexedDB stub.
const thumbnailUrl =
(workbenchFileId
? state.files.byId[workbenchFileId]?.thumbnailUrl
: undefined) || stub.thumbnailUrl;
// Encrypted files never get a raster thumbnail — use undefined
// so the sidebar icon is shown instead of a stale canvas thumbnail.
const isEncryptedFile =
stub.processedFile?.isEncrypted === true;
const thumbnailUrl = isEncryptedFile
? undefined
: (workbenchFileId
? state.files.byId[workbenchFileId]?.thumbnailUrl
: undefined) || stub.thumbnailUrl;
return (
<FileItem
key={stub.id}
@@ -17,7 +17,7 @@
background: transparent;
}
.file-sidebar-file-list::-webkit-scrollbar-thumb {
background: var(--border);
background: rgb(var(--border));
border-radius: 2px;
}
@@ -3,8 +3,12 @@ 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 { FileDocIcon } from "@app/components/shared/FileDocIcon";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useFileManagement } from "@app/contexts/FileContext";
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
import "@app/components/shared/FileSidebarFileItem.css";
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
@@ -18,6 +22,7 @@ function useLazyThumbnail(
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
const attempted = useRef(false);
const indexedDB = useIndexedDB();
const { updateStirlingFileStub } = useFileManagement();
// Sync prop changes (e.g. thumbnail arrives after TTL bump)
useEffect(() => {
@@ -38,6 +43,7 @@ function useLazyThumbnail(
if (cancelled || !thumbnail) return;
setThumb(thumbnail);
void indexedDB.updateThumbnail(fileId, thumbnail);
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
} catch {
// non-critical
}
@@ -46,7 +52,7 @@ function useLazyThumbnail(
return () => {
cancelled = true;
};
}, [fileId, size, thumbnailUrl, indexedDB]);
}, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]);
return thumb;
}
@@ -128,85 +134,6 @@ export function formatFileDate(lastModifiedTs: number): string {
});
}
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,
@@ -232,6 +159,11 @@ function CheckIcon({
);
}
function getSidebarFileIcon(ext: string): React.ReactElement {
const cls = "file-sidebar-file-icon file-sidebar-file-icon-hover-hide";
return <FileDocIcon className={cls} variant={getFileDocVariant(ext)} />;
}
export interface FileItemProps {
fileId: FileId;
name: string;
@@ -258,11 +190,16 @@ export function FileItem({
onEyeClick,
}: FileItemProps) {
const ext = getFileExtension(name);
const isPdf = ext === "pdf";
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
const typeLabel = isPdf ? "" : ext ? ext.toUpperCase() : "File";
const typeLabel = ext ? ext.toUpperCase() : "File";
const resolvedThumbnail = useLazyThumbnail(fileId, size ?? 0, thumbnailUrl);
// Only use raster thumbnails for PDFs and images — everything else uses scalable SVG icons
const useRasterThumb = ext === "pdf" || IMAGE_EXTENSIONS.has(ext);
const resolvedThumbnail = useLazyThumbnail(
fileId,
size ?? 0,
useRasterThumb ? thumbnailUrl : undefined,
);
const itemRef = useRef<HTMLDivElement>(null);
const [hoverRect, setHoverRect] = useState<DOMRect | null>(null);
@@ -302,17 +239,7 @@ export function FileItem({
) : (
<>
<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" }}
/>
)}
{getSidebarFileIcon(ext)}
</>
)}
</div>
@@ -349,7 +276,8 @@ export function FileItem({
</button>
</div>
{thumbPos &&
{useRasterThumb &&
thumbPos &&
resolvedThumbnail &&
createPortal(
<div
@@ -50,6 +50,7 @@ interface InfoBannerProps {
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
minHeight?: number | string;
closeIconColor?: string;
compact?: boolean;
}
/**
@@ -75,6 +76,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
buttonVariant = "light",
minHeight = 56,
closeIconColor,
compact = false,
}) => {
if (!show) {
return null;
@@ -85,9 +87,13 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
onDismiss?.();
};
const iconSize = compact ? "1rem" : "1.2rem";
const textSize = compact ? "xs" : "sm";
const buttonSize = compact ? "xs" : "xs";
return (
<Paper
p="sm"
p={compact ? "xs" : "sm"}
radius={0}
style={{
background: background ?? toneStyle.background,
@@ -105,22 +111,22 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
style={{ width: "100%" }}
>
<Group
gap="sm"
gap={compact ? "xs" : "sm"}
align="center"
wrap="nowrap"
style={{ flex: 1, minWidth: 0 }}
>
<LocalIcon
icon={icon}
width="1.2rem"
height="1.2rem"
width={iconSize}
height={iconSize}
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
fw={600}
size="sm"
size={textSize}
style={{ color: textColor ?? toneStyle.text }}
>
{title}
@@ -128,9 +134,9 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
)}
<Text
fw={title ? 400 : 500}
size="sm"
size={textSize}
style={{ color: textColor ?? toneStyle.text }}
lineClamp={2}
lineClamp={compact ? 1 : 2}
>
{message}
</Text>
@@ -141,11 +147,15 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
<Button
variant={buttonVariant}
color={buttonColor ?? toneStyle.buttonColor}
size="xs"
size={buttonSize}
onClick={onButtonClick}
loading={loading}
leftSection={
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
<LocalIcon
icon={buttonIcon}
width={compact ? "0.75rem" : "0.9rem"}
height={compact ? "0.75rem" : "0.9rem"}
/>
}
>
{buttonText}
@@ -160,7 +170,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
aria-label="Dismiss"
style={closeIconColor ? { color: closeIconColor } : undefined}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
<LocalIcon
icon="close-rounded"
width={compact ? "0.85rem" : "1rem"}
height={compact ? "0.85rem" : "1rem"}
/>
</ActionIcon>
)}
</Group>
@@ -91,10 +91,10 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
: "transparent",
color: disabled
? "light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))"
? "var(--text-muted)"
: isSelected
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))"
: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))",
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-3))"
: "var(--text-primary)",
transition: "all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
cursor: disabled ? "not-allowed" : "pointer",
"&:hover": !disabled
@@ -300,8 +300,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
styles={{
root: {
border: "none",
color:
"light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))",
color: "var(--text-primary)",
transition:
"background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
"&:hover": {
@@ -476,7 +476,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.general.defaultStartupViewDescription",
"Choose which tab is active in the left column when the app starts",
"Choose which view is active when the app starts",
)}
</Text>
</div>
@@ -1,12 +1,19 @@
import React from "react";
import { Box, Center, Image } from "@mantine/core";
import { Box, Center, Loader, Stack, Text } from "@mantine/core";
import LockIcon from "@mui/icons-material/Lock";
import { getFileTypeIcon } from "@app/components/shared/filePreview/getFileTypeIcon";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { detectFileExtension } from "@app/utils/fileUtils";
export interface DocumentThumbnailProps {
file: File | StirlingFileStub | null;
thumbnail?: string | null;
isEncrypted?: boolean;
isLoading?: boolean;
iconSize?: string | number;
imgClassName?: string;
onImageError?: React.ReactEventHandler<HTMLImageElement>;
style?: React.CSSProperties;
onClick?: () => void;
children?: React.ReactNode;
@@ -15,14 +22,19 @@ export interface DocumentThumbnailProps {
const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
file,
thumbnail,
isEncrypted = false,
isLoading = false,
iconSize = "4rem",
imgClassName,
onImageError,
style = {},
onClick,
children,
}) => {
if (!file) return null;
const containerStyle = {
position: "relative" as const,
const containerStyle: React.CSSProperties = {
position: "relative",
cursor: onClick ? "pointer" : "default",
transition: "opacity 0.2s ease",
width: "100%",
@@ -33,20 +45,29 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
...style,
};
if (thumbnail) {
if (thumbnail && !isEncrypted) {
return (
<Box style={containerStyle} onClick={onClick}>
<PrivateContent>
<Image
<img
src={thumbnail}
alt={`Preview of ${file.name}`}
fit="contain"
style={{
maxWidth: "100%",
maxHeight: "100%",
width: "auto",
height: "auto",
}}
className={imgClassName}
style={
imgClassName
? undefined
: {
maxWidth: "100%",
maxHeight: "100%",
width: "auto",
height: "auto",
objectFit: "contain",
}
}
draggable={false}
loading="lazy"
decoding="async"
onError={onImageError}
/>
</PrivateContent>
{children}
@@ -54,17 +75,93 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
);
}
if (isEncrypted) {
return (
<Box style={containerStyle} onClick={onClick}>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "calc(100% - 8px)",
height: "calc(100% - 8px)",
gap: "0.5rem",
border: "2px dashed var(--mantine-color-red-5)",
borderRadius: "10px",
}}
>
<LockIcon
style={{ fontSize: iconSize, color: "var(--mantine-color-red-6)" }}
/>
<span
style={{
fontSize: "0.65rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--mantine-color-red-6)",
background: "rgba(220,38,38,0.1)",
padding: "2px 8px",
borderRadius: "6px",
}}
>
Locked
</span>
</div>
{children}
</Box>
);
}
if (isLoading) {
return (
<Box style={containerStyle} onClick={onClick}>
<Stack
align="center"
justify="center"
gap="xs"
style={{ height: "100%" }}
>
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading thumbnail...
</Text>
</Stack>
{children}
</Box>
);
}
const ext = detectFileExtension(file.name ?? "").toUpperCase();
return (
<Box style={containerStyle} onClick={onClick}>
<Center
style={{
width: "100%",
height: "100%",
backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: "0.25rem",
flexDirection: "column",
gap: "0.75rem",
}}
>
<PrivateContent>{getFileTypeIcon(file)}</PrivateContent>
<PrivateContent>{getFileTypeIcon(file, iconSize)}</PrivateContent>
{ext && (
<span
style={{
fontSize: "0.7rem",
fontWeight: 700,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "var(--text-secondary)",
background: "rgb(var(--border))",
padding: "3px 10px",
borderRadius: "6px",
}}
>
{ext}
</span>
)}
</Center>
{children}
</Box>
@@ -1,17 +1,84 @@
import React from "react";
import JavascriptIcon from "@mui/icons-material/Javascript";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import type { FileDocVariant } from "@app/components/shared/FileDocIcon";
import type { StirlingFileStub } from "@app/types/fileContext";
import { detectFileExtension } from "@app/utils/fileUtils";
export const SPREADSHEET_EXTS = new Set(["csv", "xls", "xlsx", "ods"]);
export const DOC_EXTS = new Set([
"md",
"markdown",
"txt",
"doc",
"docx",
"odt",
"rtf",
]);
export const IMAGE_EXTS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"bmp",
"tiff",
"tif",
]);
export const ARCHIVE_EXTS = new Set([
"zip",
"tar",
"gz",
"rar",
"7z",
"cbz",
"cbr",
]);
export const CODE_EXTS = new Set([
"js",
"ts",
"jsx",
"tsx",
"html",
"css",
"json",
"xml",
"yaml",
"yml",
]);
type FileLike = File | StirlingFileStub;
/**
* Returns an appropriate file type icon for the provided file.
* - Uses the real file type and extension to decide the icon.
* - No any-casts; accepts File or StirlingFileStub.
*/
export function getFileDocVariant(ext: string, mime = ""): FileDocVariant {
if (ext === "pdf" || mime === "application/pdf") return "pdf";
if (
SPREADSHEET_EXTS.has(ext) ||
mime.includes("spreadsheet") ||
mime === "text/csv"
)
return "spreadsheet";
if (
DOC_EXTS.has(ext) ||
mime === "text/markdown" ||
mime === "text/plain" ||
mime.includes("word") ||
mime.includes("opendocument.text")
)
return "doc";
if (IMAGE_EXTS.has(ext) || mime.startsWith("image/")) return "image";
if (ARCHIVE_EXTS.has(ext) || mime.includes("zip") || mime.includes("archive"))
return "archive";
if (
CODE_EXTS.has(ext) ||
mime.includes("javascript") ||
mime.includes("json") ||
mime.includes("xml") ||
mime === "text/html"
)
return "code";
return "generic";
}
export function getFileTypeIcon(
file: FileLike,
size: number | string = "2rem",
@@ -19,29 +86,10 @@ export function getFileTypeIcon(
const name = (file?.name ?? "").toLowerCase();
const mime = (file?.type ?? "").toLowerCase();
const ext = detectFileExtension(name);
// JavaScript
if (ext === "js" || mime.includes("javascript")) {
return (
<JavascriptIcon
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
/>
);
}
// PDF
if (ext === "pdf" || mime === "application/pdf") {
return (
<PictureAsPdfIcon
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
/>
);
}
// Fallback generic
return (
<InsertDriveFileIcon
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
<FileDocIcon
variant={getFileDocVariant(ext, mime)}
style={{ width: size, height: "auto" }}
/>
);
}
@@ -1,6 +1,5 @@
import { useState, useRef } from "react";
import { ActionIcon, ScrollArea, Switch } from "@mantine/core";
import DoubleArrowIcon from "@mui/icons-material/DoubleArrow";
import { useRef } from "react";
import { ScrollArea, Switch } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import FullscreenToolList from "@app/components/tools/FullscreenToolList";
@@ -9,10 +8,8 @@ import { ToolId } from "@app/types/toolId";
import { useFocusTrap } from "@app/hooks/useFocusTrap";
import { LogoIcon } from "@app/components/shared/LogoIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
import { Tooltip } from "@app/components/shared/Tooltip";
import "@app/components/tools/ToolPanel.css";
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
interface FullscreenToolSurfaceProps {
searchQuery: string;
@@ -28,7 +25,6 @@ interface FullscreenToolSurfaceProps {
onSelect: (id: ToolId) => void;
onToggleDescriptions: () => void;
onExitFullscreenMode: () => void;
toggleLabel: string;
geometry: ToolPanelGeometry | null;
}
@@ -42,47 +38,17 @@ const FullscreenToolSurface = ({
onSearchChange,
onSelect,
onToggleDescriptions,
onExitFullscreenMode,
toggleLabel,
onExitFullscreenMode: _onExitFullscreenMode,
geometry,
}: FullscreenToolSurfaceProps) => {
const { t } = useTranslation();
const [isExiting, setIsExiting] = useState(false);
const surfaceRef = useRef<HTMLDivElement>(null);
const isRTL =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
// Enable focus trap when surface is active
useFocusTrap(surfaceRef, !isExiting);
useFocusTrap(surfaceRef, true);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const handleExit = () => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (prefersReducedMotion) {
onExitFullscreenMode();
return;
}
setIsExiting(true);
const el = surfaceRef.current;
if (!el) {
onExitFullscreenMode();
return;
}
// Rely on CSS animation end rather than duplicating timing in JS
el.addEventListener(
"animationend",
() => {
onExitFullscreenMode();
},
{ once: true },
);
};
const style = geometry
? {
left: `${geometry.left}px`,
@@ -103,10 +69,7 @@ const FullscreenToolSurface = ({
)}
data-tour="tool-panel"
>
<div
ref={surfaceRef}
className={`tool-panel__fullscreen-surface-inner ${isExiting ? "tool-panel__fullscreen-surface-inner--exiting" : ""}`}
>
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
<header className="tool-panel__fullscreen-header">
<div className="tool-panel__fullscreen-brand">
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
@@ -115,29 +78,6 @@ const FullscreenToolSurface = ({
className="tool-panel__fullscreen-brand-text"
/>
</div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<Tooltip
content={toggleLabel}
position="bottom"
arrow={true}
openOnFocus={false}
containerStyle={{ zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE }}
>
<ActionIcon
variant="subtle"
radius="xl"
size="md"
onClick={handleExit}
aria-label={toggleLabel}
style={{ color: "var(--right-rail-icon)" }}
>
<DoubleArrowIcon
fontSize="small"
style={{ transform: isRTL ? undefined : "rotate(180deg)" }}
/>
</ActionIcon>
</Tooltip>
</div>
</header>
<div className="tool-panel__fullscreen-controls">
@@ -120,16 +120,8 @@
var(--text-primary) 20%,
var(--border-subtle)
);
--fullscreen-text-icon: color-mix(
in srgb,
var(--text-primary) 90%,
var(--text-muted)
);
--fullscreen-text-icon-compact: color-mix(
in srgb,
var(--text-primary) 88%,
var(--text-muted)
);
--fullscreen-text-icon: var(--text-primary);
--fullscreen-text-icon-compact: var(--text-primary);
}
.tool-panel {
@@ -190,7 +182,9 @@
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.35rem 0.5rem;
height: 48px;
padding: 0 0.5rem;
box-sizing: border-box;
}
.tool-panel__search-row .search-input-container {
@@ -523,6 +517,8 @@
.tool-panel__fullscreen-name {
color: var(--text-primary);
font-size: 13px !important;
font-weight: 500 !important;
}
.tool-panel__fullscreen-description {
@@ -8,7 +8,7 @@ import ToolRenderer from "@app/components/tools/ToolRenderer";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from "@app/styles/rainbow.module.css";
import { ActionIcon, ScrollArea } from "@mantine/core";
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
import { ToolId } from "@app/types/toolId";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useTranslation } from "react-i18next";
@@ -74,10 +74,6 @@ export default function ToolPanel() {
rightRailRef,
});
const toggleLabel = isFullscreenMode
? t("toolPanel.toggle.sidebar", "Switch to sidebar mode")
: t("toolPanel.toggle.fullscreen", "Switch to fullscreen mode");
const handleExpand = () => {
if (readerMode) setReaderMode(false);
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
@@ -196,20 +192,6 @@ export default function ToolPanel() {
toolRegistry={toolRegistry}
mode="filter"
autoFocus={focusSearch}
iconOverride={
leftPanelView === "toolContent" ? (
<ActionIcon
variant="transparent"
radius="md"
size="1.25rem"
onClick={handleBackToTools}
aria-label={t("toolPanel.backToTools", "Back to tools")}
style={{ color: "var(--search-text-and-icon-color)" }}
>
<ArrowBackIcon sx={{ fontSize: "1.25rem" }} />
</ActionIcon>
) : undefined
}
/>
<ActionIcon
variant="outline"
@@ -245,6 +227,26 @@ export default function ToolPanel() {
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div
style={{
borderBottom: "1px solid var(--border-subtle)",
flexShrink: 0,
}}
>
<Button
variant="light"
color="blue"
size="sm"
fullWidth
radius={0}
leftSection={<ArrowBackIcon sx={{ fontSize: "0.9rem" }} />}
onClick={handleBackToTools}
aria-label={t("toolPanel.backToTools", "Back to all tools")}
styles={{ root: { justifyContent: "flex-start" } }}
>
{t("toolPanel.backToTools", "Back to all tools")}
</Button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
@@ -284,7 +286,6 @@ export default function ToolPanel() {
)
}
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
toggleLabel={toggleLabel}
geometry={fullscreenGeometry}
/>
)}
@@ -76,9 +76,7 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({
) : null}
<span className="tool-panel__fullscreen-list-body">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
{tool.versionStatus === "alpha" && (
<Badge size="xs" variant="light" color="orange">
{t("toolPanel.alpha", "Alpha")}
@@ -78,9 +78,7 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({
) : null}
<span className="tool-panel__fullscreen-body">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
{tool.versionStatus === "alpha" && (
<Badge size="xs" variant="light" color="orange">
{/* we can add more translations for different badges in future, like beta, etc. */}
@@ -92,9 +92,10 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
fileId: FileId,
existingThumbnail?: string,
): Promise<StirlingFileStub> => {
// Use existing thumbnail or generate new one if none provided
const thumbnail =
// Use existing thumbnail or generate new one if none provided (empty string = no raster thumbnail)
const generated =
existingThumbnail || (await generateThumbnailForFile(file));
const thumbnail = generated || undefined;
// History is handled via direct fileStorage calls, not here
const stirlingFile = createStirlingFile(file, fileId);
@@ -0,0 +1,19 @@
import { StirlingFileStub } from "@app/types/fileContext";
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
export function useFileThumbnail(
fileStub: StirlingFileStub | null | undefined,
): {
isEncrypted: boolean;
thumbnail: string | null;
isGenerating: boolean;
} {
const isEncrypted = Boolean(fileStub?.processedFile?.isEncrypted);
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(
isEncrypted ? null : fileStub,
);
const thumbnail = isEncrypted
? null
: fileStub?.thumbnailUrl || indexedDBThumb || null;
return { isEncrypted, thumbnail, isGenerating };
}
+5 -6
View File
@@ -60,15 +60,14 @@ export function useNavigationUrlSync(
firePixel(currentPath);
const route = parseToolRoute(registry);
if (route.toolId !== selectedTool) {
if (route.toolId) {
if (route.toolId) {
// URL specifies a tool — navigate to it (URL takes precedence over startup view preference)
if (route.toolId !== selectedTool) {
checkPremiumAndSelect(route.toolId);
} else if (selectedTool !== null) {
// Only clear selection if we actually had a tool selected
// Don't clear on initial load when selectedTool starts as null
clearToolSelection();
}
}
// When the URL is the home path (no tool), leave selectedTool untouched so that
// the startup view preference (defaultStartupView) is respected.
hasInitialized.current = true;
}, [checkPremiumAndSelect, config, enableSync, registry, selectedTool]); // Include dependencies
+2 -2
View File
@@ -228,7 +228,7 @@
--icon-inactive-color: #ffffff;
/* New theme colors for text and icons */
--tools-text-and-icon-color: #6b7280;
--tools-text-and-icon-color: var(--text-primary);
/* Tool picker sticky header variables (light mode) */
--tool-header-bg: #dbefff;
@@ -621,7 +621,7 @@
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
--tools-text-and-icon-color: #9ca3af;
--tools-text-and-icon-color: var(--text-primary);
/* Tool picker sticky header variables (dark mode) */
--tool-header-bg: #2a2f36;
+12 -1
View File
@@ -107,7 +107,7 @@ export type NonPdfFileType =
| "html"
| "unknown";
const IMAGE_EXTENSIONS = new Set([
export const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
@@ -124,6 +124,17 @@ const TEXT_EXTENSIONS = new Set(["txt"]);
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown"]);
const HTML_EXTENSIONS = new Set(["html", "htm"]);
/** All file extensions that the built-in viewer can render. */
export const VIEWER_SUPPORTED_EXTENSIONS: string[] = [
"pdf",
...IMAGE_EXTENSIONS,
...CSV_EXTENSIONS,
...JSON_EXTENSIONS,
...TEXT_EXTENSIONS,
...MARKDOWN_EXTENSIONS,
...HTML_EXTENSIONS,
];
/**
* Detects the non-PDF file type category for viewer routing.
* Returns 'unknown' for PDFs or unrecognized formats.
+13 -560
View File
@@ -16,16 +16,6 @@ export interface ThumbnailWithMetadata {
isEncrypted?: boolean;
}
interface ColorScheme {
bgTop: string;
bgBottom: string;
border: string;
icon: string;
badge: string;
textPrimary: string;
textSecondary: string;
}
/**
* Calculate thumbnail scale based on file size (modern 2024 scaling)
*/
@@ -38,495 +28,6 @@ export function calculateScaleFromFileSize(fileSize: number): number {
return 0.3; // Still usable quality, not tiny
}
/**
* Generate encrypted PDF thumbnail with lock icon
*/
function generateEncryptedPDFThumbnail(file: File): string {
const canvas = document.createElement("canvas");
canvas.width = 120;
canvas.height = 150;
const ctx = canvas.getContext("2d")!;
// Use PDF color scheme but with encrypted styling
const colorScheme = getFileTypeColorScheme("PDF");
// Create gradient background
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, colorScheme.bgTop);
gradient.addColorStop(1, colorScheme.bgBottom);
// Rounded rectangle background
drawRoundedRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8);
ctx.fillStyle = gradient;
ctx.fill();
// Border with dashed pattern for encrypted indicator
ctx.strokeStyle = colorScheme.border;
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.stroke();
ctx.setLineDash([]); // Reset dash pattern
// Large lock icon as main element
drawLargeLockIcon(ctx, canvas.width / 2, canvas.height / 2 - 10, colorScheme);
// "PDF" text under the lock
ctx.font =
'bold 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = colorScheme.icon;
ctx.textAlign = "center";
ctx.fillText("PDF", canvas.width / 2, canvas.height / 2 + 35);
// File size with subtle styling
const sizeText = formatFileSize(file.size);
ctx.font =
'11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = colorScheme.textSecondary;
ctx.textAlign = "center";
ctx.fillText(sizeText, canvas.width / 2, canvas.height - 15);
return canvas.toDataURL();
}
/**
* Generate modern placeholder thumbnail with file extension
*/
function generatePlaceholderThumbnail(file: File): string {
const canvas = document.createElement("canvas");
canvas.width = 120;
canvas.height = 150;
const ctx = canvas.getContext("2d")!;
// Get file extension for color theming
const extension = file.name.split(".").pop()?.toUpperCase() || "FILE";
const colorScheme = getFileTypeColorScheme(extension);
// Create gradient background
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, colorScheme.bgTop);
gradient.addColorStop(1, colorScheme.bgBottom);
// Rounded rectangle background
drawRoundedRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8);
ctx.fillStyle = gradient;
ctx.fill();
// Subtle shadow/border
ctx.strokeStyle = colorScheme.border;
ctx.lineWidth = 1.5;
ctx.stroke();
// Modern document icon
drawModernDocumentIcon(ctx, canvas.width / 2, 45, colorScheme.icon);
// Extension badge
drawExtensionBadge(
ctx,
canvas.width / 2,
canvas.height / 2 + 15,
extension,
colorScheme,
);
// File size with subtle styling
const sizeText = formatFileSize(file.size);
ctx.font =
'11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = colorScheme.textSecondary;
ctx.textAlign = "center";
ctx.fillText(sizeText, canvas.width / 2, canvas.height - 15);
return canvas.toDataURL();
}
/**
* Get color scheme based on file extension
*/
function getFileTypeColorScheme(extension: string): ColorScheme {
const schemes: Record<string, ColorScheme> = {
// Documents
PDF: {
bgTop: "#FF6B6B20",
bgBottom: "#FF6B6B10",
border: "#FF6B6B40",
icon: "#FF6B6B",
badge: "#FF6B6B",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
DOC: {
bgTop: "#4ECDC420",
bgBottom: "#4ECDC410",
border: "#4ECDC440",
icon: "#4ECDC4",
badge: "#4ECDC4",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
DOCX: {
bgTop: "#4ECDC420",
bgBottom: "#4ECDC410",
border: "#4ECDC440",
icon: "#4ECDC4",
badge: "#4ECDC4",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
ODT: {
bgTop: "#4ECDC420",
bgBottom: "#4ECDC410",
border: "#4ECDC440",
icon: "#4ECDC4",
badge: "#4ECDC4",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
TXT: {
bgTop: "#95A5A620",
bgBottom: "#95A5A610",
border: "#95A5A640",
icon: "#95A5A6",
badge: "#95A5A6",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
RTF: {
bgTop: "#95A5A620",
bgBottom: "#95A5A610",
border: "#95A5A640",
icon: "#95A5A6",
badge: "#95A5A6",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
// Spreadsheets
XLS: {
bgTop: "#2ECC7120",
bgBottom: "#2ECC7110",
border: "#2ECC7140",
icon: "#2ECC71",
badge: "#2ECC71",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
XLSX: {
bgTop: "#2ECC7120",
bgBottom: "#2ECC7110",
border: "#2ECC7140",
icon: "#2ECC71",
badge: "#2ECC71",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
ODS: {
bgTop: "#2ECC7120",
bgBottom: "#2ECC7110",
border: "#2ECC7140",
icon: "#2ECC71",
badge: "#2ECC71",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
CSV: {
bgTop: "#2ECC7120",
bgBottom: "#2ECC7110",
border: "#2ECC7140",
icon: "#2ECC71",
badge: "#2ECC71",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
// Presentations
PPT: {
bgTop: "#E67E2220",
bgBottom: "#E67E2210",
border: "#E67E2240",
icon: "#E67E22",
badge: "#E67E22",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
PPTX: {
bgTop: "#E67E2220",
bgBottom: "#E67E2210",
border: "#E67E2240",
icon: "#E67E22",
badge: "#E67E22",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
ODP: {
bgTop: "#E67E2220",
bgBottom: "#E67E2210",
border: "#E67E2240",
icon: "#E67E22",
badge: "#E67E22",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
// Images
JPG: {
bgTop: "#FF9F4320",
bgBottom: "#FF9F4310",
border: "#FF9F4340",
icon: "#FF9F43",
badge: "#FF9F43",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
JPEG: {
bgTop: "#FF9F4320",
bgBottom: "#FF9F4310",
border: "#FF9F4340",
icon: "#FF9F43",
badge: "#FF9F43",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
PNG: {
bgTop: "#FF9F4320",
bgBottom: "#FF9F4310",
border: "#FF9F4340",
icon: "#FF9F43",
badge: "#FF9F43",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
GIF: {
bgTop: "#FF9F4320",
bgBottom: "#FF9F4310",
border: "#FF9F4340",
icon: "#FF9F43",
badge: "#FF9F43",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
BMP: {
bgTop: "#FF9F4320",
bgBottom: "#FF9F4310",
border: "#FF9F4340",
icon: "#FF9F43",
badge: "#FF9F43",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
TIFF: {
bgTop: "#FF9F4320",
bgBottom: "#FF9F4310",
border: "#FF9F4340",
icon: "#FF9F43",
badge: "#FF9F43",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
WEBP: {
bgTop: "#FF9F4320",
bgBottom: "#FF9F4310",
border: "#FF9F4340",
icon: "#FF9F43",
badge: "#FF9F43",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
SVG: {
bgTop: "#FF9F4320",
bgBottom: "#FF9F4310",
border: "#FF9F4340",
icon: "#FF9F43",
badge: "#FF9F43",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
// Web
HTML: {
bgTop: "#FD79A820",
bgBottom: "#FD79A810",
border: "#FD79A840",
icon: "#FD79A8",
badge: "#FD79A8",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
XML: {
bgTop: "#FD79A820",
bgBottom: "#FD79A810",
border: "#FD79A840",
icon: "#FD79A8",
badge: "#FD79A8",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
// Text/Markup
MD: {
bgTop: "#6C5CE720",
bgBottom: "#6C5CE710",
border: "#6C5CE740",
icon: "#6C5CE7",
badge: "#6C5CE7",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
// Email
EML: {
bgTop: "#A29BFE20",
bgBottom: "#A29BFE10",
border: "#A29BFE40",
icon: "#A29BFE",
badge: "#A29BFE",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
// Archives
ZIP: {
bgTop: "#9B59B620",
bgBottom: "#9B59B610",
border: "#9B59B640",
icon: "#9B59B6",
badge: "#9B59B6",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
RAR: {
bgTop: "#9B59B620",
bgBottom: "#9B59B610",
border: "#9B59B640",
icon: "#9B59B6",
badge: "#9B59B6",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
"7Z": {
bgTop: "#9B59B620",
bgBottom: "#9B59B610",
border: "#9B59B640",
icon: "#9B59B6",
badge: "#9B59B6",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
// Default
DEFAULT: {
bgTop: "#74B9FF20",
bgBottom: "#74B9FF10",
border: "#74B9FF40",
icon: "#74B9FF",
badge: "#74B9FF",
textPrimary: "#FFFFFF",
textSecondary: "#666666",
},
};
return schemes[extension] || schemes["DEFAULT"];
}
/**
* Draw rounded rectangle
*/
function drawRoundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
/**
* Draw modern document icon
*/
function drawModernDocumentIcon(
ctx: CanvasRenderingContext2D,
centerX: number,
centerY: number,
color: string,
) {
const size = 24;
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
// Document body
drawRoundedRect(
ctx,
centerX - size / 2,
centerY - size / 2,
size,
size * 1.2,
3,
);
ctx.fill();
// Folded corner
ctx.beginPath();
ctx.moveTo(centerX + size / 2 - 6, centerY - size / 2);
ctx.lineTo(centerX + size / 2, centerY - size / 2 + 6);
ctx.lineTo(centerX + size / 2 - 6, centerY - size / 2 + 6);
ctx.closePath();
ctx.fillStyle = "#FFFFFF40";
ctx.fill();
}
/**
* Draw large lock icon for encrypted PDFs
*/
function drawLargeLockIcon(
ctx: CanvasRenderingContext2D,
centerX: number,
centerY: number,
colorScheme: ColorScheme,
) {
const size = 48;
ctx.fillStyle = colorScheme.icon;
ctx.strokeStyle = colorScheme.icon;
ctx.lineWidth = 3;
// Lock body (rectangle)
const bodyWidth = size;
const bodyHeight = size * 0.75;
const bodyX = centerX - bodyWidth / 2;
const bodyY = centerY - bodyHeight / 4;
drawRoundedRect(ctx, bodyX, bodyY, bodyWidth, bodyHeight, 4);
ctx.fill();
// Lock shackle (semicircle)
const shackleRadius = size * 0.32;
const shackleY = centerY - size * 0.25;
ctx.beginPath();
ctx.arc(centerX, shackleY, shackleRadius, Math.PI, 2 * Math.PI);
ctx.stroke();
// Keyhole
const keyholeX = centerX;
const keyholeY = bodyY + bodyHeight * 0.4;
ctx.fillStyle = colorScheme.textPrimary;
ctx.beginPath();
ctx.arc(keyholeX, keyholeY, 4, 0, 2 * Math.PI);
ctx.fill();
ctx.fillRect(keyholeX - 2, keyholeY, 4, 8);
}
/** PDFium error code 4 = password required (encrypted PDF). */
const PDFIUM_ERR_PASSWORD = 4;
@@ -611,53 +112,8 @@ async function renderPdfThumbnailPdfium(
}
}
/**
* Draw extension badge
*/
function drawExtensionBadge(
ctx: CanvasRenderingContext2D,
centerX: number,
centerY: number,
extension: string,
colorScheme: ColorScheme,
) {
const badgeWidth = Math.max(extension.length * 8 + 16, 40);
const badgeHeight = 22;
// Badge background
drawRoundedRect(
ctx,
centerX - badgeWidth / 2,
centerY - badgeHeight / 2,
badgeWidth,
badgeHeight,
11,
);
ctx.fillStyle = colorScheme.badge;
ctx.fill();
// Badge text
ctx.font =
'bold 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = colorScheme.textPrimary;
ctx.textAlign = "center";
ctx.fillText(extension, centerX, centerY + 4);
}
/**
* Format file size for display
*/
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
async function generatePDFThumbnail(
arrayBuffer: ArrayBuffer,
file: File,
scale: number,
): Promise<string> {
const result = await renderPdfThumbnailPdfium(
@@ -667,7 +123,7 @@ async function generatePDFThumbnail(
false,
);
if (result.isEncrypted) {
return generateEncryptedPDFThumbnail(file);
return "";
}
return result.thumbnail;
}
@@ -676,13 +132,14 @@ async function generatePDFThumbnail(
* Generate thumbnail for any file type - always returns a thumbnail (placeholder if needed)
*/
export async function generateThumbnailForFile(file: File): Promise<string> {
// Skip very large files
// Very large PDFs skip thumbnail generation — SVG icon shown in UI instead
if (file.size >= 100 * 1024 * 1024) {
return generatePlaceholderThumbnail(file);
return "";
}
// Handle image files - convert to data URL for persistence
if (file.type.startsWith("image/")) {
if (file.size >= 100 * 1024 * 1024) return "";
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
@@ -701,26 +158,23 @@ export async function generateThumbnailForFile(file: File): Promise<string> {
const arrayBuffer = await chunk.arrayBuffer();
try {
return await generatePDFThumbnail(arrayBuffer, file, scale);
return await generatePDFThumbnail(arrayBuffer, scale);
} catch {
// PDFium needs the xref table at the end of the file, so the 2MB
// chunk can fail to open for PDFs larger than that. Retry with the
// full buffer before falling back to a placeholder.
// full buffer before falling back to an empty thumbnail.
try {
const fullArrayBuffer = await file.arrayBuffer();
return await generatePDFThumbnail(fullArrayBuffer, file, scale);
return await generatePDFThumbnail(fullArrayBuffer, scale);
} catch (error) {
console.warn(
`PDF processing failed for ${file.name} - using placeholder:`,
error,
);
return generatePlaceholderThumbnail(file);
console.warn(`PDF processing failed for ${file.name}:`, error);
return "";
}
}
}
// All other files get placeholder
return generatePlaceholderThumbnail(file);
// Non-PDF, non-image files use scalable SVG icons in the UI — no raster thumbnail needed
return "";
}
/**
@@ -752,7 +206,7 @@ export async function generateThumbnailWithMetadata(
if (result.isEncrypted) {
return {
thumbnail: generateEncryptedPDFThumbnail(file),
thumbnail: "",
pageCount: 1,
isEncrypted: true,
};
@@ -765,7 +219,6 @@ export async function generateThumbnailWithMetadata(
pageDimensions: result.pageDimensions,
};
} catch {
const thumbnail = generatePlaceholderThumbnail(file);
return { thumbnail, pageCount: 1 };
return { thumbnail: "", pageCount: 1 };
}
}
@@ -360,7 +360,8 @@ const UpgradeBanner: React.FC = () => {
closeIconColor="#fff"
buttonVariant="white"
buttonColor="blue"
minHeight={64}
minHeight={48}
compact
/>
)}
{renderUrgentBanner()}