general fixes & enhancements

This commit is contained in:
2026-08-11 19:08:16 -03:00
parent e08853fe54
commit 31de8265d9
25 changed files with 561 additions and 625 deletions
+5 -38
View File
@@ -8,43 +8,14 @@ import ConfigurationButton from './components/layout/ConfigurationButton';
import CategoryGroup from './components/layout/CategoryGroup';
import Wallpaper from './components/Wallpaper';
import { ConfigurationService } from './components/services/ConfigurationService';
import { getAlignmentClass } from './components/utils/styleUtils';
import { getRandomWallpaperIndex } from './components/utils/wallpaperUtils';
import { PlusIcon } from './components/icons';
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
const getAlignmentClass = (alignment: string) => {
switch (alignment) {
case 'top':
return 'justify-start';
case 'middle':
return 'justify-center';
case 'bottom':
return 'justify-end';
default:
return 'justify-center';
}
};
const getHorizontalAlignmentClass = (alignment: string) => {
switch (alignment) {
case 'left':
return 'justify-start';
case 'middle':
return 'justify-center';
case 'right':
return 'justify-end';
default:
return 'justify-center';
}
};
const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
if (wallpaperCount <= 1) return 0;
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
return (currentIndex + offset) % wallpaperCount;
};
const App: React.FC = () => {
const [categories, setCategories] = useState<Category[]>(() => {
try {
@@ -224,13 +195,12 @@ const App: React.FC = () => {
setAddingWebsite={setAddingWebsite}
setEditingWebsite={setEditingWebsite}
handleMoveWebsite={handleMoveWebsite}
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
horizontalAlignment={config.horizontalAlignment}
tileSize={config.tileSize}
/>
))}
{isEditing && (
<div className={`flex justify-center transition-all duration-200 ease-ios transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
<div className="flex justify-center transition-all duration-200 ease-ios transform scale-100 opacity-100">
<button
onClick={() => {
setEditingCategory(null);
@@ -239,10 +209,7 @@ const App: React.FC = () => {
className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
aria-label="Add category"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</svg>
<PlusIcon size={22} />
Add category
</button>
</div>
+17 -38
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Category } from '../types';
import ModalShell from './ModalShell';
interface CategoryEditModalProps {
category?: Category;
@@ -12,45 +13,23 @@ interface CategoryEditModalProps {
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(category ? category.name : '');
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
<div className="flex flex-col gap-4">
<input
type="text"
placeholder="Category Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="liquid-input p-3"
/>
</div>
<div className="flex justify-between items-center mt-8">
<div>
{edit && (
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
Delete
</button>
)}
</div>
<div className="flex justify-end gap-3">
<button onClick={() => onSave(name)} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
Save
</button>
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
Close
</button>
</div>
</div>
</div>
</div>
<ModalShell
title={edit ? 'Edit Category' : 'Add Category'}
edit={edit}
onClose={onClose}
onSave={() => onSave(name)}
onDelete={edit ? onDelete : undefined}
>
<input
type="text"
placeholder="Category Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="liquid-input p-3"
/>
</ModalShell>
);
};
export default CategoryEditModal;
export default CategoryEditModal;
+3 -3
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { getClockSizeClass } from './utils/styleUtils';
interface ClockProps {
config: {
@@ -9,10 +10,9 @@ interface ClockProps {
format: string;
};
};
getClockSizeClass: (size: string) => string;
}
const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
const Clock: React.FC<ClockProps> = ({ config }) => {
const [time, setTime] = useState(new Date());
useEffect(() => {
@@ -66,4 +66,4 @@ const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
);
};
export default Clock;
export default Clock;
+7 -14
View File
@@ -28,7 +28,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const importInputRef = useRef<HTMLInputElement>(null);
const isSaving = useRef(false);
@@ -64,8 +63,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
setConfig((prev) => ({ ...prev, ...updates }));
};
const handleAddWallpaper = async (name: string, url: string) => {
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
const handleAddWallpaperEntry = async (promise: Promise<Wallpaper>) => {
const newWallpaper = await promise;
const updated = [...userWallpapers, newWallpaper];
setUserWallpapers(updated);
ConfigurationService.saveUserWallpapers(updated);
@@ -75,16 +74,11 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
}));
};
const handleAddWallpaperFile = async (file: File) => {
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
const updated = [...userWallpapers, newWallpaper];
setUserWallpapers(updated);
ConfigurationService.saveUserWallpapers(updated);
setConfig((prev) => ({
...prev,
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
}));
};
const handleAddWallpaper = (name: string, url: string) =>
handleAddWallpaperEntry(ConfigurationService.addWallpaper(name, url));
const handleAddWallpaperFile = (file: File) =>
handleAddWallpaperEntry(ConfigurationService.addWallpaperFile(file));
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
try {
@@ -140,7 +134,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
/>
<div
ref={menuRef}
className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
isVisible ? 'translate-x-0' : 'translate-x-full'
}`}
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
interface ModalShellProps {
title: string;
edit: boolean;
onClose: () => void;
onSave: () => void;
onDelete?: () => void;
children: React.ReactNode;
}
const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, children }) => {
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{title}</h2>
<div className="flex flex-col gap-4">{children}</div>
<div className="flex justify-between items-center mt-8">
<div>
{edit && onDelete && (
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
Delete
</button>
)}
</div>
<div className="flex justify-end gap-3">
<button onClick={onSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
Save
</button>
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
Close
</button>
</div>
</div>
</div>
</div>
);
};
export default ModalShell;
+6 -9
View File
@@ -12,17 +12,14 @@ interface ServerWidgetProps {
};
}
const getStatusColor = (status: string) => {
switch (status) {
case 'online':
return 'bg-green-400 text-green-400';
case 'offline':
return 'bg-red-400 text-red-400';
default:
return 'bg-slate-400 text-slate-400';
}
const STATUS_COLORS: Record<string, string> = {
online: 'bg-green-400 text-green-400',
offline: 'bg-red-400 text-red-400',
};
const getStatusColor = (status: string): string =>
STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400';
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
const serversRef = useRef(config.serverWidget.servers);
+25 -28
View File
@@ -1,8 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { baseWallpapers } from './utils/baseWallpapers';
import { Wallpaper as WallpaperType } from '../types';
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
import { getRandomWallpaperIndex, getWallpaperFrequencyMs } from './utils/wallpaperUtils';
interface WallpaperProps {
wallpaperNames: string[];
@@ -13,28 +13,18 @@ interface WallpaperProps {
wallpaperVersion: number;
}
const MIN_WALLPAPER_FREQUENCY_MS = 60 * 60 * 1000;
const MAX_WALLPAPER_FREQUENCY_MS = 48 * 60 * 60 * 1000;
const DEFAULT_WALLPAPER_FREQUENCY_MS = 24 * 60 * 60 * 1000;
const parseFrequencyToMs = (freq: string): number => {
if (!freq) return DEFAULT_WALLPAPER_FREQUENCY_MS;
const match = freq.match(/^(\d+)(h|d)$/);
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_MS;
const value = parseInt(match[1], 10);
const unit = match[2];
const frequencyMs = unit === 'd' ? value * 24 * 60 * 60 * 1000 : value * 60 * 60 * 1000;
return Math.min(MAX_WALLPAPER_FREQUENCY_MS, Math.max(MIN_WALLPAPER_FREQUENCY_MS, frequencyMs));
};
const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
if (wallpaperCount <= 1) return 0;
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
return (currentIndex + offset) % wallpaperCount;
};
const MAX_WALLPAPER_URL_CACHE = 3;
const wallpaperUrlCache = new Map<string, string | undefined>();
const rememberWallpaperUrl = (name: string, resolved: string | undefined): void => {
wallpaperUrlCache.set(name, resolved);
while (wallpaperUrlCache.size > MAX_WALLPAPER_URL_CACHE) {
const oldest = wallpaperUrlCache.keys().next().value;
if (oldest === undefined) break;
wallpaperUrlCache.delete(oldest);
}
};
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
if (!name) return undefined;
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
@@ -65,18 +55,19 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
}
}
wallpaperUrlCache.set(name, resolved);
rememberWallpaperUrl(name, resolved);
return resolved;
};
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
const resolvedRef = useRef(false);
useEffect(() => {
let cancelled = false;
const updateWallpaper = async () => {
if (wallpaperNames.length === 0) {
setImageUrl(undefined);
if (!cancelled) setImageUrl(undefined);
localStorage.setItem(
'wallpaperState',
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
@@ -89,7 +80,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
? new Date(wallpaperState.lastWallpaperChange).getTime()
: 0;
const now = Date.now();
const freqMs = parseFrequencyToMs(wallpaperFrequency);
const freqMs = getWallpaperFrequencyMs(wallpaperFrequency);
let storedIndex =
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
@@ -107,6 +98,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
if (tried.has(resolvedIndex)) break;
tried.add(resolvedIndex);
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
if (cancelled) return;
if (url) {
resolvedUrl = url;
break;
@@ -114,6 +106,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
}
if (cancelled) return;
const nextLastChange = shouldRotate
? new Date().toISOString()
: wallpaperState.lastWallpaperChange || new Date().toISOString();
@@ -126,10 +120,13 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
}),
);
resolvedRef.current = true;
setImageUrl(resolvedUrl);
};
updateWallpaper();
return () => {
cancelled = true;
};
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
if (!imageUrl) return null;
@@ -150,4 +147,4 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
);
};
export default Wallpaper;
export default Wallpaper;
+87 -104
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { Website } from '../types';
import { getWebsiteIcon } from './utils/iconService';
import ModalShell from './ModalShell';
interface WebsiteEditModalProps {
website?: Website;
@@ -27,6 +28,9 @@ interface IconMetadata {
let iconMetadataCache: IconMetadata[] | null = null;
const getIconPickUrl = (iconData: IconMetadata): string =>
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(website ? website.name : '');
const [url, setUrl] = useState(website ? website.url : '');
@@ -37,6 +41,12 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
const debounceRef = useRef<number | null>(null);
useEffect(() => {
return () => {
iconMetadataCache = null;
};
}, []);
const ensureIconMetadata = () => {
if (iconMetadataCache) {
setIconMetadata(iconMetadataCache);
@@ -68,10 +78,10 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
filtered.push(ic);
if (filtered.length >= 50) break;
}
if (ic.colors) {
const colors = Object.values(ic.colors).filter(key => key !== ic.name);
for (const color of colors) {
if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) {
if (ic.colors && typeof ic.colors === 'object') {
const colors = Object.values(ic.colors).filter(key => typeof key === 'string' && key !== ic.name);
for (const color of colors as string[]) {
if (color.toLowerCase().includes(lowerCaseQuery)) {
filtered.push({ ...ic, name: color });
if (filtered.length >= 50) break;
}
@@ -96,109 +106,82 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
}
};
const handleSave = () => {
onSave({ id: website?.id, name, url, icon });
};
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
<div className="flex flex-col gap-4">
<div className="flex justify-center mb-4">
{icon ? (
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
) : (
<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">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
</svg>
</div>
)}
<ModalShell
title={edit ? 'Edit Website' : 'Add Website'}
edit={edit}
onClose={onClose}
onSave={() => onSave({ id: website?.id, name, url, icon })}
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" />
) : (
<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">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
</svg>
</div>
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="liquid-input p-3"
/>
<input
type="text"
placeholder="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="liquid-input p-3"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-full">
<input
type="text"
placeholder="Icon URL or name"
value={icon}
onChange={(e) => {
setIcon(e.target.value);
setIconQuery(e.target.value);
}}
onFocus={ensureIconMetadata}
className="liquid-input p-3"
/>
{filteredIcons.length > 0 && (
<div className="liquid-panel liquid-dropdown-list absolute z-20 w-full rounded-xl mt-2 max-h-60 overflow-y-auto">
{filteredIcons.map(iconData => (
<div
key={iconData.name}
onClick={() => {
const iconUrl = `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
setIcon(iconUrl);
setFilteredIcons([]);
}}
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
>
<img
src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`}
alt={iconData.name}
className="h-6 w-6 mr-2"
/>
<span>{iconData.name}</span>
</div>
))}
</div>
)}
</div>
<button onClick={fetchIcon} className="liquid-button liquid-button-secondary liquid-focus py-3 px-4">
Fetch
</button>
</div>
</div>
<div className="flex justify-between items-center mt-8">
<div>
{edit && (
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
Delete
</button>
)}
</div>
<div className="flex justify-end gap-3">
<button onClick={handleSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
Save
</button>
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
Close
</button>
</div>
</div>
)}
</div>
</div>
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="liquid-input p-3"
/>
<input
type="text"
placeholder="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="liquid-input p-3"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-full">
<input
type="text"
placeholder="Icon URL or name"
value={icon}
onChange={(e) => {
setIcon(e.target.value);
setIconQuery(e.target.value);
}}
onFocus={ensureIconMetadata}
className="liquid-input p-3"
/>
{filteredIcons.length > 0 && (
<div className="liquid-panel liquid-dropdown-list absolute z-20 w-full rounded-xl mt-2 max-h-60 overflow-y-auto">
{filteredIcons.map((iconData, index) => (
<div
key={`${iconData.name}-${index}`}
onClick={() => {
setIcon(getIconPickUrl(iconData));
setFilteredIcons([]);
}}
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
>
<img
src={getIconPickUrl(iconData)}
alt={iconData.name}
className="h-6 w-6 mr-2"
/>
<span>{iconData.name}</span>
</div>
))}
</div>
)}
</div>
<button onClick={fetchIcon} className="liquid-button liquid-button-secondary liquid-focus py-3 px-4">
Fetch
</button>
</div>
</ModalShell>
);
};
export default WebsiteEditModal;
export default WebsiteEditModal;
+5 -49
View File
@@ -1,6 +1,8 @@
import React, { memo, useEffect, useState } from 'react';
import { Website } from '../types';
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
interface WebsiteTileProps {
website: Website;
@@ -10,46 +12,6 @@ interface WebsiteTileProps {
tileSize?: string;
}
const getTileSizeClass = (size: string | undefined) => {
switch (size) {
case 'small':
return 'w-28 h-28';
case 'medium':
return 'w-32 h-32';
case 'large':
return 'w-36 h-36';
default:
return 'w-32 h-32';
}
};
const getIconPixelSize = (size: string | undefined): number => {
switch (size) {
case 'small':
return 34;
case 'medium':
return 42;
case 'large':
return 48;
default:
return 40;
}
};
const getIconLoadingPixelSize = (size: string | undefined): number => {
switch (size) {
case 'small':
return 24;
case 'medium':
return 32;
case 'large':
return 40;
default:
return 32;
}
};
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
const [isLoading, setIsLoading] = useState(false);
@@ -141,15 +103,9 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
</a>
{isEditing && (
<div className="liquid-surface liquid-edit-toolbar">
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
</svg></button>
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
</svg></button>
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
</svg></button>
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><ChevronLeftIcon size={14} /></button>
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><PencilIcon size={14} /></button>
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><ChevronRightIcon size={14} /></button>
</div>
)}
</div>
+2 -6
View File
@@ -2,6 +2,7 @@ import React from 'react';
import Dropdown from '../Dropdown';
import ToggleSwitch from '../ToggleSwitch';
import { Config } from '../../types';
import { SIZE_OPTIONS } from '../utils/styleUtils';
interface ClockTabProps {
config: Config;
@@ -28,12 +29,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
name="clock.size"
value={config.clock.size}
onChange={(e) => updateClock({ size: e.target.value as string })}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
options={SIZE_OPTIONS}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+2 -6
View File
@@ -1,6 +1,7 @@
import React from 'react';
import Dropdown from '../Dropdown';
import { Config } from '../../types';
import { SIZE_OPTIONS } from '../utils/styleUtils';
interface GeneralTabProps {
config: Config;
@@ -25,12 +26,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
name="titleSize"
value={config.titleSize}
onChange={(e) => onChange({ titleSize: e.target.value as string })}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
options={SIZE_OPTIONS}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
type RangeStyle = React.CSSProperties & { '--range-progress': string };
export const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
return { '--range-progress': `${progress}%` };
};
interface RangeSliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
valueSuffix?: string;
formatValue?: (value: number) => string;
onChange: (value: number) => void;
}
const RangeSlider: React.FC<RangeSliderProps> = ({
label,
value,
min,
max,
step = 1,
valueSuffix,
formatValue,
onChange,
}) => {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">{label}</label>
<div className="flex items-center gap-4">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="liquid-range"
style={getRangeStyle(value, min, max)}
/>
<span className="min-w-20 text-right text-sm text-slate-200">
{formatValue ? formatValue(value) : `${value}${valueSuffix ?? ''}`}
</span>
</div>
</div>
);
};
export default RangeSlider;
+12 -37
View File
@@ -2,19 +2,14 @@ import React, { useState } from 'react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import ToggleSwitch from '../ToggleSwitch';
import { Config, Server } from '../../types';
import RangeSlider from './RangeSlider';
import { TrashIcon } from '../icons';
interface ServerWidgetTabProps {
config: Config;
onChange: (updates: Partial<Config>) => void;
}
type RangeStyle = React.CSSProperties & { '--range-progress': string };
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
return { '--range-progress': `${progress}%` };
};
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
const [newServerName, setNewServerName] = useState('');
const [newServerAddress, setNewServerAddress] = useState('');
@@ -60,21 +55,14 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
</div>
{config.serverWidget.enabled && (
<>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
<div className="flex items-center gap-4">
<input
type="range"
min="5"
max="60"
value={config.serverWidget.pingFrequency}
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.serverWidget.pingFrequency}s</span>
</div>
</div>
<RangeSlider
label="Ping Frequency"
value={config.serverWidget.pingFrequency}
min={5}
max={60}
valueSuffix="s"
onChange={(value) => updateServerWidget({ pingFrequency: value })}
/>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
<DragDropContext onDragEnd={onDragEnd}>
@@ -103,20 +91,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Remove ${server.name}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path
fillRule="evenodd"
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
/>
</svg>
<TrashIcon size={16} />
</button>
</div>
)}
@@ -156,4 +131,4 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
);
};
export default ServerWidgetTab;
export default ServerWidgetTab;
+45 -112
View File
@@ -1,6 +1,14 @@
import React, { useRef, useState } from 'react';
import React, { useState } from 'react';
import Dropdown from '../Dropdown';
import { Config, Wallpaper } from '../../types';
import RangeSlider from './RangeSlider';
import { TrashIcon } from '../icons';
import {
getWallpaperFrequencyHours,
formatWallpaperFrequency,
MIN_WALLPAPER_FREQUENCY_HOURS,
MAX_WALLPAPER_FREQUENCY_HOURS,
} from '../utils/wallpaperUtils';
interface ThemeTabProps {
config: Config;
@@ -14,31 +22,6 @@ interface ThemeTabProps {
onRandomWallpaper: () => void;
}
type RangeStyle = React.CSSProperties & { '--range-progress': string };
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
return { '--range-progress': `${progress}%` };
};
const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
const clampWallpaperFrequencyHours = (hours: number): number =>
Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, Math.round(hours)));
const getWallpaperFrequencyHours = (frequency: string): number => {
const match = frequency.match(/^(\d+)(h|d)$/);
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
const value = Number(match[1]);
const hours = match[2] === 'd' ? value * 24 : value;
return clampWallpaperFrequencyHours(hours);
};
const formatWallpaperFrequency = (hours: number): string =>
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
const ThemeTab: React.FC<ThemeTabProps> = ({
config,
onChange,
@@ -52,7 +35,6 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
}) => {
const [newWallpaperName, setNewWallpaperName] = useState('');
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
const handleAddWallpaper = async () => {
@@ -76,8 +58,8 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
if (!file) return;
try {
await onAddWallpaperFile(file);
} catch (error: any) {
alert(error?.message || 'Error adding wallpaper. Please try again.');
} catch (error) {
alert(error instanceof Error ? error.message : 'Error adding wallpaper. Please try again.');
console.error(error);
}
e.target.value = '';
@@ -96,74 +78,39 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
/>
</div>
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
<div className="flex items-center gap-4">
<input
type="range"
min={MIN_WALLPAPER_FREQUENCY_HOURS}
max={MAX_WALLPAPER_FREQUENCY_HOURS}
step="1"
value={wallpaperFrequencyHours}
onChange={(e) => onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })}
className="liquid-range"
style={getRangeStyle(
wallpaperFrequencyHours,
MIN_WALLPAPER_FREQUENCY_HOURS,
MAX_WALLPAPER_FREQUENCY_HOURS,
)}
/>
<span className="w-20 text-right text-sm text-slate-200">
{formatWallpaperFrequency(wallpaperFrequencyHours)}
</span>
</div>
</div>
<RangeSlider
label="Change Frequency"
value={wallpaperFrequencyHours}
min={MIN_WALLPAPER_FREQUENCY_HOURS}
max={MAX_WALLPAPER_FREQUENCY_HOURS}
formatValue={formatWallpaperFrequency}
onChange={(value) => onChange({ wallpaperFrequency: `${value}h` })}
/>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="50"
value={config.wallpaperBlur}
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="200"
value={config.wallpaperBrightness}
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1"
max="100"
value={config.wallpaperOpacity}
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
</div>
</div>
<RangeSlider
label="Wallpaper Blur"
value={config.wallpaperBlur}
min={0}
max={50}
valueSuffix="px"
onChange={(value) => onChange({ wallpaperBlur: value })}
/>
<RangeSlider
label="Wallpaper Brightness"
value={config.wallpaperBrightness}
min={0}
max={200}
valueSuffix="%"
onChange={(value) => onChange({ wallpaperBrightness: value })}
/>
<RangeSlider
label="Wallpaper Opacity"
value={config.wallpaperOpacity}
min={1}
max={100}
valueSuffix="%"
onChange={(value) => onChange({ wallpaperOpacity: value })}
/>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
<div className="flex flex-col gap-2">
@@ -178,20 +125,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Delete ${wallpaper.name}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path
fillRule="evenodd"
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
/>
</svg>
<TrashIcon size={16} />
</button>
</div>
))}
@@ -254,7 +188,6 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
type="file"
className="hidden"
onChange={handleFileUpload}
ref={fileInputRef}
/>
</label>
</div>
@@ -283,4 +216,4 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
);
};
export default ThemeTab;
export default ThemeTab;
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
interface IconProps {
size?: number;
className?: string;
}
export const PencilIcon: React.FC<IconProps> = ({ size = 14, className }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
</svg>
);
export const PlusIcon: React.FC<IconProps> = ({ size = 22, className }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</svg>
);
export const TrashIcon: React.FC<IconProps> = ({ size = 16, className }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
</svg>
);
export const ChevronLeftIcon: React.FC<IconProps> = ({ size = 14, className }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
</svg>
);
export const ChevronRightIcon: React.FC<IconProps> = ({ size = 14, className }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
</svg>
);
+8 -26
View File
@@ -1,6 +1,8 @@
import React, { memo } from 'react';
import WebsiteTile from '../WebsiteTile';
import { Category, Website } from '../../types';
import { getTileSizeClass, getAlignmentClass } from '../utils/styleUtils';
import { PencilIcon, PlusIcon } from '../icons';
interface CategoryGroupProps {
category: Category;
@@ -10,24 +12,10 @@ interface CategoryGroupProps {
setAddingWebsite: (category: Category) => void;
setEditingWebsite: (website: Website) => void;
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
getHorizontalAlignmentClass: (alignment: string) => string;
horizontalAlignment: string;
tileSize?: string;
}
const getAddTileSizeClass = (size: string | undefined) => {
switch (size) {
case 'small':
return 'w-28 h-28';
case 'medium':
return 'w-32 h-32';
case 'large':
return 'w-36 h-36';
default:
return 'w-32 h-32';
}
};
const CategoryGroup: React.FC<CategoryGroupProps> = ({
category,
isEditing,
@@ -36,13 +24,12 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
setAddingWebsite,
setEditingWebsite,
handleMoveWebsite,
getHorizontalAlignmentClass,
horizontalAlignment,
tileSize,
}) => {
return (
<div key={category.id} className="w-full">
<div className={`flex ${getHorizontalAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
<div className={`flex ${getAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
<h2 className={`liquid-category-title text-2xl font-extrabold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
{isEditing && (
<button
@@ -53,13 +40,11 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
className={`liquid-surface liquid-edit-action liquid-focus ml-2 shrink-0 transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
aria-label={`Edit ${category.name} category`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
</svg>
<PencilIcon size={14} />
</button>
)}
</div>
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
<div className={`flex flex-wrap ${getAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
{category.websites.map((website) => (
<WebsiteTile
key={website.id}
@@ -73,13 +58,10 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
{isEditing && (
<button
onClick={() => setAddingWebsite(category)}
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getAddTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
aria-label={`Add website to ${category.name}`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</svg>
<PlusIcon size={28} />
<span className="text-sm font-bold">Add</span>
</button>
)}
@@ -88,4 +70,4 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
);
};
export default memo(CategoryGroup);
export default memo(CategoryGroup);
+3 -5
View File
@@ -1,4 +1,4 @@
import { PencilIcon } from '../icons';
interface EditButtonProps {
isEditing: boolean;
@@ -13,13 +13,11 @@ const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
<PencilIcon size={16} />
{isEditing ? 'Done' : ''}
</button>
</div>
);
};
export default EditButton;
export default EditButton;
+3 -49
View File
@@ -1,63 +1,17 @@
import Clock from '../Clock';
import { Config } from '../../types';
import { getTitleSizeClass } from '../utils/styleUtils';
interface HeaderProps {
config: Config;
}
const getClockSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-3xl';
case 'small':
return 'text-4xl';
case 'medium':
return 'text-5xl';
case 'large':
return 'text-6xl';
default:
return 'text-5xl';
}
};
const getTitleSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-4xl';
case 'small':
return 'text-5xl';
case 'medium':
return 'text-6xl';
case 'large':
return 'text-7xl';
default:
return 'text-6xl';
}
};
const getSubtitleSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-lg';
case 'small':
return 'text-xl';
case 'medium':
return 'text-2xl';
case 'large':
return 'text-3xl';
default:
return 'text-2xl';
}
};
export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass };
const Header: React.FC<HeaderProps> = ({ config }) => {
return (
<>
{config.clock.enabled && (
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
<Clock config={config} getClockSizeClass={getClockSizeClass} />
<Clock config={config} />
</div>
)}
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
@@ -75,4 +29,4 @@ const Header: React.FC<HeaderProps> = ({ config }) => {
);
};
export default Header;
export default Header;
+2 -11
View File
@@ -4,6 +4,7 @@ import {
checkChromeStorageLocalAvailable,
removeWallpaperFromChromeStorageLocal,
} from '../utils/StorageLocalManager';
import { getFileNameFromUrl } from '../utils/urlUtils';
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
@@ -44,16 +45,6 @@ const safeParse = (value: string | null): unknown => {
const toStorageString = (value: unknown): string =>
typeof value === 'string' ? value : JSON.stringify(value);
const getWallpaperNameFromUrl = (url: URL): string => {
const pathName = url.pathname.split('/').filter(Boolean).pop();
if (!pathName) return url.hostname;
try {
return decodeURIComponent(pathName);
} catch {
return pathName;
}
};
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -121,7 +112,7 @@ export const ConfigurationService = {
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
}
const finalName = name.trim() || getWallpaperNameFromUrl(parsedUrl) || 'Wallpaper';
const finalName = name.trim() || getFileNameFromUrl(parsedUrl) || 'Wallpaper';
return { name: finalName, url: parsedUrl.href };
},
+53 -74
View File
@@ -1,4 +1,6 @@
// TypeScript interface for window.chrome
import { getFileNameFromUrl } from './urlUtils';
declare global {
interface Window {
chrome?: {
@@ -23,7 +25,6 @@ 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.
*/
@@ -37,54 +38,60 @@ export function checkChromeStorageLocalAvailable(): boolean {
return isChromeStorageLocalAvailable;
}
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
if (!checkChromeStorageLocalAvailable()) return null;
type StorageResult = { [key: string]: string };
return new Promise<string | null>((resolve) => {
const chromeLocalCall = <T>(
operation: (callback: (result: T) => void) => void,
): Promise<T> =>
new Promise<T>((resolve, reject) => {
if (!window.chrome?.storage?.local) {
resolve(null);
reject(new Error('chrome.storage.local is not available'));
return;
}
const key = getIconCacheKey(sourceUrl);
window.chrome.storage.local.get([key], function (result: { [key: string]: string }) {
operation((result) => {
if (window.chrome?.runtime?.lastError) {
resolve(null);
return;
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve(result);
}
resolve(result[key] || null);
});
});
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
if (!checkChromeStorageLocalAvailable()) return null;
try {
const key = getIconCacheKey(sourceUrl);
const result = await chromeLocalCall<StorageResult>((cb) =>
window.chrome?.storage?.local?.get([key], cb),
);
return result[key] || null;
} catch {
return null;
}
}
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
if (!checkChromeStorageLocalAvailable()) return false;
return new Promise<boolean>((resolve) => {
if (!window.chrome?.storage?.local) {
resolve(false);
return;
}
window.chrome.storage.local.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, function () {
resolve(!window.chrome?.runtime?.lastError);
});
});
try {
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, cb),
);
return true;
} catch {
return false;
}
}
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
if (!checkChromeStorageLocalAvailable()) return false;
return new Promise<boolean>((resolve) => {
if (!window.chrome?.storage?.local) {
resolve(false);
return;
}
window.chrome.storage.local.remove(getIconCacheKey(sourceUrl), function () {
resolve(!window.chrome?.runtime?.lastError);
});
});
try {
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.remove(getIconCacheKey(sourceUrl), cb),
);
return true;
} catch {
return false;
}
}
/**
@@ -113,22 +120,13 @@ export async function addWallpaperToChromeStorageLocal(name: string, url: string
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
}
finalName = finalName || parsedUrl.pathname.split('/').filter(Boolean).pop() || parsedUrl.hostname;
finalName = finalName || getFileNameFromUrl(parsedUrl);
}
return new Promise<void>((resolve, reject) => {
if (!window.chrome?.storage?.local) {
reject(new Error('chrome.storage.local is not available'));
return;
}
window.chrome.storage.local.set({ [finalName]: url }, function () {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve();
}
});
}).then(() => finalName);
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
);
return finalName;
}
/**
@@ -141,19 +139,10 @@ export async function getWallpaperFromChromeStorageLocal(name: string): Promise<
if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available');
}
return new Promise<string | null>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.get([name], function (result: { [key: string]: string }) {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve(result[name] || null);
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
});
const result = await chromeLocalCall<StorageResult>((cb) =>
window.chrome?.storage?.local?.get([name], cb),
);
return result[name] || null;
}
/**
@@ -166,17 +155,7 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi
if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available');
}
return new Promise<void>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.remove(name, function () {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
});
}
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.remove(name, cb),
);
}
+26 -8
View File
@@ -6,10 +6,19 @@ import {
} from './StorageLocalManager';
const MAX_CACHED_ICON_BYTES = 256 * 1024;
const MAX_RESOLVED_ICONS = 50;
const resolvedIconCache = new Map<string, string>();
const iconCacheLookups = new Map<string, Promise<string | null>>();
const iconCacheRequests = new Map<string, Promise<string | null>>();
const rememberResolvedIcon = (iconUrl: string, dataUrl: string): void => {
resolvedIconCache.set(iconUrl, dataUrl);
if (resolvedIconCache.size > MAX_RESOLVED_ICONS) {
const oldest = resolvedIconCache.keys().next().value;
if (oldest !== undefined) resolvedIconCache.delete(oldest);
}
};
const isDataUrl = (value: string): boolean => value.startsWith('data:');
const isCacheableIconUrl = (value: string): boolean => {
@@ -38,9 +47,14 @@ const blobToDataUrl = (blob: Blob): Promise<string> =>
reader.readAsDataURL(blob);
});
async function getWebsiteIcon(url: string): Promise<string> {
async function getWebsiteIcon(rawUrl: string): Promise<string> {
let targetUrl = rawUrl.trim();
if (targetUrl && !/^https?:\/\//i.test(targetUrl)) {
targetUrl = `https://${targetUrl}`;
}
try {
const response = await fetch(url);
const response = await fetch(targetUrl);
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
@@ -48,7 +62,7 @@ async function getWebsiteIcon(url: string): Promise<string> {
if (appleTouchIcon) {
const href = appleTouchIcon.getAttribute('href');
if (href) {
return new URL(href, url).href;
return new URL(href, targetUrl).href;
}
}
@@ -56,15 +70,19 @@ async function getWebsiteIcon(url: string): Promise<string> {
if (iconLink) {
const href = iconLink.getAttribute('href');
if (href) {
return new URL(href, url).href;
return new URL(href, targetUrl).href;
}
}
} catch (error) {
console.error('Error fetching and parsing HTML for icon:', error);
}
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
try {
const hostname = new URL(targetUrl).hostname;
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=128`;
} catch {
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(rawUrl)}&sz=128`;
}
}
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
@@ -80,7 +98,7 @@ async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
.then((cachedIcon) => {
if (isValidCachedIcon(cachedIcon)) {
resolvedIconCache.set(iconUrl, cachedIcon);
rememberResolvedIcon(iconUrl, cachedIcon);
return cachedIcon;
}
return null;
@@ -121,7 +139,7 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
}
const dataUrl = await blobToDataUrl(blob);
resolvedIconCache.set(iconUrl, dataUrl);
rememberResolvedIcon(iconUrl, dataUrl);
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
return dataUrl;
} catch {
+64
View File
@@ -0,0 +1,64 @@
export const SIZE_OPTIONS: { value: string; label: string }[] = [
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
];
export const TILE_SIZE_CLASSES: Record<string, string> = {
small: 'w-28 h-28',
medium: 'w-32 h-32',
large: 'w-36 h-36',
};
export const getTileSizeClass = (size: string | undefined): string =>
TILE_SIZE_CLASSES[size ?? ''] ?? 'w-32 h-32';
export const ICON_PIXEL_SIZES: Record<string, number> = {
small: 34,
medium: 42,
large: 48,
};
export const getIconPixelSize = (size: string | undefined): number =>
ICON_PIXEL_SIZES[size ?? ''] ?? 40;
export const ICON_LOADING_PIXEL_SIZES: Record<string, number> = {
small: 24,
medium: 32,
large: 40,
};
export const getIconLoadingPixelSize = (size: string | undefined): number =>
ICON_LOADING_PIXEL_SIZES[size ?? ''] ?? 32;
export const CLOCK_SIZE_CLASSES: Record<string, string> = {
tiny: 'text-3xl',
small: 'text-4xl',
medium: 'text-5xl',
large: 'text-6xl',
};
export const getClockSizeClass = (size: string): string =>
CLOCK_SIZE_CLASSES[size] ?? 'text-5xl';
export const TITLE_SIZE_CLASSES: Record<string, string> = {
tiny: 'text-4xl',
small: 'text-5xl',
medium: 'text-6xl',
large: 'text-7xl',
};
export const getTitleSizeClass = (size: string): string =>
TITLE_SIZE_CLASSES[size] ?? 'text-6xl';
export const ALIGNMENT_CLASSES: Record<string, string> = {
top: 'justify-start',
left: 'justify-start',
middle: 'justify-center',
bottom: 'justify-end',
right: 'justify-end',
};
export const getAlignmentClass = (alignment: string): string =>
ALIGNMENT_CLASSES[alignment] ?? 'justify-center';
+9
View File
@@ -0,0 +1,9 @@
export const getFileNameFromUrl = (url: URL): string => {
const pathName = url.pathname.split('/').filter(Boolean).pop();
if (!pathName) return url.hostname;
try {
return decodeURIComponent(pathName);
} catch {
return pathName;
}
};
+24
View File
@@ -0,0 +1,24 @@
export const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
export const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
export const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
export const getWallpaperFrequencyHours = (frequency: string): number => {
if (!frequency) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
const match = frequency.match(/^(\d+)(h|d)$/);
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
const value = parseInt(match[1], 10);
const hours = match[2] === 'd' ? value * 24 : value;
return Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, hours));
};
export const getWallpaperFrequencyMs = (frequency: string): number =>
getWallpaperFrequencyHours(frequency) * 60 * 60 * 1000;
export const formatWallpaperFrequency = (hours: number): string =>
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
export const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
if (wallpaperCount <= 1) return 0;
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
return (currentIndex + offset) % wallpaperCount;
};
+16 -8
View File
@@ -1,6 +1,6 @@
---
project_name: Vision Start
date: 2026-08-07
date: 2026-08-11
type: general_overview
---
@@ -65,13 +65,15 @@ The startpage is composed of widgets and a configuration panel:
Performance notes:
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population uses ordinary CORS-enabled `fetch`, deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests.
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load.
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load. `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` updates on the minute boundary (one `setTimeout``setInterval(60_000)`) instead of every second.
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
- Icon metadata (`/icon-metadata.json`) is module-level cached and hydrated into each icon-picker instance, 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.
- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`.
- 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. The module-level metadata cache is released when the picker modal unmounts (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper.
- `Wallpaper` resolves wallpaper URLs through a module-level `Map` bounded to the last 3 entries — so uploaded base64 wallpapers (up to ~4.5MB strings) are not retained in memory for the page's lifetime; 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 keeps an in-memory resolved data-URL cache bounded to 50 entries (FIFO eviction) to avoid unbounded growth from many tiles.
- 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`):
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
@@ -98,9 +100,11 @@ vision-start/
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
│ ├── CategoryEditModal.tsx # Add/edit a category
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
│ ├── ModalShell.tsx # Shared centered-modal shell (backdrop, title, footer buttons)
│ ├── ServerWidget.tsx # Bottom server status pill
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
│ ├── ToggleSwitch.tsx # Reusable toggle switch
│ ├── icons.tsx # Shared inline SVG icons (pencil, plus, trash, chevrons)
│ │
│ ├── layout/
│ │ ├── Header.tsx # Renders Clock + Title
@@ -112,7 +116,8 @@ vision-start/
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
│ │ ── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
│ │ ── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
│ │ └── RangeSlider.tsx # Shared labeled range slider (with progress fill)
│ │
│ ├── services/
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
@@ -121,7 +126,10 @@ vision-start/
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
│ ├── 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 and icon cache storage
── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper and icon cache storage
│ ├── styleUtils.ts # Shared tile/clock/title size classes, icon pixel sizes, alignment classes, size presets
│ ├── wallpaperUtils.ts # Wallpaper frequency parsing (h/d → hours/ms) and random-index helpers
│ └── urlUtils.ts # URL → filename extraction (shared by wallpaper storage paths)
├── public/
│ ├── favicon.ico
@@ -232,7 +240,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
## 8. Notable Behaviors & Quirks
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, both of which share the `ModalShell` component.
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it.
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, selects a random non-current wallpaper when the frequency window has elapsed, and writes its index back; the frequency is clamped to 148 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 "Random Wallpaper" button in the Theme tab also picks a random non-current wallpaper and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
@@ -266,4 +274,4 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
---
_Last updated: 2026-08-07. Generated as a general project overview; not a coding-style guide._
_Last updated: 2026-08-11. Generated as a general project overview; not a coding-style guide._