This commit is contained in:
Reece 2025-06-20 23:00:26 +01:00
parent cbc5616a39
commit 25e9db2570
8 changed files with 215 additions and 120 deletions

View File

@ -1653,6 +1653,17 @@
"uploadError": "Failed to upload some files.", "uploadError": "Failed to upload some files.",
"failedToOpen": "Failed to open file. It may have been removed from storage.", "failedToOpen": "Failed to open file. It may have been removed from storage.",
"failedToLoad": "Failed to load file to active set.", "failedToLoad": "Failed to load file to active set.",
"storageCleared": "Browser cleared storage. Files have been removed. Please re-upload." "storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.",
"clearAll": "Clear All",
"reloadFiles": "Reload Files"
},
"storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
"storageLimit": "Storage limit",
"storageUsed": "Temporary Storage used",
"storageFull": "Storage is nearly full. Consider removing some files.",
"fileTooLarge": "File too large. Maximum size per file is",
"storageQuotaExceeded": "Storage quota exceeded. Please remove some files before uploading more.",
"approximateSize": "Approximate size"
} }
} }

View File

@ -31,10 +31,10 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
radius="md" radius="md"
withBorder withBorder
p="xs" p="xs"
style={{ style={{
width: 225, width: 225,
minWidth: 180, minWidth: 180,
maxWidth: 260, maxWidth: 260,
cursor: onDoubleClick ? "pointer" : undefined, cursor: onDoubleClick ? "pointer" : undefined,
position: 'relative', position: 'relative',
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined, border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
@ -109,25 +109,25 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
</div> </div>
)} )}
{thumb ? ( {thumb ? (
<Image <Image
src={thumb} src={thumb}
alt="PDF thumbnail" alt="PDF thumbnail"
height={110} height={110}
width={80} width={80}
fit="contain" fit="contain"
radius="sm" radius="sm"
/> />
) : isGenerating ? ( ) : isGenerating ? (
<div style={{ <div style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center'
}}> }}>
<div style={{ <div style={{
width: 20, width: 20,
height: 20, height: 20,
border: '2px solid #ddd', border: '2px solid #ddd',
borderTop: '2px solid #666', borderTop: '2px solid #666',
borderRadius: '50%', borderRadius: '50%',
animation: 'spin 1s linear infinite', animation: 'spin 1s linear infinite',
@ -136,11 +136,11 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
<Text size="xs" c="dimmed">Generating...</Text> <Text size="xs" c="dimmed">Generating...</Text>
</div> </div>
) : ( ) : (
<div style={{ <div style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center'
}}> }}>
<ThemeIcon <ThemeIcon
variant="light" variant="light"
@ -157,30 +157,30 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
</div> </div>
)} )}
</Box> </Box>
<Text fw={500} size="sm" lineClamp={1} ta="center"> <Text fw={500} size="sm" lineClamp={1} ta="center">
{file.name} {file.name}
</Text> </Text>
<Group gap="xs" justify="center"> <Group gap="xs" justify="center">
<Badge color="gray" variant="light" size="sm"> <Badge color="red" variant="light" size="sm">
{getFileSize(file)} {getFileSize(file)}
</Badge> </Badge>
<Badge color="blue" variant="light" size="sm"> <Badge color="blue" variant="light" size="sm">
{getFileDate(file)} {getFileDate(file)}
</Badge> </Badge>
{file.storedInIndexedDB && ( {file.storedInIndexedDB && (
<Badge <Badge
color="green" color="green"
variant="light" variant="light"
size="sm" size="sm"
leftSection={<StorageIcon style={{ fontSize: 12 }} />} leftSection={<StorageIcon style={{ fontSize: 12 }} />}
> >
DB DB
</Badge> </Badge>
)} )}
</Group> </Group>
<Button <Button
color="red" color="red"
size="xs" size="xs"
@ -198,4 +198,4 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
); );
}; };
export default FileCard; export default FileCard;

View File

