Merge branch 'feat/icon-cache'
This commit is contained in:
@@ -63,7 +63,7 @@ npm run dev
|
|||||||
* [x] Multiple Wallpapers
|
* [x] Multiple Wallpapers
|
||||||
* [x] Remake icons
|
* [x] Remake icons
|
||||||
* [/] Increase offline compatibility (might not be possible)
|
* [/] Increase offline compatibility (might not be possible)
|
||||||
- [x] Use chrome.storage.local for user wallpapers -- this one is
|
- [x] Use chrome.storage.local for uploaded wallpaper files; remote wallpapers stay as URLs
|
||||||
- [ ] Use chrome.storage.local for some logos -- a bit hard
|
- [ ] Use chrome.storage.local for some logos -- a bit hard
|
||||||
- Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local
|
- Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local
|
||||||
* Dynamic Weather Widget
|
* Dynamic Weather Widget
|
||||||
|
|||||||
@@ -43,16 +43,14 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
|
|||||||
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
||||||
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
||||||
if (foundInUser) {
|
if (foundInUser) {
|
||||||
try {
|
resolved = foundInUser.url || foundInUser.base64;
|
||||||
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
|
if (!resolved) {
|
||||||
if (wallpaperData && wallpaperData.startsWith('http')) {
|
try {
|
||||||
resolved = wallpaperData;
|
resolved = (await getWallpaperFromChromeStorageLocal(name)) || undefined;
|
||||||
} else {
|
} catch (error) {
|
||||||
resolved = wallpaperData || undefined;
|
console.error('Error getting wallpaper from chrome storage', error);
|
||||||
|
resolved = undefined;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting wallpaper from chrome storage', error);
|
|
||||||
resolved = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { memo, useState } from 'react';
|
import React, { memo, useEffect, useState } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
|
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
|
||||||
|
|
||||||
interface WebsiteTileProps {
|
interface WebsiteTileProps {
|
||||||
website: Website;
|
website: Website;
|
||||||
@@ -52,6 +53,46 @@ const getIconLoadingPixelSize = (size: string | undefined): number => {
|
|||||||
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
|
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [iconSource, setIconSource] = useState<string | null>(null);
|
||||||
|
const [usingCachedIcon, setUsingCachedIcon] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
setIconSource(null);
|
||||||
|
setUsingCachedIcon(false);
|
||||||
|
|
||||||
|
const loadIcon = async () => {
|
||||||
|
const cachedIcon = await getCachedWebsiteIcon(website.icon);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (cachedIcon) {
|
||||||
|
setIconSource(cachedIcon);
|
||||||
|
setUsingCachedIcon(cachedIcon !== website.icon);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIconSource(website.icon);
|
||||||
|
const newlyCachedIcon = await cacheWebsiteIcon(website.icon);
|
||||||
|
if (!cancelled && newlyCachedIcon) {
|
||||||
|
setIconSource(newlyCachedIcon);
|
||||||
|
setUsingCachedIcon(newlyCachedIcon !== website.icon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadIcon();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [website.icon]);
|
||||||
|
|
||||||
|
const handleIconError = () => {
|
||||||
|
if (!usingCachedIcon) return;
|
||||||
|
setIconSource(website.icon);
|
||||||
|
setUsingCachedIcon(false);
|
||||||
|
void removeCachedWebsiteIcon(website.icon);
|
||||||
|
};
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -84,7 +125,14 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
|||||||
)}
|
)}
|
||||||
<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={`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}`}>
|
<div className={`transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)] ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
|
||||||
<img src={website.icon} alt={`${website.name} icon`} className="object-contain w-full h-full" />
|
{iconSource && (
|
||||||
|
<img
|
||||||
|
src={iconSource}
|
||||||
|
alt={`${website.name} icon`}
|
||||||
|
className="object-contain w-full h-full"
|
||||||
|
onError={handleIconError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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' : ''}`}>
|
<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' : ''}`}>
|
||||||
{website.name}
|
{website.name}
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
setNewWallpaperName('');
|
setNewWallpaperName('');
|
||||||
setNewWallpaperUrl('');
|
setNewWallpaperUrl('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Error adding wallpaper. Please check the URL and try again.');
|
alert(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Error adding wallpaper. Please check the URL and try again.',
|
||||||
|
);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -160,105 +164,103 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
|
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{chromeStorageAvailable && (
|
<div>
|
||||||
<>
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
||||||
<div>
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
{userWallpapers.map((wallpaper) => (
|
||||||
<div className="flex flex-col gap-2">
|
<div
|
||||||
{userWallpapers.map((wallpaper) => (
|
key={wallpaper.name}
|
||||||
<div
|
className="liquid-surface flex items-center justify-between rounded-xl p-2.5"
|
||||||
key={wallpaper.name}
|
>
|
||||||
className="liquid-surface flex items-center justify-between rounded-xl p-2.5"
|
<span className="truncate">{wallpaper.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteWallpaper(wallpaper)}
|
||||||
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
|
aria-label={`Delete ${wallpaper.name}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
className="bi bi-trash"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
>
|
>
|
||||||
<span className="truncate">{wallpaper.name}</span>
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
<button
|
<path
|
||||||
onClick={() => onDeleteWallpaper(wallpaper)}
|
fillRule="evenodd"
|
||||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
|
||||||
aria-label={`Delete ${wallpaper.name}`}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
fill="currentColor"
|
|
||||||
className="bi bi-trash"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
>
|
|
||||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Wallpaper Name (optional for URLs)"
|
|
||||||
value={newWallpaperName}
|
|
||||||
onChange={(e) => setNewWallpaperName(e.target.value)}
|
|
||||||
className="liquid-input p-2.5"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Image URL"
|
|
||||||
value={newWallpaperUrl}
|
|
||||||
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
|
||||||
className="liquid-input p-2.5"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleAddWallpaper}
|
|
||||||
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center w-full">
|
|
||||||
<label
|
|
||||||
htmlFor="file-upload"
|
|
||||||
className="liquid-surface liquid-ghost-tile flex flex-col items-center justify-center w-full h-32 cursor-pointer transition-all duration-200 ease-ios"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
|
||||||
<svg
|
|
||||||
className="w-8 h-8 mb-4 text-gray-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 20 16"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="mb-2 text-sm text-gray-400">
|
|
||||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="file-upload"
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
ref={fileInputRef}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Wallpaper Name (optional for URLs)"
|
||||||
|
value={newWallpaperName}
|
||||||
|
onChange={(e) => setNewWallpaperName(e.target.value)}
|
||||||
|
className="liquid-input p-2.5"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Image URL"
|
||||||
|
value={newWallpaperUrl}
|
||||||
|
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
||||||
|
className="liquid-input p-2.5"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddWallpaper}
|
||||||
|
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
{chromeStorageAvailable && (
|
||||||
)}
|
<div className="flex items-center justify-center w-full">
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className="liquid-surface liquid-ghost-tile flex flex-col items-center justify-center w-full h-32 cursor-pointer transition-all duration-200 ease-ios"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 mb-4 text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 20 16"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="mb-2 text-sm text-gray-400">
|
||||||
|
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
ref={fileInputRef}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex justify-center pt-2">
|
<div className="flex justify-center pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={onNextWallpaper}
|
onClick={onNextWallpaper}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Config, Wallpaper } from '../../types';
|
import { Config, Wallpaper } from '../../types';
|
||||||
import {
|
import {
|
||||||
addWallpaperToChromeStorageLocal,
|
addWallpaperToChromeStorageLocal,
|
||||||
|
checkChromeStorageLocalAvailable,
|
||||||
removeWallpaperFromChromeStorageLocal,
|
removeWallpaperFromChromeStorageLocal,
|
||||||
} from '../utils/StorageLocalManager';
|
} from '../utils/StorageLocalManager';
|
||||||
|
|
||||||
@@ -43,6 +44,16 @@ const safeParse = (value: string | null): unknown => {
|
|||||||
const toStorageString = (value: unknown): string =>
|
const toStorageString = (value: unknown): string =>
|
||||||
typeof value === 'string' ? value : JSON.stringify(value);
|
typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|
||||||
|
const getWallpaperNameFromUrl = (url: URL): string => {
|
||||||
|
const pathName = url.pathname.split('/').filter(Boolean).pop();
|
||||||
|
if (!pathName) return url.hostname;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(pathName);
|
||||||
|
} catch {
|
||||||
|
return pathName;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
@@ -100,8 +111,18 @@ export const ConfigurationService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async addWallpaper(name: string, url: string): Promise<Wallpaper> {
|
async addWallpaper(name: string, url: string): Promise<Wallpaper> {
|
||||||
const finalName = await addWallpaperToChromeStorageLocal(name, url);
|
const trimmedUrl = url.trim();
|
||||||
return { name: finalName };
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(trimmedUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Please enter a valid wallpaper URL.');
|
||||||
|
}
|
||||||
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||||
|
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||||
|
}
|
||||||
|
const finalName = name.trim() || getWallpaperNameFromUrl(parsedUrl) || 'Wallpaper';
|
||||||
|
return { name: finalName, url: parsedUrl.href };
|
||||||
},
|
},
|
||||||
|
|
||||||
async addWallpaperFile(file: File): Promise<Wallpaper> {
|
async addWallpaperFile(file: File): Promise<Wallpaper> {
|
||||||
@@ -130,6 +151,7 @@ export const ConfigurationService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
|
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
|
||||||
|
if (wallpaper.url || wallpaper.base64 || !checkChromeStorageLocalAvailable()) return;
|
||||||
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ declare global {
|
|||||||
|
|
||||||
let isChromeStorageLocalAvailable: boolean | null = null;
|
let isChromeStorageLocalAvailable: boolean | null = null;
|
||||||
|
|
||||||
|
const ICON_CACHE_KEY_PREFIX = 'vision-start:icon:';
|
||||||
|
|
||||||
|
const getIconCacheKey = (sourceUrl: string): string =>
|
||||||
|
`${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if chrome.storage.local is available and caches the result.
|
* Checks if chrome.storage.local is available and caches the result.
|
||||||
@@ -32,85 +37,98 @@ export function checkChromeStorageLocalAvailable(): boolean {
|
|||||||
return isChromeStorageLocalAvailable;
|
return isChromeStorageLocalAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
|
||||||
|
if (!checkChromeStorageLocalAvailable()) return null;
|
||||||
|
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
if (!window.chrome?.storage?.local) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getIconCacheKey(sourceUrl);
|
||||||
|
window.chrome.storage.local.get([key], function (result: { [key: string]: string }) {
|
||||||
|
if (window.chrome?.runtime?.lastError) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(result[key] || null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
|
||||||
|
if (!checkChromeStorageLocalAvailable()) return false;
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
if (!window.chrome?.storage?.local) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.chrome.storage.local.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, function () {
|
||||||
|
resolve(!window.chrome?.runtime?.lastError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
|
||||||
|
if (!checkChromeStorageLocalAvailable()) return false;
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
if (!window.chrome?.storage?.local) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.chrome.storage.local.remove(getIconCacheKey(sourceUrl), function () {
|
||||||
|
resolve(!window.chrome?.runtime?.lastError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new wallpaper to chrome.storage.local.
|
* Adds a new wallpaper to chrome.storage.local.
|
||||||
* If the URL is fetchable, it will be stored as base64 and the name will be derived from the URL.
|
* File uploads are stored as base64 while remote wallpapers remain URLs.
|
||||||
* If the URL is not fetchable (e.g., CORS), it will be stored as a URL and the provided name will be used.
|
|
||||||
* @param name Wallpaper name (string), used as a fallback.
|
* @param name Wallpaper name (string), used as a fallback.
|
||||||
* @param url Wallpaper image URL (string) or base64 data URL.
|
* @param url Wallpaper image URL (string) or base64 data URL.
|
||||||
* @returns Promise<string> The name under which the wallpaper was stored.
|
* @returns Promise<string> The name under which the wallpaper was stored.
|
||||||
* @throws Error if chrome.storage.local is unavailable or if a name is not provided for a non-fetchable URL.
|
* @throws Error if chrome.storage.local is unavailable or the wallpaper data is invalid.
|
||||||
*/
|
*/
|
||||||
export async function addWallpaperToChromeStorageLocal(name: string, url: string): Promise<string> {
|
export async function addWallpaperToChromeStorageLocal(name: string, url: string): Promise<string> {
|
||||||
if (!checkChromeStorageLocalAvailable()) {
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
throw new Error('chrome.storage.local is not available');
|
throw new Error('chrome.storage.local is not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let finalName = name.trim();
|
||||||
if (url.startsWith('data:')) {
|
if (url.startsWith('data:')) {
|
||||||
// This is a base64 encoded image from a file upload.
|
if (!finalName) throw new Error('A name is required for an uploaded wallpaper.');
|
||||||
// The name is the file name.
|
} else {
|
||||||
return new Promise<void>((resolve, reject) => {
|
let parsedUrl: URL;
|
||||||
if (window.chrome?.storage?.local) {
|
try {
|
||||||
window.chrome.storage.local.set({ [name]: url }, function () {
|
parsedUrl = new URL(url);
|
||||||
if (window.chrome?.runtime?.lastError) {
|
} catch {
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
throw new Error('Please enter a valid wallpaper URL.');
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
|
||||||
}
|
|
||||||
}).then(() => name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a URL. Let's try to fetch it.
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch image');
|
|
||||||
const imageBlob = await response.blob();
|
|
||||||
const reader = new FileReader();
|
|
||||||
const base64 = await new Promise<string>((resolve, reject) => {
|
|
||||||
reader.onloadend = () => resolve(reader.result as string);
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(imageBlob);
|
|
||||||
});
|
|
||||||
|
|
||||||
// If successful, use the filename from URL as the name.
|
|
||||||
const finalName = url.substring(url.lastIndexOf('/') + 1).replace(/[?#].*$/, '') || name;
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
if (window.chrome?.storage?.local) {
|
|
||||||
window.chrome.storage.local.set({ [finalName]: base64 }, function () {
|
|
||||||
if (window.chrome?.runtime?.lastError) {
|
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
|
||||||
}
|
|
||||||
}).then(() => finalName);
|
|
||||||
} catch (error) {
|
|
||||||
// If fetch fails (e.g., CORS), store the URL directly with the user-provided name.
|
|
||||||
console.warn('Could not fetch wallpaper, storing URL instead. Error:', error);
|
|
||||||
if (!name) {
|
|
||||||
throw new Error("A name for the wallpaper is required when the URL can't be accessed.");
|
|
||||||
}
|
}
|
||||||
return new Promise<void>((resolve, reject) => {
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||||
if (window.chrome?.storage?.local) {
|
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||||
window.chrome.storage.local.set({ [name]: url }, function () {
|
}
|
||||||
if (window.chrome?.runtime?.lastError) {
|
finalName = finalName || parsedUrl.pathname.split('/').filter(Boolean).pop() || parsedUrl.hostname;
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
|
||||||
}
|
|
||||||
}).then(() => name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (!window.chrome?.storage?.local) {
|
||||||
|
reject(new Error('chrome.storage.local is not available'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.chrome.storage.local.set({ [finalName]: url }, function () {
|
||||||
|
if (window.chrome?.runtime?.lastError) {
|
||||||
|
reject(new Error(window.chrome.runtime.lastError.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).then(() => finalName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,4 +179,4 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi
|
|||||||
reject(new Error('chrome.storage.local is not available'));
|
reject(new Error('chrome.storage.local is not available'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,42 @@
|
|||||||
|
import {
|
||||||
|
checkChromeStorageLocalAvailable,
|
||||||
|
getCachedIconFromChromeStorageLocal,
|
||||||
|
removeCachedIconFromChromeStorageLocal,
|
||||||
|
saveCachedIconToChromeStorageLocal,
|
||||||
|
} from './StorageLocalManager';
|
||||||
|
|
||||||
|
const MAX_CACHED_ICON_BYTES = 256 * 1024;
|
||||||
|
const resolvedIconCache = new Map<string, string>();
|
||||||
|
const iconCacheLookups = new Map<string, Promise<string | null>>();
|
||||||
|
const iconCacheRequests = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
const isDataUrl = (value: string): boolean => value.startsWith('data:');
|
||||||
|
|
||||||
|
const isCacheableIconUrl = (value: string): boolean => {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidCachedIcon = (value: string | null): value is string =>
|
||||||
|
typeof value === 'string' && isDataUrl(value);
|
||||||
|
|
||||||
|
const blobToDataUrl = (blob: Blob): Promise<string> =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
resolve(reader.result);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Could not convert icon to a data URL'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error || new Error('Could not read icon data'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
async function getWebsiteIcon(url: string): Promise<string> {
|
async function getWebsiteIcon(url: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
@@ -28,4 +67,83 @@ async function getWebsiteIcon(url: string): Promise<string> {
|
|||||||
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
|
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getWebsiteIcon };
|
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||||
|
if (isDataUrl(iconUrl)) return iconUrl;
|
||||||
|
|
||||||
|
const inMemoryIcon = resolvedIconCache.get(iconUrl);
|
||||||
|
if (inMemoryIcon) return inMemoryIcon;
|
||||||
|
if (!isCacheableIconUrl(iconUrl) || !checkChromeStorageLocalAvailable()) return null;
|
||||||
|
|
||||||
|
const existingLookup = iconCacheLookups.get(iconUrl);
|
||||||
|
if (existingLookup) return existingLookup;
|
||||||
|
|
||||||
|
const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
|
||||||
|
.then((cachedIcon) => {
|
||||||
|
if (isValidCachedIcon(cachedIcon)) {
|
||||||
|
resolvedIconCache.set(iconUrl, cachedIcon);
|
||||||
|
return cachedIcon;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
.finally(() => {
|
||||||
|
iconCacheLookups.delete(iconUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
iconCacheLookups.set(iconUrl, lookup);
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||||
|
if (!isCacheableIconUrl(iconUrl) || !checkChromeStorageLocalAvailable()) return null;
|
||||||
|
|
||||||
|
const inMemoryIcon = resolvedIconCache.get(iconUrl);
|
||||||
|
if (inMemoryIcon) return inMemoryIcon;
|
||||||
|
|
||||||
|
const existingRequest = iconCacheRequests.get(iconUrl);
|
||||||
|
if (existingRequest) return existingRequest;
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
const cachedIcon = await getCachedWebsiteIcon(iconUrl);
|
||||||
|
if (cachedIcon) return cachedIcon;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(iconUrl, { mode: 'cors' });
|
||||||
|
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 dataUrl = await blobToDataUrl(blob);
|
||||||
|
resolvedIconCache.set(iconUrl, dataUrl);
|
||||||
|
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
|
||||||
|
return dataUrl;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
iconCacheRequests.delete(iconUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
iconCacheRequests.set(iconUrl, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCachedWebsiteIcon(iconUrl: string): Promise<void> {
|
||||||
|
resolvedIconCache.delete(iconUrl);
|
||||||
|
iconCacheLookups.delete(iconUrl);
|
||||||
|
await removeCachedIconFromChromeStorageLocal(iconUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
cacheWebsiteIcon,
|
||||||
|
getCachedWebsiteIcon,
|
||||||
|
getWebsiteIcon,
|
||||||
|
removeCachedWebsiteIcon,
|
||||||
|
};
|
||||||
|
|||||||
+18
-11
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
project_name: Vision Start
|
project_name: Vision Start
|
||||||
date: 2026-07-08
|
date: 2026-08-07
|
||||||
type: general_overview
|
type: general_overview
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -57,13 +57,14 @@ The startpage is composed of widgets and a configuration panel:
|
|||||||
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
|
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
|
||||||
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Supports rotating through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`).
|
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Supports rotating through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`).
|
||||||
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
||||||
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure.
|
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL. Remote URLs remain lightweight entries in the `userWallpapers` index; uploaded image data is stored in `chrome.storage.local` when available.
|
||||||
- **Icon library & auto-fetch** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service.
|
- **Icon library, auto-fetch & cache** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable.
|
||||||
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
|
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
|
||||||
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
|
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
|
||||||
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
|
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
|
||||||
|
|
||||||
Performance notes:
|
Performance notes:
|
||||||
|
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population uses ordinary CORS-enabled `fetch`, deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests.
|
||||||
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load.
|
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load.
|
||||||
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
|
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
|
||||||
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
||||||
@@ -118,9 +119,9 @@ vision-start/
|
|||||||
│ │
|
│ │
|
||||||
│ └── utils/
|
│ └── utils/
|
||||||
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
|
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
|
||||||
│ ├── iconService.ts # getWebsiteIcon: fetch HTML, parse apple-touch-icon/icon, fallback to Google favicons
|
│ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population
|
||||||
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
|
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
|
||||||
│ └── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper fetch/base64/URL storage
|
│ └── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper and icon cache storage
|
||||||
│
|
│
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── favicon.ico
|
│ ├── favicon.ico
|
||||||
@@ -155,6 +156,8 @@ vision-start/
|
|||||||
|
|
||||||
## 5. Data Model & State
|
## 5. Data Model & State
|
||||||
|
|
||||||
|
The icon service also resolves website icon URLs through the persistent CORS-safe cache used by `WebsiteTile`; this cache is separate from the `Website` data model.
|
||||||
|
|
||||||
The shape of all persisted data lives in `types.ts`:
|
The shape of all persisted data lives in `types.ts`:
|
||||||
|
|
||||||
- **`Website`** — `id`, `name`, `url`, `icon`, `categoryId`
|
- **`Website`** — `id`, `name`, `url`, `icon`, `categoryId`
|
||||||
@@ -171,11 +174,12 @@ Storage layout (browser-side):
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `config` | `localStorage` | The full `Config` JSON |
|
| `config` | `localStorage` | The full `Config` JSON |
|
||||||
| `categories` | `localStorage` | `Category[]` JSON |
|
| `categories` | `localStorage` | `Category[]` JSON |
|
||||||
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names) |
|
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) |
|
||||||
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
|
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
|
||||||
| `<wallpaperName>` | `chrome.storage.local` (when available) | base64 (or URL on CORS failure) image data |
|
| `<wallpaperName>` | `chrome.storage.local` (when available) | Base64 image data for uploaded wallpaper files and legacy URL wallpapers |
|
||||||
|
| `vision-start:icon:<encoded-source-url>` | `chrome.storage.local` (when available) | CORS-readable website icon data URL, capped at 256KiB |
|
||||||
|
|
||||||
Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`.
|
Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`. Remote wallpaper URLs are included through `userWallpapers`; uploaded image data and rebuildable icon cache entries are not included.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -191,7 +195,8 @@ Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaper
|
|||||||
- One `<CategoryGroup>` per category (renders its `<WebsiteTile>`s and, in edit mode, add/edit/move controls).
|
- One `<CategoryGroup>` per category (renders its `<WebsiteTile>`s and, in edit mode, add/edit/move controls).
|
||||||
- Optional `<ServerWidget>` if enabled.
|
- Optional `<ServerWidget>` if enabled.
|
||||||
- Conditionally one of: `<WebsiteEditModal>`, `<CategoryEditModal>`, `<ConfigurationModal>`.
|
- Conditionally one of: `<WebsiteEditModal>`, `<CategoryEditModal>`, `<ConfigurationModal>`.
|
||||||
5. Edit / configuration interactions update central `App` state; the existing `useEffect`s persist it.
|
5. Each `<WebsiteTile>` checks the icon cache before loading the external URL, then asynchronously populates the cache on a miss.
|
||||||
|
6. Edit / configuration interactions update central `App` state; the existing `useEffect`s persist it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -229,9 +234,10 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
|
|
||||||
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
|
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
|
||||||
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
|
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it.
|
||||||
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back; the frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back; the frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
||||||
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
||||||
|
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only CORS-readable `http`/`https` image responses no larger than 256KiB are cached.
|
||||||
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
||||||
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
||||||
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
||||||
@@ -253,10 +259,11 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` |
|
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` |
|
||||||
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
|
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
|
||||||
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
|
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
|
||||||
|
| Website icon cache | `components/utils/iconService.ts`, `components/WebsiteTile.tsx`, `components/utils/StorageLocalManager.ts` |
|
||||||
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
|
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
|
||||||
| Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` |
|
| Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` |
|
||||||
| Docker build | `Dockerfile` |
|
| Docker build | `Dockerfile` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Last updated: 2026-07-10. Generated as a general project overview; not a coding-style guide._
|
_Last updated: 2026-08-07. Generated as a general project overview; not a coding-style guide._
|
||||||
|
|||||||
Reference in New Issue
Block a user