From 31de8265d9b1c8eedc50fff2c126798f39e2d3d3 Mon Sep 17 00:00:00 2001 From: Jose Henrique Date: Tue, 11 Aug 2026 19:08:16 -0300 Subject: [PATCH 1/2] general fixes & enhancements --- App.tsx | 43 +---- components/CategoryEditModal.tsx | 55 ++---- components/Clock.tsx | 6 +- components/ConfigurationModal.tsx | 21 +- components/ModalShell.tsx | 46 +++++ components/ServerWidget.tsx | 15 +- components/Wallpaper.tsx | 53 +++-- components/WebsiteEditModal.tsx | 191 +++++++++---------- components/WebsiteTile.tsx | 54 +----- components/configuration/ClockTab.tsx | 8 +- components/configuration/GeneralTab.tsx | 8 +- components/configuration/RangeSlider.tsx | 53 +++++ components/configuration/ServerWidgetTab.tsx | 49 ++--- components/configuration/ThemeTab.tsx | 157 +++++---------- components/icons.tsx | 38 ++++ components/layout/CategoryGroup.tsx | 34 +--- components/layout/EditButton.tsx | 8 +- components/layout/Header.tsx | 52 +---- components/services/ConfigurationService.ts | 13 +- components/utils/StorageLocalManager.ts | 127 +++++------- components/utils/iconService.ts | 34 +++- components/utils/styleUtils.ts | 64 +++++++ components/utils/urlUtils.ts | 9 + components/utils/wallpaperUtils.ts | 24 +++ project-context.md | 24 ++- 25 files changed, 561 insertions(+), 625 deletions(-) create mode 100644 components/ModalShell.tsx create mode 100644 components/configuration/RangeSlider.tsx create mode 100644 components/icons.tsx create mode 100644 components/utils/styleUtils.ts create mode 100644 components/utils/urlUtils.ts create mode 100644 components/utils/wallpaperUtils.ts diff --git a/App.tsx b/App.tsx index b2b5cbb..cdf2396 100644 --- a/App.tsx +++ b/App.tsx @@ -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(() => { try { @@ -224,13 +195,12 @@ const App: React.FC = () => { setAddingWebsite={setAddingWebsite} setEditingWebsite={setEditingWebsite} handleMoveWebsite={handleMoveWebsite} - getHorizontalAlignmentClass={getHorizontalAlignmentClass} horizontalAlignment={config.horizontalAlignment} tileSize={config.tileSize} /> ))} {isEditing && ( -
+
diff --git a/components/CategoryEditModal.tsx b/components/CategoryEditModal.tsx index dccf6f9..eb6bb76 100644 --- a/components/CategoryEditModal.tsx +++ b/components/CategoryEditModal.tsx @@ -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 = ({ category, edit, onClose, onSave, onDelete }) => { const [name, setName] = useState(category ? category.name : ''); - const handleOverlayClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - return ( -
-
-

{edit ? 'Edit Category' : 'Add Category'}

-
- setName(e.target.value)} - className="liquid-input p-3" - /> -
-
-
- {edit && ( - - )} -
-
- - -
-
-
-
+ onSave(name)} + onDelete={edit ? onDelete : undefined} + > + setName(e.target.value)} + className="liquid-input p-3" + /> + ); }; -export default CategoryEditModal; +export default CategoryEditModal; \ No newline at end of file diff --git a/components/Clock.tsx b/components/Clock.tsx index c29af91..d400631 100644 --- a/components/Clock.tsx +++ b/components/Clock.tsx @@ -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 = ({ config, getClockSizeClass }) => { +const Clock: React.FC = ({ config }) => { const [time, setTime] = useState(new Date()); useEffect(() => { @@ -66,4 +66,4 @@ const Clock: React.FC = ({ config, getClockSizeClass }) => { ); }; -export default Clock; +export default Clock; \ No newline at end of file diff --git a/components/ConfigurationModal.tsx b/components/ConfigurationModal.tsx index 42da019..71f2f10 100644 --- a/components/ConfigurationModal.tsx +++ b/components/ConfigurationModal.tsx @@ -28,7 +28,6 @@ const ConfigurationModal: React.FC = ({ const [userWallpapers, setUserWallpapers] = useState([]); const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false); const [isVisible, setIsVisible] = useState(false); - const menuRef = useRef(null); const importInputRef = useRef(null); const isSaving = useRef(false); @@ -64,8 +63,8 @@ const ConfigurationModal: React.FC = ({ setConfig((prev) => ({ ...prev, ...updates })); }; - const handleAddWallpaper = async (name: string, url: string) => { - const newWallpaper = await ConfigurationService.addWallpaper(name, url); + const handleAddWallpaperEntry = async (promise: Promise) => { + const newWallpaper = await promise; const updated = [...userWallpapers, newWallpaper]; setUserWallpapers(updated); ConfigurationService.saveUserWallpapers(updated); @@ -75,16 +74,11 @@ const ConfigurationModal: React.FC = ({ })); }; - 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 = ({ />
void; + onSave: () => void; + onDelete?: () => void; + children: React.ReactNode; +} + +const ModalShell: React.FC = ({ title, edit, onClose, onSave, onDelete, children }) => { + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+

{title}

+
{children}
+
+
+ {edit && onDelete && ( + + )} +
+
+ + +
+
+
+
+ ); +}; + +export default ModalShell; \ No newline at end of file diff --git a/components/ServerWidget.tsx b/components/ServerWidget.tsx index f43c606..6cabc7b 100644 --- a/components/ServerWidget.tsx +++ b/components/ServerWidget.tsx @@ -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 = { + 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 = ({ config }) => { const [serverStatus, setServerStatus] = useState>({}); const serversRef = useRef(config.serverWidget.servers); diff --git a/components/Wallpaper.tsx b/components/Wallpaper.tsx index 8ffacd4..45ae56a 100644 --- a/components/Wallpaper.tsx +++ b/components/Wallpaper.tsx @@ -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(); +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 => { if (!name) return undefined; if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name); @@ -65,18 +55,19 @@ const getWallpaperUrlByName = async (name: string): Promise } } - wallpaperUrlCache.set(name, resolved); + rememberWallpaperUrl(name, resolved); return resolved; }; const Wallpaper: React.FC = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => { const [imageUrl, setImageUrl] = useState(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ wallpaperNames, blur, brightness, ); }; -export default Wallpaper; +export default Wallpaper; \ No newline at end of file diff --git a/components/WebsiteEditModal.tsx b/components/WebsiteEditModal.tsx index 186f4b4..3fa5025 100644 --- a/components/WebsiteEditModal.tsx +++ b/components/WebsiteEditModal.tsx @@ -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 = ({ 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 = ({ website, edit, onCl const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null); const debounceRef = useRef(null); + useEffect(() => { + return () => { + iconMetadataCache = null; + }; + }, []); + const ensureIconMetadata = () => { if (iconMetadataCache) { setIconMetadata(iconMetadataCache); @@ -68,10 +78,10 @@ const WebsiteEditModal: React.FC = ({ 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 = ({ website, edit, onCl } }; - const handleSave = () => { - onSave({ id: website?.id, name, url, icon }); - }; - - const handleOverlayClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - return ( -
-
-

{edit ? 'Edit Website' : 'Add Website'}

-
-
- {icon ? ( - Website Icon - ) : ( -
- - - - - -
- )} + onSave({ id: website?.id, name, url, icon })} + onDelete={edit ? onDelete : undefined} + > +
+ {icon ? ( + Website Icon + ) : ( +
+ + + + +
- setName(e.target.value)} - className="liquid-input p-3" - /> - setUrl(e.target.value)} - className="liquid-input p-3" - /> -
-
- { - setIcon(e.target.value); - setIconQuery(e.target.value); - }} - onFocus={ensureIconMetadata} - className="liquid-input p-3" - /> - {filteredIcons.length > 0 && ( -
- {filteredIcons.map(iconData => ( -
{ - 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" - > - {iconData.name} - {iconData.name} -
- ))} -
- )} -
- -
-
-
-
- {edit && ( - - )} -
-
- - -
-
+ )}
-
+ setName(e.target.value)} + className="liquid-input p-3" + /> + setUrl(e.target.value)} + className="liquid-input p-3" + /> +
+
+ { + setIcon(e.target.value); + setIconQuery(e.target.value); + }} + onFocus={ensureIconMetadata} + className="liquid-input p-3" + /> + {filteredIcons.length > 0 && ( +
+ {filteredIcons.map((iconData, index) => ( +
{ + setIcon(getIconPickUrl(iconData)); + setFilteredIcons([]); + }} + className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20" + > + {iconData.name} + {iconData.name} +
+ ))} +
+ )} +
+ +
+ ); }; -export default WebsiteEditModal; +export default WebsiteEditModal; \ No newline at end of file diff --git a/components/WebsiteTile.tsx b/components/WebsiteTile.tsx index e1397c3..5287bba 100644 --- a/components/WebsiteTile.tsx +++ b/components/WebsiteTile.tsx @@ -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 = ({ website, isEditing, onEdit, onMove, tileSize }) => { const [isLoading, setIsLoading] = useState(false); @@ -141,15 +103,9 @@ const WebsiteTile: React.FC = ({ website, isEditing, onEdit, o {isEditing && (
- - - + + +
)}
diff --git a/components/configuration/ClockTab.tsx b/components/configuration/ClockTab.tsx index a509210..7b628bc 100644 --- a/components/configuration/ClockTab.tsx +++ b/components/configuration/ClockTab.tsx @@ -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 = ({ 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} />
diff --git a/components/configuration/GeneralTab.tsx b/components/configuration/GeneralTab.tsx index cfd20f7..9820180 100644 --- a/components/configuration/GeneralTab.tsx +++ b/components/configuration/GeneralTab.tsx @@ -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 = ({ 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} />
diff --git a/components/configuration/RangeSlider.tsx b/components/configuration/RangeSlider.tsx new file mode 100644 index 0000000..e2256ad --- /dev/null +++ b/components/configuration/RangeSlider.tsx @@ -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 = ({ + label, + value, + min, + max, + step = 1, + valueSuffix, + formatValue, + onChange, +}) => { + return ( +
+ +
+ onChange(Number(e.target.value))} + className="liquid-range" + style={getRangeStyle(value, min, max)} + /> + + {formatValue ? formatValue(value) : `${value}${valueSuffix ?? ''}`} + +
+
+ ); +}; + +export default RangeSlider; \ No newline at end of file diff --git a/components/configuration/ServerWidgetTab.tsx b/components/configuration/ServerWidgetTab.tsx index 25395e3..fc63b5c 100644 --- a/components/configuration/ServerWidgetTab.tsx +++ b/components/configuration/ServerWidgetTab.tsx @@ -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) => 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 = ({ config, onChange }) => { const [newServerName, setNewServerName] = useState(''); const [newServerAddress, setNewServerAddress] = useState(''); @@ -60,21 +55,14 @@ const ServerWidgetTab: React.FC = ({ config, onChange }) =
{config.serverWidget.enabled && ( <> -
- -
- updateServerWidget({ pingFrequency: Number(e.target.value) })} - className="liquid-range" - style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)} - /> - {config.serverWidget.pingFrequency}s -
-
+ updateServerWidget({ pingFrequency: value })} + />

Servers

@@ -103,20 +91,7 @@ const ServerWidgetTab: React.FC = ({ config, onChange }) = className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100" aria-label={`Remove ${server.name}`} > - - - - +
)} @@ -156,4 +131,4 @@ const ServerWidgetTab: React.FC = ({ config, onChange }) = ); }; -export default ServerWidgetTab; +export default ServerWidgetTab; \ No newline at end of file diff --git a/components/configuration/ThemeTab.tsx b/components/configuration/ThemeTab.tsx index 042f8ab..990d7b7 100644 --- a/components/configuration/ThemeTab.tsx +++ b/components/configuration/ThemeTab.tsx @@ -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 = ({ config, onChange, @@ -52,7 +35,6 @@ const ThemeTab: React.FC = ({ }) => { const [newWallpaperName, setNewWallpaperName] = useState(''); const [newWallpaperUrl, setNewWallpaperUrl] = useState(''); - const fileInputRef = useRef(null); const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency); const handleAddWallpaper = async () => { @@ -76,8 +58,8 @@ const ThemeTab: React.FC = ({ 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 = ({ />
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && ( -
- -
- onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })} - className="liquid-range" - style={getRangeStyle( - wallpaperFrequencyHours, - MIN_WALLPAPER_FREQUENCY_HOURS, - MAX_WALLPAPER_FREQUENCY_HOURS, - )} - /> - - {formatWallpaperFrequency(wallpaperFrequencyHours)} - -
-
+ onChange({ wallpaperFrequency: `${value}h` })} + /> )} -
- -
- onChange({ wallpaperBlur: Number(e.target.value) })} - className="liquid-range" - style={getRangeStyle(config.wallpaperBlur, 0, 50)} - /> - {config.wallpaperBlur}px -
-
-
- -
- onChange({ wallpaperBrightness: Number(e.target.value) })} - className="liquid-range" - style={getRangeStyle(config.wallpaperBrightness, 0, 200)} - /> - {config.wallpaperBrightness}% -
-
-
- -
- onChange({ wallpaperOpacity: Number(e.target.value) })} - className="liquid-range" - style={getRangeStyle(config.wallpaperOpacity, 1, 100)} - /> - {config.wallpaperOpacity}% -
-
+ onChange({ wallpaperBlur: value })} + /> + onChange({ wallpaperBrightness: value })} + /> + onChange({ wallpaperOpacity: value })} + />

