diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index d404cd07c0..2b95e9d515 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css b/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css index 9430fba2be..d09f142524 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css @@ -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; diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index 87185d93ec..aad5442596 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -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 ? ( - - {file.name} { - e.currentTarget.style.display = "none"; - }} - /> - - ) : file.type?.startsWith("application/pdf") ? ( - - - - Loading thumbnail... - - - ) : null} + { + e.currentTarget.style.display = "none"; + }} + /> {/* Badges — visible on hover via CSS */}
diff --git a/frontend/src/core/components/fileManager/FileHistoryGroup.tsx b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx index cda54edf71..a405bd37a5 100644 --- a/frontend/src/core/components/fileManager/FileHistoryGroup.tsx +++ b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx @@ -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 = ({ @@ -21,7 +20,6 @@ const FileHistoryGroup: React.FC = ({ onDownloadSingle, onFileDoubleClick, onHistoryFileRemove, - isFileSupported, }) => { const { t } = useTranslation(); @@ -50,7 +48,6 @@ const FileHistoryGroup: React.FC = ({ 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)} diff --git a/frontend/src/core/components/fileManager/FileListArea.tsx b/frontend/src/core/components/fileManager/FileListArea.tsx index 467ac398e1..4ee5d7f2bd 100644 --- a/frontend/src/core/components/fileManager/FileListArea.tsx +++ b/frontend/src/core/components/fileManager/FileListArea.tsx @@ -28,7 +28,6 @@ const FileListArea: React.FC = ({ onHistoryFileRemove, onFileDoubleClick, onDownloadSingle, - isFileSupported, isLoading, activeFileIds, } = useFileManagerContext(); @@ -65,7 +64,6 @@ const FileListArea: React.FC = ({ onFileSelect(file, index, shiftKey)} onRemove={() => onFileRemove(index)} onDownload={() => onDownloadSingle(file)} @@ -82,7 +80,6 @@ const FileListArea: React.FC = ({ onDownloadSingle={onDownloadSingle} onFileDoubleClick={onFileDoubleClick} onHistoryFileRemove={onHistoryFileRemove} - isFileSupported={isFileSupported} /> ); diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx index 47273c7d69..6cf1251d65 100644 --- a/frontend/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/src/core/components/fileManager/FileListItem.tsx @@ -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 = ({ file, isSelected, - isSupported, onSelect, onRemove, onDownload, @@ -192,7 +190,7 @@ const FileListItem: React.FC = ({ 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", diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index d6188f5207..4d99f1b642 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -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 ( { setCurrentView("pageEditor"); diff --git a/frontend/src/core/components/shared/FileCard.tsx b/frontend/src/core/components/shared/FileCard.tsx index 6bb1f8ee84..2837f22a86 100644 --- a/frontend/src/core/components/shared/FileCard.tsx +++ b/frontend/src/core/components/shared/FileCard.tsx @@ -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 ( setIsHovered(true)} @@ -146,51 +142,13 @@ const FileCard = ({ )}
)} - {thumb ? ( - PDF thumbnail - ) : isGenerating || isHydrating ? ( - - - - Loading... - - - ) : ( -
- 100 * 1024 * 1024 ? "orange" : "red"} - size={60} - radius="sm" - style={{ - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - - - {file.size > 100 * 1024 * 1024 && ( - - Large File - - )} -
- )} + diff --git a/frontend/src/core/components/shared/FileDocIcon.tsx b/frontend/src/core/components/shared/FileDocIcon.tsx new file mode 100644 index 0000000000..135162c54d --- /dev/null +++ b/frontend/src/core/components/shared/FileDocIcon.tsx @@ -0,0 +1,204 @@ +export type FileDocVariant = + | "pdf" + | "spreadsheet" + | "doc" + | "image" + | "archive" + | "code" + | "generic"; + +export const VARIANT_COLORS: Record = { + 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 = ( + + + + + + + ); + } else if (variant === "image") { + interior = ( + <> + + + + ); + } else if (variant === "code") { + interior = ( + <> + + + + ); + } else if (variant === "archive") { + interior = ( + <> + + + + + ); + } else { + // pdf, doc, generic — text lines + interior = ( + <> + + + + + ); + } + + return ( + + ); +} diff --git a/frontend/src/core/components/shared/FilePickerModal.tsx b/frontend/src/core/components/shared/FilePickerModal.tsx index 16f35f9fb1..da1bb91b6c 100644 --- a/frontend/src/core/components/shared/FilePickerModal.tsx +++ b/frontend/src/core/components/shared/FilePickerModal.tsx @@ -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 ? ( - PDF thumbnail - ) : ( - - - - )} + {/* File info */} diff --git a/frontend/src/core/components/shared/FileSidebar.css b/frontend/src/core/components/shared/FileSidebar.css index 144d316de4..4a27d2977d 100644 --- a/frontend/src/core/components/shared/FileSidebar.css +++ b/frontend/src/core/components/shared/FileSidebar.css @@ -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; diff --git a/frontend/src/core/components/shared/FileSidebar.tsx b/frontend/src/core/components/shared/FileSidebar.tsx index fa6f69723b..54f8d4b7ac 100644 --- a/frontend/src/core/components/shared/FileSidebar.tsx +++ b/frontend/src/core/components/shared/FileSidebar.tsx @@ -500,10 +500,15 @@ const FileSidebar = forwardRef( ); 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 ( (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 ( - - ); -} - -function FileGenericIcon({ - className, - style, -}: { - className?: string; - style?: React.CSSProperties; -}) { - return ( - - - - - ); -} - 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 ; +} + 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(null); const [hoverRect, setHoverRect] = useState(null); @@ -302,17 +239,7 @@ export function FileItem({ ) : ( <>
- {isPdf ? ( - - ) : ( - - )} + {getSidebarFileIcon(ext)} )}
@@ -349,7 +276,8 @@ export function FileItem({ - {thumbPos && + {useRasterThumb && + thumbPos && resolvedThumbnail && createPortal(
= ({ buttonVariant = "light", minHeight = 56, closeIconColor, + compact = false, }) => { if (!show) { return null; @@ -85,9 +87,13 @@ export const InfoBanner: React.FC = ({ onDismiss?.(); }; + const iconSize = compact ? "1rem" : "1.2rem"; + const textSize = compact ? "xs" : "sm"; + const buttonSize = compact ? "xs" : "xs"; + return ( = ({ style={{ width: "100%" }} > - + {title && ( {title} @@ -128,9 +134,9 @@ export const InfoBanner: React.FC = ({ )} {message} @@ -141,11 +147,15 @@ export const InfoBanner: React.FC = ({
diff --git a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx index e0b61d6a40..9b5ce16eaf 100644 --- a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx @@ -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; style?: React.CSSProperties; onClick?: () => void; children?: React.ReactNode; @@ -15,14 +22,19 @@ export interface DocumentThumbnailProps { const DocumentThumbnail: React.FC = ({ 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 = ({ ...style, }; - if (thumbnail) { + if (thumbnail && !isEncrypted) { return ( - {`Preview {children} @@ -54,17 +75,93 @@ const DocumentThumbnail: React.FC = ({ ); } + if (isEncrypted) { + return ( + +
+ + + Locked + +
+ {children} +
+ ); + } + + if (isLoading) { + return ( + + + + + Loading thumbnail... + + + {children} + + ); + } + + const ext = detectFileExtension(file.name ?? "").toUpperCase(); + return (
- {getFileTypeIcon(file)} + {getFileTypeIcon(file, iconSize)} + {ext && ( + + {ext} + + )}
{children}
diff --git a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx index 2666f06606..6be3d35163 100644 --- a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx +++ b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx @@ -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 ( - - ); - } - - // PDF - if (ext === "pdf" || mime === "application/pdf") { - return ( - - ); - } - - // Fallback generic return ( - ); } diff --git a/frontend/src/core/components/tools/FullscreenToolSurface.tsx b/frontend/src/core/components/tools/FullscreenToolSurface.tsx index 99683f3923..264f00c35c 100644 --- a/frontend/src/core/components/tools/FullscreenToolSurface.tsx +++ b/frontend/src/core/components/tools/FullscreenToolSurface.tsx @@ -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(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" > -
+
@@ -115,29 +78,6 @@ const FullscreenToolSurface = ({ className="tool-panel__fullscreen-brand-text" />
-
- - - - - -
diff --git a/frontend/src/core/components/tools/ToolPanel.css b/frontend/src/core/components/tools/ToolPanel.css index 69169ef090..0e52f93abd 100644 --- a/frontend/src/core/components/tools/ToolPanel.css +++ b/frontend/src/core/components/tools/ToolPanel.css @@ -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 { diff --git a/frontend/src/core/components/tools/ToolPanel.tsx b/frontend/src/core/components/tools/ToolPanel.tsx index 5fe8bf1070..d8394cf429 100644 --- a/frontend/src/core/components/tools/ToolPanel.tsx +++ b/frontend/src/core/components/tools/ToolPanel.tsx @@ -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" ? ( - - - - ) : undefined - } /> ) : (
+
+ +
{selectedToolKey ? ( @@ -284,7 +286,6 @@ export default function ToolPanel() { ) } onExitFullscreenMode={() => setToolPanelMode("sidebar")} - toggleLabel={toggleLabel} geometry={fullscreenGeometry} /> )} diff --git a/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx b/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx index 858e1fd572..a6f4d257b3 100644 --- a/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx @@ -76,9 +76,7 @@ const CompactToolItem: React.FC = ({ ) : null}
- - {tool.name} - + {tool.name} {tool.versionStatus === "alpha" && ( {t("toolPanel.alpha", "Alpha")} diff --git a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx index 61372fad05..32fa181a16 100644 --- a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx @@ -78,9 +78,7 @@ const DetailedToolItem: React.FC = ({ ) : null}
- - {tool.name} - + {tool.name} {tool.versionStatus === "alpha" && ( {/* we can add more translations for different badges in future, like beta, etc. */} diff --git a/frontend/src/core/contexts/IndexedDBContext.tsx b/frontend/src/core/contexts/IndexedDBContext.tsx index 6aabd7d47c..ca8702917b 100644 --- a/frontend/src/core/contexts/IndexedDBContext.tsx +++ b/frontend/src/core/contexts/IndexedDBContext.tsx @@ -92,9 +92,10 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { fileId: FileId, existingThumbnail?: string, ): Promise => { - // 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); diff --git a/frontend/src/core/hooks/useFileThumbnail.ts b/frontend/src/core/hooks/useFileThumbnail.ts new file mode 100644 index 0000000000..e564142c7c --- /dev/null +++ b/frontend/src/core/hooks/useFileThumbnail.ts @@ -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 }; +} diff --git a/frontend/src/core/hooks/useUrlSync.ts b/frontend/src/core/hooks/useUrlSync.ts index 27ae833a70..ec4ede495d 100644 --- a/frontend/src/core/hooks/useUrlSync.ts +++ b/frontend/src/core/hooks/useUrlSync.ts @@ -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 diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index a2ac2f9474..43400fcfa5 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -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; diff --git a/frontend/src/core/utils/fileUtils.ts b/frontend/src/core/utils/fileUtils.ts index 57a09d7bb8..6a256a9073 100644 --- a/frontend/src/core/utils/fileUtils.ts +++ b/frontend/src/core/utils/fileUtils.ts @@ -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. diff --git a/frontend/src/core/utils/thumbnailUtils.ts b/frontend/src/core/utils/thumbnailUtils.ts index 36ec904521..11ba6ae56b 100644 --- a/frontend/src/core/utils/thumbnailUtils.ts +++ b/frontend/src/core/utils/thumbnailUtils.ts @@ -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 = { - // 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 { 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 { - // 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 { 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 }; } } diff --git a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx index d0ee1612e2..649416f205 100644 --- a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx +++ b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx @@ -360,7 +360,8 @@ const UpgradeBanner: React.FC = () => { closeIconColor="#fff" buttonVariant="white" buttonColor="blue" - minHeight={64} + minHeight={48} + compact /> )} {renderUrgentBanner()}