@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { GlobalWorkerOptions } from "pdfjs-dist"; import { GlobalWorkerOptions } from "pdfjs-dist";
import { StorageStats } from "../../services/fileStorage"; import { StorageStats } from "../../services/fileStorage";
import { FileWithUrl, defaultStorageConfig } from "../../types/file"; import { FileWithUrl, defaultStorageConfig, initializeStorageConfig, StorageConfig } from "../../types/file";
// Refactored imports // Refactored imports
import { fileOperationsService } from "../../services/fileOperationsService"; import { fileOperationsService } from "../../services/fileOperationsService";
@ -39,6 +39,7 @@ const FileManager = ({
const [notification, setNotification] = useState<string | null>(null); const [notification, setNotification] = useState<string | null>(null);
const [filesLoaded, setFilesLoaded] = useState(false); const [filesLoaded, setFilesLoaded] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]); const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [storageConfig, setStorageConfig] = useState<StorageConfig>(defaultStorageConfig);
// Extract operations from service for cleaner code // Extract operations from service for cleaner code
const { const {
@ -75,6 +76,21 @@ const FileManager = ({
} }
}, [filesLoaded]); }, [filesLoaded]);
// Initialize storage configuration on mount
useEffect(() => {
const initStorage = async () => {
try {
const config = await initializeStorageConfig();
setStorageConfig(config);
console.log('Initialized storage config:', config);
} catch (error) {
console.warn('Failed to initialize storage config, using defaults:', error);
}
};
initStorage();
}, []);
// Load storage stats and set up periodic updates // Load storage stats and set up periodic updates
useEffect(() => { useEffect(() => {
handleLoadStorageStats(); handleLoadStorageStats();
@ -143,11 +159,47 @@ const FileManager = ({
} }
}; };
const validateStorageLimits = (filesToUpload: File[]): { valid: boolean; error?: string } => {
// Check individual file sizes
for (const file of filesToUpload) {
if (file.size > storageConfig.maxFileSize) {
const maxSizeMB = Math.round(storageConfig.maxFileSize / (1024 * 1024));
return {
valid: false,
error: `${t("storage.fileTooLarge", "File too large. Maximum size per file is")} ${maxSizeMB}MB`
};
}
}
// Check total storage capacity
if (storageStats) {
const totalNewSize = filesToUpload.reduce((sum, file) => sum + file.size, 0);
const projectedUsage = storageStats.totalSize + totalNewSize;
if (projectedUsage > storageConfig.maxTotalStorage) {
return {
valid: false,
error: t("storage.storageQuotaExceeded", "Storage quota exceeded. Please remove some files before uploading more.")
};
}
}
return { valid: true };
};
const handleDrop = async (uploadedFiles: File[]) => { const handleDrop = async (uploadedFiles: File[]) => {
setLoading(true); setLoading(true);
try { try {
const newFiles = await uploadFiles(uploadedFiles, defaultStorageConfig.useIndexedDB); // Validate storage limits before uploading
const validation = validateStorageLimits(uploadedFiles);
if (!validation.valid) {
setNotification(validation.error);
setLoading(false);
return;
}
const newFiles = await uploadFiles(uploadedFiles, storageConfig.useIndexedDB);
// Update files state // Update files state
setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...newFiles] : newFiles)); setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...newFiles] : newFiles));
@ -269,8 +321,8 @@ const FileManager = ({
}; };
const toggleFileSelection = (fileId: string) => { const toggleFileSelection = (fileId: string) => {
setSelectedFiles(prev => setSelectedFiles(prev =>
prev.includes(fileId) prev.includes(fileId)
? prev.filter(id => id !== fileId) ? prev.filter(id => id !== fileId)
: [...prev, fileId] : [...prev, fileId]
); );
@ -286,12 +338,11 @@ const FileManager = ({
return ( return (
<div style={{ <div style={{
width: "100%", width: "100%",
margin: "0 auto",
justifyContent: "center", justifyContent: "center",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
padding: "20px" paddingTop: "3rem"
}}> }}>
{/* File upload is now handled by FileUploadSelector when no files exist */} {/* File upload is now handled by FileUploadSelector when no files exist */}
@ -302,6 +353,7 @@ const FileManager = ({
filesCount={files.length} filesCount={files.length}
onClearAll={handleClearAll} onClearAll={handleClearAll}
onReloadFiles={handleReloadFiles} onReloadFiles={handleReloadFiles}
storageConfig={storageConfig}
/> />
{/* Multi-selection controls */} {/* Multi-selection controls */}
@ -312,16 +364,16 @@ const FileManager = ({
{selectedFiles.length} {t("fileManager.filesSelected", "files selected")} {selectedFiles.length} {t("fileManager.filesSelected", "files selected")}
</Text> </Text>
<Group> <Group>
<Button <Button
size="xs" size="xs"
variant="light" variant="light"
onClick={() => setSelectedFiles([])} onClick={() => setSelectedFiles([])}
> >
{t("fileManager.clearSelection", "Clear Selection")} {t("fileManager.clearSelection", "Clear Selection")}
</Button> </Button>
<Button <Button
size="xs" size="xs"
color="orange" color="orange"
onClick={handleOpenSelectedInEditor} onClick={handleOpenSelectedInEditor}
disabled={selectedFiles.length === 0} disabled={selectedFiles.length === 0}
> >
@ -332,31 +384,12 @@ const FileManager = ({
</Box> </Box>
)} )}
{/* Files Display */}
{files.length === 0 ? (
<FileUploadSelector
title={t("fileManager.title", "Upload PDF Files")}
subtitle={t("fileManager.subtitle", "Add files to your storage for easy access across tools")}
sharedFiles={[]} // FileManager is the source, so no shared files
onFilesSelect={(uploadedFiles) => {
// Handle multiple files - add to storage AND active set
handleDrop(uploadedFiles);
if (onLoadFileToActive && uploadedFiles.length > 0) {
uploadedFiles.forEach(onLoadFileToActive);
}
}}
allowMultiple={allowMultiple}
accept={["application/pdf"]}
loading={loading}
showDropzone={true}
/>
) : (
<Box>
<Flex <Flex
wrap="wrap" wrap="wrap"
gap="lg" gap="lg"
justify="flex-start" justify="flex-start"
style={{ width: "fit-content", margin: "0 auto" }} style={{ width: "90%", marginTop: "1rem"}}
> >
{files.map((file, idx) => ( {files.map((file, idx) => (
<FileCard <FileCard
@ -371,8 +404,7 @@ const FileManager = ({
/> />
))} ))}
</Flex> </Flex>
</Box>
)}
{/* Notifications */} {/* Notifications */}
{notification && ( {notification && (

View File

@ -1,17 +1,20 @@
import React from "react"; import React from "react";
import { Card, Group, Text, Button, Progress } from "@mantine/core"; import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import StorageIcon from "@mui/icons-material/Storage"; import StorageIcon from "@mui/icons-material/Storage";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import WarningIcon from "@mui/icons-material/Warning";
import { StorageStats } from "../../services/fileStorage"; import { StorageStats } from "../../services/fileStorage";
import { formatFileSize } from "../../utils/fileUtils"; import { formatFileSize } from "../../utils/fileUtils";
import { getStorageUsagePercent } from "../../utils/storageUtils"; import { getStorageUsagePercent } from "../../utils/storageUtils";
import { StorageConfig } from "../../types/file";
interface StorageStatsCardProps { interface StorageStatsCardProps {
storageStats: StorageStats | null; storageStats: StorageStats | null;
filesCount: number; filesCount: number;
onClearAll: () => void; onClearAll: () => void;
onReloadFiles: () => void; onReloadFiles: () => void;
storageConfig: StorageConfig;
} }
const StorageStatsCard = ({ const StorageStatsCard = ({
@ -19,58 +22,71 @@ const StorageStatsCard = ({
filesCount, filesCount,
onClearAll, onClearAll,
onReloadFiles, onReloadFiles,
storageConfig,
}: StorageStatsCardProps) => { }: StorageStatsCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!storageStats) return null; if (!storageStats) return null;
const storageUsagePercent = getStorageUsagePercent(storageStats); const storageUsagePercent = getStorageUsagePercent(storageStats);
const totalUsed = storageStats.totalSize || storageStats.used;
const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100;
const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100;
return ( return (
<Card withBorder p="sm" mb="md" style={{ width: "90%", maxWidth: 600 }}> <Stack gap="sm" style={{ width: "90%", maxWidth: 600 }}>
<Group align="center" gap="md"> <Card withBorder p="sm">
<StorageIcon /> <Group align="center" gap="md">
<div style={{ flex: 1 }}> <StorageIcon />
<Text size="sm" fw={500}> <div style={{ flex: 1 }}>
{t("fileManager.storage", "Storage")}: {formatFileSize(storageStats.used)} <Text size="sm" fw={500}>
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`} {t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
</Text> </Text>
{storageStats.quota && (
<Progress <Progress
value={storageUsagePercent} value={hardLimitPercent}
color={storageUsagePercent > 80 ? "red" : storageUsagePercent > 60 ? "yellow" : "blue"} color={isNearLimit ? "red" : hardLimitPercent > 60 ? "yellow" : "blue"}
size="sm" size="sm"
mt={4} mt={4}
/> />
)} <Group justify="space-between" mt={2}>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{storageStats.fileCount} {t("fileManager.filesStored", "files stored")} {storageStats.fileCount} files {t("storage.approximateSize", "Approximate size")}
</Text> </Text>
</div> <Text size="xs" c={isNearLimit ? "red" : "dimmed"}>
<Group gap="xs"> {Math.round(hardLimitPercent)}% used
{filesCount > 0 && ( </Text>
</Group>
{isNearLimit && (
<Text size="xs" c="red" mt={4}>
{t("storage.storageFull", "Storage is nearly full. Consider removing some files.")}
</Text>
)}
</div>
<Group gap="xs">
{filesCount > 0 && (
<Button
variant="light"
color="red"
size="xs"
onClick={onClearAll}
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
>
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button <Button
variant="light" variant="light"
color="red" color="blue"
size="xs" size="xs"
onClick={onClearAll} onClick={onReloadFiles}
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
> >
{t("fileManager.clearAll", "Clear All")} {t("fileManager.reloadFiles", "Reload Files")}
</Button> </Button>
)} </Group>
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
Reload Files
</Button>
</Group> </Group>
</Group> </Card>
</Card> </Stack>
); );
}; };
export default StorageStatsCard; export default StorageStatsCard;

View File

@ -59,7 +59,7 @@ const FileUploadSelector = ({
}, [allowMultiple, onFileSelect, onFilesSelect]); }, [allowMultiple, onFileSelect, onFilesSelect]);
// Get default title and subtitle from translations if not provided // Get default title and subtitle from translations if not provided
const displayTitle = title || t(allowMultiple ? "fileUpload.selectFiles" : "fileUpload.selectFile", const displayTitle = title || t(allowMultiple ? "fileUpload.selectFiles" : "fileUpload.selectFile",
allowMultiple ? "Select files" : "Select a file"); allowMultiple ? "Select files" : "Select a file");
const displaySubtitle = subtitle || t(allowMultiple ? "fileUpload.chooseFromStorageMultiple" : "fileUpload.chooseFromStorage", const displaySubtitle = subtitle || t(allowMultiple ? "fileUpload.chooseFromStorageMultiple" : "fileUpload.chooseFromStorage",
allowMultiple ? "Choose files from storage or upload new PDFs" : "Choose a file from storage or upload a new PDF"); allowMultiple ? "Choose files from storage or upload new PDFs" : "Choose a file from storage or upload a new PDF");
@ -87,10 +87,7 @@ const FileUploadSelector = ({
disabled={disabled || sharedFiles.length === 0} disabled={disabled || sharedFiles.length === 0}
loading={loading} loading={loading}
> >
{loading {loading ? "Loading..." : `Load from Storage (${sharedFiles.length} files available)`}
? t("fileUpload.loading", "Loading...")
: `${t("fileUpload.loadFromStorage", "Load from Storage")} (${sharedFiles.length} ${t("fileUpload.filesAvailable", "files available")})`
}
</Button> </Button>
<Text size="md" c="dimmed"> <Text size="md" c="dimmed">
@ -112,7 +109,7 @@ const FileUploadSelector = ({
allowMultiple ? "Drop files here or click to upload" : "Drop file here or click to upload")} allowMultiple ? "Drop files here or click to upload" : "Drop file here or click to upload")}
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{accept.includes('application/pdf') {accept.includes('application/pdf')
? t("fileUpload.pdfFilesOnly", "PDF files only") ? t("fileUpload.pdfFilesOnly", "PDF files only")
: t("fileUpload.supportedFileTypes", "Supported file types") : t("fileUpload.supportedFileTypes", "Supported file types")
} }

View File

@ -2,6 +2,14 @@
/* Import minimal theme variables */ /* Import minimal theme variables */
@import './theme.css'; @import './theme.css';
@tailwind base; @layer base {
@tailwind components; @tailwind base;
@tailwind utilities; }
@layer components {
@tailwind components;
}
@layer utilities {
@tailwind utilities;
}

View File

@ -30,7 +30,7 @@ const gray: MantineColorsTuple = [
export const mantineTheme = createTheme({ export const mantineTheme = createTheme({
// Primary color // Primary color
primaryColor: 'primary', primaryColor: 'primary',
// Color palette // Color palette
colors: { colors: {
primary, primary,
@ -245,7 +245,7 @@ export const mantineTheme = createTheme({
}, },
control: { control: {
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
'&[data-active]': { '[dataActive]': {
backgroundColor: 'var(--bg-surface)', backgroundColor: 'var(--bg-surface)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
boxShadow: 'var(--shadow-sm)', boxShadow: 'var(--shadow-sm)',
@ -261,7 +261,7 @@ export const mantineTheme = createTheme({
'*': { '*': {
transition: 'background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease', transition: 'background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease',
}, },
// Custom scrollbar styling // Custom scrollbar styling
'*::-webkit-scrollbar': { '*::-webkit-scrollbar': {
width: '8px', width: '8px',
@ -278,4 +278,4 @@ export const mantineTheme = createTheme({
backgroundColor: 'var(--color-primary-500)', backgroundColor: 'var(--color-primary-500)',
}, },
}), }),
}); });

View File

@ -11,9 +11,40 @@ export interface FileWithUrl extends File {
export interface StorageConfig { export interface StorageConfig {
useIndexedDB: boolean; useIndexedDB: boolean;
// Simplified - no thresholds needed, IndexedDB for everything maxFileSize: number; // Maximum size per file in bytes
maxTotalStorage: number; // Maximum total storage in bytes
warningThreshold: number; // Warning threshold (percentage 0-1)
} }
export const defaultStorageConfig: StorageConfig = { export const defaultStorageConfig: StorageConfig = {
useIndexedDB: true, useIndexedDB: true,
maxFileSize: 100 * 1024 * 1024, // 100MB per file
maxTotalStorage: 1024 * 1024 * 1024, // 1GB default, will be updated dynamically
warningThreshold: 0.8, // Warn at 80% capacity
};
// Calculate and update storage limit: half of available storage or 10GB, whichever is smaller
export const initializeStorageConfig = async (): Promise<StorageConfig> => {
const tenGB = 10 * 1024 * 1024 * 1024; // 10GB in bytes
const oneGB = 1024 * 1024 * 1024; // 1GB fallback
let maxTotalStorage = oneGB; // Default fallback
// Try to estimate available storage
if ('storage' in navigator && 'estimate' in navigator.storage) {
try {
const estimate = await navigator.storage.estimate();
if (estimate.quota) {
const halfQuota = estimate.quota / 2;
maxTotalStorage = Math.min(halfQuota, tenGB);
}
} catch (error) {
console.warn('Could not estimate storage quota, using 1GB default:', error);
}
}
return {
...defaultStorageConfig,
maxTotalStorage
};
}; };