User Wallpapers

@@ -178,20 +125,7 @@ const ThemeTab: React.FC = ({ className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100" aria-label={`Delete ${wallpaper.name}`} > - - - - +
))} @@ -254,7 +188,6 @@ const ThemeTab: React.FC = ({ type="file" className="hidden" onChange={handleFileUpload} - ref={fileInputRef} />
@@ -283,4 +216,4 @@ const ThemeTab: React.FC = ({ ); }; -export default ThemeTab; +export default ThemeTab; \ No newline at end of file diff --git a/components/icons.tsx b/components/icons.tsx new file mode 100644 index 0000000..4ec1cf0 --- /dev/null +++ b/components/icons.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +interface IconProps { + size?: number; + className?: string; +} + +export const PencilIcon: React.FC = ({ size = 14, className }) => ( + +); + +export const PlusIcon: React.FC = ({ size = 22, className }) => ( + +); + +export const TrashIcon: React.FC = ({ size = 16, className }) => ( + +); + +export const ChevronLeftIcon: React.FC = ({ size = 14, className }) => ( + +); + +export const ChevronRightIcon: React.FC = ({ size = 14, className }) => ( + +); \ No newline at end of file diff --git a/components/layout/CategoryGroup.tsx b/components/layout/CategoryGroup.tsx index 0fcb5c5..43fca4d 100644 --- a/components/layout/CategoryGroup.tsx +++ b/components/layout/CategoryGroup.tsx @@ -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 = ({ category, isEditing, @@ -36,13 +24,12 @@ const CategoryGroup: React.FC = ({ setAddingWebsite, setEditingWebsite, handleMoveWebsite, - getHorizontalAlignmentClass, horizontalAlignment, tileSize, }) => { return (
-
+

{category.name}

{isEditing && ( )}
-
+
{category.websites.map((website) => ( = ({ {isEditing && ( )} @@ -88,4 +70,4 @@ const CategoryGroup: React.FC = ({ ); }; -export default memo(CategoryGroup); +export default memo(CategoryGroup); \ No newline at end of file diff --git a/components/layout/EditButton.tsx b/components/layout/EditButton.tsx index 18ec3e6..c212732 100644 --- a/components/layout/EditButton.tsx +++ b/components/layout/EditButton.tsx @@ -1,4 +1,4 @@ - +import { PencilIcon } from '../icons'; interface EditButtonProps { isEditing: boolean; @@ -13,13 +13,11 @@ const EditButton: React.FC = ({ 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'} > - + {isEditing ? 'Done' : ''}
); }; -export default EditButton; +export default EditButton; \ No newline at end of file diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 91e27ff..fcbb202 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -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 = ({ config }) => { return ( <> {config.clock.enabled && (
- +
)}
@@ -75,4 +29,4 @@ const Header: React.FC = ({ config }) => { ); }; -export default Header; +export default Header; \ No newline at end of file diff --git a/components/services/ConfigurationService.ts b/components/services/ConfigurationService.ts index 43b23d1..6b34de8 100644 --- a/components/services/ConfigurationService.ts +++ b/components/services/ConfigurationService.ts @@ -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 => 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 }; }, diff --git a/components/utils/StorageLocalManager.ts b/components/utils/StorageLocalManager.ts index b270ed6..acadf1d 100644 --- a/components/utils/StorageLocalManager.ts +++ b/components/utils/StorageLocalManager.ts @@ -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 { - if (!checkChromeStorageLocalAvailable()) return null; +type StorageResult = { [key: string]: string }; - return new Promise((resolve) => { +const chromeLocalCall = ( + operation: (callback: (result: T) => void) => void, +): Promise => + new Promise((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 { + if (!checkChromeStorageLocalAvailable()) return null; + try { + const key = getIconCacheKey(sourceUrl); + const result = await chromeLocalCall((cb) => + window.chrome?.storage?.local?.get([key], cb), + ); + return result[key] || null; + } catch { + return null; + } } export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise { if (!checkChromeStorageLocalAvailable()) return false; - - return new Promise((resolve) => { - if (!window.chrome?.storage?.local) { - resolve(false); - return; - } - - window.chrome.storage.local.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, function () { - resolve(!window.chrome?.runtime?.lastError); - }); - }); + try { + await chromeLocalCall((cb) => + window.chrome?.storage?.local?.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, cb), + ); + return true; + } catch { + return false; + } } export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise { if (!checkChromeStorageLocalAvailable()) return false; - - return new Promise((resolve) => { - if (!window.chrome?.storage?.local) { - resolve(false); - return; - } - - window.chrome.storage.local.remove(getIconCacheKey(sourceUrl), function () { - resolve(!window.chrome?.runtime?.lastError); - }); - }); + try { + await chromeLocalCall((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((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((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((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((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((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((cb) => + window.chrome?.storage?.local?.remove(name, cb), + ); +} \ No newline at end of file diff --git a/components/utils/iconService.ts b/components/utils/iconService.ts index 39f0022..0fd25d1 100644 --- a/components/utils/iconService.ts +++ b/components/utils/iconService.ts @@ -6,10 +6,19 @@ import { } from './StorageLocalManager'; const MAX_CACHED_ICON_BYTES = 256 * 1024; +const MAX_RESOLVED_ICONS = 50; const resolvedIconCache = new Map(); const iconCacheLookups = new Map>(); const iconCacheRequests = new Map>(); +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 => reader.readAsDataURL(blob); }); -async function getWebsiteIcon(url: string): Promise { +async function getWebsiteIcon(rawUrl: string): Promise { + 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 { 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 { 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 { @@ -80,7 +98,7 @@ async function getCachedWebsiteIcon(iconUrl: string): Promise { 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 { } const dataUrl = await blobToDataUrl(blob); - resolvedIconCache.set(iconUrl, dataUrl); + rememberResolvedIcon(iconUrl, dataUrl); await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl); return dataUrl; } catch { diff --git a/components/utils/styleUtils.ts b/components/utils/styleUtils.ts new file mode 100644 index 0000000..4bea93d --- /dev/null +++ b/components/utils/styleUtils.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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'; \ No newline at end of file diff --git a/components/utils/urlUtils.ts b/components/utils/urlUtils.ts new file mode 100644 index 0000000..9b97ec0 --- /dev/null +++ b/components/utils/urlUtils.ts @@ -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; + } +}; \ No newline at end of file diff --git a/components/utils/wallpaperUtils.ts b/components/utils/wallpaperUtils.ts new file mode 100644 index 0000000..a0d9f6c --- /dev/null +++ b/components/utils/wallpaperUtils.ts @@ -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; +}; \ No newline at end of file diff --git a/project-context.md b/project-context.md index dde6c0c..49b0944 100644 --- a/project-context.md +++ b/project-context.md @@ -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:` 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 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "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._ From 4885fd68c138d7c55cceb4a3109446a85700ff6d Mon Sep 17 00:00:00 2001 From: Jose Henrique Date: Tue, 11 Aug 2026 19:55:29 -0300 Subject: [PATCH 2/2] fixing pipeline --- .gitea/workflows/main.yaml | 14 ++++++++++++-- .gitea/workflows/pull-request.yaml | 2 +- .gitea/workflows/release.yaml | 23 ++++++++++++++++++----- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/main.yaml b/.gitea/workflows/main.yaml index f63e9bd..778e92c 100644 --- a/.gitea/workflows/main.yaml +++ b/.gitea/workflows/main.yaml @@ -39,20 +39,30 @@ jobs: - name: Prepare release run: | + mkdir -p vision-start mv dist vision-start/ mv extension vision-start/ mv manifest.json vision-start/ + - name: Set archive name + run: | + SAFE_REF="${GITEA_REF_NAME//\//-}" + ARCHIVE="vision-start-${SAFE_REF}.zip" + if [ -n "${GITEA_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITEA_ENV"; fi + if [ -n "${GITHUB_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITHUB_ENV"; fi + env: + GITEA_REF_NAME: ${{ gitea.ref_name }} + - name: Create zip archive run: | cd vision-start - zip -r ../vision-start-${{ gitea.ref_name }}.zip * + zip -r "../${ARCHIVE_NAME}" * - name: Upload artifact uses: actions/upload-artifact@v3 with: name: release-zip - path: vision-start-${{ gitea.ref_name }}.zip + path: ${{ env.ARCHIVE_NAME }} build_vision_start: name: Build Vision Start Image diff --git a/.gitea/workflows/pull-request.yaml b/.gitea/workflows/pull-request.yaml index b61ef88..50c6b40 100644 --- a/.gitea/workflows/pull-request.yaml +++ b/.gitea/workflows/pull-request.yaml @@ -37,7 +37,7 @@ jobs: - name: Prepare archive run: | - mkdir vision-start + mkdir -p vision-start mv dist vision-start/ mv extension vision-start/ mv manifest.json vision-start/ diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 6ac8f96..54defc4 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -15,7 +15,7 @@ jobs: build: runs-on: ubuntu-latest outputs: - zip-file: vision-start-${{ gitea.ref_name }}.zip + zip-file: ${{ steps.set-archive.outputs.archive-name }} steps: - name: Check out repository code uses: actions/checkout@v4 @@ -50,20 +50,33 @@ jobs: - name: Prepare release run: | + mkdir -p vision-start mv dist vision-start/ mv extension vision-start/ mv manifest.json vision-start/ + - name: Set archive name + id: set-archive + run: | + SAFE_REF="${GITEA_REF_NAME//\//-}" + ARCHIVE="vision-start-${SAFE_REF}.zip" + if [ -n "${GITEA_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITEA_ENV"; fi + if [ -n "${GITHUB_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITHUB_ENV"; fi + if [ -n "${GITEA_OUTPUT:-}" ]; then echo "archive-name=${ARCHIVE}" >> "$GITEA_OUTPUT"; fi + if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "archive-name=${ARCHIVE}" >> "$GITHUB_OUTPUT"; fi + env: + GITEA_REF_NAME: ${{ gitea.ref_name }} + - name: Create zip archive run: | cd vision-start - zip -r ../vision-start-${{ gitea.ref_name }}.zip * + zip -r "../${ARCHIVE_NAME}" * - name: Upload artifact uses: actions/upload-artifact@v3 with: name: release-zip - path: vision-start-${{ gitea.ref_name }}.zip + path: ${{ env.ARCHIVE_NAME }} virus-total-check: runs-on: ubuntu-latest @@ -84,7 +97,7 @@ jobs: id: vt-check env: virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }} - VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip + VIRUS_TOTAL_FILE: ${{ needs.build.outputs.zip-file }} run: | # Run the VirusTotal check script and capture output in real-time set -o pipefail @@ -124,7 +137,7 @@ jobs: name: ${{ gitea.ref_name }} tag_name: ${{ gitea.ref_name }} files: | - vision-start-${{ gitea.ref_name }}.zip + ${{ needs.build.outputs.zip-file }} release-screenshots/home.png release-screenshots/editing.png release-screenshots/configuration.png