improving general usability and stability
This commit is contained in:
1 parent
643e7acf30
commit
b9159cf61f
17 files changed
+754
-218
No files matched your search
+15
-14
@@ -18,22 +18,23 @@ const Clock: React.FC<ClockProps> = ({ config }) => {
|
||||
useEffect(() => {
|
||||
if (!config.clock.enabled) return;
|
||||
|
||||
const scheduleNext = () => {
|
||||
const now = new Date();
|
||||
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setTime(new Date());
|
||||
intervalId = window.setInterval(() => setTime(new Date()), 60_000);
|
||||
}, msToNextMinute);
|
||||
};
|
||||
|
||||
let timeoutId = 0;
|
||||
let intervalId = 0;
|
||||
scheduleNext();
|
||||
const refresh = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = new Date();
|
||||
setTime(now);
|
||||
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||
timeoutId = window.setTimeout(refresh, msToNextMinute);
|
||||
};
|
||||
refresh();
|
||||
document.addEventListener('visibilitychange', refresh);
|
||||
window.addEventListener('focus', refresh);
|
||||
|
||||
return () => {
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
window.clearTimeout(timeoutId);
|
||||
document.removeEventListener('visibilitychange', refresh);
|
||||
window.removeEventListener('focus', refresh);
|
||||
};
|
||||
}, [config.clock.enabled]);
|
||||
|
||||
@@ -66,4 +67,4 @@ const Clock: React.FC<ClockProps> = ({ config }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Clock;
|
||||
export default Clock;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, lazy, Suspense } from 'react';
|
||||
import { Config, Wallpaper } from '../types';
|
||||
import { baseWallpapers } from './utils/baseWallpapers';
|
||||
import { checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
|
||||
@@ -6,7 +6,7 @@ import { ConfigurationService } from './services/ConfigurationService';
|
||||
import GeneralTab from './configuration/GeneralTab';
|
||||
import ThemeTab from './configuration/ThemeTab';
|
||||
import ClockTab from './configuration/ClockTab';
|
||||
import ServerWidgetTab from './configuration/ServerWidgetTab';
|
||||
const ServerWidgetTab = lazy(() => import('./configuration/ServerWidgetTab'));
|
||||
|
||||
interface ConfigurationModalProps {
|
||||
onClose: () => void;
|
||||
@@ -30,6 +30,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const isSaving = useRef(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const wallpaperMutation = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
|
||||
@@ -43,8 +45,10 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(closeTimer.current);
|
||||
if (!isSaving.current) {
|
||||
onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers });
|
||||
const availableNames = new Set([...baseWallpapers, ...ConfigurationService.loadUserWallpapers()].map(wallpaper => wallpaper.name));
|
||||
onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers.filter(name => availableNames.has(name)) });
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -55,39 +59,49 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleConfigChange = (updates: Partial<Config>) => {
|
||||
setConfig((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const handleAddWallpaperEntry = async (promise: Promise<Wallpaper>) => {
|
||||
const newWallpaper = await promise;
|
||||
const updated = [...userWallpapers, newWallpaper];
|
||||
setUserWallpapers(updated);
|
||||
const mutateWallpapers = (mutate: () => Promise<void>): Promise<void> => {
|
||||
const operation = wallpaperMutation.current.then(mutate);
|
||||
wallpaperMutation.current = operation.catch(() => {});
|
||||
return operation;
|
||||
};
|
||||
|
||||
const handleAddWallpaperEntry = (createWallpaper: () => Promise<Wallpaper>) => mutateWallpapers(async () => {
|
||||
const newWallpaper = await createWallpaper();
|
||||
const updated = [...ConfigurationService.loadUserWallpapers(), newWallpaper];
|
||||
if (updated.slice(0, -1).some(wallpaper => wallpaper.name === newWallpaper.name)) {
|
||||
throw new Error('A wallpaper with this name already exists. Please choose a different name.');
|
||||
}
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
setUserWallpapers(updated);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
const handleAddWallpaper = (name: string, url: string) =>
|
||||
handleAddWallpaperEntry(ConfigurationService.addWallpaper(name, url));
|
||||
handleAddWallpaperEntry(() => ConfigurationService.addWallpaper(name, url));
|
||||
|
||||
const handleAddWallpaperFile = (file: File) =>
|
||||
handleAddWallpaperEntry(ConfigurationService.addWallpaperFile(file));
|
||||
handleAddWallpaperEntry(() => ConfigurationService.addWallpaperFile(file));
|
||||
|
||||
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
||||
try {
|
||||
await ConfigurationService.deleteWallpaper(wallpaper);
|
||||
const updated = userWallpapers.filter((w) => w.name !== wallpaper.name);
|
||||
setUserWallpapers(updated);
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
const newCurrentWallpapers = config.currentWallpapers.filter((n) => n !== wallpaper.name);
|
||||
setConfig((prev) => ({ ...prev, currentWallpapers: newCurrentWallpapers }));
|
||||
onWallpaperChange({ currentWallpapers: newCurrentWallpapers });
|
||||
await mutateWallpapers(async () => {
|
||||
await ConfigurationService.deleteWallpaper(wallpaper);
|
||||
const updated = ConfigurationService.loadUserWallpapers().filter((w) => w.name !== wallpaper.name);
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
setUserWallpapers(updated);
|
||||
setConfig((prev) => ({ ...prev, currentWallpapers: prev.currentWallpapers.filter(name => name !== wallpaper.name) }));
|
||||
});
|
||||
} catch (error) {
|
||||
alert('Error deleting wallpaper. Please try again.');
|
||||
console.error(error);
|
||||
@@ -103,6 +117,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
setConfig(importedConfig);
|
||||
setUserWallpapers(importedWallpapers);
|
||||
onWallpaperChange({ currentWallpapers: importedConfig.currentWallpapers || [] });
|
||||
isSaving.current = true;
|
||||
onSave(importedConfig);
|
||||
alert('Configuration imported successfully. The page will reload to apply all data.');
|
||||
window.location.reload();
|
||||
@@ -176,7 +191,9 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
<ClockTab config={config} onChange={handleConfigChange} />
|
||||
)}
|
||||
{activeTab === 'serverWidget' && (
|
||||
<ServerWidgetTab config={config} onChange={handleConfigChange} />
|
||||
<Suspense fallback={null}>
|
||||
<ServerWidgetTab config={config} onChange={handleConfigChange} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+43
-27
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Server } from '../types';
|
||||
import ping from './utils/jsping.js';
|
||||
import ping from './utils/jsping';
|
||||
|
||||
interface ServerWidgetProps {
|
||||
config: {
|
||||
@@ -25,42 +25,58 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||
const serversRef = useRef(config.serverWidget.servers);
|
||||
serversRef.current = config.serverWidget.servers;
|
||||
|
||||
const serversSignature = config.serverWidget.servers
|
||||
.map(s => `${s.id}:${s.address}`)
|
||||
.join('|');
|
||||
const serverCount = config.serverWidget.servers.length;
|
||||
const serversSignature = JSON.stringify(config.serverWidget.servers.map(s => [s.id, s.address]));
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.serverWidget.enabled) return;
|
||||
if (!config.serverWidget.enabled || serversRef.current.length === 0) return;
|
||||
|
||||
const pingServers = () => {
|
||||
const pending: Record<string, string> = {};
|
||||
for (const s of serversRef.current) pending[s.id] = 'pending';
|
||||
setServerStatus(prev => ({ ...prev, ...pending }));
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let controller: AbortController | undefined;
|
||||
let cancelled = false;
|
||||
const frequency = Math.min(60, Math.max(5, config.serverWidget.pingFrequency || 15)) * 1000;
|
||||
setServerStatus(Object.fromEntries(serversRef.current.map(server => [server.id, 'pending'])));
|
||||
|
||||
serversRef.current.forEach((server) => {
|
||||
ping(server.address)
|
||||
.then(() => {
|
||||
setServerStatus(prev =>
|
||||
prev[server.id] === 'online' ? prev : { ...prev, [server.id]: 'online' }
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setServerStatus(prev =>
|
||||
prev[server.id] === 'offline' ? prev : { ...prev, [server.id]: 'offline' }
|
||||
);
|
||||
});
|
||||
});
|
||||
const pingServers = async () => {
|
||||
if (cancelled || document.visibilityState !== 'visible') return;
|
||||
const cycle = new AbortController();
|
||||
controller = cycle;
|
||||
const servers = serversRef.current;
|
||||
const statuses: Record<string, string> = {};
|
||||
let next = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(4, servers.length) }, async () => {
|
||||
while (next < servers.length && !cycle.signal.aborted) {
|
||||
const server = servers[next++];
|
||||
try {
|
||||
await ping(server.address, 1, cycle.signal);
|
||||
statuses[server.id] = 'online';
|
||||
} catch {
|
||||
statuses[server.id] = 'offline';
|
||||
}
|
||||
}
|
||||
}));
|
||||
if (cancelled || cycle.signal.aborted) return;
|
||||
setServerStatus(previous => servers.every(server => previous[server.id] === statuses[server.id])
|
||||
? previous : statuses);
|
||||
timer = setTimeout(() => void pingServers(), frequency);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
if (document.visibilityState === 'visible') void pingServers();
|
||||
};
|
||||
|
||||
pingServers();
|
||||
const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000);
|
||||
return () => clearInterval(interval);
|
||||
void pingServers();
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
}, [
|
||||
config.serverWidget.enabled,
|
||||
config.serverWidget.pingFrequency,
|
||||
serversSignature,
|
||||
serverCount,
|
||||
]);
|
||||
|
||||
if (!config.serverWidget.enabled) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { baseWallpapers } from './utils/baseWallpapers';
|
||||
import { Wallpaper as WallpaperType } from '../types';
|
||||
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||
import { getRandomWallpaperIndex, getWallpaperFrequencyMs, loadWallpaperState, saveWallpaperState } from './utils/wallpaperUtils';
|
||||
import { ConfigurationService } from './services/ConfigurationService';
|
||||
|
||||
interface WallpaperProps {
|
||||
wallpaperNames: string[];
|
||||
@@ -17,6 +18,7 @@ const MAX_WALLPAPER_URL_CACHE = 3;
|
||||
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||
|
||||
const rememberWallpaperUrl = (name: string, resolved: string | undefined): void => {
|
||||
if (resolved?.startsWith('data:')) return;
|
||||
wallpaperUrlCache.set(name, resolved);
|
||||
while (wallpaperUrlCache.size > MAX_WALLPAPER_URL_CACHE) {
|
||||
const oldest = wallpaperUrlCache.keys().next().value;
|
||||
@@ -35,8 +37,7 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
|
||||
resolved = foundInBase.url || foundInBase.base64;
|
||||
} else {
|
||||
try {
|
||||
const storedUserWallpapers: WallpaperType[] =
|
||||
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
||||
const storedUserWallpapers = ConfigurationService.loadUserWallpapers();
|
||||
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
||||
if (foundInUser) {
|
||||
resolved = foundInUser.url || foundInUser.base64;
|
||||
@@ -71,6 +72,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
const updateWallpaper = async () => {
|
||||
const request = ++revision;
|
||||
clearTimeout(timer);
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (wallpaperNames.length === 0) {
|
||||
setImageUrl(undefined);
|
||||
saveWallpaperState([], 0, loadWallpaperState([]).lastChange);
|
||||
@@ -109,6 +111,10 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') refresh();
|
||||
else {
|
||||
++revision;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === 'wallpaperState' || event.key === 'userWallpapers' || event.key === null) {
|
||||
@@ -124,6 +130,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
wallpaperUrlCache.clear();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
window.removeEventListener('focus', refresh);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
|
||||
@@ -14,20 +14,9 @@ interface WebsiteEditModalProps {
|
||||
interface IconMetadata {
|
||||
name: string;
|
||||
base: string;
|
||||
aliases: string[];
|
||||
categories: string[];
|
||||
update: {
|
||||
timestamp: string;
|
||||
author: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
colors: any;
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
let iconMetadataCache: IconMetadata[] | null = null;
|
||||
|
||||
const getIconPickUrl = (iconData: IconMetadata): string =>
|
||||
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
||||
|
||||
@@ -37,34 +26,45 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
const [icon, setIcon] = useState(website ? website.icon : '');
|
||||
const [iconQuery, setIconQuery] = useState('');
|
||||
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>(() => iconMetadataCache ?? []);
|
||||
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
|
||||
const metadataRequest = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
iconMetadataCache = null;
|
||||
metadataRequest.current?.abort();
|
||||
if (debounceRef.current !== null) window.clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ensureIconMetadata = () => {
|
||||
if (iconMetadataCache) {
|
||||
setIconMetadata(iconMetadataCache);
|
||||
return;
|
||||
}
|
||||
if (iconsFetched) return;
|
||||
setIconsFetched(true);
|
||||
fetch('/icon-metadata.json', { cache: 'force-cache' })
|
||||
.then(response => response.json())
|
||||
if (iconMetadata.length > 0 || metadataRequest.current) return;
|
||||
const controller = new AbortController();
|
||||
metadataRequest.current = controller;
|
||||
const timeout = window.setTimeout(() => controller.abort(), 10_000);
|
||||
fetch(new URL('icon-metadata.json', document.baseURI).href, { cache: 'force-cache', signal: controller.signal })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Icon metadata is unavailable');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
const iconsArray: IconMetadata[] = Object.entries(data).map(([name, details]) => ({
|
||||
name,
|
||||
...(details as object),
|
||||
})) as IconMetadata[];
|
||||
iconMetadataCache = iconsArray;
|
||||
if (controller.signal.aborted) return;
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid icon metadata');
|
||||
const iconsArray: IconMetadata[] = Object.entries(data).flatMap(([name, details]) => {
|
||||
if (!details || typeof details !== 'object' || !('base' in details) || typeof details.base !== 'string') return [];
|
||||
const colors = 'colors' in details && details.colors && typeof details.colors === 'object'
|
||||
? Object.values(details.colors).filter((color): color is string => typeof color === 'string' && color !== name) : [];
|
||||
return [{ name, base: details.base, colors }];
|
||||
});
|
||||
setIconMetadata(iconsArray);
|
||||
})
|
||||
.catch(err => console.error('Failed to load icon metadata', err));
|
||||
.catch(err => {
|
||||
if (!controller.signal.aborted) console.error('Failed to load icon metadata', err);
|
||||
})
|
||||
.finally(() => {
|
||||
window.clearTimeout(timeout);
|
||||
if (metadataRequest.current === controller) metadataRequest.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,9 +78,8 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
filtered.push(ic);
|
||||
if (filtered.length >= 50) break;
|
||||
}
|
||||
if (ic.colors && typeof ic.colors === 'object') {
|
||||
const colors = Object.values(ic.colors).filter(key => typeof key === 'string' && key !== ic.name);
|
||||
for (const color of colors as string[]) {
|
||||
if (ic.colors.length > 0) {
|
||||
for (const color of ic.colors) {
|
||||
if (color.toLowerCase().includes(lowerCaseQuery)) {
|
||||
filtered.push({ ...ic, name: color });
|
||||
if (filtered.length >= 50) break;
|
||||
@@ -161,6 +160,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
key={`${iconData.name}-${index}`}
|
||||
onClick={() => {
|
||||
setIcon(getIconPickUrl(iconData));
|
||||
setIconQuery('');
|
||||
setFilteredIcons([]);
|
||||
}}
|
||||
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
|
||||
@@ -184,4 +184,4 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsiteEditModal;
|
||||
export default WebsiteEditModal;
|
||||
@@ -18,6 +18,12 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
const [iconSource, setIconSource] = useState<string | null>(null);
|
||||
const [usingCachedIcon, setUsingCachedIcon] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const resetLoading = () => setIsLoading(false);
|
||||
window.addEventListener('pageshow', resetLoading);
|
||||
return () => window.removeEventListener('pageshow', resetLoading);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -50,7 +56,10 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
}, [website.icon]);
|
||||
|
||||
const handleIconError = () => {
|
||||
if (!usingCachedIcon) return;
|
||||
if (!usingCachedIcon) {
|
||||
setIconSource(null);
|
||||
return;
|
||||
}
|
||||
setIconSource(website.icon);
|
||||
setUsingCachedIcon(false);
|
||||
void removeCachedWebsiteIcon(website.icon);
|
||||
@@ -61,11 +70,12 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
if (!e.defaultPrevented && e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
const iconSizeClass = `w-[${getIconPixelSize(tileSize)}px] h-[${getIconPixelSize(tileSize)}px]`;
|
||||
const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
|
||||
const iconSize = isLoading ? getIconLoadingPixelSize(tileSize) : getIconPixelSize(tileSize);
|
||||
|
||||
return (
|
||||
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-200 ease-ios ${isEditing ? 'mb-4' : ''}`}>
|
||||
@@ -86,14 +96,17 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
</div>
|
||||
)}
|
||||
<div className={`relative z-10 flex items-center transition-all duration-200 ease-ios ${isLoading ? 'translate-y-5 gap-2' : 'flex-col gap-3'}`}>
|
||||
<div className={`transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)] ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
|
||||
{iconSource && (
|
||||
<div className="flex items-center justify-center transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)]" style={{ width: iconSize, height: iconSize }}>
|
||||
{iconSource ? (
|
||||
<img
|
||||
src={iconSource}
|
||||
alt={`${website.name} icon`}
|
||||
className="object-contain w-full h-full"
|
||||
decoding="async"
|
||||
onError={handleIconError}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-2xl font-semibold text-slate-300" aria-hidden="true">{website.name.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { Config, Server } from '../../types';
|
||||
import RangeSlider from './RangeSlider';
|
||||
import { TrashIcon } from '../icons';
|
||||
import { createId } from '../utils/idUtils';
|
||||
|
||||
interface ServerWidgetTabProps {
|
||||
config: Config;
|
||||
@@ -21,9 +22,9 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
||||
const handleAddServer = () => {
|
||||
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
|
||||
const newServer: Server = {
|
||||
id: Date.now().toString(),
|
||||
name: newServerName,
|
||||
address: newServerAddress,
|
||||
id: createId(),
|
||||
name: newServerName.trim(),
|
||||
address: newServerAddress.trim(),
|
||||
};
|
||||
updateServerWidget({ servers: [...config.serverWidget.servers, newServer] });
|
||||
setNewServerName('');
|
||||
@@ -36,7 +37,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = (result: any) => {
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const items = Array.from(config.serverWidget.servers);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
@@ -131,4 +132,4 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerWidgetTab;
|
||||
export default ServerWidgetTab;
|
||||
@@ -179,13 +179,14 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
/>
|
||||
</svg>
|
||||
<p className="mb-2 text-sm text-gray-400">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
<span className="font-semibold">Click to upload</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
|
||||
</div>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
@@ -216,4 +217,4 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeTab;
|
||||
export default ThemeTab;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Config, Wallpaper } from '../../types';
|
||||
import { Category, Config, Wallpaper } from '../../types';
|
||||
import { DEFAULT_CATEGORIES } from '../../constants';
|
||||
import { baseWallpapers } from '../utils/baseWallpapers';
|
||||
import {
|
||||
addWallpaperToChromeStorageLocal,
|
||||
checkChromeStorageLocalAvailable,
|
||||
@@ -53,21 +55,88 @@ const deepMerge = <T>(base: T, stored: unknown): T => {
|
||||
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||
const storedObj = isPlainObject(stored) ? stored : {};
|
||||
for (const key of Object.keys(result)) {
|
||||
if (Object.prototype.hasOwnProperty.call(storedObj, key)) {
|
||||
result[key] = deepMerge(result[key], storedObj[key]);
|
||||
}
|
||||
result[key] = deepMerge(result[key], Object.prototype.hasOwnProperty.call(storedObj, key) ? storedObj[key] : undefined);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
if (Array.isArray(base)) {
|
||||
return (Array.isArray(stored) ? stored : base) as T;
|
||||
return [...(Array.isArray(stored) ? stored : base)] as T;
|
||||
}
|
||||
if (stored === null) return base;
|
||||
return typeof stored === typeof base ? (stored as T) : base;
|
||||
};
|
||||
|
||||
export const normalizeConfig = (stored: unknown): Config =>
|
||||
deepMerge(DEFAULT_CONFIG, stored);
|
||||
const boundedNumber = (value: number, fallback: number, min: number, max: number): number =>
|
||||
Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
||||
|
||||
const choice = (value: string, values: string[], fallback: string): string =>
|
||||
values.includes(value) ? value : fallback;
|
||||
|
||||
const hasStrings = (value: unknown, keys: string[]): value is Record<string, string> =>
|
||||
isPlainObject(value) && keys.every(key => typeof value[key] === 'string');
|
||||
|
||||
export const normalizeConfig = (stored: unknown): Config => {
|
||||
const config = deepMerge(DEFAULT_CONFIG, stored);
|
||||
config.currentWallpapers = [...new Set(config.currentWallpapers.filter(name => typeof name === 'string' && name.length > 0))];
|
||||
config.wallpaperBlur = boundedNumber(config.wallpaperBlur, DEFAULT_CONFIG.wallpaperBlur, 0, 50);
|
||||
config.wallpaperBrightness = boundedNumber(config.wallpaperBrightness, DEFAULT_CONFIG.wallpaperBrightness, 0, 200);
|
||||
config.wallpaperOpacity = boundedNumber(config.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity, 1, 100);
|
||||
config.titleSize = choice(config.titleSize, ['tiny', 'small', 'medium', 'large'], DEFAULT_CONFIG.titleSize);
|
||||
config.tileSize = choice(config.tileSize || 'medium', ['small', 'medium', 'large'], 'medium');
|
||||
config.alignment = choice(config.alignment, ['top', 'middle', 'bottom'], DEFAULT_CONFIG.alignment);
|
||||
config.horizontalAlignment = choice(config.horizontalAlignment, ['left', 'middle', 'right'], DEFAULT_CONFIG.horizontalAlignment);
|
||||
config.clock.size = choice(config.clock.size, ['tiny', 'small', 'medium', 'large'], DEFAULT_CONFIG.clock.size);
|
||||
config.clock.format = choice(config.clock.format, ['HH:mm', 'h:mm A'], DEFAULT_CONFIG.clock.format);
|
||||
config.serverWidget.pingFrequency = boundedNumber(config.serverWidget.pingFrequency, DEFAULT_CONFIG.serverWidget.pingFrequency, 5, 60);
|
||||
const serverIds = new Set<string>();
|
||||
config.serverWidget.servers = config.serverWidget.servers.filter(server => {
|
||||
if (!hasStrings(server, ['id', 'name', 'address']) || !server.id || serverIds.has(server.id)) return false;
|
||||
serverIds.add(server.id);
|
||||
return true;
|
||||
}).map(server => ({ id: server.id, name: server.name, address: server.address }));
|
||||
return config;
|
||||
};
|
||||
|
||||
export const normalizeCategories = (stored: unknown): Category[] => {
|
||||
if (!Array.isArray(stored)) return normalizeCategories(DEFAULT_CATEGORIES);
|
||||
const categoryIds = new Set<string>();
|
||||
const websiteIds = new Set<string>();
|
||||
return stored.flatMap(category => {
|
||||
if (!hasStrings(category, ['id', 'name']) || !category.id || categoryIds.has(category.id)) return [];
|
||||
categoryIds.add(category.id);
|
||||
const websites = isPlainObject(category) && Array.isArray(category.websites) ? category.websites : [];
|
||||
return [{
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
websites: websites.flatMap(website => {
|
||||
if (!hasStrings(website, ['id', 'name', 'url']) || !website.id || websiteIds.has(website.id)) return [];
|
||||
websiteIds.add(website.id);
|
||||
return [{ id: website.id, name: website.name, url: website.url, icon: typeof website.icon === 'string' ? website.icon : '', categoryId: category.id }];
|
||||
}),
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
export const normalizeUserWallpapers = (stored: unknown): Wallpaper[] => {
|
||||
if (!Array.isArray(stored)) return [];
|
||||
const names = new Set(baseWallpapers.map(wallpaper => wallpaper.name));
|
||||
return stored.flatMap(wallpaper => {
|
||||
if (!hasStrings(wallpaper, ['name']) || !wallpaper.name || names.has(wallpaper.name)) return [];
|
||||
names.add(wallpaper.name);
|
||||
return [{
|
||||
name: wallpaper.name,
|
||||
...(typeof wallpaper.url === 'string' ? { url: wallpaper.url } : {}),
|
||||
...(typeof wallpaper.base64 === 'string' ? { base64: wallpaper.base64 } : {}),
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const assertWallpaperNameAvailable = (name: string): void => {
|
||||
const names = [...baseWallpapers, ...ConfigurationService.loadUserWallpapers()];
|
||||
if (name.startsWith('vision-start:icon:') || names.some(wallpaper => wallpaper.name === name)) {
|
||||
throw new Error('A wallpaper with this name already exists or the name is reserved. Please choose a different name.');
|
||||
}
|
||||
};
|
||||
|
||||
export const ConfigurationService = {
|
||||
loadConfig(): Config {
|
||||
@@ -80,19 +149,31 @@ export const ConfigurationService = {
|
||||
} catch (error) {
|
||||
console.error('Error parsing config from localStorage', error);
|
||||
}
|
||||
return { ...DEFAULT_CONFIG };
|
||||
return normalizeConfig(null);
|
||||
},
|
||||
|
||||
saveConfig(config: Config): void {
|
||||
localStorage.setItem('config', JSON.stringify(config));
|
||||
try {
|
||||
localStorage.setItem('config', JSON.stringify(config));
|
||||
} catch (error) {
|
||||
console.error('Error saving config to localStorage', error);
|
||||
}
|
||||
},
|
||||
|
||||
loadCategories(): Category[] {
|
||||
try {
|
||||
return normalizeCategories(safeParse(localStorage.getItem('categories')));
|
||||
} catch (error) {
|
||||
console.error('Error reading categories from localStorage', error);
|
||||
return normalizeCategories(null);
|
||||
}
|
||||
},
|
||||
|
||||
loadUserWallpapers(): Wallpaper[] {
|
||||
try {
|
||||
const stored = localStorage.getItem('userWallpapers');
|
||||
if (stored) return JSON.parse(stored);
|
||||
if (stored) return normalizeUserWallpapers(JSON.parse(stored));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return [];
|
||||
},
|
||||
@@ -113,10 +194,13 @@ export const ConfigurationService = {
|
||||
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||
}
|
||||
const finalName = name.trim() || getFileNameFromUrl(parsedUrl) || 'Wallpaper';
|
||||
assertWallpaperNameAvailable(finalName);
|
||||
return { name: finalName, url: parsedUrl.href };
|
||||
},
|
||||
|
||||
async addWallpaperFile(file: File): Promise<Wallpaper> {
|
||||
if (!file.type.startsWith('image/')) throw new Error('Please choose an image file.');
|
||||
assertWallpaperNameAvailable(file.name.trim());
|
||||
return new Promise((resolve, reject) => {
|
||||
if (file.size > 4 * 1024 * 1024) {
|
||||
reject(new Error('File size exceeds 4MB. Please choose a smaller file.'));
|
||||
@@ -130,6 +214,7 @@ export const ConfigurationService = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
assertWallpaperNameAvailable(file.name.trim());
|
||||
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
|
||||
resolve({ name: finalName });
|
||||
} catch (error) {
|
||||
@@ -172,6 +257,7 @@ export const ConfigurationService = {
|
||||
},
|
||||
|
||||
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
|
||||
if (file.size > 10 * 1024 * 1024) throw new Error('Configuration files must be no larger than 10MB.');
|
||||
const fileContent = await file.text();
|
||||
const parsed = JSON.parse(fileContent);
|
||||
const localStorageData =
|
||||
@@ -179,30 +265,47 @@ export const ConfigurationService = {
|
||||
? parsed.localStorage
|
||||
: parsed;
|
||||
|
||||
if (!localStorageData || typeof localStorageData !== 'object') {
|
||||
if (!isPlainObject(localStorageData)) {
|
||||
throw new Error('Invalid import file format.');
|
||||
}
|
||||
|
||||
let importedAny = false;
|
||||
REQUIRED_LOCAL_STORAGE_KEYS.forEach((key) => {
|
||||
const updates = new Map<RequiredLocalStorageKey, string>();
|
||||
for (const key of REQUIRED_LOCAL_STORAGE_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(localStorageData, key)) {
|
||||
const rawValue = (localStorageData as Record<string, unknown>)[key];
|
||||
localStorage.setItem(key, toStorageString(rawValue));
|
||||
importedAny = true;
|
||||
const raw = localStorageData[key];
|
||||
const value = typeof raw === 'string' ? safeParse(raw) : raw;
|
||||
if (value === null) continue;
|
||||
if ((key === 'config' || key === 'wallpaperState') && !isPlainObject(value)) {
|
||||
throw new Error(`Invalid ${key} in import file.`);
|
||||
}
|
||||
if ((key === 'categories' || key === 'userWallpapers') && !Array.isArray(value)) {
|
||||
throw new Error(`Invalid ${key} in import file.`);
|
||||
}
|
||||
const normalized = key === 'config' ? normalizeConfig(value)
|
||||
: key === 'categories' ? normalizeCategories(value)
|
||||
: key === 'userWallpapers' ? normalizeUserWallpapers(value) : value;
|
||||
updates.set(key, toStorageString(normalized));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!importedAny) {
|
||||
if (updates.size === 0) {
|
||||
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
||||
}
|
||||
|
||||
const importedConfig = (localStorageData as Record<string, unknown>).config;
|
||||
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
||||
.userWallpapers;
|
||||
const previous = new Map([...updates.keys()].map(key => [key, localStorage.getItem(key)]));
|
||||
try {
|
||||
for (const [key, value] of updates) localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
for (const key of updates.keys()) localStorage.removeItem(key);
|
||||
for (const [key, value] of previous) {
|
||||
if (value !== null) localStorage.setItem(key, value);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
config: normalizeConfig(importedConfig),
|
||||
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||
config: this.loadConfig(),
|
||||
userWallpapers: this.loadUserWallpapers(),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -142,7 +142,7 @@ export async function getWallpaperFromChromeStorageLocal(name: string): Promise<
|
||||
const result = await chromeLocalCall<StorageResult>((cb) =>
|
||||
window.chrome?.storage?.local?.get([name], cb),
|
||||
);
|
||||
return result[name] || null;
|
||||
return typeof result[name] === 'string' ? result[name] || null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,4 +158,4 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi
|
||||
await chromeLocalCall<void>((cb) =>
|
||||
window.chrome?.storage?.local?.remove(name, cb),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,25 @@ import {
|
||||
|
||||
const MAX_CACHED_ICON_BYTES = 256 * 1024;
|
||||
const MAX_RESOLVED_ICONS = 50;
|
||||
const MAX_RESOLVED_ICON_MEMORY = 2 * 1024 * 1024;
|
||||
const MAX_CONCURRENT_ICON_REQUESTS = 4;
|
||||
let resolvedIconBytes = 0;
|
||||
let activeIconRequests = 0;
|
||||
const iconRequestQueue: (() => void)[] = [];
|
||||
const resolvedIconCache = new Map<string, string>();
|
||||
const iconCacheLookups = new Map<string, Promise<string | null>>();
|
||||
const iconCacheRequests = new Map<string, Promise<string | null>>();
|
||||
|
||||
const rememberResolvedIcon = (iconUrl: string, dataUrl: string): void => {
|
||||
resolvedIconBytes -= (resolvedIconCache.get(iconUrl)?.length ?? 0) * 2;
|
||||
resolvedIconCache.delete(iconUrl);
|
||||
resolvedIconCache.set(iconUrl, dataUrl);
|
||||
if (resolvedIconCache.size > MAX_RESOLVED_ICONS) {
|
||||
resolvedIconBytes += dataUrl.length * 2;
|
||||
while (resolvedIconCache.size > MAX_RESOLVED_ICONS || resolvedIconBytes > MAX_RESOLVED_ICON_MEMORY) {
|
||||
const oldest = resolvedIconCache.keys().next().value;
|
||||
if (oldest !== undefined) resolvedIconCache.delete(oldest);
|
||||
if (oldest === undefined) break;
|
||||
resolvedIconBytes -= (resolvedIconCache.get(oldest)?.length ?? 0) * 2;
|
||||
resolvedIconCache.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,7 +41,33 @@ const isCacheableIconUrl = (value: string): boolean => {
|
||||
};
|
||||
|
||||
const isValidCachedIcon = (value: string | null): value is string =>
|
||||
typeof value === 'string' && isDataUrl(value);
|
||||
typeof value === 'string' && value.startsWith('data:image/') && value.length <= Math.ceil(MAX_CACHED_ICON_BYTES * 4 / 3) + 256;
|
||||
|
||||
const readIconBlob = async (response: Response): Promise<Blob | null> => {
|
||||
const contentType = (response.headers.get('content-type') || '').split(';', 1)[0].trim().toLowerCase();
|
||||
if (!contentType.startsWith('image/') || Number(response.headers.get('content-length')) > MAX_CACHED_ICON_BYTES || !response.body) {
|
||||
await response.body?.cancel();
|
||||
return null;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > MAX_CACHED_ICON_BYTES) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
chunks.push(value as Uint8Array<ArrayBuffer>);
|
||||
}
|
||||
return bytes > 0 ? new Blob(chunks, { type: contentType }) : null;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
|
||||
const blobToDataUrl = (blob: Blob): Promise<string> =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
@@ -134,6 +170,7 @@ async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
rememberResolvedIcon(iconUrl, cachedIcon);
|
||||
return cachedIcon;
|
||||
}
|
||||
if (cachedIcon) void removeCachedIconFromChromeStorageLocal(iconUrl);
|
||||
return null;
|
||||
})
|
||||
.catch(() => null)
|
||||
@@ -153,6 +190,7 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
|
||||
const existingRequest = iconCacheRequests.get(iconUrl);
|
||||
if (existingRequest) return existingRequest;
|
||||
if (iconCacheRequests.size >= MAX_RESOLVED_ICONS) return null;
|
||||
|
||||
const request = (async () => {
|
||||
const cachedIcon = await getCachedWebsiteIcon(iconUrl);
|
||||
@@ -161,18 +199,20 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
const fetchSource = getIconFetchSource(iconUrl);
|
||||
if (!fetchSource) return null;
|
||||
|
||||
if (activeIconRequests >= MAX_CONCURRENT_ICON_REQUESTS) {
|
||||
await new Promise<void>(resolve => iconRequestQueue.push(resolve));
|
||||
} else {
|
||||
activeIconRequests++;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
try {
|
||||
const response = await fetch(fetchSource);
|
||||
const response = await fetch(fetchSource, { signal: controller.signal, credentials: 'omit' });
|
||||
if (!response.ok || response.type === 'opaque') return null;
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentType = (blob.type || response.headers.get('content-type') || '')
|
||||
.split(';', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!contentType.startsWith('image/') || blob.size === 0 || blob.size > MAX_CACHED_ICON_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const blob = await readIconBlob(response);
|
||||
if (!blob) return null;
|
||||
|
||||
const dataUrl = await blobToDataUrl(blob);
|
||||
rememberResolvedIcon(iconUrl, dataUrl);
|
||||
@@ -180,6 +220,12 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
return dataUrl;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
const nextRequest = iconRequestQueue.shift();
|
||||
if (nextRequest) nextRequest();
|
||||
else activeIconRequests--;
|
||||
}
|
||||
})().finally(() => {
|
||||
iconCacheRequests.delete(iconUrl);
|
||||
@@ -190,6 +236,7 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
async function removeCachedWebsiteIcon(iconUrl: string): Promise<void> {
|
||||
resolvedIconBytes -= (resolvedIconCache.get(iconUrl)?.length ?? 0) * 2;
|
||||
resolvedIconCache.delete(iconUrl);
|
||||
iconCacheLookups.delete(iconUrl);
|
||||
await removeCachedIconFromChromeStorageLocal(iconUrl);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const createId = (): string =>
|
||||
globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
@@ -45,7 +45,11 @@ export const saveWallpaperState = (names: string[], currentIndex: number, lastCh
|
||||
currentName: names[currentIndex],
|
||||
lastWallpaperChange: new Date(lastChange).toISOString(),
|
||||
});
|
||||
if (localStorage.getItem('wallpaperState') !== payload) {
|
||||
localStorage.setItem('wallpaperState', payload);
|
||||
try {
|
||||
if (localStorage.getItem('wallpaperState') !== payload) {
|
||||
localStorage.setItem('wallpaperState', payload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving wallpaper state', error);
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user