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

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