diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index 64cd9408d..efcc3de0c 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -2031,7 +2031,14 @@
"fileSize": "Size",
"fileVersion": "Version",
"totalSelected": "Total Selected",
- "dropFilesHere": "Drop files here"
+ "dropFilesHere": "Drop files here",
+ "selectAll": "Select All",
+ "deselectAll": "Deselect All",
+ "deleteSelected": "Delete Selected",
+ "downloadSelected": "Download Selected",
+ "selectedCount": "{{count}} selected",
+ "download": "Download",
+ "delete": "Delete"
},
"storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
diff --git a/frontend/src/components/fileManager/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx
index be701ff20..8d1e32ffc 100644
--- a/frontend/src/components/fileManager/DesktopLayout.tsx
+++ b/frontend/src/components/fileManager/DesktopLayout.tsx
@@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons';
import FileDetails from './FileDetails';
import SearchInput from './SearchInput';
import FileListArea from './FileListArea';
+import FileActions from './FileActions';
import HiddenFileInput from './HiddenFileInput';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
@@ -17,27 +18,27 @@ const DesktopLayout: React.FC = () => {
return (
{/* Column 1: File Sources */}
-
-
+
{/* Column 2: File List */}
-
- {
overflow: 'hidden'
}}>
{activeSource === 'recent' && (
-
-
-
+ <>
+
+
+
+
+
+
+ >
)}
-
+
0 ? modalHeight : '100%',
backgroundColor: 'transparent',
border: 'none',
@@ -66,12 +75,12 @@ const DesktopLayout: React.FC = () => {
-
+
{/* Column 3: File Details */}
-
@@ -79,11 +88,11 @@ const DesktopLayout: React.FC = () => {
-
+
{/* Hidden file input for local file selection */}
);
};
-export default DesktopLayout;
\ No newline at end of file
+export default DesktopLayout;
diff --git a/frontend/src/components/fileManager/FileActions.tsx b/frontend/src/components/fileManager/FileActions.tsx
new file mode 100644
index 000000000..7bc8d27bc
--- /dev/null
+++ b/frontend/src/components/fileManager/FileActions.tsx
@@ -0,0 +1,115 @@
+import React from "react";
+import { Group, Text, ActionIcon, Tooltip } from "@mantine/core";
+import SelectAllIcon from "@mui/icons-material/SelectAll";
+import DeleteIcon from "@mui/icons-material/Delete";
+import DownloadIcon from "@mui/icons-material/Download";
+import { useTranslation } from "react-i18next";
+import { useFileManagerContext } from "../../contexts/FileManagerContext";
+
+const FileActions: React.FC = () => {
+ const { t } = useTranslation();
+ const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
+ useFileManagerContext();
+
+ const handleSelectAll = () => {
+ onSelectAll();
+ };
+
+ const handleDeleteSelected = () => {
+ if (selectedFileIds.length > 0) {
+ onDeleteSelected();
+ }
+ };
+
+ const handleDownloadSelected = () => {
+ if (selectedFileIds.length > 0) {
+ onDownloadSelected();
+ }
+ };
+
+ // Only show actions if there are files
+ if (recentFiles.length === 0) {
+ return null;
+ }
+
+ const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
+ const hasSelection = selectedFileIds.length > 0;
+
+ return (
+
+ {/* Left: Select All */}
+
+
+ {/* Center: Selected count */}
+
+ {hasSelection && (
+
+ {t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })}
+
+ )}
+
+
+ {/* Right: Delete and Download */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default FileActions;
diff --git a/frontend/src/components/fileManager/FileListArea.tsx b/frontend/src/components/fileManager/FileListArea.tsx
index 8e1975137..fd9357a94 100644
--- a/frontend/src/components/fileManager/FileListArea.tsx
+++ b/frontend/src/components/fileManager/FileListArea.tsx
@@ -19,22 +19,23 @@ const FileListArea: React.FC = ({
activeSource,
recentFiles,
filteredFiles,
- selectedFileIds,
+ selectedFilesSet,
onFileSelect,
onFileRemove,
onFileDoubleClick,
+ onDownloadSingle,
isFileSupported,
} = useFileManagerContext();
const { t } = useTranslation();
if (activeSource === 'recent') {
return (
-
@@ -53,10 +54,11 @@ const FileListArea: React.FC = ({
onFileSelect(file)}
+ onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
onRemove={() => onFileRemove(index)}
+ onDownload={() => onDownloadSingle(file)}
onDoubleClick={() => onFileDoubleClick(file)}
/>
))
@@ -77,4 +79,4 @@ const FileListArea: React.FC = ({
);
};
-export default FileListArea;
\ No newline at end of file
+export default FileListArea;
diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx
index 147133009..4b0e408d1 100644
--- a/frontend/src/components/fileManager/FileListItem.tsx
+++ b/frontend/src/components/fileManager/FileListItem.tsx
@@ -1,6 +1,9 @@
import React, { useState } from 'react';
-import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
+import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu } from '@mantine/core';
+import MoreVertIcon from '@mui/icons-material/MoreVert';
import DeleteIcon from '@mui/icons-material/Delete';
+import DownloadIcon from '@mui/icons-material/Download';
+import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
@@ -8,33 +11,44 @@ interface FileListItemProps {
file: FileWithUrl;
isSelected: boolean;
isSupported: boolean;
- onSelect: () => void;
+ onSelect: (shiftKey?: boolean) => void;
onRemove: () => void;
+ onDownload?: () => void;
onDoubleClick?: () => void;
isLast?: boolean;
}
-const FileListItem: React.FC = ({
- file,
- isSelected,
- isSupported,
- onSelect,
- onRemove,
+const FileListItem: React.FC = ({
+ file,
+ isSelected,
+ isSupported,
+ onSelect,
+ onRemove,
+ onDownload,
onDoubleClick
}) => {
const [isHovered, setIsHovered] = useState(false);
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const { t } = useTranslation();
+
+ // Keep item in hovered state if menu is open
+ const shouldShowHovered = isHovered || isMenuOpen;
return (
<>
- onSelect(e.shiftKey)}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@@ -54,26 +68,59 @@ const FileListItem: React.FC = ({
}}
/>
-
+
{file.name}
{getFileSize(file)} • {getFileDate(file)}
- {/* Delete button - fades in/out on hover */}
- { e.stopPropagation(); onRemove(); }}
- style={{
- opacity: isHovered ? 1 : 0,
- transform: isHovered ? 'scale(1)' : 'scale(0.8)',
- transition: 'opacity 0.3s ease, transform 0.3s ease',
- pointerEvents: isHovered ? 'auto' : 'none'
- }}
+
+ {/* Three dots menu - fades in/out on hover */}
+
+
+ e.stopPropagation()}
+ style={{
+ opacity: shouldShowHovered ? 1 : 0,
+ transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
+ transition: 'opacity 0.3s ease, transform 0.3s ease',
+ pointerEvents: shouldShowHovered ? 'auto' : 'none'
+ }}
+ >
+
+
+
+
+
+ {onDownload && (
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ onDownload();
+ }}
+ >
+ {t('fileManager.download', 'Download')}
+
+ )}
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ onRemove();
+ }}
+ >
+ {t('fileManager.delete', 'Delete')}
+
+
+
{ }
@@ -81,4 +128,4 @@ const FileListItem: React.FC = ({
);
};
-export default FileListItem;
\ No newline at end of file
+export default FileListItem;
diff --git a/frontend/src/components/fileManager/MobileLayout.tsx b/frontend/src/components/fileManager/MobileLayout.tsx
index 30d1ad6b9..5201aafb4 100644
--- a/frontend/src/components/fileManager/MobileLayout.tsx
+++ b/frontend/src/components/fileManager/MobileLayout.tsx
@@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons';
import FileDetails from './FileDetails';
import SearchInput from './SearchInput';
import FileListArea from './FileListArea';
+import FileActions from './FileActions';
import HiddenFileInput from './HiddenFileInput';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
@@ -22,10 +23,11 @@ const MobileLayout: React.FC = () => {
// Estimate heights of fixed components
const fileSourceHeight = '3rem'; // FileSourceButtons height
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
+ const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom)
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
- const gapHeight = activeSource === 'recent' ? '3rem' : '2rem'; // Stack gaps
+ const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps
- return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${searchHeight} - ${gapHeight})`;
+ return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
};
return (
@@ -51,12 +53,20 @@ const MobileLayout: React.FC = () => {
minHeight: 0
}}>
{activeSource === 'recent' && (
-
-
-
+ <>
+
+
+
+
+
+
+ >
)}
diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx
index 98b93cf0b..4115e873d 100644
--- a/frontend/src/contexts/FileManagerContext.tsx
+++ b/frontend/src/contexts/FileManagerContext.tsx
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
import { FileWithUrl } from '../types/file';
-import { StoredFile } from '../services/fileStorage';
+import { StoredFile, fileStorage } from '../services/fileStorage';
+import { downloadFiles } from '../utils/downloadUtils';
// Type for the context value - now contains everything directly
interface FileManagerContextValue {
@@ -11,16 +12,21 @@ interface FileManagerContextValue {
selectedFiles: FileWithUrl[];
filteredFiles: FileWithUrl[];
fileInputRef: React.RefObject;
+ selectedFilesSet: Set;
// Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
onLocalFileClick: () => void;
- onFileSelect: (file: FileWithUrl) => void;
+ onFileSelect: (file: FileWithUrl, index: number, shiftKey?: boolean) => void;
onFileRemove: (index: number) => void;
onFileDoubleClick: (file: FileWithUrl) => void;
onOpenFiles: () => void;
onSearchChange: (value: string) => void;
onFileInputChange: (event: React.ChangeEvent) => void;
+ onSelectAll: () => void;
+ onDeleteSelected: () => void;
+ onDownloadSelected: () => void;
+ onDownloadSingle: (file: FileWithUrl) => void;
// External props
recentFiles: FileWithUrl[];
@@ -60,22 +66,29 @@ export const FileManagerProvider: React.FC = ({
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
const [selectedFileIds, setSelectedFileIds] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
+ const [lastClickedIndex, setLastClickedIndex] = useState(null);
const fileInputRef = useRef(null);
// Track blob URLs for cleanup
const createdBlobUrls = useRef>(new Set());
// Computed values (with null safety)
- const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name));
- const filteredFiles = (recentFiles || []).filter(file =>
- file.name.toLowerCase().includes(searchTerm.toLowerCase())
- );
+ const selectedFilesSet = new Set(selectedFileIds);
+
+ const selectedFiles = selectedFileIds.length === 0 ? [] :
+ (recentFiles || []).filter(file => selectedFilesSet.has(file.id || file.name));
+
+ const filteredFiles = !searchTerm ? recentFiles || [] :
+ (recentFiles || []).filter(file =>
+ file.name.toLowerCase().includes(searchTerm.toLowerCase())
+ );
const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
setActiveSource(source);
if (source !== 'recent') {
setSelectedFileIds([]);
setSearchTerm('');
+ setLastClickedIndex(null);
}
}, []);
@@ -83,19 +96,46 @@ export const FileManagerProvider: React.FC = ({
fileInputRef.current?.click();
}, []);
- const handleFileSelect = useCallback((file: FileWithUrl) => {
- setSelectedFileIds(prev => {
- if (file.id) {
- if (prev.includes(file.id)) {
- return prev.filter(id => id !== file.id);
- } else {
- return [...prev, file.id];
+ const handleFileSelect = useCallback((file: FileWithUrl, currentIndex: number, shiftKey?: boolean) => {
+ const fileId = file.id || file.name;
+ if (!fileId) return;
+
+ if (shiftKey && lastClickedIndex !== null) {
+ // Range selection with shift-click
+ const startIndex = Math.min(lastClickedIndex, currentIndex);
+ const endIndex = Math.max(lastClickedIndex, currentIndex);
+
+ setSelectedFileIds(prev => {
+ const selectedSet = new Set(prev);
+
+ // Add all files in the range to selection
+ for (let i = startIndex; i <= endIndex; i++) {
+ const rangeFileId = filteredFiles[i]?.id || filteredFiles[i]?.name;
+ if (rangeFileId) {
+ selectedSet.add(rangeFileId);
+ }
}
- } else {
- return prev;
- }
- });
- }, []);
+
+ return Array.from(selectedSet);
+ });
+ } else {
+ // Normal click behavior - optimized with Set for O(1) lookup
+ setSelectedFileIds(prev => {
+ const selectedSet = new Set(prev);
+
+ if (selectedSet.has(fileId)) {
+ selectedSet.delete(fileId);
+ } else {
+ selectedSet.add(fileId);
+ }
+
+ return Array.from(selectedSet);
+ });
+
+ // Update last clicked index for future range selections
+ setLastClickedIndex(currentIndex);
+ }
+ }, [filteredFiles, lastClickedIndex]);
const handleFileRemove = useCallback((index: number) => {
const fileToRemove = filteredFiles[index];
@@ -152,6 +192,72 @@ export const FileManagerProvider: React.FC = ({
event.target.value = '';
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
+ const handleSelectAll = useCallback(() => {
+ const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
+ if (allFilesSelected) {
+ // Deselect all
+ setSelectedFileIds([]);
+ setLastClickedIndex(null);
+ } else {
+ // Select all filtered files
+ setSelectedFileIds(filteredFiles.map(file => file.id || file.name));
+ setLastClickedIndex(null);
+ }
+ }, [filteredFiles, selectedFileIds]);
+
+ const handleDeleteSelected = useCallback(async () => {
+ if (selectedFileIds.length === 0) return;
+
+ try {
+ // Get files to delete based on current filtered view
+ const filesToDelete = filteredFiles.filter(file =>
+ selectedFileIds.includes(file.id || file.name)
+ );
+
+ // Delete files from storage
+ for (const file of filesToDelete) {
+ const lookupKey = file.id || file.name;
+ await fileStorage.deleteFile(lookupKey);
+ }
+
+ // Clear selection
+ setSelectedFileIds([]);
+
+ // Refresh the file list
+ await refreshRecentFiles();
+ } catch (error) {
+ console.error('Failed to delete selected files:', error);
+ }
+ }, [selectedFileIds, filteredFiles, refreshRecentFiles]);
+
+
+ const handleDownloadSelected = useCallback(async () => {
+ if (selectedFileIds.length === 0) return;
+
+ try {
+ // Get selected files
+ const selectedFilesToDownload = filteredFiles.filter(file =>
+ selectedFileIds.includes(file.id || file.name)
+ );
+
+ // Use generic download utility
+ await downloadFiles(selectedFilesToDownload, {
+ zipFilename: `selected-files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`
+ });
+ } catch (error) {
+ console.error('Failed to download selected files:', error);
+ }
+ }, [selectedFileIds, filteredFiles]);
+
+ const handleDownloadSingle = useCallback(async (file: FileWithUrl) => {
+ try {
+ await downloadFiles([file]);
+ } catch (error) {
+ console.error('Failed to download file:', error);
+ }
+ }, []);
+
+
// Cleanup blob URLs when component unmounts
useEffect(() => {
return () => {
@@ -169,6 +275,7 @@ export const FileManagerProvider: React.FC = ({
setActiveSource('recent');
setSelectedFileIds([]);
setSearchTerm('');
+ setLastClickedIndex(null);
}
}, [isOpen]);
@@ -180,6 +287,7 @@ export const FileManagerProvider: React.FC = ({
selectedFiles,
filteredFiles,
fileInputRef,
+ selectedFilesSet,
// Handlers
onSourceChange: handleSourceChange,
@@ -190,6 +298,10 @@ export const FileManagerProvider: React.FC = ({
onOpenFiles: handleOpenFiles,
onSearchChange: handleSearchChange,
onFileInputChange: handleFileInputChange,
+ onSelectAll: handleSelectAll,
+ onDeleteSelected: handleDeleteSelected,
+ onDownloadSelected: handleDownloadSelected,
+ onDownloadSingle: handleDownloadSingle,
// External props
recentFiles,
diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts
index 27e70d461..a8552b2bb 100644
--- a/frontend/src/theme/mantineTheme.ts
+++ b/frontend/src/theme/mantineTheme.ts
@@ -191,6 +191,19 @@ export const mantineTheme = createTheme({
},
},
},
+ Tooltip: {
+ styles: {
+ tooltip: {
+ backgroundColor: 'var( --tooltip-title-bg)',
+ color: 'var( --tooltip-title-color)',
+ border: '1px solid var(--tooltip-borderp)',
+ fontSize: '0.75rem',
+ fontWeight: '500',
+ boxShadow: 'var(--shadow-md)',
+ borderRadius: 'var(--radius-sm)',
+ },
+ },
+ },
Checkbox: {
styles: {
diff --git a/frontend/src/utils/downloadUtils.ts b/frontend/src/utils/downloadUtils.ts
new file mode 100644
index 000000000..1c411c87d
--- /dev/null
+++ b/frontend/src/utils/downloadUtils.ts
@@ -0,0 +1,152 @@
+import { FileWithUrl } from '../types/file';
+import { fileStorage } from '../services/fileStorage';
+import { zipFileService } from '../services/zipFileService';
+
+/**
+ * Downloads a blob as a file using browser download API
+ * @param blob - The blob to download
+ * @param filename - The filename for the download
+ */
+export function downloadBlob(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ // Clean up the blob URL
+ URL.revokeObjectURL(url);
+}
+
+/**
+ * Downloads a single file from IndexedDB storage
+ * @param file - The file object with storage information
+ * @throws Error if file cannot be retrieved from storage
+ */
+export async function downloadFileFromStorage(file: FileWithUrl): Promise {
+ const lookupKey = file.id || file.name;
+ const storedFile = await fileStorage.getFile(lookupKey);
+
+ if (!storedFile) {
+ throw new Error(`File "${file.name}" not found in storage`);
+ }
+
+ const blob = new Blob([storedFile.data], { type: storedFile.type });
+ downloadBlob(blob, storedFile.name);
+}
+
+/**
+ * Downloads multiple files as individual downloads
+ * @param files - Array of files to download
+ */
+export async function downloadMultipleFiles(files: FileWithUrl[]): Promise {
+ for (const file of files) {
+ await downloadFileFromStorage(file);
+ }
+}
+
+/**
+ * Downloads multiple files as a single ZIP archive
+ * @param files - Array of files to include in ZIP
+ * @param zipFilename - Optional custom ZIP filename (defaults to timestamped name)
+ */
+export async function downloadFilesAsZip(files: FileWithUrl[], zipFilename?: string): Promise {
+ if (files.length === 0) {
+ throw new Error('No files provided for ZIP download');
+ }
+
+ // Convert stored files to File objects
+ const fileObjects: File[] = [];
+ for (const fileWithUrl of files) {
+ const lookupKey = fileWithUrl.id || fileWithUrl.name;
+ const storedFile = await fileStorage.getFile(lookupKey);
+
+ if (storedFile) {
+ const file = new File([storedFile.data], storedFile.name, {
+ type: storedFile.type,
+ lastModified: storedFile.lastModified
+ });
+ fileObjects.push(file);
+ }
+ }
+
+ if (fileObjects.length === 0) {
+ throw new Error('No valid files found in storage for ZIP download');
+ }
+
+ // Generate default filename if not provided
+ const finalZipFilename = zipFilename ||
+ `files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`;
+
+ // Create and download ZIP
+ const { zipFile } = await zipFileService.createZipFromFiles(fileObjects, finalZipFilename);
+ downloadBlob(zipFile, finalZipFilename);
+}
+
+/**
+ * Smart download function that handles single or multiple files appropriately
+ * - Single file: Downloads directly
+ * - Multiple files: Downloads as ZIP
+ * @param files - Array of files to download
+ * @param options - Download options
+ */
+export async function downloadFiles(
+ files: FileWithUrl[],
+ options: {
+ forceZip?: boolean;
+ zipFilename?: string;
+ multipleAsIndividual?: boolean;
+ } = {}
+): Promise {
+ if (files.length === 0) {
+ throw new Error('No files provided for download');
+ }
+
+ if (files.length === 1 && !options.forceZip) {
+ // Single file download
+ await downloadFileFromStorage(files[0]);
+ } else if (options.multipleAsIndividual) {
+ // Multiple individual downloads
+ await downloadMultipleFiles(files);
+ } else {
+ // ZIP download (default for multiple files)
+ await downloadFilesAsZip(files, options.zipFilename);
+ }
+}
+
+/**
+ * Downloads a File object directly (for files already in memory)
+ * @param file - The File object to download
+ * @param filename - Optional custom filename
+ */
+export function downloadFileObject(file: File, filename?: string): void {
+ downloadBlob(file, filename || file.name);
+}
+
+/**
+ * Downloads text content as a file
+ * @param content - Text content to download
+ * @param filename - Filename for the download
+ * @param mimeType - MIME type (defaults to text/plain)
+ */
+export function downloadTextAsFile(
+ content: string,
+ filename: string,
+ mimeType: string = 'text/plain'
+): void {
+ const blob = new Blob([content], { type: mimeType });
+ downloadBlob(blob, filename);
+}
+
+/**
+ * Downloads JSON data as a file
+ * @param data - Data to serialize and download
+ * @param filename - Filename for the download
+ */
+export function downloadJsonAsFile(data: any, filename: string): void {
+ const content = JSON.stringify(data, null, 2);
+ downloadTextAsFile(content, filename, 'application/json');
+}
\ No newline at end of file