From cac58cdaebc0c43c62a86e5f3237b105075f678b Mon Sep 17 00:00:00 2001 From: alexsparkes Date: Mon, 26 Jan 2026 16:14:09 +0000 Subject: [PATCH] feat: enhance image management features - Added new localization strings for image management, including upload and storage information. - Refactored custom background database functions to support metadata and backward compatibility. - Introduced a new FolderTaggingModal component for organizing images into folders. - Created utility functions for image metadata extraction, including dimensions, blur hash generation, and file size calculation. - Implemented functions to delete multiple backgrounds and update background metadata. --- bun.lock | 3 + package.json | 1 + .../MainModal/scss/settings/_main.scss | 142 +++- .../scss/settings/modules/tabs/_order.scss | 406 +++++++++ .../Form/Settings/Dropdown/Dropdown.scss | 2 - .../Form/Settings/FileUpload/FileUpload.jsx | 78 +- .../background/api/backgroundLoader.js | 40 +- src/features/background/options/Custom.jsx | 776 ++++++++++++++---- .../background/options/FolderTaggingModal.jsx | 65 ++ src/i18n/locales/en_GB.json | 25 +- src/utils/customBackgroundDB.js | 151 +++- src/utils/imageMetadata.js | 154 ++++ 12 files changed, 1631 insertions(+), 212 deletions(-) create mode 100644 src/features/background/options/FolderTaggingModal.jsx create mode 100644 src/utils/imageMetadata.js 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.loading')} +
+
+ {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)}% +
); } @@ -281,7 +517,7 @@ const CustomSettings = memo(() => { } >
-
+
@@ -303,41 +539,228 @@ const CustomSettings = memo(() => { icon={} label={variables.getMessage('modals.main.settings.sections.background.source.upload')} /> -
+
+ +
+
+ + {customBackground.length} {customBackground.length === 1 ? 'image' : 'images'} + + {' '} + · {formatBytes(storageUsed)} / {formatBytes(STORAGE_LIMIT)} + {storagePercent > 80 && navigator.storage && navigator.storage.persist && ( + + )} + + + · + {selectedImages.size > 0 ? ( + <> + {selectedImages.size} selected + + {selectedImages.size < customBackground.length && ( + + )} + {selectedImages.size === customBackground.length && ( + + )} + + ) : ( + customBackground.length > 0 && ( + + ) + )} +
+
+
+
- {customBackground.length > 0 ? ( -
- {customBackground.map((url, index) => ( -
- {url && !videoCheck(url) ? ( - {'Custom - ) : url && videoCheck(url) ? ( - - ) : null} - {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 ( +
+
+ toggleImageSelection(originalIndex)} + /> +
+
+ {bg.blurHash && + !isVideo && + (() => { + const blurHashDataUrl = generateBlurHashDataUrl(bg.blurHash, 32, 32); + return blurHashDataUrl ? ( +
+ ) : null; + })()} + {isVideo ? ( +
+ +
+ ) : ( + {bg.name + )} +
+ + +
+
+
+ + {bg.name || 'Unnamed'} + +
+ {bg.dimensions && ( + + {bg.dimensions.width} × {bg.dimensions.height} + + )} + {bg.fileSize && {formatBytes(bg.fileSize)}} + {bg.folder && {bg.folder}} +
+
- - )} -
- ))} +
+ ); + })}
) : (
@@ -366,14 +789,16 @@ const CustomSettings = memo(() => { )}
+ { - const index = currentBackgroundIndex + fileIndex; - handleCustomBackground(e, index); + multiple + loadFunction={async (files) => { + await handleFileInputChange(files); }} /> + {hasVideo && ( <> { /> )} + setCustomURLModal(false)} @@ -404,6 +830,82 @@ const CustomSettings = memo(() => { modalCloseOnly={() => 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(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 +

+
+
+
+
+
+
); }); diff --git a/src/features/background/options/FolderTaggingModal.jsx b/src/features/background/options/FolderTaggingModal.jsx new file mode 100644 index 00000000..7019318a --- /dev/null +++ b/src/features/background/options/FolderTaggingModal.jsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import variables from 'config/variables'; +import { MdClose } from 'react-icons/md'; +import { Button } from 'components/Elements'; + +const FolderTaggingModal = ({ files, onConfirm, onCancel }) => { + const [folderName, setFolderName] = useState(''); + + const handleConfirm = () => { + onConfirm(folderName.trim()); + }; + + return ( +
+
+ + {variables.getMessage('modals.main.settings.sections.background.source.tag_images')} + + +
+
+

+ {variables.getMessage('modals.main.settings.sections.background.source.tag_description', { + count: files.length, + })} +

+
+ + setFolderName(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleConfirm(); + } + }} + autoFocus + /> +
+
+
+
+
+ ); +}; + +export default FolderTaggingModal; diff --git a/src/i18n/locales/en_GB.json b/src/i18n/locales/en_GB.json index 151184ab..d161b845 100644 --- a/src/i18n/locales/en_GB.json +++ b/src/i18n/locales/en_GB.json @@ -267,7 +267,25 @@ }, "custom_title": "Custom Images", "custom_description": "Select images from your local computer", - "remove": "Remove Image" + "remove": "Remove Image", + "delete_selected": "Delete {count} Selected", + "uploading": "Uploading {current} of {total}...", + "images": "images", + "tag_images": "Organise Images", + "tag_description": "Add {count} images to a folder (optional)", + "folder_name": "Folder Name", + "folder_placeholder": "Leave empty for no folder", + "storage_info": "Storage Information", + "storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.", + "storage_current": "Current usage: {used} of {total} ({percent}%)", + "sort": { + "date_newest": "Date Added (Newest)", + "date_oldest": "Date Added (Oldest)", + "name_asc": "Name (A-Z)", + "name_desc": "Name (Z-A)", + "size_small": "File Size (Smallest)", + "size_large": "File Size (Largest)" + } }, "display": "Display", "display_subtitle": "Change how background and photo information are loaded", @@ -518,7 +536,10 @@ "buttons": { "reset": "Reset", "import": "Import", - "export": "Export" + "export": "Export", + "cancel": "Cancel", + "continue": "Continue", + "close": "Close" } }, "marketplace": { diff --git a/src/utils/customBackgroundDB.js b/src/utils/customBackgroundDB.js index 55f5653c..a212b968 100644 --- a/src/utils/customBackgroundDB.js +++ b/src/utils/customBackgroundDB.js @@ -23,7 +23,10 @@ function openDB() { // Create object store if it doesn't exist if (!db.objectStoreNames.contains(STORE_NAME)) { - const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); + const objectStore = db.createObjectStore(STORE_NAME, { + keyPath: 'id', + autoIncrement: true, + }); objectStore.createIndex('url', 'url', { unique: false }); } }; @@ -31,10 +34,10 @@ function openDB() { } /** - * Get all custom backgrounds - * @returns {Promise>} Array of background URLs + * Get all custom backgrounds as objects + * @returns {Promise>} Array of background objects with metadata */ -export async function getAllBackgrounds() { +export async function getAllBackgroundsWithMetadata() { try { const db = await openDB(); const transaction = db.transaction(STORE_NAME, 'readonly'); @@ -44,8 +47,26 @@ export async function getAllBackgrounds() { return new Promise((resolve, reject) => { request.onsuccess = () => { const results = request.result; - // Return array of URLs in order - resolve(results.map(item => item.url)); + // Return array of background objects in order + // For backward compatibility, convert old string URLs to objects + resolve( + results.map((item) => { + if (typeof item.url === 'string' && !item.name) { + // Old format - migrate to new format + return { + id: item.id, + url: item.url, + name: `Image ${item.id}`, + uploadDate: item.createdAt || Date.now(), + dimensions: null, + fileSize: null, + folder: '', + blurHash: null, + }; + } + return item; + }), + ); }; request.onerror = () => reject(request.error); }); @@ -56,15 +77,38 @@ export async function getAllBackgrounds() { } /** - * Add a new background - * @param {string} url - The background URL (data URL or remote URL) + * Get all custom backgrounds (URLs only for backward compatibility) + * @returns {Promise>} Array of background URLs + */ +export async function getAllBackgrounds() { + const backgrounds = await getAllBackgroundsWithMetadata(); + return backgrounds.map((bg) => bg.url || bg); +} + +/** + * Add a new background with metadata + * @param {Object} backgroundData - The background data object + * @param {string} backgroundData.url - The background URL (data URL or remote URL) + * @param {string} backgroundData.name - The filename + * @param {number} backgroundData.uploadDate - Upload timestamp + * @param {Object} backgroundData.dimensions - {width, height} + * @param {number} backgroundData.fileSize - File size in bytes + * @param {string} backgroundData.folder - Folder name (optional) + * @param {string} backgroundData.blurHash - BlurHash string (optional) * @returns {Promise} The ID of the added background */ -export async function addBackground(url) { +export async function addBackground(backgroundData) { const db = await openDB(); const transaction = db.transaction(STORE_NAME, 'readwrite'); const store = transaction.objectStore(STORE_NAME); - const request = store.add({ url, createdAt: Date.now() }); + + // Support old string format for backward compatibility + const data = + typeof backgroundData === 'string' + ? { url: backgroundData, name: 'Image', uploadDate: Date.now(), folder: '' } + : { ...backgroundData, uploadDate: backgroundData.uploadDate || Date.now() }; + + const request = store.add(data); return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); @@ -75,10 +119,10 @@ export async function addBackground(url) { /** * Update a background at a specific index * @param {number} index - The index to update (0-based) - * @param {string} url - The new URL + * @param {Object|string} backgroundData - The new background data * @returns {Promise} */ -export async function updateBackground(index, url) { +export async function updateBackground(index, backgroundData) { const db = await openDB(); const transaction = db.transaction(STORE_NAME, 'readwrite'); const store = transaction.objectStore(STORE_NAME); @@ -91,7 +135,13 @@ export async function updateBackground(index, url) { const items = getAllRequest.result; if (items[index]) { const item = items[index]; - item.url = url; + + // Support old string format + if (typeof backgroundData === 'string') { + item.url = backgroundData; + } else { + Object.assign(item, backgroundData); + } item.updatedAt = Date.now(); const updateRequest = store.put(item); @@ -99,7 +149,7 @@ export async function updateBackground(index, url) { updateRequest.onerror = () => reject(updateRequest.error); } else { // If index doesn't exist, add it - addBackground(url).then(resolve).catch(reject); + addBackground(backgroundData).then(resolve).catch(reject); } }; getAllRequest.onerror = () => reject(getAllRequest.error); @@ -134,6 +184,48 @@ export async function deleteBackground(index) { }); } +/** + * Delete multiple backgrounds by indices + * @param {Array} indices - Array of indices to delete (0-based) + * @returns {Promise} + */ +export async function deleteMultipleBackgrounds(indices) { + const db = await openDB(); + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + // Get all items first + const getAllRequest = store.getAll(); + + return new Promise((resolve, reject) => { + getAllRequest.onsuccess = () => { + const items = getAllRequest.result; + const idsToDelete = indices.filter((index) => items[index]).map((index) => items[index].id); + + // Delete all selected items + let completed = 0; + const total = idsToDelete.length; + + if (total === 0) { + resolve(); + return; + } + + idsToDelete.forEach((id) => { + const deleteRequest = store.delete(id); + deleteRequest.onsuccess = () => { + completed++; + if (completed === total) { + resolve(); + } + }; + deleteRequest.onerror = () => reject(deleteRequest.error); + }); + }; + getAllRequest.onerror = () => reject(getAllRequest.error); + }); +} + /** * Clear all custom backgrounds * @returns {Promise} @@ -166,6 +258,35 @@ export async function getBackgroundCount() { }); } +/** + * Update a background's metadata by ID + * @param {number} id - The background ID + * @param {Object} metadata - Metadata to update + * @returns {Promise} + */ +export async function updateBackgroundMetadata(id, metadata) { + const db = await openDB(); + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(id); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + const item = request.result; + if (item) { + Object.assign(item, metadata); + item.updatedAt = Date.now(); + const updateRequest = store.put(item); + updateRequest.onsuccess = () => resolve(); + updateRequest.onerror = () => reject(updateRequest.error); + } else { + reject(new Error('Background not found')); + } + }; + request.onerror = () => reject(request.error); + }); +} + /** * Migrate backgrounds from localStorage to IndexedDB * @returns {Promise} True if migration occurred @@ -188,7 +309,7 @@ export async function migrateFromLocalStorage() { } // Filter out null/empty values - backgrounds = backgrounds.filter(bg => bg && bg.trim() !== ''); + backgrounds = backgrounds.filter((bg) => bg && bg.trim() !== ''); if (backgrounds.length === 0) { return false; diff --git a/src/utils/imageMetadata.js b/src/utils/imageMetadata.js new file mode 100644 index 00000000..c059554e --- /dev/null +++ b/src/utils/imageMetadata.js @@ -0,0 +1,154 @@ +import { encode } from 'blurhash'; + +/** + * Extract image dimensions from a data URL or File + * @param {string|File} source - Image source (data URL or File) + * @returns {Promise<{width: number, height: number}>} + */ +export async function getImageDimensions(source) { + return new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = () => { + resolve({ + width: img.width, + height: img.height, + }); + // Clean up object URL if created + if (typeof source !== 'string') { + URL.revokeObjectURL(img.src); + } + }; + + img.onerror = () => reject(new Error('Failed to load image')); + + if (typeof source === 'string') { + img.src = source; + } else { + img.src = URL.createObjectURL(source); + } + }); +} + +/** + * Generate blur hash from an image + * @param {string|File} source - Image source (data URL or File) + * @param {number} componentX - Number of horizontal components (default: 4) + * @param {number} componentY - Number of vertical components (default: 3) + * @returns {Promise} BlurHash string + */ +export async function generateBlurHash(source, componentX = 4, componentY = 3) { + return new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = () => { + try { + // Create canvas to get pixel data + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + // Use smaller dimensions for faster processing + const maxSize = 64; + const scale = Math.min(maxSize / img.width, maxSize / img.height); + canvas.width = Math.floor(img.width * scale); + canvas.height = Math.floor(img.height * scale); + + context.drawImage(img, 0, 0, canvas.width, canvas.height); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + + const blurHash = encode( + imageData.data, + imageData.width, + imageData.height, + componentX, + componentY, + ); + + // Clean up + if (typeof source !== 'string') { + URL.revokeObjectURL(img.src); + } + + resolve(blurHash); + } catch (error) { + reject(error); + } + }; + + img.onerror = () => reject(new Error('Failed to load image for blur hash')); + + if (typeof source === 'string') { + img.src = source; + } else { + img.src = URL.createObjectURL(source); + } + }); +} + +/** + * Calculate file size from data URL + * @param {string} dataUrl - Data URL string + * @returns {number} File size in bytes + */ +export function getDataUrlSize(dataUrl) { + // Remove data URL prefix to get just the base64 string + const base64String = dataUrl.split(',')[1]; + if (!base64String) return 0; + + // Base64 encoding adds ~33% overhead + // Actual size = (base64 length * 3) / 4 + const padding = (base64String.match(/=/g) || []).length; + return Math.floor((base64String.length * 3) / 4 - padding); +} + +/** + * Extract filename from File object or generate default name + * @param {File|null} file - File object + * @param {number} index - Index for default naming + * @returns {string} Filename + */ +export function getFileName(file, index) { + if (file && file.name) { + return file.name; + } + return `Image ${index + 1}`; +} + +/** + * Calculate total storage used by custom backgrounds only + * Note: This estimates IndexedDB storage from data URLs + * @returns {number} Storage size in bytes (estimate) + */ +export function calculateStorageSize() { + // This is now just an estimate - actual storage is in IndexedDB + // We'll calculate it properly from the actual background data + return 0; // Will be calculated from actual backgrounds in the component +} + +/** + * Calculate total localStorage usage (all settings) + * @returns {number} Total storage size in bytes + */ +export function calculateTotalStorageSize() { + const settings = {}; + Object.keys(localStorage).forEach((key) => { + settings[key] = localStorage.getItem(key); + }); + return new TextEncoder().encode(JSON.stringify(settings)).length; +} + +/** + * Format bytes to human-readable string + * @param {number} bytes - Size in bytes + * @returns {string} Formatted string (e.g., "2.3 MB") + */ +export function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + if (!bytes) return 'Unknown'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return Math.round((bytes / Math.pow(k, i)) * 10) / 10 + ' ' + sizes[i]; +}