fixing small bugs; improving background handling and removing dead code
This commit is contained in:
1 parent
99dbea0615
commit
62e187b9f5
24 files changed
+426
-237
No files matched your search
@@ -41,9 +41,8 @@ The design language is **soft liquid glass**: lightly translucent matte surfaces
|
||||
- Keep `prefers-reduced-motion` behavior intact when adding animations.
|
||||
|
||||
### Tailwind specifics
|
||||
- Tailwind **v4** via `@tailwindcss/vite` and `@tailwindcss/postcss`. Custom easing is in `index.css` under `@theme {}` — add new theme tokens there, not in `tailwind.config.js`.
|
||||
- `tailwind.config.js` has a **safelist** of `w-[Npx]/h-[Npx]` classes because tile/icon sizes are constructed dynamically. If you add new pixel-size classes that are generated at runtime (not literally present in source), add them to the safelist or they will be purged.
|
||||
- Prefer static classes; only build dynamic class strings when unavoidable and then safelist them.
|
||||
- Tailwind **v4** via the `@tailwindcss/vite` plugin only (there is no `tailwind.config.js` or PostCSS config). Custom easing is in `index.css` under `@theme {}` — add new theme tokens there.
|
||||
- Tailwind generates only classes it finds literally in source files. Prefer static classes; for sizes computed at runtime use numeric inline styles (as tile icons do), or add an `@source inline(...)` line to `index.css` if a dynamic class is unavoidable.
|
||||
|
||||
### Components with established conventions to reuse
|
||||
- `Dropdown` (`components/Dropdown.tsx`) for selects — single or multi. Has a glassy look and custom arrow. **Always use it** instead of `<select>`/`<input type=...>` for option picking.
|
||||
|
||||
@@ -38,6 +38,15 @@ const App: React.FC = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === 'config' || event.key === null) setConfig(ConfigurationService.loadConfig());
|
||||
if (event.key === 'categories' || event.key === null) setCategories(ConfigurationService.loadCategories());
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
ConfigurationService.saveConfig(config);
|
||||
}, [config]);
|
||||
@@ -56,9 +65,19 @@ const App: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const handleWallpaperChange = useCallback((newConfig: Partial<Config>) => {
|
||||
setConfig(prev => ({ ...prev, ...newConfig }));
|
||||
setConfig(prev => {
|
||||
const next = newConfig.currentWallpapers;
|
||||
const unchanged = Object.keys(newConfig).length === 1 && next !== undefined
|
||||
&& next.length === prev.currentWallpapers.length
|
||||
&& next.every((name, index) => name === prev.currentWallpapers[index]);
|
||||
return unchanged ? prev : { ...prev, ...newConfig };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleEditing = useCallback(() => setIsEditing(editing => !editing), []);
|
||||
|
||||
const handleOpenConfig = useCallback(() => setIsConfigModalOpen(true), []);
|
||||
|
||||
const handleRandomWallpaper = useCallback(() => {
|
||||
const names = config.currentWallpapers;
|
||||
if (names.length === 0) return;
|
||||
@@ -171,8 +190,8 @@ const App: React.FC = () => {
|
||||
wallpaperFrequency={config.wallpaperFrequency}
|
||||
wallpaperVersion={wallpaperVersion}
|
||||
/>
|
||||
<EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} />
|
||||
<ConfigurationButton onClick={() => setIsConfigModalOpen(true)} />
|
||||
<EditButton isEditing={isEditing} onClick={handleToggleEditing} />
|
||||
<ConfigurationButton onClick={handleOpenConfig} />
|
||||
|
||||
<Header config={config} />
|
||||
|
||||
@@ -208,7 +227,7 @@ const App: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.serverWidget.enabled && <ServerWidget config={config} />}
|
||||
{config.serverWidget.enabled && <ServerWidget serverWidget={config.serverWidget} />}
|
||||
|
||||
{(editingWebsite || addingWebsite) && (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -12,8 +12,10 @@ interface ClockProps {
|
||||
};
|
||||
}
|
||||
|
||||
const getMinuteKey = (date: Date): number => Math.floor(date.getTime() / 60_000);
|
||||
|
||||
const Clock: React.FC<ClockProps> = ({ config }) => {
|
||||
const [time, setTime] = useState(new Date());
|
||||
const [minuteKey, setMinuteKey] = useState(() => getMinuteKey(new Date()));
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.clock.enabled) return;
|
||||
@@ -23,7 +25,7 @@ const Clock: React.FC<ClockProps> = ({ config }) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = new Date();
|
||||
setTime(now);
|
||||
setMinuteKey(getMinuteKey(now));
|
||||
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||
timeoutId = window.setTimeout(refresh, msToNextMinute);
|
||||
};
|
||||
@@ -62,7 +64,7 @@ const Clock: React.FC<ClockProps> = ({ config }) => {
|
||||
fontFamily: config.clock.font,
|
||||
}}
|
||||
>
|
||||
{formatTime(time)}
|
||||
{formatTime(new Date(minuteKey * 60_000))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleCloseRef.current();
|
||||
if (e.key === 'Escape' && !e.defaultPrevented) handleCloseRef.current();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
+11
-3
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useLayoutEffect } from 'react';
|
||||
|
||||
interface DropdownProps {
|
||||
options: { value: string; label: string }[];
|
||||
@@ -12,17 +12,25 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, []);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleOptionClick = (optionValue: string) => {
|
||||
let newValue: string | string[];
|
||||
|
||||
@@ -17,7 +17,7 @@ const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, o
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCloseRef.current();
|
||||
if (e.key === 'Escape' && !e.defaultPrevented) onCloseRef.current();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
+12
-14
@@ -1,15 +1,13 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { memo, useState, useEffect, useRef } from 'react';
|
||||
import { Server } from '../types';
|
||||
import ping from './utils/jsping';
|
||||
|
||||
interface ServerWidgetProps {
|
||||
config: {
|
||||
serverWidget: {
|
||||
enabled: boolean;
|
||||
pingFrequency: number;
|
||||
servers: Server[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
@@ -20,20 +18,20 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
const getStatusColor = (status: string): string =>
|
||||
STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400';
|
||||
|
||||
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||
const ServerWidget: React.FC<ServerWidgetProps> = ({ serverWidget }) => {
|
||||
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
||||
const serversRef = useRef(config.serverWidget.servers);
|
||||
serversRef.current = config.serverWidget.servers;
|
||||
const serversRef = useRef(serverWidget.servers);
|
||||
serversRef.current = serverWidget.servers;
|
||||
|
||||
const serversSignature = JSON.stringify(config.serverWidget.servers.map(s => [s.id, s.address]));
|
||||
const serversSignature = JSON.stringify(serverWidget.servers.map(s => [s.id, s.address]));
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.serverWidget.enabled || serversRef.current.length === 0) return;
|
||||
if (!serverWidget.enabled || serversRef.current.length === 0) return;
|
||||
|
||||
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;
|
||||
const frequency = Math.min(60, Math.max(5, serverWidget.pingFrequency || 15)) * 1000;
|
||||
setServerStatus(Object.fromEntries(serversRef.current.map(server => [server.id, 'pending'])));
|
||||
|
||||
const pingServers = async () => {
|
||||
@@ -74,19 +72,19 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
}, [
|
||||
config.serverWidget.enabled,
|
||||
config.serverWidget.pingFrequency,
|
||||
serverWidget.enabled,
|
||||
serverWidget.pingFrequency,
|
||||
serversSignature,
|
||||
]);
|
||||
|
||||
if (!config.serverWidget.enabled) {
|
||||
if (!serverWidget.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-3 left-1/2 z-20 w-auto max-w-[calc(100%-1.5rem)] -translate-x-1/2">
|
||||
<div className="liquid-surface flex items-center gap-4 rounded-full px-4 py-2">
|
||||
{config.serverWidget.servers.map((server) => (
|
||||
{serverWidget.servers.map((server) => (
|
||||
<div key={server.id} className="flex items-center gap-2">
|
||||
<div className={`liquid-status-dot w-2.5 h-2.5 rounded-full ${getStatusColor(serverStatus[server.id])}`}></div>
|
||||
<span className="text-slate-100 text-sm font-medium">
|
||||
@@ -99,4 +97,4 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerWidget;
|
||||
export default memo(ServerWidget);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { baseWallpapers } from './utils/baseWallpapers';
|
||||
import { Wallpaper as WallpaperType } from '../types';
|
||||
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||
@@ -27,9 +27,56 @@ const rememberWallpaperUrl = (name: string, resolved: string | undefined): void
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_WALLPAPER_OBJECT_URLS = 2;
|
||||
const wallpaperObjectUrls = new Map<string, string>();
|
||||
|
||||
const revokeWallpaperObjectUrls = (keepNames?: string[]): void => {
|
||||
for (const [name, url] of wallpaperObjectUrls) {
|
||||
if (keepNames?.includes(name)) continue;
|
||||
URL.revokeObjectURL(url);
|
||||
wallpaperObjectUrls.delete(name);
|
||||
}
|
||||
};
|
||||
|
||||
const rememberWallpaperObjectUrl = (name: string, url: string): string => {
|
||||
const existing = wallpaperObjectUrls.get(name);
|
||||
if (existing) {
|
||||
URL.revokeObjectURL(url);
|
||||
return existing;
|
||||
}
|
||||
wallpaperObjectUrls.set(name, url);
|
||||
while (wallpaperObjectUrls.size > MAX_WALLPAPER_OBJECT_URLS) {
|
||||
const [oldestName, oldestUrl] = wallpaperObjectUrls.entries().next().value ?? [];
|
||||
if (oldestName === undefined || oldestUrl === undefined) break;
|
||||
URL.revokeObjectURL(oldestUrl);
|
||||
wallpaperObjectUrls.delete(oldestName);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const dataUrlToObjectUrl = (dataUrl: string): string => {
|
||||
const header = dataUrl.match(/^data:([^;,]*)((?:;[^;,]*)*),/);
|
||||
if (!header || typeof URL.createObjectURL !== 'function') return dataUrl;
|
||||
try {
|
||||
const payload = dataUrl.slice(header[0].length);
|
||||
const type = header[1] || 'application/octet-stream';
|
||||
if (!/;base64/i.test(header[2])) {
|
||||
return URL.createObjectURL(new Blob([decodeURIComponent(payload)], { type }));
|
||||
}
|
||||
const binary = atob(payload);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return URL.createObjectURL(new Blob([bytes], { type }));
|
||||
} catch {
|
||||
return dataUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||
if (!name) return undefined;
|
||||
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
||||
const objectUrl = wallpaperObjectUrls.get(name);
|
||||
if (objectUrl) return objectUrl;
|
||||
|
||||
let resolved: string | undefined;
|
||||
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
|
||||
@@ -56,10 +103,22 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved?.startsWith('data:')) {
|
||||
const url = dataUrlToObjectUrl(resolved);
|
||||
return url.startsWith('blob:') ? rememberWallpaperObjectUrl(name, url) : url;
|
||||
}
|
||||
rememberWallpaperUrl(name, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const getWallpaperFilter = (blur: number, brightness: number): string => {
|
||||
const filters = [
|
||||
blur > 0 ? `blur(${blur}px)` : '',
|
||||
brightness !== 100 ? `brightness(${brightness / 100})` : '',
|
||||
].filter(Boolean);
|
||||
return filters.length > 0 ? filters.join(' ') : 'none';
|
||||
};
|
||||
|
||||
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
@@ -68,6 +127,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let revision = 0;
|
||||
wallpaperUrlCache.clear();
|
||||
revokeWallpaperObjectUrls(wallpaperNames);
|
||||
|
||||
const updateWallpaper = async () => {
|
||||
const request = ++revision;
|
||||
@@ -119,6 +179,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === 'wallpaperState' || event.key === 'userWallpapers' || event.key === null) {
|
||||
wallpaperUrlCache.clear();
|
||||
if (event.key !== 'wallpaperState') revokeWallpaperObjectUrls();
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
@@ -137,6 +198,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
};
|
||||
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
||||
|
||||
useEffect(() => () => revokeWallpaperObjectUrls(), []);
|
||||
|
||||
if (!imageUrl) return null;
|
||||
|
||||
return (
|
||||
@@ -145,7 +208,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
className="wallpaper-layer wallpaper-transition"
|
||||
style={{
|
||||
backgroundImage: `url(${JSON.stringify(imageUrl)})`,
|
||||
filter: `blur(${blur}px) brightness(${brightness / 100})`,
|
||||
filter: getWallpaperFilter(blur, brightness),
|
||||
transform: blur > 0 ? 'scale(1.015)' : 'none',
|
||||
opacity: opacity / 100,
|
||||
}}
|
||||
aria-label="Wallpaper background"
|
||||
@@ -155,4 +219,4 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
);
|
||||
};
|
||||
|
||||
export default Wallpaper;
|
||||
export default memo(Wallpaper);
|
||||
@@ -21,6 +21,8 @@ interface IconMetadata {
|
||||
const getIconPickUrl = (iconData: IconMetadata): string =>
|
||||
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
||||
|
||||
const isIconUrl = (value: string): boolean => /^(https?:|data:image\/)/i.test(value.trim());
|
||||
|
||||
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
|
||||
const [name, setName] = useState(website ? website.name : '');
|
||||
const [url, setUrl] = useState(website ? website.url : '');
|
||||
@@ -28,6 +30,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
const [iconQuery, setIconQuery] = useState('');
|
||||
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
|
||||
const [previewFailed, setPreviewFailed] = useState(false);
|
||||
const metadataRequest = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
@@ -105,13 +108,30 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
alert('Please enter a valid website URL (http, https, ftp, or mailto).');
|
||||
return;
|
||||
}
|
||||
onSave({ id: website?.id, name: name.trim(), url: normalizedUrl, icon });
|
||||
onSave({ id: website?.id, name: name.trim(), url: normalizedUrl, icon: resolveIcon(icon) });
|
||||
};
|
||||
|
||||
const resolveIcon = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isIconUrl(trimmed)) return trimmed;
|
||||
const query = trimmed.toLowerCase();
|
||||
for (const ic of iconMetadata) {
|
||||
if (ic.name.toLowerCase() === query) return getIconPickUrl(ic);
|
||||
const color = ic.colors.find(c => c.toLowerCase() === query);
|
||||
if (color) return getIconPickUrl({ ...ic, name: color });
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const updateIcon = (value: string) => {
|
||||
setIcon(value);
|
||||
setPreviewFailed(false);
|
||||
};
|
||||
|
||||
const fetchIcon = async () => {
|
||||
if (url) {
|
||||
const fetchedIcon = await getWebsiteIcon(url);
|
||||
setIcon(fetchedIcon);
|
||||
if (fetchedIcon) updateIcon(fetchedIcon);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,8 +145,8 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
onDelete={edit ? onDelete : undefined}
|
||||
>
|
||||
<div className="flex justify-center mb-4">
|
||||
{icon ? (
|
||||
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
||||
{isIconUrl(icon) && !previewFailed ? (
|
||||
<img src={icon.trim()} alt="Website Icon" className="h-24 w-24 object-contain" onError={() => setPreviewFailed(true)} />
|
||||
) : (
|
||||
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
|
||||
@@ -158,7 +178,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
placeholder="Icon URL or name"
|
||||
value={icon}
|
||||
onChange={(e) => {
|
||||
setIcon(e.target.value);
|
||||
updateIcon(e.target.value);
|
||||
setIconQuery(e.target.value);
|
||||
}}
|
||||
onFocus={ensureIconMetadata}
|
||||
@@ -170,7 +190,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
<div
|
||||
key={`${iconData.name}-${index}`}
|
||||
onClick={() => {
|
||||
setIcon(getIconPickUrl(iconData));
|
||||
updateIcon(getIconPickUrl(iconData));
|
||||
setIconQuery('');
|
||||
setFilteredIcons([]);
|
||||
}}
|
||||
@@ -179,6 +199,8 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
<img
|
||||
src={getIconPickUrl(iconData)}
|
||||
alt={iconData.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-6 w-6 mr-2"
|
||||
/>
|
||||
<span>{iconData.name}</span>
|
||||
|
||||
+23
-10
@@ -1,6 +1,6 @@
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import { Website } from '../types';
|
||||
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
|
||||
import { cacheWebsiteIcon, getCachedWebsiteIcon, getInitialIconSource, removeCachedWebsiteIcon } from './utils/iconService';
|
||||
import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
|
||||
|
||||
@@ -12,22 +12,33 @@ interface WebsiteTileProps {
|
||||
tileSize?: string;
|
||||
}
|
||||
|
||||
const LOADING_RESET_MS = 10_000;
|
||||
|
||||
const isNavigableUrl = (url: string): boolean => /^https?:/i.test(url);
|
||||
|
||||
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [iconSource, setIconSource] = useState<string | null>(null);
|
||||
const [iconSource, setIconSource] = useState<string | null | undefined>(() => getInitialIconSource(website.icon));
|
||||
const [usingCachedIcon, setUsingCachedIcon] = useState(false);
|
||||
const loadingTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const resetLoading = () => setIsLoading(false);
|
||||
const resetLoading = () => {
|
||||
clearTimeout(loadingTimer.current);
|
||||
setIsLoading(false);
|
||||
};
|
||||
window.addEventListener('pageshow', resetLoading);
|
||||
return () => window.removeEventListener('pageshow', resetLoading);
|
||||
return () => {
|
||||
clearTimeout(loadingTimer.current);
|
||||
window.removeEventListener('pageshow', resetLoading);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
setIconSource(null);
|
||||
setIconSource(getInitialIconSource(website.icon));
|
||||
setUsingCachedIcon(false);
|
||||
|
||||
const loadIcon = async () => {
|
||||
@@ -40,7 +51,7 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
return;
|
||||
}
|
||||
|
||||
setIconSource(website.icon);
|
||||
setIconSource(website.icon || null);
|
||||
const newlyCachedIcon = await cacheWebsiteIcon(website.icon);
|
||||
if (!cancelled && newlyCachedIcon) {
|
||||
setIconSource(newlyCachedIcon);
|
||||
@@ -70,8 +81,10 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!e.defaultPrevented && e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
|
||||
if (!e.defaultPrevented && e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && isNavigableUrl(website.url)) {
|
||||
setIsLoading(true);
|
||||
clearTimeout(loadingTimer.current);
|
||||
loadingTimer.current = setTimeout(() => setIsLoading(false), LOADING_RESET_MS);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,9 +118,9 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
decoding="async"
|
||||
onError={handleIconError}
|
||||
/>
|
||||
) : (
|
||||
) : iconSource === null ? (
|
||||
<span className="text-2xl font-semibold text-slate-300" aria-hidden="true">{website.name.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
) : null}
|
||||
</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' : ''}`}>
|
||||
{website.name}
|
||||
|
||||
@@ -184,7 +184,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
<p className="mb-2 text-sm text-gray-400">
|
||||
<span className="font-semibold">Click to upload</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
|
||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc. (up to 20MB)</p>
|
||||
</div>
|
||||
<input
|
||||
id="file-upload"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
|
||||
import { memo } from 'react';
|
||||
interface ConfigurationButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
@@ -21,4 +20,4 @@ const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) =>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigurationButton;
|
||||
export default memo(ConfigurationButton);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { PencilIcon } from '../icons';
|
||||
|
||||
interface EditButtonProps {
|
||||
@@ -20,4 +21,4 @@ const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default EditButton;
|
||||
export default memo(EditButton);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import Clock from '../Clock';
|
||||
import { Config } from '../../types';
|
||||
import { getTitleSizeClass } from '../utils/styleUtils';
|
||||
@@ -29,4 +30,4 @@ const Header: React.FC<HeaderProps> = ({ config }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default memo(Header);
|
||||
@@ -138,6 +138,50 @@ export const normalizeUserWallpapers = (stored: unknown): Wallpaper[] => {
|
||||
});
|
||||
};
|
||||
|
||||
const MAX_WALLPAPER_SOURCE_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_WALLPAPER_DATA_URL_LENGTH = 4.5 * 1024 * 1024;
|
||||
const MIN_WALLPAPER_EDGE = 1920;
|
||||
const MAX_WALLPAPER_EDGE = 2560;
|
||||
|
||||
const readFileAsDataUrl = (file: Blob): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') resolve(reader.result);
|
||||
else reject(new Error('Could not read the image file.'));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error('Could not read the image file.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const downscaleWallpaper = async (file: File): Promise<Blob> => {
|
||||
if (typeof createImageBitmap !== 'function' || typeof document === 'undefined' || file.type === 'image/gif' || file.type === 'image/svg+xml') {
|
||||
return file;
|
||||
}
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const screenEdge = Math.max(window.screen?.width ?? 0, window.screen?.height ?? 0) * (window.devicePixelRatio || 1);
|
||||
const maxEdge = Math.min(MAX_WALLPAPER_EDGE, Math.max(MIN_WALLPAPER_EDGE, screenEdge));
|
||||
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return file;
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/webp', 0.85));
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
return blob && blob.type === 'image/webp' && blob.size < file.size ? blob : file;
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
};
|
||||
|
||||
const assertWallpaperNameAvailable = (name: string): void => {
|
||||
const names = [...baseWallpapers, ...ConfigurationService.loadUserWallpapers()];
|
||||
if (name.startsWith('vision-start:icon:') || names.some(wallpaper => wallpaper.name === name)) {
|
||||
@@ -208,29 +252,16 @@ export const ConfigurationService = {
|
||||
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.'));
|
||||
return;
|
||||
if (file.size > MAX_WALLPAPER_SOURCE_BYTES) {
|
||||
throw new Error('File size exceeds 20MB. Please choose a smaller file.');
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const base64 = reader.result as string;
|
||||
if (base64.length > 4.5 * 1024 * 1024) {
|
||||
reject(new Error('The uploaded image is too large. Please choose a smaller file.'));
|
||||
return;
|
||||
const base64 = await readFileAsDataUrl(await downscaleWallpaper(file));
|
||||
if (base64.length > MAX_WALLPAPER_DATA_URL_LENGTH) {
|
||||
throw new Error('The uploaded image is too large. Please choose a smaller file.');
|
||||
}
|
||||
try {
|
||||
assertWallpaperNameAvailable(file.name.trim());
|
||||
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
|
||||
resolve({ name: finalName });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
return { name: finalName };
|
||||
},
|
||||
|
||||
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
|
||||
@@ -260,7 +291,7 @@ export const ConfigurationService = {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
},
|
||||
|
||||
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
|
||||
|
||||
@@ -8,6 +8,7 @@ declare global {
|
||||
local?: {
|
||||
set: (items: object, callback?: () => void) => void;
|
||||
get: (keys: string[] | string | null, callback: (items: { [key: string]: string }) => void) => void;
|
||||
getKeys?: (callback: (keys: string[]) => void) => void;
|
||||
remove: (keys: string | string[], callback?: () => void) => void;
|
||||
};
|
||||
};
|
||||
@@ -57,17 +58,36 @@ const chromeLocalCall = <T>(
|
||||
});
|
||||
});
|
||||
|
||||
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
|
||||
if (!checkChromeStorageLocalAvailable()) return null;
|
||||
let pendingIconLookups: Map<string, ((value: string | null) => void)[]> | null = null;
|
||||
|
||||
const flushIconLookups = async (): Promise<void> => {
|
||||
const lookups = pendingIconLookups;
|
||||
pendingIconLookups = null;
|
||||
if (!lookups) return;
|
||||
let result: StorageResult = {};
|
||||
try {
|
||||
const key = getIconCacheKey(sourceUrl);
|
||||
const result = await chromeLocalCall<StorageResult>((cb) =>
|
||||
window.chrome?.storage?.local?.get([key], cb),
|
||||
result = await chromeLocalCall<StorageResult>((cb) =>
|
||||
window.chrome?.storage?.local?.get([...lookups.keys()], cb),
|
||||
);
|
||||
return result[key] || null;
|
||||
} catch {
|
||||
return null;
|
||||
result = {};
|
||||
}
|
||||
for (const [key, resolvers] of lookups) {
|
||||
const value = typeof result[key] === 'string' ? result[key] || null : null;
|
||||
resolvers.forEach(resolve => resolve(value));
|
||||
}
|
||||
};
|
||||
|
||||
export function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
|
||||
if (!checkChromeStorageLocalAvailable()) return Promise.resolve(null);
|
||||
return new Promise(resolve => {
|
||||
if (!pendingIconLookups) {
|
||||
pendingIconLookups = new Map();
|
||||
queueMicrotask(() => void flushIconLookups());
|
||||
}
|
||||
const key = getIconCacheKey(sourceUrl);
|
||||
pendingIconLookups.set(key, [...(pendingIconLookups.get(key) ?? []), resolve]);
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
|
||||
@@ -98,10 +118,11 @@ export async function pruneCachedIconsFromChromeStorageLocal(keepSourceUrls: str
|
||||
if (!checkChromeStorageLocalAvailable()) return 0;
|
||||
try {
|
||||
const keep = new Set(keepSourceUrls.map(getIconCacheKey));
|
||||
const items = await chromeLocalCall<StorageResult>((cb) =>
|
||||
window.chrome?.storage?.local?.get(null, cb),
|
||||
);
|
||||
const stale = Object.keys(items).filter(key => key.startsWith(ICON_CACHE_KEY_PREFIX) && !keep.has(key));
|
||||
const local = window.chrome?.storage?.local;
|
||||
const keys = local?.getKeys
|
||||
? await chromeLocalCall<string[]>((cb) => local.getKeys?.(cb))
|
||||
: Object.keys(await chromeLocalCall<StorageResult>((cb) => local?.get(null, cb)));
|
||||
const stale = keys.filter(key => key.startsWith(ICON_CACHE_KEY_PREFIX) && !keep.has(key));
|
||||
if (stale.length === 0) return 0;
|
||||
await chromeLocalCall<void>((cb) =>
|
||||
window.chrome?.storage?.local?.remove(stale, cb),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
removeCachedIconFromChromeStorageLocal,
|
||||
saveCachedIconToChromeStorageLocal,
|
||||
} from './StorageLocalManager';
|
||||
import { normalizeWebsiteUrl } from './urlUtils';
|
||||
|
||||
const MAX_CACHED_ICON_BYTES = 256 * 1024;
|
||||
const MAX_RESOLVED_ICONS = 50;
|
||||
@@ -138,10 +139,10 @@ const getIconFetchSource = (iconUrl: string): string | null => {
|
||||
async function getWebsiteIcon(rawUrl: string): Promise<string> {
|
||||
const trimmed = rawUrl.trim();
|
||||
const hasProtocol = /^https?:\/\//i.test(trimmed);
|
||||
const targetUrl = hasProtocol ? trimmed : `https://${trimmed}`;
|
||||
|
||||
try {
|
||||
const parsed = new URL(targetUrl);
|
||||
const normalized = normalizeWebsiteUrl(trimmed);
|
||||
if (!normalized) return '';
|
||||
const parsed = new URL(normalized);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
|
||||
if (!isTrustedTld(parsed.hostname)) {
|
||||
if (!hasProtocol) {
|
||||
return `http://${parsed.host}/favicon.ico`;
|
||||
@@ -149,9 +150,14 @@ async function getWebsiteIcon(rawUrl: string): Promise<string> {
|
||||
return `${parsed.origin}/favicon.ico`;
|
||||
}
|
||||
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=128`;
|
||||
} catch {
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(rawUrl)}&sz=128`;
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialIconSource(iconUrl: string): string | null | undefined {
|
||||
if (!iconUrl) return null;
|
||||
if (isDataUrl(iconUrl)) return iconUrl;
|
||||
const inMemoryIcon = resolvedIconCache.get(iconUrl);
|
||||
if (inMemoryIcon) return inMemoryIcon;
|
||||
return isCacheableIconUrl(iconUrl) && checkChromeStorageLocalAvailable() ? undefined : iconUrl;
|
||||
}
|
||||
|
||||
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
@@ -245,6 +251,7 @@ async function removeCachedWebsiteIcon(iconUrl: string): Promise<void> {
|
||||
export {
|
||||
cacheWebsiteIcon,
|
||||
getCachedWebsiteIcon,
|
||||
getInitialIconSource,
|
||||
getWebsiteIcon,
|
||||
removeCachedWebsiteIcon,
|
||||
};
|
||||
@@ -55,7 +55,6 @@ body {
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transform: scale(1.015);
|
||||
}
|
||||
|
||||
.wallpaper-luminance {
|
||||
|
||||
Generated
+111
-102
@@ -9,40 +9,25 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"preact": "^10.26.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"playwright": "1.61.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -383,6 +368,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -399,6 +385,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -415,6 +402,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -431,6 +419,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -447,6 +436,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -463,6 +453,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -479,6 +470,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -495,6 +487,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -511,6 +504,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -527,6 +521,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -543,6 +538,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -559,6 +555,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -575,6 +572,7 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -591,6 +589,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -607,6 +606,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -623,6 +623,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -639,6 +640,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -655,6 +657,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -671,6 +674,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -687,6 +691,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -703,6 +708,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -719,6 +725,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -735,6 +742,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -751,6 +759,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -767,6 +776,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -783,6 +793,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -813,6 +824,7 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
@@ -825,6 +837,7 @@
|
||||
"version": "0.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
|
||||
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -846,6 +859,7 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -855,12 +869,14 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.29",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
|
||||
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -874,6 +890,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1008,6 +1025,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1021,6 +1039,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1034,6 +1053,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1047,6 +1067,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1060,6 +1081,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1073,6 +1095,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1086,6 +1109,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1102,6 +1126,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -1118,6 +1143,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1134,6 +1160,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -1150,6 +1177,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1166,6 +1194,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -1182,6 +1211,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1198,6 +1228,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -1214,6 +1245,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1230,6 +1262,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -1246,6 +1279,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1262,6 +1296,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1278,6 +1313,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -1294,6 +1330,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1307,6 +1344,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1320,6 +1358,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1333,6 +1372,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1346,6 +1386,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1359,6 +1400,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1369,6 +1411,7 @@
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
|
||||
"integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
@@ -1384,6 +1427,7 @@
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz",
|
||||
"integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1415,6 +1459,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1431,6 +1476,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1447,6 +1493,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1463,6 +1510,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1479,6 +1527,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1495,6 +1544,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1511,6 +1561,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1527,6 +1578,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1543,6 +1595,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1567,6 +1620,7 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1588,6 +1642,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1604,6 +1659,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1613,24 +1669,11 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz",
|
||||
"integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.1.11",
|
||||
"@tailwindcss/oxide": "4.1.11",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz",
|
||||
"integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.1.11",
|
||||
@@ -1645,13 +1688,14 @@
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.3.tgz",
|
||||
"integrity": "sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -1683,44 +1727,6 @@
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.24.4",
|
||||
"caniuse-lite": "^1.0.30001702",
|
||||
"fraction.js": "^4.3.7",
|
||||
"normalize-range": "^0.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-transform-hook-names": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
|
||||
@@ -1810,6 +1816,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -1890,6 +1897,7 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -1965,6 +1973,7 @@
|
||||
"version": "5.18.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
|
||||
"integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
@@ -1991,6 +2000,7 @@
|
||||
"version": "0.25.6",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
|
||||
"integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -2049,6 +2059,7 @@
|
||||
"version": "6.4.6",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
||||
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
@@ -2059,24 +2070,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "patreon",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -2101,6 +2099,7 @@
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/he": {
|
||||
@@ -2117,6 +2116,7 @@
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
@@ -2166,6 +2166,7 @@
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
@@ -2197,6 +2198,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2217,6 +2219,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2237,6 +2240,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2257,6 +2261,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2277,6 +2282,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2297,6 +2303,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2317,6 +2324,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2337,6 +2345,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2357,6 +2366,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2377,6 +2387,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2411,6 +2422,7 @@
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
@@ -2420,6 +2432,7 @@
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -2429,6 +2442,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
@@ -2448,6 +2462,7 @@
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2483,16 +2498,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-range": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
|
||||
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
@@ -2510,12 +2515,14 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -2575,6 +2582,7 @@
|
||||
"version": "8.5.29",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
||||
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2599,13 +2607,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.0",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz",
|
||||
@@ -2678,6 +2679,7 @@
|
||||
"version": "4.64.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.3.tgz",
|
||||
"integrity": "sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
@@ -2760,6 +2762,7 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -2779,12 +2782,14 @@
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
|
||||
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
|
||||
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -2794,6 +2799,7 @@
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
@@ -2816,6 +2822,7 @@
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.4.4",
|
||||
@@ -2846,7 +2853,7 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -2893,6 +2900,7 @@
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz",
|
||||
"integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
@@ -2985,6 +2993,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
+1
-4
@@ -13,18 +13,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"preact": "^10.26.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"playwright": "1.61.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.2.0"
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}, // ← use the new package name
|
||||
autoprefixer: {},
|
||||
}
|
||||
}
|
||||
+13
-10
@@ -28,7 +28,7 @@ Live instances / artifacts:
|
||||
| Language | TypeScript (~5.7), target ES2020, strict mode |
|
||||
| UI runtime | Preact 10 (via `@preact/preset-vite`); types from `@types/react` 19 |
|
||||
| Bundler / dev server | Vite 6 |
|
||||
| Styling | Tailwind CSS v4 (`@tailwindcss/vite` plugin + `@tailwindcss/postcss` + `autoprefixer`) |
|
||||
| Styling | Tailwind CSS v4 via the `@tailwindcss/vite` plugin only (no `tailwind.config.js` or PostCSS config) |
|
||||
| Drag & drop | `@hello-pangea/dnd` 18 |
|
||||
| Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle; modals code-split into separate JS chunks via `React.lazy`) |
|
||||
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
|
||||
@@ -51,24 +51,29 @@ 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. Probes use cancellable, credential-free HTTP HEAD requests with a 5s timeout. Online means the browser received a response; cross-origin HTTP status codes are opaque, so this is reachability rather than an HTTP health check. Network failures, timeouts, and browser-blocked requests appear offline.
|
||||
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Randomly rotates 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. Remote URLs remain lightweight entries in the `userWallpapers` index; uploaded image data is stored in `chrome.storage.local` when available.
|
||||
- User wallpapers: upload from a file (≤20MB source; non-GIF/SVG images are downscaled to a long edge between 1920px and 2560px depending on the screen and re-encoded as WebP when smaller; ≤4.5MB stored 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 & 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 for trusted public TLDs as a Google S2 favicon URL derived from the site's hostname (no HTML fetching — the S2 URL is used purely as an `<img>` source). Non-trusted TLDs (e.g. local TLDs, IP addresses) fetch `${origin}/favicon.ico` directly. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable. Cache population fetches trusted TLDs through a CORS-open favicon service (`icon.horse`) because Google's S2 endpoint 301-redirects without `Access-Control-Allow-Origin`, which would block cross-origin `fetch`.
|
||||
- **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. Add/edit modals disable Save until required fields are filled, and website URLs without a scheme get `https://` (only `http`, `https`, `ftp`, `mailto` are accepted). Deleting a category that has websites, or an uploaded wallpaper, asks for confirmation first. All modals close with Escape.
|
||||
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles. Add/edit modals disable Save until required fields are filled, and website URLs without a scheme get `https://` (only `http`, `https`, `ftp`, `mailto` are accepted). Deleting a category that has websites, or an uploaded wallpaper, asks for confirmation first. All modals close with Escape (an open `Dropdown` consumes Escape first). The navigation spinner only appears for `http(s)` links and resets after 10s.
|
||||
- **Cross-tab sync** — `App.tsx` listens for `storage` events on `config` and `categories` and reloads normalized state, so an older open tab never overwrites edits made in another tab.
|
||||
- **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:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population fetches Google S2 favicon URLs for trusted public TLDs through `https://icon.horse/icon/<host>` (a CORS-open favicon service), while non-trusted TLDs and direct icon URLs render directly in `<img>` tags without triggering background `fetch` calls. It deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests.
|
||||
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Lookups made in the same tick are batched into one `chrome.storage.local.get`, and tiles render an empty icon box (not the fallback initial) while the lookup is pending. Cache population fetches Google S2 favicon URLs for trusted public TLDs through `https://icon.horse/icon/<host>` (a CORS-open favicon service), while non-trusted TLDs and direct icon URLs render directly in `<img>` tags without triggering background `fetch` calls. It 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. `ServerWidgetTab` is independently lazy-loaded, so its `@hello-pangea/dnd` dependency is loaded only when the Server Widget settings tab is selected. `WebsiteEditModal` and `CategoryEditModal` share a common `ModalShell` component.
|
||||
- `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` schedules one timeout for the next minute boundary, pauses while hidden, and refreshes immediately on focus or visibility return.
|
||||
- `jsping` parses addresses with the shared `parseServerAddress` (also used to validate new servers in the settings tab), uses HTTP HEAD without downloading or decoding probe images, propagates failures, and cleans up its timeout and abort listener.
|
||||
- `ServerWidget` runs at most four probes concurrently, batches completed results into one state update, avoids overlapping polling cycles, cancels work when hidden or unmounted, and depends on a stable ids/addresses signature. Hidden tabs stop polling and refresh on visibility return; removed servers leave no retained status entries.
|
||||
- The icon picker only previews `http(s)`/`data:image` values (typed names never trigger relative-URL requests), lazy-loads result images, and resolves an exactly matching icon name to its Dashboard Icons URL on save. The Fetch button ignores non-`http(s)` website URLs.
|
||||
- Icon metadata (`/icon-metadata.json`) is fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront. Only names, file formats, and color variants are retained in component state; requests are aborted on close, failed loads can retry, and metadata URLs resolve relative to the page so subdirectory deployments work (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper.
|
||||
- `Wallpaper` resolves remote wallpaper URLs through a module-level `Map` bounded to the last 3 entries. Uploaded data URLs are excluded from that cache, and the cache clears on unmount; its image transition and readability overlay classes live in `index.css`. Wallpaper rotation and frequency parsing (`h`/`d` → hours/ms) share helpers with the Theme tab via `components/utils/wallpaperUtils.ts`.
|
||||
- The website icon service bounds its extra resolved data-URL cache to 50 entries and 2MiB of string storage (conservatively counted at two bytes per character). Cache downloads run at most four at a time, with at most 50 outstanding requests and an 8s timeout. Streaming reads stop at 256KiB before decoding; invalid or oversized persisted entries are discarded. Tile images still retain the data they display, separate from this extra cache.
|
||||
- Tile icon dimensions use numeric inline styles, independent of generated Tailwind classes. Failed icons show the website initial, modified link clicks avoid navigation spinners, and browser history restoration clears the loading state. Category mutations preserve unaffected object references and use stable functional state handlers. Shared IDs use UUIDs with a fallback for ordinary HTTP deployments.
|
||||
- Permanent `will-change` hints are removed from tiles and wallpaper so the browser chooses when to allocate compositing layers.
|
||||
- Permanent `will-change` hints are removed from tiles and wallpaper so the browser chooses when to allocate compositing layers. The wallpaper only applies `blur()`/`brightness()` filters and the edge-hiding `scale(1.015)` when they are not neutral.
|
||||
- Uploaded wallpaper data URLs are converted once into `blob:` object URLs (at most two kept, revoked on deselection, cross-tab `userWallpapers` changes, and unmount), so focus/visibility refreshes do not re-read multi-MB base64 from storage and inline styles stay small.
|
||||
- `Wallpaper`, `Header`, `ServerWidget`, `EditButton`, and `ConfigurationButton` are memoized; `Clock` stores a minute key so duplicate focus/visibility refreshes do not re-render; `Dropdown` attaches document listeners only while open.
|
||||
- Startup icon-cache pruning lists keys with `chrome.storage.local.getKeys()` (Chrome 130+) instead of loading every stored value, falling back to `get(null)` on older browsers.
|
||||
- Shared styling/frequency helpers live in `components/utils/styleUtils.ts` (tile/clock/title size classes, icon pixel sizes, alignment classes, size presets), `wallpaperUtils.ts`, and `urlUtils.ts` (URL→filename extraction, also used by wallpaper storage). Range sliders use the shared `RangeSlider` component; repeated glyphs (pencil, plus, trash, chevrons) use `components/icons.tsx`.
|
||||
|
||||
Planned / To-do (tracked in `README.md`):
|
||||
@@ -149,8 +154,6 @@ vision-start/
|
||||
├── nginx.conf # nginx gzip config (JSON/JS/CSS/SVG/XML), mounted into container
|
||||
├── .dockerignore
|
||||
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
|
||||
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
|
||||
├── tailwind.config.js # Content globs + legacy pixel-size safelist
|
||||
├── tsconfig.json # Strict TS, bundler resolution, `@/*` path alias to project root
|
||||
├── vite.config.ts # preact + tailwindcss plugins; single-bundle output; base './'
|
||||
├── package.json # Scripts: dev, build, preview, typecheck, test, capture:screenshots
|
||||
@@ -220,7 +223,7 @@ npm test # scripts/check_regressions.mjs; run after building; isolated
|
||||
npm audit # Audit the locked dependency tree
|
||||
```
|
||||
|
||||
The regression script covers malformed persisted data, website URL/clock font/wallpaper frequency normalization, icon cache pruning, partial/legacy imports and quota rollback, wallpaper validation, server probe failure/timeout/cancellation, icon request concurrency/stream limits/cache eviction, hidden-tab clock and polling behavior, modal request cancellation and retained JS heap after repeated picker cycles, lazy settings chunks, and loading an unpacked Manifest V3 extension in an isolated Chromium profile. It uses existing Vite/Playwright dependencies and does not modify the normal browser profile or shipped metadata.
|
||||
The regression script covers malformed persisted data, website URL/clock font/wallpaper frequency normalization, icon cache pruning (including the `getKeys` path), Fetch-icon URL derivation, batched icon lookups, cross-tab category sync, partial/legacy imports and quota rollback, wallpaper validation, server probe failure/timeout/cancellation, icon request concurrency/stream limits/cache eviction, hidden-tab clock and polling behavior, modal request cancellation and retained JS heap after repeated picker cycles, lazy settings chunks, and loading an unpacked Manifest V3 extension in an isolated Chromium profile. It uses existing Vite/Playwright dependencies and does not modify the normal browser profile or shipped metadata.
|
||||
|
||||
### Chrome extension install (manual)
|
||||
Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked" from `chrome://extensions`. The release workflow automates this zip.
|
||||
@@ -253,7 +256,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
||||
- **Icon picker** in `WebsiteEditModal` loads page-relative `icon-metadata.json` on focus, validates the response, and lazily expands color variants into up to 50 searchable results. Closing it cancels metadata fetching and filtering timers.
|
||||
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only `image/*` responses no larger than 256KiB are cached. Trusted public TLDs use Google S2 and the CORS-open `icon.horse` service for favicon caching, while non-trusted TLDs (local TLDs, IP addresses, custom domains) fetch `${origin}/favicon.ico` directly. No site HTML is ever fetched for icon discovery.
|
||||
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
||||
- **Tile/icon sizing** uses static tile classes and numeric inline icon dimensions. The legacy pixel-size safelist remains in `tailwind.config.js`, but tile icons no longer depend on it.
|
||||
- **Tile/icon sizing** uses static tile classes and numeric inline icon dimensions. Tailwind v4 only generates classes found literally in source; there is no safelist.
|
||||
- **Project guidance note** in `AGENTS.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
||||
- **`.claude/` and `.env.local`** are local-only / gitignored; not part of shipped artifacts.
|
||||
|
||||
@@ -283,4 +286,4 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-10-10. Generated as a general project overview; not a coding-style guide._
|
||||
_Last updated: 2026-10-28. Generated as a general project overview; not a coding-style guide._
|
||||
@@ -112,7 +112,18 @@ await test('website URLs, clock fonts, and wallpaper frequency are normalized',
|
||||
chromeStorage.set('My Wallpaper', 'data:image/png;base64,c');
|
||||
assert.equal(await pruneCachedIconsFromChromeStorageLocal([iconUrl('keep')]), 1);
|
||||
assert.deepEqual([...chromeStorage.keys()].sort(), ['My Wallpaper', `vision-start:icon:${encodeURIComponent(iconUrl('keep'))}`].sort());
|
||||
chromeStorage.set(`vision-start:icon:${encodeURIComponent(iconUrl('stale'))}`, 'data:image/png;base64,d');
|
||||
window.chrome.storage.local.getKeys = callback => callback([...chromeStorage.keys()]);
|
||||
try {
|
||||
const reads = chromeReads;
|
||||
assert.equal(await pruneCachedIconsFromChromeStorageLocal([iconUrl('keep')]), 1);
|
||||
assert.equal(chromeReads, reads, 'Pruning must list keys without reading stored values');
|
||||
assert(chromeStorage.has('My Wallpaper'));
|
||||
} finally { delete window.chrome.storage.local.getKeys; }
|
||||
chromeStorage.clear();
|
||||
assert.equal(await icons.getWebsiteIcon('mailto:me@example.com'), '');
|
||||
assert.equal(await icons.getWebsiteIcon('github.com'), 'https://www.google.com/s2/favicons?domain=github.com&sz=128');
|
||||
assert.equal(await icons.getWebsiteIcon('192.168.1.10:8080'), 'http://192.168.1.10:8080/favicon.ico');
|
||||
});
|
||||
|
||||
await test('imports validate before writing, roll back failures, and preserve omitted settings', async () => {
|
||||
@@ -191,6 +202,9 @@ await test('icon requests deduplicate, cap concurrency, reject oversized streams
|
||||
const reads = chromeReads;
|
||||
await icons.getCachedWebsiteIcon(iconUrl('site0'));
|
||||
assert(chromeReads > reads, 'The oldest large icon must leave the extra memory cache');
|
||||
const batchReads = chromeReads;
|
||||
await Promise.all(['batch-a', 'batch-b', 'batch-c'].map(name => icons.getCachedWebsiteIcon(iconUrl(name))));
|
||||
assert.equal(chromeReads - batchReads, 1, 'Icon cache lookups in one tick must share one storage read');
|
||||
const before = requests;
|
||||
await Promise.all([icons.cacheWebsiteIcon(iconUrl('shared')), icons.cacheWebsiteIcon(iconUrl('shared'))]);
|
||||
assert.equal(requests - before, 1);
|
||||
@@ -269,6 +283,15 @@ await test('production UI survives malformed data, distinguishes server status,
|
||||
await page.getByRole('button', { name: 'Edit page' }).click();
|
||||
await page.getByRole('button', { name: 'Move Beta left' }).click();
|
||||
assert.deepEqual(await page.locator('a[aria-label$="edit controls"]').evaluateAll(links => links.map(link => [...link.querySelectorAll('span')].at(-1).textContent)), ['Beta', 'Alpha']);
|
||||
await page.clock.runFor(100);
|
||||
await page.evaluate(() => {
|
||||
const categories = JSON.parse(localStorage.getItem('categories'));
|
||||
categories[0].websites.push({ id: 'g', name: 'Gamma', url: 'https://example.net', icon: '', categoryId: categories[0].id });
|
||||
localStorage.setItem('categories', JSON.stringify(categories));
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'categories' }));
|
||||
});
|
||||
await page.locator('a[aria-label="Gamma edit controls"]').waitFor();
|
||||
assert.deepEqual(await page.locator('a[aria-label$="edit controls"]').evaluateAll(links => links.map(link => [...link.querySelectorAll('span')].at(-1).textContent)), ['Beta', 'Alpha', 'Gamma']);
|
||||
const openPicker = async () => {
|
||||
await page.getByRole('button', { name: 'Edit Alpha', exact: true }).click();
|
||||
const input = page.getByPlaceholder('Icon URL or name');
|
||||
@@ -290,6 +313,7 @@ await test('production UI survives malformed data, distinguishes server status,
|
||||
const growth = (await heap()) - before;
|
||||
assert(growth < 2 * 1024 * 1024, `Retained JS heap grew by ${growth} bytes`);
|
||||
assert.equal(metadataLoads, 16);
|
||||
assert(!downloaded.some(url => url === `${baseUrl}/service`), 'Typing an icon name must not request it as a relative URL');
|
||||
await page.evaluate(() => {
|
||||
const original = window.fetch;
|
||||
window.fetch = (url, options) => {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
safelist: [
|
||||
'w-[24px]', 'h-[24px]',
|
||||
'w-[28px]', 'h-[28px]',
|
||||
'w-[32px]', 'h-[32px]',
|
||||
'w-[34px]', 'h-[34px]',
|
||||
'w-[36px]', 'h-[36px]',
|
||||
'w-[40px]', 'h-[40px]',
|
||||
'w-[42px]', 'h-[42px]',
|
||||
'w-[48px]', 'h-[48px]',
|
||||
// add any other sizes you use
|
||||
],
|
||||
}
|
||||
Reference in new issue
Block a user