import variables from 'config/variables'; import { memo, useRef, useState, useEffect, useCallback } from 'react'; import { toast } from 'react-toastify'; import { MdCancel, MdAddLink, MdAddPhotoAlternate, MdPersonalVideo, MdOutlineFileUpload, MdFolder, MdChevronLeft, MdChevronRight, MdImage, } from 'react-icons/md'; import { BiSolidFilm } from 'react-icons/bi'; import EventBus from 'utils/eventbus'; import { compressAccurately, filetoDataURL } from 'image-conversion'; import videoCheck from '../api/videoCheck'; import { getAllBackgrounds, getAllBackgroundsWithMetadata, addBackground, deleteBackground, deleteMultipleBackgrounds, migrateFromLocalStorage, updateBackgroundMetadata, } from 'utils/customBackgroundDB'; import { getImageDimensions, generateBlurHash, getDataUrlSize, getFileName, calculateStorageSize, calculateTotalStorageSize, formatBytes, extractVideoThumbnail, } from 'utils/imageMetadata'; import { generateBlurHashDataUrl } from '../api/blurHash'; import { Checkbox, FileUpload, Dropdown } from 'components/Form/Settings'; import { Tooltip, Button } from 'components/Elements'; import Modal from 'react-modal'; import CustomURLModal from './CustomURLModal'; import FolderTaggingModal from './FolderTaggingModal'; const CustomSettings = memo(() => { const [customBackground, setCustomBackground] = useState([]); const [customURLModal, setCustomURLModal] = useState(false); const [folderTaggingModal, setFolderTaggingModal] = useState(false); const [pendingFiles, setPendingFiles] = useState([]); const [urlError, setUrlError] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState({ current: 0, total: 0 }); const [isDragging, setIsDragging] = useState(false); const [selectedImages, setSelectedImages] = useState(new Set()); const [sortBy, setSortBy] = useState(localStorage.getItem('customImageSort') || 'date_desc'); const [storageQuotaModal, setStorageQuotaModal] = useState(false); const [storageQuota, setStorageQuota] = useState({ usage: 0, quota: 0 }); const customDnd = useRef(null); const dragCounter = useRef(0); // IndexedDB typically has 50MB+ quota, we'll check dynamically const FALLBACK_STORAGE_LIMIT = 50000000; // 50MB fallback if API unavailable // Fetch storage quota useEffect(() => { const fetchQuota = async () => { if (navigator.storage && navigator.storage.estimate) { try { const estimate = await navigator.storage.estimate(); setStorageQuota({ usage: estimate.usage || 0, quota: estimate.quota || FALLBACK_STORAGE_LIMIT, }); } catch (error) { console.warn('Could not get storage estimate:', error); setStorageQuota({ usage: 0, quota: FALLBACK_STORAGE_LIMIT }); } } else { setStorageQuota({ usage: 0, quota: FALLBACK_STORAGE_LIMIT }); } }; fetchQuota(); }, [customBackground]); // Load backgrounds from IndexedDB on mount useEffect(() => { const loadBackgrounds = async () => { try { // Try migration first await migrateFromLocalStorage(); // Load from IndexedDB const backgrounds = await getAllBackgroundsWithMetadata(); setCustomBackground(backgrounds); // Backfill missing metadata for existing images backgrounds.forEach(async (bg) => { if (!bg.dimensions && bg.url && !videoCheck(bg.url)) { try { const dimensions = await getImageDimensions(bg.url); const blurHash = await generateBlurHash(bg.url); await updateBackgroundMetadata(bg.id, { dimensions, blurHash }); // Reload backgrounds to show updated metadata const updatedBackgrounds = await getAllBackgroundsWithMetadata(); setCustomBackground(updatedBackgrounds); } catch (error) { console.warn('Could not extract metadata for existing image:', error); } } }); } catch (error) { console.error('Error loading backgrounds:', error); toast(variables.getMessage('toasts.error')); } finally { setIsLoading(false); } }; loadBackgrounds(); }, []); const handleCustomBackground = useCallback( async (file, dataUrl, metadata, skipRefresh = false) => { try { const backgroundData = { url: dataUrl, name: metadata.name, uploadDate: Date.now(), dimensions: metadata.dimensions, fileSize: metadata.fileSize, folder: metadata.folder || '', blurHash: metadata.blurHash, thumbnail: metadata.thumbnail || null, }; await addBackground(backgroundData); // Reload from IndexedDB to get the latest state and update React state const backgrounds = await getAllBackgroundsWithMetadata(); setCustomBackground(backgrounds); try { localStorage.setItem('customBackground', JSON.stringify(backgrounds.map((bg) => bg.url))); } catch (_quotaError) { console.warn('localStorage quota exceeded, storing count only'); localStorage.setItem('customBackgroundCount', backgrounds.length.toString()); } // Only emit refresh if not part of a batch upload if (!skipRefresh) { EventBus.emit('refresh', 'background'); } } catch (error) { console.error('Error saving background:', error); toast(variables.getMessage('toasts.error')); } }, [], ); const processImageFile = async (file, folderName = '') => { // Calculate actual storage from existing backgrounds const storageSize = customBackground.reduce((total, bg) => { if (bg.url && bg.url.startsWith('data:')) { return total + getDataUrlSize(bg.url); } return total; }, 0); const availableQuota = storageQuota.quota || FALLBACK_STORAGE_LIMIT; // Request persistent storage if approaching limit (90%) if (storageSize / availableQuota > 0.9 && navigator.storage && navigator.storage.persist) { try { const isPersisted = await navigator.storage.persist(); if (isPersisted) { console.log('Storage persistence granted'); } } catch (error) { console.warn('Could not request storage persistence:', error); } } if (videoCheck(file.type)) { if (storageSize + file.size > availableQuota) { throw new Error('no_storage'); } const reader = new FileReader(); return new Promise((resolve, reject) => { reader.onloadend = async () => { try { // Extract thumbnail and dimensions from video const { thumbnail, dimensions } = await extractVideoThumbnail(reader.result); resolve({ dataUrl: reader.result, metadata: { name: getFileName(file, customBackground.length), dimensions, fileSize: file.size, folder: folderName, blurHash: null, thumbnail, }, }); } catch (error) { console.warn('Could not extract video thumbnail:', error); // Fallback to no thumbnail if extraction fails resolve({ dataUrl: reader.result, metadata: { name: getFileName(file, customBackground.length), dimensions: null, fileSize: file.size, folder: folderName, blurHash: null, thumbnail: null, }, }); } }; reader.onerror = reject; reader.readAsDataURL(file); }); } else { // Compress image const compressed = await compressAccurately(file, { size: 450, accuracy: 0.9, }); const availableQuota = storageQuota.quota || FALLBACK_STORAGE_LIMIT; if (storageSize + compressed.size > availableQuota) { throw new Error('no_storage'); } const dataUrl = await filetoDataURL(compressed); // Generate metadata in parallel const [dimensions, blurHash] = await Promise.all([ getImageDimensions(dataUrl), generateBlurHash(dataUrl).catch(() => null), // Don't fail if blur hash fails ]); return { dataUrl, metadata: { name: getFileName(file, customBackground.length), dimensions, fileSize: getDataUrlSize(dataUrl), folder: folderName, blurHash, }, }; } }; const handleBatchUpload = async (files, folderName = '') => { setIsUploading(true); setUploadProgress({ current: 0, total: files.length }); const errors = []; for (let i = 0; i < files.length; i++) { try { const result = await processImageFile(files[i], folderName); // Skip refresh during batch upload to prevent background flashing await handleCustomBackground(files[i], result.dataUrl, result.metadata, true); setUploadProgress({ current: i + 1, total: files.length }); } catch (error) { if (error.message === 'no_storage') { toast(variables.getMessage('toasts.no_storage')); break; } errors.push(files[i].name); } } if (errors.length > 0) { toast(variables.getMessage('toasts.error') + `: ${errors.join(', ')}`); } // Emit refresh once after all images are uploaded EventBus.emit('refresh', 'background'); setIsUploading(false); setUploadProgress({ current: 0, total: 0 }); }; const handleFolderTagging = async (folderName) => { setFolderTaggingModal(false); await handleBatchUpload(pendingFiles, folderName); setPendingFiles([]); }; const modifyCustomBackground = useCallback(async (type, index) => { try { if (type === 'add') { await addBackground(''); } else { await deleteBackground(index); } // Reload from IndexedDB to get the latest state const backgrounds = await getAllBackgroundsWithMetadata(); setCustomBackground(backgrounds); // Store in localStorage with quota handling try { localStorage.setItem('customBackground', JSON.stringify(backgrounds.map((bg) => bg.url))); } catch (_quotaError) { console.warn('localStorage quota exceeded, storing count only'); localStorage.setItem('customBackgroundCount', backgrounds.length.toString()); } EventBus.emit('refresh', 'background'); } catch (error) { console.error('Error modifying background:', error); toast(variables.getMessage('toasts.error')); } }, []); const handleBatchDelete = async () => { if (selectedImages.size === 0) { return; } try { const indices = Array.from(selectedImages).sort((a, b) => b - a); await deleteMultipleBackgrounds(indices); // Reload from IndexedDB const backgrounds = await getAllBackgroundsWithMetadata(); setCustomBackground(backgrounds); setSelectedImages(new Set()); // Update localStorage try { localStorage.setItem('customBackground', JSON.stringify(backgrounds.map((bg) => bg.url))); } catch (_quotaError) { localStorage.setItem('customBackgroundCount', backgrounds.length.toString()); } EventBus.emit('refresh', 'background'); toast(variables.getMessage('toasts.deleted')); } catch (error) { console.error('Error batch deleting:', error); toast(variables.getMessage('toasts.error')); } }; const toggleImageSelection = (index) => { const newSelection = new Set(selectedImages); if (newSelection.has(index)) { newSelection.delete(index); } else { newSelection.add(index); } setSelectedImages(newSelection); }; const handleSort = (sortOption) => { setSortBy(sortOption); localStorage.setItem('customImageSort', sortOption); }; const uploadCustomBackground = useCallback(() => { document.getElementById('bg-input').click(); }, []); const addCustomURL = useCallback( async (e) => { // regex: https://ihateregex.io/expr/url/ const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._~#=]{1,256}\.[a-zA-Z0-9()]{1,63}\b([-a-zA-Z0-9()!@:%_.~#?&=]*)/; if (urlRegex.test(e) === false) { return setUrlError(variables.getMessage('widgets.quicklinks.url_error')); } setCustomURLModal(false); try { // Extract filename from URL const urlParts = e.split('/'); const filename = urlParts[urlParts.length - 1].split('?')[0] || 'Remote Image'; // Try to extract metadata from the remote image let dimensions = null; let blurHash = null; try { dimensions = await getImageDimensions(e); blurHash = await generateBlurHash(e); } catch (metadataError) { console.warn('Could not extract metadata from remote image:', metadataError); } const backgroundData = { url: e, name: filename, uploadDate: Date.now(), dimensions, fileSize: null, // Cannot determine file size for remote URLs without fetching folder: '', blurHash, }; await addBackground(backgroundData); const backgrounds = await getAllBackgroundsWithMetadata(); setCustomBackground(backgrounds); } catch (error) { console.error('Error adding URL:', error); toast(variables.getMessage('toasts.error')); } try { localStorage.setItem( 'customBackground', JSON.stringify(updatedBackgrounds.map((bg) => bg.url)), ); } catch (_quotaError) { localStorage.setItem('customBackgroundCount', updatedBackgrounds.length.toString()); } EventBus.emit('refresh', 'background'); }, [customBackground.length], ); const handleFileInputChange = async (files) => { if (files.length > 1) { // Multiple files - show tagging modal setPendingFiles(files); setFolderTaggingModal(true); } else { // Single file - upload directly await handleBatchUpload(files, ''); } }; // Sorted backgrounds const sortedBackgrounds = [...customBackground].sort((a, b) => { switch (sortBy) { case 'date_asc': return a.uploadDate - b.uploadDate; case 'date_desc': return b.uploadDate - a.uploadDate; case 'name_asc': return (a.name || '').localeCompare(b.name || ''); case 'name_desc': return (b.name || '').localeCompare(a.name || ''); case 'size_asc': return (a.fileSize || 0) - (b.fileSize || 0); case 'size_desc': return (b.fileSize || 0) - (a.fileSize || 0); default: return 0; } }); // Calculate storage usage from actual background data const storageUsed = customBackground.reduce((total, bg) => { // Calculate size of the data URL if (bg.url && bg.url.startsWith('data:')) { return total + getDataUrlSize(bg.url); } return total; }, 0); const availableStorageLimit = storageQuota.quota || FALLBACK_STORAGE_LIMIT; const storagePercent = (storageUsed / availableStorageLimit) * 100; const totalStorageUsed = calculateTotalStorageSize(); const TOTAL_STORAGE_LIMIT = 5242880; // 5MB total localStorage limit (browser default) useEffect(() => { const dnd = customDnd.current; if (!dnd) return; const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); }; const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); dragCounter.current++; if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { setIsDragging(true); } }; const handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); dragCounter.current--; if (dragCounter.current === 0) { setIsDragging(false); } }; const handleDrop = async (e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); dragCounter.current = 0; const files = Array.from(e.dataTransfer.files); if (files.length === 0) { return; } if (files.length > 1) { // Multiple files - show tagging modal setPendingFiles(files); setFolderTaggingModal(true); } else { // Single file - upload directly await handleBatchUpload(files, ''); } }; dnd.ondragover = handleDragOver; dnd.ondragenter = handleDragEnter; dnd.ondragleave = handleDragLeave; dnd.ondrop = handleDrop; return () => { if (dnd) { dnd.ondragover = null; dnd.ondragenter = null; dnd.ondragleave = null; dnd.ondrop = null; } }; }, [customBackground.length, handleBatchUpload]); const hasVideo = sortedBackgrounds.filter((bg) => bg && videoCheck(bg.url)).length > 0; if (isLoading) { return (
{variables.getMessage('modals.main.loading')}
); } if (isUploading) { return (
{variables.getMessage('modals.main.settings.sections.background.source.uploading', { current: uploadProgress.current, total: uploadProgress.total, })} {Math.round((uploadProgress.current / uploadProgress.total) * 100)}%
); } return ( <>
{variables.getMessage( 'modals.main.settings.sections.background.source.custom_title', )} {variables.getMessage( 'modals.main.settings.sections.background.source.custom_description', )}
{customBackground.length} {customBackground.length === 1 ? 'image' : 'images'} {' '} · {formatBytes(storageUsed)} / {formatBytes(availableStorageLimit)} {storagePercent > 80 && navigator.storage && navigator.storage.persist && ( )} {customBackground.length > 0 && ·} {selectedImages.size > 0 ? ( <> {selectedImages.size} selected {selectedImages.size < customBackground.length && ( )} {selectedImages.size === customBackground.length && ( )} ) : ( customBackground.length > 0 && ( ) )}
{sortedBackgrounds.length > 0 ? (
0 ? 'selection-mode' : ''}`}> {sortedBackgrounds.map((bg, index) => { const originalIndex = customBackground.findIndex((item) => item === bg); const isVideo = bg && videoCheck(bg.url); return (
{ // Only select if clicking the card itself, not navigation buttons if (!e.target.closest('.image-nav-buttons')) { toggleImageSelection(originalIndex); } }} >
toggleImageSelection(originalIndex)} />
{bg.blurHash && !isVideo && (() => { const blurHashDataUrl = generateBlurHashDataUrl(bg.blurHash, 32, 32); return blurHashDataUrl ? (
) : null; })()} {isVideo ? ( bg.thumbnail ? ( <> {bg.name
) : (
) ) : ( <> {bg.name
)}
{bg.name || 'Unnamed'}
{bg.dimensions && ( {bg.dimensions.width} × {bg.dimensions.height} )} {bg.fileSize && {formatBytes(bg.fileSize)}} {bg.folder && {bg.folder}}
); })}
) : (
{variables.getMessage( 'modals.main.settings.sections.background.source.drop_to_upload', )} {variables.getMessage('modals.main.settings.sections.background.source.formats', { list: 'jpeg, png, webp, webm, gif, mp4, webm, ogg', })}
)}
{ await handleFileInputChange(files); }} /> {hasVideo && ( <> )} setCustomURLModal(false)} isOpen={customURLModal} className="Modal resetmodal mainModal" overlayClassName="Overlay resetoverlay" ariaHideApp={false} > setCustomURLModal(false)} /> { setFolderTaggingModal(false); setPendingFiles([]); }} isOpen={folderTaggingModal} className="Modal resetmodal mainModal" overlayClassName="Overlay resetoverlay" ariaHideApp={false} > { setFolderTaggingModal(false); setPendingFiles([]); }} /> setStorageQuotaModal(false)} isOpen={storageQuotaModal} className="Modal resetmodal mainModal" overlayClassName="Overlay resetoverlay" ariaHideApp={false} >
{variables.getMessage('modals.main.settings.sections.background.source.storage_info')}

{variables.getMessage( 'modals.main.settings.sections.background.source.storage_description', )}

Custom Backgrounds

{formatBytes(storageUsed)} / {formatBytes(availableStorageLimit)} ( {Math.round(storagePercent)}%)

Total localStorage Usage

{formatBytes(totalStorageUsed)} / {formatBytes(TOTAL_STORAGE_LIMIT)} ( {Math.round((totalStorageUsed / TOTAL_STORAGE_LIMIT) * 100)}%)

Includes all Mue settings and custom images

); }); CustomSettings.displayName = 'CustomSettings'; export default CustomSettings;