mirror of
https://github.com/mue/mue.git
synced 2026-07-15 13:03:55 +02:00
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:
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
65
src/features/background/options/FolderTaggingModal.jsx
Normal file
65
src/features/background/options/FolderTaggingModal.jsx
Normal 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;
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
154
src/utils/imageMetadata.js
Normal 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];
|
||||
}
|
||||
Reference in New Issue
Block a user