diff --git a/components/WebsiteTile.tsx b/components/WebsiteTile.tsx index c90e63a..e1397c3 100644 --- a/components/WebsiteTile.tsx +++ b/components/WebsiteTile.tsx @@ -1,5 +1,6 @@ -import React, { memo, useState } from 'react'; +import React, { memo, useEffect, useState } from 'react'; import { Website } from '../types'; +import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService'; interface WebsiteTileProps { website: Website; @@ -52,6 +53,46 @@ const getIconLoadingPixelSize = (size: string | undefined): number => { const WebsiteTile: React.FC = ({ website, isEditing, onEdit, onMove, tileSize }) => { const [isLoading, setIsLoading] = useState(false); + const [iconSource, setIconSource] = useState(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) => { if (isEditing) { @@ -84,7 +125,14 @@ const WebsiteTile: React.FC = ({ website, isEditing, onEdit, o )}
- {`${website.name} + {iconSource && ( + {`${website.name} + )}
{website.name} diff --git a/components/utils/StorageLocalManager.ts b/components/utils/StorageLocalManager.ts index 1989725..fd73bb0 100644 --- a/components/utils/StorageLocalManager.ts +++ b/components/utils/StorageLocalManager.ts @@ -18,6 +18,11 @@ declare global { 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. @@ -32,6 +37,56 @@ export function checkChromeStorageLocalAvailable(): boolean { return isChromeStorageLocalAvailable; } +export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise { + if (!checkChromeStorageLocalAvailable()) return null; + + return new Promise((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 { + if (!checkChromeStorageLocalAvailable()) return false; + + return new Promise((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 { + if (!checkChromeStorageLocalAvailable()) return false; + + return new Promise((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. * If the URL is fetchable, it will be stored as base64 and the name will be derived from the URL. @@ -161,4 +216,4 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi reject(new Error('chrome.storage.local is not available')); } }); -} \ No newline at end of file +} diff --git a/components/utils/iconService.ts b/components/utils/iconService.ts index 8c1ec0c..39f0022 100644 --- a/components/utils/iconService.ts +++ b/components/utils/iconService.ts @@ -1,3 +1,42 @@ +import { + checkChromeStorageLocalAvailable, + getCachedIconFromChromeStorageLocal, + removeCachedIconFromChromeStorageLocal, + saveCachedIconToChromeStorageLocal, +} from './StorageLocalManager'; + +const MAX_CACHED_ICON_BYTES = 256 * 1024; +const resolvedIconCache = new Map(); +const iconCacheLookups = new Map>(); +const iconCacheRequests = new Map>(); + +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 => + new Promise((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 { try { @@ -28,4 +67,83 @@ async function getWebsiteIcon(url: string): Promise { return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`; } -export { getWebsiteIcon }; +async function getCachedWebsiteIcon(iconUrl: string): Promise { + 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 { + 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 { + resolvedIconCache.delete(iconUrl); + iconCacheLookups.delete(iconUrl); + await removeCachedIconFromChromeStorageLocal(iconUrl); +} + +export { + cacheWebsiteIcon, + getCachedWebsiteIcon, + getWebsiteIcon, + removeCachedWebsiteIcon, +}; diff --git a/project-context.md b/project-context.md index b443a23..ce85128 100644 --- a/project-context.md +++ b/project-context.md @@ -58,12 +58,13 @@ The startpage is composed of widgets and a configuration panel: - **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`). - 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. -- **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). - **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`. Performance notes: +- Website tile icons check a deterministic `vision-start:icon:` 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. - `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. @@ -118,9 +119,9 @@ vision-start/ │ │ │ └── utils/ │ ├── 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) -│ └── 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/ │ ├── favicon.ico @@ -155,6 +156,8 @@ vision-start/ ## 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`: - **`Website`** — `id`, `name`, `url`, `icon`, `categoryId` @@ -174,8 +177,9 @@ Storage layout (browser-side): | `userWallpapers` | `localStorage` | `Wallpaper[]` index (names) | | `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation | | `` | `chrome.storage.local` (when available) | base64 (or URL on CORS failure) image data | +| `vision-start:icon:` | `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`. Icon cache entries are rebuildable and are not included. --- @@ -191,7 +195,8 @@ Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaper - One `` per category (renders its ``s and, in edit mode, add/edit/move controls). - Optional `` if enabled. - Conditionally one of: ``, ``, ``. -5. Edit / configuration interactions update central `App` state; the existing `useEffect`s persist it. +5. Each `` 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. --- @@ -232,6 +237,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`: - **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. - **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. +- **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. - **`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`. @@ -253,6 +259,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`: | 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` | | 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`) | | 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` |