diff --git a/bun.lock b/bun.lock index 36a1b42e..ad2a5ac2 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@fontsource/inter": "^5.2.8", "@fontsource/lexend-deca": "5.0.14", "@sentry/react": "^10.36.0", + "blurhash": "^2.0.5", "fast-blurhash": "^1.1.4", "image-conversion": "^2.1.1", "react": "^19.2.3", @@ -435,6 +436,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.8.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ=="], + "blurhash": ["blurhash@2.0.5", "", {}, "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w=="], + "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], diff --git a/package.json b/package.json index aaaea3d8..7dd0d182 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@fontsource/inter": "^5.2.8", "@fontsource/lexend-deca": "5.0.14", "@sentry/react": "^10.36.0", + "blurhash": "^2.0.5", "fast-blurhash": "^1.1.4", "image-conversion": "^2.1.1", "react": "^19.2.3", diff --git a/src/components/Elements/MainModal/scss/settings/_main.scss b/src/components/Elements/MainModal/scss/settings/_main.scss index f43b0126..38a2ebd9 100644 --- a/src/components/Elements/MainModal/scss/settings/_main.scss +++ b/src/components/Elements/MainModal/scss/settings/_main.scss @@ -92,13 +92,20 @@ h4 { } .imagesTopBar { - padding-top: 25px; + position: sticky; + top: -20px; + z-index: 90; + padding: 25px 0 15px 0; display: flex; flex-flow: row; justify-content: space-between; align-items: center; - div:nth-child(1) { + @include themed { + background: t($modal-background); + } + + .imagesTopBarTitle { display: flex; flex-flow: row; align-items: center; @@ -121,18 +128,139 @@ h4 { .topbarbuttons { display: flex; flex-flow: row; - gap: 25px; + gap: 15px; + align-items: center; } - button { + button:not(.MuiButtonBase-root) { padding: 0 20px; } } +.imagesControlBar { + position: sticky; + top: 68px; + z-index: 89; + padding: 12px 0; + margin-bottom: 15px; + display: flex; + flex-flow: row; + justify-content: space-between; + align-items: center; + + @include themed { + background: t($modal-background); + border-bottom: 1px solid t($modal-sidebarActive); + } + + .controlBarLeft { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + + @include themed { + color: t($subColor); + } + + .image-count { + font-weight: 500; + display: flex; + + @include themed { + color: t($color); + } + + .storage-info { + font-weight: 400; + + @include themed { + color: t($subColor); + } + + .request-storage-link { + background: none; + border: none; + padding: 0; + margin-left: 5px; + cursor: pointer; + text-decoration: underline; + font-size: 13px; + transition: opacity 0.2s; + + @include themed { + color: #ff5c25; + } + + &:hover { + opacity: 0.8; + } + } + } + } + + .selection-separator { + opacity: 0.5; + } + + .selected-count { + font-weight: 500; + } + + .delete-link { + background: none; + border: none; + padding: 0; + margin-left: 5px; + cursor: pointer; + text-decoration: underline; + font-size: 14px; + transition: opacity 0.2s; + + @include themed { + color: rgb(255 71 87); + } + + &:hover { + opacity: 0.8; + } + } + + .select-all-link { + background: none; + border: none; + padding: 0; + margin-left: 5px; + cursor: pointer; + text-decoration: underline; + font-size: 14px; + transition: opacity 0.2s; + + @include themed { + color: t($subColor); + } + + &:hover { + opacity: 0.8; + + @include themed { + color: t($color); + } + } + } + } + + .controlBarRight { + display: flex; + align-items: center; + } +} + .customcss textarea { - font-family: Consolas, 'Andale Mono WT', 'Andale Mono', 'Lucida Console', - 'Lucida Sans Typewriter', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono', - 'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace !important; + font-family: + Consolas, 'Andale Mono WT', 'Andale Mono', 'Lucida Console', 'Lucida Sans Typewriter', + 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono', 'Nimbus Mono L', Monaco, + 'Courier New', Courier, monospace !important; } .preferences { diff --git a/src/components/Elements/MainModal/scss/settings/modules/tabs/_order.scss b/src/components/Elements/MainModal/scss/settings/modules/tabs/_order.scss index 02a8460b..b4c5c147 100644 --- a/src/components/Elements/MainModal/scss/settings/modules/tabs/_order.scss +++ b/src/components/Elements/MainModal/scss/settings/modules/tabs/_order.scss @@ -95,6 +95,412 @@ } } +// Enhanced custom images grid +.images-grid { + display: grid; + padding: 1px; + + // Show all checkboxes when in selection mode (any image selected) + &.selection-mode { + .image-checkbox { + opacity: 1; + } + } + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 20px; + + @include themed { + .image-card { + position: relative; + border-radius: t($borderRadius); + background: t($modal-secondaryColour); + overflow: hidden; + transition: all 0.3s ease; + box-shadow: t($boxShadow); + + &.selected { + outline: 3px solid #ff5c25; + outline-offset: -3px; + } + + &:hover { + transform: translateY(-4px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); + + .image-nav-buttons { + opacity: 1; + } + + .delete-button { + opacity: 1; + } + + .image-checkbox { + opacity: 1; + } + } + + .image-checkbox { + position: absolute; + top: 8px; + left: 8px; + z-index: 12; + opacity: 0; + transition: opacity 0.2s; + + input[type='checkbox'] { + width: 20px; + height: 20px; + cursor: pointer; + appearance: none; + border: 2px solid #fff; + border-radius: 4px; + background: rgba(0, 0, 0, 0.6); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + transition: all 0.2s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + position: relative; + + &:checked { + background: #ff5c25; + border-color: #ff5c25; + opacity: 1; + + &::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } + + &:hover { + border-color: #ff5c25; + transform: scale(1.1); + } + } + + // Keep checkbox visible when checked + &:has(input:checked) { + opacity: 1; + } + } + + .image-preview { + position: relative; + width: 100%; + height: 200px; + overflow: hidden; + background: t($modal-sidebar); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .video-icon-wrapper { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: t($modal-sidebar); + + .customvideoicon { + font-size: 60px; + color: t($subColor); + } + } + + .blur-placeholder { + background-size: cover; + background-position: center; + } + + .image-nav-buttons { + position: absolute; + top: 50%; + left: 0; + right: 0; + transform: translateY(-50%); + display: flex; + justify-content: space-between; + padding: 0 8px; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + + .nav-button { + pointer-events: all; + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.6); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + + svg { + font-size: 24px; + } + + &:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.8); + transform: scale(1.1); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + } + } + } + + .image-metadata { + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; + + .image-name { + font-size: 14px; + font-weight: 500; + color: t($color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .image-details { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 12px; + color: t($subColor); + + .detail { + display: inline-block; + } + + .folder-tag { + padding: 2px 8px; + background: t($modal-sidebarActive); + border-radius: 4px; + font-size: 11px; + } + } + } + + .delete-button { + position: absolute; + top: 8px; + right: 8px; + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: rgba(255, 71, 87, 0.9); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: all 0.2s; + z-index: 11; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + + svg { + font-size: 20px; + } + + &:hover { + background: rgb(255 71 87); + transform: scale(1.1); + } + } + + // Show delete button when card is hovered or has checkbox visible + &:hover .delete-button, + .image-checkbox:has(input:checked) ~ * .delete-button { + opacity: 1; + } + } + } +} + +// Storage quota display +.storage-quota { + padding: 15px 20px; + margin-top: 10px; + margin-bottom: 50px; + + @include themed { + background: t($modal-secondaryColour); + border-top: 1px solid t($modal-sidebarActive); + } + + .quota-info { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + + .quota-text { + font-size: 13px; + + @include themed { + color: t($subColor); + } + } + + .quota-info-button { + width: 24px; + height: 24px; + border-radius: 50%; + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + @include themed { + color: t($subColor); + + &:hover { + background: t($modal-sidebarActive); + } + } + + svg { + font-size: 18px; + } + } + } + + .quota-bar { + width: 100%; + height: 6px; + border-radius: 3px; + overflow: hidden; + + @include themed { + background: t($modal-sidebar); + } + + .quota-fill { + height: 100%; + border-radius: 3px; + transition: all 0.3s ease; + } + } +} + +// Folder tagging modal styles +.taggingModalContent { + padding: 20px; + + p.subtitle { + margin-bottom: 20px; + } + + .taggingInput { + display: flex; + flex-direction: column; + gap: 8px; + + label { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + + @include themed { + color: t($subColor); + } + } + + input { + padding: 12px 16px; + border-radius: 8px; + border: 1px solid; + font-size: 14px; + transition: all 0.2s; + + @include themed { + background: t($modal-background); + color: t($color); + border-color: t($modal-sidebarActive); + + &:focus { + border-color: #ff5c25; + box-shadow: 0 0 0 3px rgba(255, 92, 37, 0.1); + } + } + } + } +} + +.dropzone { + margin-bottom: 100px; + + @include themed { + background: t($modal-background); + } + + .dropzone-content { + min-height: 200px; + } + + .photosEmpty { + padding: 60px 20px; + display: flex; + align-items: center; + justify-content: center; + + .emptyNewMessage { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + text-align: center; + + .title { + font-size: 18px; + font-weight: 500; + + @include themed { + color: t($color); + } + } + + .subtitle { + font-size: 14px; + + @include themed { + color: t($subColor); + } + } + } + } +} + .overviewGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); diff --git a/src/components/Form/Settings/Dropdown/Dropdown.scss b/src/components/Form/Settings/Dropdown/Dropdown.scss index 637fad37..458c8de7 100644 --- a/src/components/Form/Settings/Dropdown/Dropdown.scss +++ b/src/components/Form/Settings/Dropdown/Dropdown.scss @@ -16,12 +16,10 @@ .dropdown { position: relative; width: 300px; - margin-top: 10px; gap: 8px; display: flex; flex-flow: column; - &.disabled { opacity: 0.5; cursor: not-allowed; diff --git a/src/components/Form/Settings/FileUpload/FileUpload.jsx b/src/components/Form/Settings/FileUpload/FileUpload.jsx index a0352e1f..82b8fc53 100644 --- a/src/components/Form/Settings/FileUpload/FileUpload.jsx +++ b/src/components/Form/Settings/FileUpload/FileUpload.jsx @@ -4,7 +4,7 @@ import { toast } from 'react-toastify'; import { compressAccurately, filetoDataURL } from 'image-conversion'; import videoCheck from 'features/background/api/videoCheck'; -const FileUpload = memo(({ id, type, accept, loadFunction }) => { +const FileUpload = memo(({ id, type, accept, loadFunction, multiple }) => { useEffect(() => { const fileInput = document.getElementById(id); if (!fileInput) return; @@ -20,40 +20,48 @@ const FileUpload = memo(({ id, type, accept, loadFunction }) => { return loadFunction(e.target.result); }; } else { - // background upload - handle multiple files - const settings = {}; + // Pass files directly to loadFunction if it's a newer implementation + if (typeof loadFunction === 'function' && loadFunction.length === 1) { + loadFunction(files); + } else { + // Legacy background upload - handle multiple files + const settings = {}; - Object.keys(localStorage).forEach((key) => { - settings[key] = localStorage.getItem(key); - }); - - const settingsSize = new TextEncoder().encode(JSON.stringify(settings)).length; - - // Process each file - files.forEach((file, index) => { - if (videoCheck(file.type) === true) { - if (settingsSize + file.size > 4850000) { - return toast(variables.getMessage('toasts.no_storage')); - } - - return loadFunction(file, index); - } - - compressAccurately(file, { - size: 450, - accuracy: 0.9, - }).then(async (res) => { - if (settingsSize + res.size > 4850000) { - return toast(variables.getMessage('toasts.no_storage')); - } - - loadFunction({ - target: { - result: await filetoDataURL(res), - }, - }, index); + Object.keys(localStorage).forEach((key) => { + settings[key] = localStorage.getItem(key); }); - }); + + const settingsSize = new TextEncoder().encode(JSON.stringify(settings)).length; + + // Process each file + files.forEach((file, index) => { + if (videoCheck(file.type) === true) { + if (settingsSize + file.size > 4850000) { + return toast(variables.getMessage('toasts.no_storage')); + } + + return loadFunction(file, index); + } + + compressAccurately(file, { + size: 450, + accuracy: 0.9, + }).then(async (res) => { + if (settingsSize + res.size > 4850000) { + return toast(variables.getMessage('toasts.no_storage')); + } + + loadFunction( + { + target: { + result: await filetoDataURL(res), + }, + }, + index, + ); + }); + }); + } } }; @@ -64,7 +72,7 @@ const FileUpload = memo(({ id, type, accept, loadFunction }) => { fileInput.onchange = null; } }; - }, [id, type, loadFunction]); + }, [id, type, loadFunction, multiple]); return ( { type="file" style={{ display: 'none' }} accept={accept} - multiple={type !== 'settings'} + multiple={multiple !== undefined ? multiple : type !== 'settings'} /> ); }); diff --git a/src/features/background/api/backgroundLoader.js b/src/features/background/api/backgroundLoader.js index 1c9add26..0f3806df 100644 --- a/src/features/background/api/backgroundLoader.js +++ b/src/features/background/api/backgroundLoader.js @@ -3,7 +3,7 @@ import { supportsAVIF } from './avifSupport'; import { getOfflineImage } from './offlineImage'; import { randomColourStyleBuilder } from './randomColour'; import videoCheck from './videoCheck'; -import { getAllBackgrounds } from 'utils/customBackgroundDB'; +import { getAllBackgrounds, getAllBackgroundsWithMetadata } from 'utils/customBackgroundDB'; const parseJSON = (key, fallback = null) => { const item = localStorage.getItem(key); @@ -172,12 +172,16 @@ async function getAPIBackground(isOffline) { * Gets custom background */ async function getCustomBackground(isOffline) { - // Try to get from IndexedDB first - let backgrounds = await getAllBackgrounds(); + // Get full metadata from IndexedDB + let backgrounds = await getAllBackgroundsWithMetadata(); - // Fallback to localStorage if IndexedDB is empty + // Fallback to localStorage URLs if IndexedDB is empty if (!backgrounds || backgrounds.length === 0) { - backgrounds = parseJSON('customBackground', []); + const urls = parseJSON('customBackground', []); + if (urls && urls.length > 0) { + // Convert old URL format to metadata format + backgrounds = urls.map((url) => ({ url, photoInfo: { hidden: true } })); + } } if (!backgrounds || backgrounds.length === 0) return null; @@ -187,23 +191,31 @@ async function getCustomBackground(isOffline) { // Check if selected is valid before using it if (!selected) return null; - if (isOffline && !selected.startsWith('data:')) return getOfflineImage('custom'); + const url = selected.url || selected; + + if (isOffline && !url.startsWith('data:')) return getOfflineImage('custom'); const data = { - url: selected, + url, type: 'custom', - video: videoCheck(selected), - photoInfo: { hidden: true }, + video: videoCheck(url), + photoInfo: { + hidden: true, + blur_hash: selected.blurHash || null, + }, }; // Don't store full image data in localStorage to avoid quota errors // Just store metadata try { - localStorage.setItem('currentBackground', JSON.stringify({ - type: 'custom', - video: data.video, - photoInfo: data.photoInfo, - })); + localStorage.setItem( + 'currentBackground', + JSON.stringify({ + type: 'custom', + video: data.video, + photoInfo: data.photoInfo, + }), + ); } catch (e) { // Ignore quota errors for currentBackground console.warn('Could not save currentBackground to localStorage:', e); diff --git a/src/features/background/options/Custom.jsx b/src/features/background/options/Custom.jsx index 8b7f8012..3af6202b 100644 --- a/src/features/background/options/Custom.jsx +++ b/src/features/background/options/Custom.jsx @@ -8,35 +8,59 @@ import { MdPersonalVideo, MdOutlineFileUpload, MdFolder, + MdChevronLeft, + MdChevronRight, + MdDelete, + MdInfo, } from 'react-icons/md'; import EventBus from 'utils/eventbus'; import { compressAccurately, filetoDataURL } from 'image-conversion'; import videoCheck from '../api/videoCheck'; import { getAllBackgrounds, + getAllBackgroundsWithMetadata, addBackground, - updateBackground, deleteBackground, - clearAllBackgrounds, + deleteMultipleBackgrounds, migrateFromLocalStorage, + updateBackgroundMetadata, } from 'utils/customBackgroundDB'; +import { + getImageDimensions, + generateBlurHash, + getDataUrlSize, + getFileName, + calculateStorageSize, + calculateTotalStorageSize, + formatBytes, +} from 'utils/imageMetadata'; +import { generateBlurHashDataUrl } from '../api/blurHash'; -import { Checkbox, FileUpload } from 'components/Form/Settings'; +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 [currentBackgroundIndex, setCurrentBackgroundIndex] = useState(0); 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 customDnd = useRef(null); const dragCounter = useRef(0); + const STORAGE_LIMIT = 4850000; // 4.85MB + // Load backgrounds from IndexedDB on mount useEffect(() => { const loadBackgrounds = async () => { @@ -45,8 +69,24 @@ const CustomSettings = memo(() => { await migrateFromLocalStorage(); // Load from IndexedDB - const backgrounds = await getAllBackgrounds(); + 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')); @@ -58,44 +98,146 @@ const CustomSettings = memo(() => { loadBackgrounds(); }, []); - const handleCustomBackground = useCallback( - async (e, index) => { - const result = e.target.result; + const handleCustomBackground = useCallback(async (file, dataUrl, metadata) => { + try { + const backgroundData = { + url: dataUrl, + name: metadata.name, + uploadDate: Date.now(), + dimensions: metadata.dimensions, + fileSize: metadata.fileSize, + folder: metadata.folder || '', + blurHash: metadata.blurHash, + }; + + await addBackground(backgroundData); + + // Reload from IndexedDB to get the latest state and update React state + const backgrounds = await getAllBackgroundsWithMetadata(); + setCustomBackground(backgrounds); try { - // Update or add to IndexedDB - if (index < customBackground.length) { - await updateBackground(index, result); - } else { - await addBackground(result); - } - - // Reload from IndexedDB to get the latest state - const backgrounds = await getAllBackgrounds(); - setCustomBackground(backgrounds); - - // Store count in localStorage for backward compatibility - try { - localStorage.setItem('customBackground', JSON.stringify(backgrounds)); - } catch (_quotaError) { - // If quota exceeded, just store the count - console.warn('localStorage quota exceeded, storing count only'); - localStorage.setItem('customBackgroundCount', backgrounds.length.toString()); - } - - const reminderInfo = document.querySelector('.reminder-info'); - if (reminderInfo) { - reminderInfo.style.display = 'flex'; - } - localStorage.setItem('showReminder', true); - EventBus.emit('refresh', 'background'); - } catch (error) { - console.error('Error saving background:', error); - toast(variables.getMessage('toasts.error')); + 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()); } - }, - [customBackground.length], - ); + + 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); + + // Request more storage if approaching limit (90%) + if (storageSize / STORAGE_LIMIT > 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 > STORAGE_LIMIT) { + throw new Error('no_storage'); + } + + const reader = new FileReader(); + return new Promise((resolve, reject) => { + reader.onloadend = () => { + resolve({ + dataUrl: reader.result, + metadata: { + name: getFileName(file, customBackground.length), + dimensions: null, + fileSize: file.size, + folder: folderName, + blurHash: null, + }, + }); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } else { + // Compress image + const compressed = await compressAccurately(file, { + size: 450, + accuracy: 0.9, + }); + + if (storageSize + compressed.size > STORAGE_LIMIT) { + 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); + await handleCustomBackground(files[i], result.dataUrl, result.metadata); + 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(', ')}`); + } + + 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 { @@ -106,22 +248,17 @@ const CustomSettings = memo(() => { } // Reload from IndexedDB to get the latest state - const backgrounds = await getAllBackgrounds(); + const backgrounds = await getAllBackgroundsWithMetadata(); setCustomBackground(backgrounds); // Store in localStorage with quota handling try { - localStorage.setItem('customBackground', JSON.stringify(backgrounds)); + 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()); } - const reminderInfo = document.querySelector('.reminder-info'); - if (reminderInfo) { - reminderInfo.style.display = 'flex'; - } - localStorage.setItem('showReminder', true); EventBus.emit('refresh', 'background'); } catch (error) { console.error('Error modifying background:', error); @@ -129,12 +266,51 @@ const CustomSettings = memo(() => { } }, []); + 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(() => { - const newIndex = customBackground.length; - document.getElementById('bg-input').setAttribute('index', newIndex); document.getElementById('bg-input').click(); - setCurrentBackgroundIndex(newIndex); - }, [customBackground.length]); + }, []); const addCustomURL = useCallback( async (e) => { @@ -145,14 +321,95 @@ const CustomSettings = memo(() => { return setUrlError(variables.getMessage('widgets.quicklinks.url_error')); } - const newIndex = customBackground.length; setCustomURLModal(false); - setCurrentBackgroundIndex(newIndex); - await handleCustomBackground({ target: { result: e } }, newIndex); + + 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(backgrounds.map((bg) => bg.url))); + } catch (_quotaError) { + localStorage.setItem('customBackgroundCount', backgrounds.length.toString()); + } + + EventBus.emit('refresh', 'background'); }, - [customBackground.length, handleCustomBackground], + [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 storagePercent = (storageUsed / STORAGE_LIMIT) * 100; + const totalStorageUsed = calculateTotalStorageSize(); + const TOTAL_STORAGE_LIMIT = 5242880; // 5MB total localStorage limit (browser default) + useEffect(() => { const dnd = customDnd.current; if (!dnd) return; @@ -166,10 +423,8 @@ const CustomSettings = memo(() => { e.preventDefault(); e.stopPropagation(); dragCounter.current++; - console.log('Drag enter, counter:', dragCounter.current); if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { setIsDragging(true); - console.log('Setting isDragging to true'); } }; @@ -182,61 +437,24 @@ const CustomSettings = memo(() => { } }; - const handleDrop = (e) => { + const handleDrop = async (e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); dragCounter.current = 0; const files = Array.from(e.dataTransfer.files); - const settings = {}; - Object.keys(localStorage).forEach((key) => { - settings[key] = localStorage.getItem(key); - }); + if (files.length === 0) return; - const settingsSize = new TextEncoder().encode(JSON.stringify(settings)).length; - - // Process each dropped file - files.forEach((file, index) => { - const fileIndex = customBackground.length + index; - - if (videoCheck(file.type) === true) { - if (settingsSize + file.size > 4850000) { - return toast(variables.getMessage('toasts.no_storage')); - } - - const reader = new FileReader(); - reader.onloadend = () => { - handleCustomBackground({ target: { result: reader.result } }, fileIndex); - }; - reader.readAsDataURL(file); - } else { - // Handle image files - compressAccurately(file, { - size: 450, - accuracy: 0.9, - }) - .then(async (res) => { - if (settingsSize + res.size > 4850000) { - return toast(variables.getMessage('toasts.no_storage')); - } - - handleCustomBackground( - { - target: { - result: await filetoDataURL(res), - }, - }, - fileIndex, - ); - }) - .catch((error) => { - console.error('Error compressing image:', error); - toast(variables.getMessage('toasts.error')); - }); - } - }); + if (files.length > 1) { + // Multiple files - show tagging modal + setPendingFiles(files); + setFolderTaggingModal(true); + } else { + // Single file - upload directly + await handleBatchUpload(files, ''); + } }; dnd.ondragover = handleDragOver; @@ -252,14 +470,32 @@ const CustomSettings = memo(() => { dnd.ondrop = null; } }; - }, [customBackground.length, handleCustomBackground]); + }, [customBackground.length, handleBatchUpload]); - const hasVideo = customBackground.filter((bg) => bg && videoCheck(bg)).length > 0; + const hasVideo = sortedBackgrounds.filter((bg) => bg && videoCheck(bg.url)).length > 0; if (isLoading) { return ( -
+ {variables.getMessage( + 'modals.main.settings.sections.background.source.storage_description', + )} +
++ Custom Backgrounds +
++ {formatBytes(storageUsed)} / {formatBytes(STORAGE_LIMIT)} ( + {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 +
++ {variables.getMessage('modals.main.settings.sections.background.source.tag_description', { + count: files.length, + })} +
+