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.
This commit is contained in:
alexsparkes
2026-01-26 16:14:09 +00:00
parent e42a218116
commit cac58cdaeb
12 changed files with 1631 additions and 212 deletions

View File

@@ -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 {

View File

@@ -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));

View File

@@ -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;

View File

@@ -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 (
<input
@@ -72,7 +80,7 @@ const FileUpload = memo(({ id, type, accept, loadFunction }) => {
type="file"
style={{ display: 'none' }}
accept={accept}
multiple={type !== 'settings'}
multiple={multiple !== undefined ? multiple : type !== 'settings'}
/>
);
});

View File

@@ -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);

View File

@@ -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 (
<div style={{ padding: '20px', textAlign: 'center' }}>
<span>{variables.getMessage('modals.main.loading')}</span>
<div className="loaderHolder">
<div id="loader"></div>
<span className="subtitle">{variables.getMessage('modals.main.loading')}</span>
</div>
);
}
if (isUploading) {
return (
<div className="loaderHolder">
<div id="loader"></div>
<span className="subtitle">
{variables.getMessage('modals.main.settings.sections.background.source.uploading', {
current: uploadProgress.current,
total: uploadProgress.total,
})}
</span>
<span className="subtitle">
{Math.round((uploadProgress.current / uploadProgress.total) * 100)}%
</span>
</div>
);
}
@@ -281,7 +517,7 @@ const CustomSettings = memo(() => {
}
>
<div className="imagesTopBar">
<div>
<div className="imagesTopBarTitle">
<MdAddPhotoAlternate />
<div>
<span className="title">
@@ -303,41 +539,228 @@ const CustomSettings = memo(() => {
icon={<MdOutlineFileUpload />}
label={variables.getMessage('modals.main.settings.sections.background.source.upload')}
/>
<Button
type="settings"
onClick={() => setCustomURLModal(true)}
icon={<MdAddLink />}
label={variables.getMessage(
'modals.main.settings.sections.background.source.add_url',
)}
</div>
</div>
<div className="imagesControlBar">
<div className="controlBarLeft">
<span className="image-count">
{customBackground.length} {customBackground.length === 1 ? 'image' : 'images'}
<span className="storage-info">
{' '}
· {formatBytes(storageUsed)} / {formatBytes(STORAGE_LIMIT)}
{storagePercent > 80 && navigator.storage && navigator.storage.persist && (
<button
className="request-storage-link"
onClick={async () => {
try {
const isPersisted = await navigator.storage.persist();
if (isPersisted) {
toast('Storage persistence granted');
} else {
toast('Storage persistence not available');
}
} catch (error) {
console.error('Storage request error:', error);
toast('Could not request storage');
}
}}
>
Request more
</button>
)}
</span>
</span>
<span className="selection-separator">·</span>
{selectedImages.size > 0 ? (
<>
<span className="selected-count">{selectedImages.size} selected</span>
<button className="delete-link" onClick={handleBatchDelete}>
Delete
</button>
{selectedImages.size < customBackground.length && (
<button
className="select-all-link"
onClick={() => {
const allIndices = new Set(customBackground.map((_, i) => i));
setSelectedImages(allIndices);
}}
>
Select all
</button>
)}
{selectedImages.size === customBackground.length && (
<button className="select-all-link" onClick={() => setSelectedImages(new Set())}>
Deselect all
</button>
)}
</>
) : (
customBackground.length > 0 && (
<button
className="select-all-link"
onClick={() => {
const allIndices = new Set(customBackground.map((_, i) => i));
setSelectedImages(allIndices);
}}
>
Select all
</button>
)
)}
</div>
<div className="controlBarRight">
<Dropdown
name="customImageSort"
category="customImageSort"
onChange={handleSort}
items={[
{
value: 'date_desc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.date_newest',
),
},
{
value: 'date_asc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.date_oldest',
),
},
{
value: 'name_asc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.name_asc',
),
},
{
value: 'name_desc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.name_desc',
),
},
{
value: 'size_asc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.size_small',
),
},
{
value: 'size_desc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.size_large',
),
},
]}
/>
</div>
</div>
<div className="dropzone-content">
{customBackground.length > 0 ? (
<div className="images-row">
{customBackground.map((url, index) => (
<div key={index}>
{url && !videoCheck(url) ? (
<img alt={'Custom background ' + (index || 0)} src={customBackground[index]} />
) : url && videoCheck(url) ? (
<MdPersonalVideo className="customvideoicon" />
) : null}
{customBackground.length > 0 && (
{sortedBackgrounds.length > 0 ? (
<div className={`images-grid ${selectedImages.size > 0 ? 'selection-mode' : ''}`}>
{sortedBackgrounds.map((bg, index) => {
const originalIndex = customBackground.findIndex((item) => item === bg);
const isVideo = bg && videoCheck(bg.url);
return (
<div key={originalIndex} className="image-card">
<div className="image-checkbox">
<input
type="checkbox"
checked={selectedImages.has(originalIndex)}
onChange={() => toggleImageSelection(originalIndex)}
/>
</div>
<div className="image-preview">
{bg.blurHash &&
!isVideo &&
(() => {
const blurHashDataUrl = generateBlurHashDataUrl(bg.blurHash, 32, 32);
return blurHashDataUrl ? (
<div
className="blur-placeholder"
style={{
backgroundImage: `url(${blurHashDataUrl})`,
filter: 'blur(20px)',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 0,
}}
/>
) : null;
})()}
{isVideo ? (
<div className="video-icon-wrapper">
<MdPersonalVideo className="customvideoicon" />
</div>
) : (
<img
alt={bg.name || 'Custom background'}
src={bg.url}
loading="lazy"
style={{ position: 'relative', zIndex: 1 }}
/>
)}
<div className="image-nav-buttons">
<button
className="nav-button nav-prev"
onClick={() => {
if (index > 0) {
const prevBg = sortedBackgrounds[index - 1];
EventBus.emit('refresh', 'background', prevBg.url);
}
}}
disabled={index === 0}
>
<MdChevronLeft />
</button>
<button
className="nav-button nav-next"
onClick={() => {
if (index < sortedBackgrounds.length - 1) {
const nextBg = sortedBackgrounds[index + 1];
EventBus.emit('refresh', 'background', nextBg.url);
}
}}
disabled={index === sortedBackgrounds.length - 1}
>
<MdChevronRight />
</button>
</div>
</div>
<div className="image-metadata">
<span className="image-name" title={bg.name}>
{bg.name || 'Unnamed'}
</span>
<div className="image-details">
{bg.dimensions && (
<span className="detail">
{bg.dimensions.width} × {bg.dimensions.height}
</span>
)}
{bg.fileSize && <span className="detail">{formatBytes(bg.fileSize)}</span>}
{bg.folder && <span className="detail folder-tag">{bg.folder}</span>}
</div>
</div>
<Tooltip
title={variables.getMessage(
'modals.main.settings.sections.background.source.remove',
)}
>
<Button
type="settings"
onClick={() => modifyCustomBackground('remove', index)}
icon={<MdCancel />}
/>
<button
className="delete-button"
onClick={() => modifyCustomBackground('remove', originalIndex)}
>
<MdCancel />
</button>
</Tooltip>
)}
</div>
))}
</div>
);
})}
</div>
) : (
<div className="photosEmpty">
@@ -366,14 +789,16 @@ const CustomSettings = memo(() => {
)}
</div>
</div>
<FileUpload
id="bg-input"
accept="image/jpeg, image/png, image/webp, image/webm, image/gif, video/mp4, video/webm, video/ogg"
loadFunction={(e, fileIndex) => {
const index = currentBackgroundIndex + fileIndex;
handleCustomBackground(e, index);
multiple
loadFunction={async (files) => {
await handleFileInputChange(files);
}}
/>
{hasVideo && (
<>
<Checkbox
@@ -390,6 +815,7 @@ const CustomSettings = memo(() => {
/>
</>
)}
<Modal
closeTimeoutMS={100}
onRequestClose={() => setCustomURLModal(false)}
@@ -404,6 +830,82 @@ const CustomSettings = memo(() => {
modalCloseOnly={() => setCustomURLModal(false)}
/>
</Modal>
<Modal
closeTimeoutMS={100}
onRequestClose={() => {
setFolderTaggingModal(false);
setPendingFiles([]);
}}
isOpen={folderTaggingModal}
className="Modal resetmodal mainModal"
overlayClassName="Overlay resetoverlay"
ariaHideApp={false}
>
<FolderTaggingModal
files={pendingFiles}
onConfirm={handleFolderTagging}
onCancel={() => {
setFolderTaggingModal(false);
setPendingFiles([]);
}}
/>
</Modal>
<Modal
closeTimeoutMS={100}
onRequestClose={() => setStorageQuotaModal(false)}
isOpen={storageQuotaModal}
className="Modal resetmodal mainModal"
overlayClassName="Overlay resetoverlay"
ariaHideApp={false}
>
<div className="smallModal">
<div className="shareHeader">
<span className="title">
{variables.getMessage('modals.main.settings.sections.background.source.storage_info')}
</span>
<button className="closeModal" onClick={() => setStorageQuotaModal(false)}>
<MdCancel />
</button>
</div>
<div style={{ padding: '20px' }}>
<p className="subtitle">
{variables.getMessage(
'modals.main.settings.sections.background.source.storage_description',
)}
</p>
<div style={{ marginTop: '20px' }}>
<p className="subtitle" style={{ fontWeight: '600', marginBottom: '8px' }}>
Custom Backgrounds
</p>
<p className="subtitle">
{formatBytes(storageUsed)} / {formatBytes(STORAGE_LIMIT)} (
{Math.round(storagePercent)}%)
</p>
</div>
<div style={{ marginTop: '15px' }}>
<p className="subtitle" style={{ fontWeight: '600', marginBottom: '8px' }}>
Total localStorage Usage
</p>
<p className="subtitle">
{formatBytes(totalStorageUsed)} / {formatBytes(TOTAL_STORAGE_LIMIT)} (
{Math.round((totalStorageUsed / TOTAL_STORAGE_LIMIT) * 100)}%)
</p>
<p className="subtitle" style={{ marginTop: '8px', fontSize: '12px', opacity: 0.7 }}>
Includes all Mue settings and custom images
</p>
</div>
</div>
<div className="resetFooter">
<Button
type="settings"
onClick={() => setStorageQuotaModal(false)}
label={variables.getMessage('modals.main.settings.buttons.close')}
/>
</div>
</div>
</Modal>
</>
);
});

View File

@@ -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 (
<div className="smallModal">
<div className="shareHeader">
<span className="title">
{variables.getMessage('modals.main.settings.sections.background.source.tag_images')}
</span>
<button className="closeModal" onClick={onCancel}>
<MdClose />
</button>
</div>
<div className="taggingModalContent">
<p className="subtitle">
{variables.getMessage('modals.main.settings.sections.background.source.tag_description', {
count: files.length,
})}
</p>
<div className="taggingInput">
<label>
{variables.getMessage('modals.main.settings.sections.background.source.folder_name')}
</label>
<input
type="text"
placeholder={variables.getMessage(
'modals.main.settings.sections.background.source.folder_placeholder',
)}
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleConfirm();
}
}}
autoFocus
/>
</div>
</div>
<div className="resetFooter">
<Button
type="settings"
onClick={onCancel}
label={variables.getMessage('modals.main.settings.buttons.cancel')}
/>
<Button
type="settings"
onClick={handleConfirm}
label={variables.getMessage('modals.main.settings.buttons.continue')}
/>
</div>
</div>
);
};
export default FolderTaggingModal;

View File

@@ -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": {

View File

@@ -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<string>>} Array of background URLs
* Get all custom backgrounds as objects
* @returns {Promise<Array<Object>>} 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<string>>} 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<number>} 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<void>}
*/
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<number>} indices - Array of indices to delete (0-based)
* @returns {Promise<void>}
*/
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<void>}
@@ -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<void>}
*/
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<boolean>} 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;

154
src/utils/imageMetadata.js Normal file
View File

@@ -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<string>} 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];
}