These adress the change requests along with some other issues I found with our UI
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user