Merge pull request 'Feat/general enhancements' (#4) from feat/general-enhancements into main
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m13s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
Build and Release / build (push) Successful in 11m13s
Build and Release / virus-total-check (push) Successful in 4m37s
Build and Release / Build Vision Start Image (push) Successful in 1m7s
Build and Release / Deploy Vision Start (production) (push) Successful in 3s
Build and Release / Capture Vision Start Screenshots (push) Successful in 54s
Build and Release / release (push) Successful in 10s

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-08-12 00:25:13 +00:00
28 changed files with 592 additions and 633 deletions
+12 -2
View File
@@ -39,20 +39,30 @@ jobs:
- name: Prepare release - name: Prepare release
run: | run: |
mkdir -p vision-start
mv dist vision-start/ mv dist vision-start/
mv extension vision-start/ mv extension vision-start/
mv manifest.json 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 - name: Create zip archive
run: | run: |
cd vision-start cd vision-start
zip -r ../vision-start-${{ gitea.ref_name }}.zip * zip -r "../${ARCHIVE_NAME}" *
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: release-zip name: release-zip
path: vision-start-${{ gitea.ref_name }}.zip path: ${{ env.ARCHIVE_NAME }}
build_vision_start: build_vision_start:
name: Build Vision Start Image name: Build Vision Start Image
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
- name: Prepare archive - name: Prepare archive
run: | run: |
mkdir vision-start mkdir -p vision-start
mv dist vision-start/ mv dist vision-start/
mv extension vision-start/ mv extension vision-start/
mv manifest.json vision-start/ mv manifest.json vision-start/
+18 -5
View File
@@ -15,7 +15,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
zip-file: vision-start-${{ gitea.ref_name }}.zip zip-file: ${{ steps.set-archive.outputs.archive-name }}
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -50,20 +50,33 @@ jobs:
- name: Prepare release - name: Prepare release
run: | run: |
mkdir -p vision-start
mv dist vision-start/ mv dist vision-start/
mv extension vision-start/ mv extension vision-start/
mv manifest.json 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 - name: Create zip archive
run: | run: |
cd vision-start cd vision-start
zip -r ../vision-start-${{ gitea.ref_name }}.zip * zip -r "../${ARCHIVE_NAME}" *
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: release-zip name: release-zip
path: vision-start-${{ gitea.ref_name }}.zip path: ${{ env.ARCHIVE_NAME }}
virus-total-check: virus-total-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -84,7 +97,7 @@ jobs:
id: vt-check id: vt-check
env: env:
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }} virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip VIRUS_TOTAL_FILE: ${{ needs.build.outputs.zip-file }}
run: | run: |
# Run the VirusTotal check script and capture output in real-time # Run the VirusTotal check script and capture output in real-time
set -o pipefail set -o pipefail
@@ -124,7 +137,7 @@ jobs:
name: ${{ gitea.ref_name }} name: ${{ gitea.ref_name }}
tag_name: ${{ gitea.ref_name }} tag_name: ${{ gitea.ref_name }}
files: | files: |
vision-start-${{ gitea.ref_name }}.zip ${{ needs.build.outputs.zip-file }}
release-screenshots/home.png release-screenshots/home.png
release-screenshots/editing.png release-screenshots/editing.png
release-screenshots/configuration.png release-screenshots/configuration.png
+5 -38
View File
@@ -8,43 +8,14 @@ import ConfigurationButton from './components/layout/ConfigurationButton';
import CategoryGroup from './components/layout/CategoryGroup'; import CategoryGroup from './components/layout/CategoryGroup';
import Wallpaper from './components/Wallpaper'; import Wallpaper from './components/Wallpaper';
import { ConfigurationService } from './components/services/ConfigurationService'; 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 ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal')); const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal')); 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 App: React.FC = () => {
const [categories, setCategories] = useState<Category[]>(() => { const [categories, setCategories] = useState<Category[]>(() => {
try { try {
@@ -224,13 +195,12 @@ const App: React.FC = () => {
setAddingWebsite={setAddingWebsite} setAddingWebsite={setAddingWebsite}
setEditingWebsite={setEditingWebsite} setEditingWebsite={setEditingWebsite}
handleMoveWebsite={handleMoveWebsite} handleMoveWebsite={handleMoveWebsite}
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
horizontalAlignment={config.horizontalAlignment} horizontalAlignment={config.horizontalAlignment}
tileSize={config.tileSize} tileSize={config.tileSize}
/> />
))} ))}
{isEditing && ( {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 <button
onClick={() => { onClick={() => {
setEditingCategory(null); 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" className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
aria-label="Add category" 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"> <PlusIcon size={22} />
<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>
Add category Add category
</button> </button>
</div> </div>
+17 -38
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Category } from '../types'; import { Category } from '../types';
import ModalShell from './ModalShell';
interface CategoryEditModalProps { interface CategoryEditModalProps {
category?: Category; category?: Category;
@@ -12,45 +13,23 @@ interface CategoryEditModalProps {
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => { const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(category ? category.name : ''); const [name, setName] = useState(category ? category.name : '');
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return ( return (
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}> <ModalShell
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white"> title={edit ? 'Edit Category' : 'Add Category'}
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2> edit={edit}
<div className="flex flex-col gap-4"> onClose={onClose}
<input onSave={() => onSave(name)}
type="text" onDelete={edit ? onDelete : undefined}
placeholder="Category Name" >
value={name} <input
onChange={(e) => setName(e.target.value)} type="text"
className="liquid-input p-3" placeholder="Category Name"
/> value={name}
</div> onChange={(e) => setName(e.target.value)}
<div className="flex justify-between items-center mt-8"> className="liquid-input p-3"
<div> />
{edit && ( </ModalShell>
<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>
); );
}; };
export default CategoryEditModal; export default CategoryEditModal;
+3 -3
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { getClockSizeClass } from './utils/styleUtils';
interface ClockProps { interface ClockProps {
config: { config: {
@@ -9,10 +10,9 @@ interface ClockProps {
format: string; 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()); const [time, setTime] = useState(new Date());
useEffect(() => { 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 [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false); const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const importInputRef = useRef<HTMLInputElement>(null); const importInputRef = useRef<HTMLInputElement>(null);
const isSaving = useRef(false); const isSaving = useRef(false);
@@ -64,8 +63,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
setConfig((prev) => ({ ...prev, ...updates })); setConfig((prev) => ({ ...prev, ...updates }));
}; };
const handleAddWallpaper = async (name: string, url: string) => { const handleAddWallpaperEntry = async (promise: Promise<Wallpaper>) => {
const newWallpaper = await ConfigurationService.addWallpaper(name, url); const newWallpaper = await promise;
const updated = [...userWallpapers, newWallpaper]; const updated = [...userWallpapers, newWallpaper];
setUserWallpapers(updated); setUserWallpapers(updated);
ConfigurationService.saveUserWallpapers(updated); ConfigurationService.saveUserWallpapers(updated);
@@ -75,16 +74,11 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
})); }));
}; };
const handleAddWallpaperFile = async (file: File) => { const handleAddWallpaper = (name: string, url: string) =>
const newWallpaper = await ConfigurationService.addWallpaperFile(file); handleAddWallpaperEntry(ConfigurationService.addWallpaper(name, url));
const updated = [...userWallpapers, newWallpaper];
setUserWallpapers(updated); const handleAddWallpaperFile = (file: File) =>
ConfigurationService.saveUserWallpapers(updated); handleAddWallpaperEntry(ConfigurationService.addWallpaperFile(file));
setConfig((prev) => ({
...prev,
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
}));
};
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => { const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
try { try {
@@ -140,7 +134,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
/> />
<div <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 ${ 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' 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) => { const STATUS_COLORS: Record<string, string> = {
switch (status) { online: 'bg-green-400 text-green-400',
case 'online': offline: 'bg-red-400 text-red-400',
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 getStatusColor = (status: string): string =>
STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400';
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => { const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
const [serverStatus, setServerStatus] = useState<Record<string, string>>({}); const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
const serversRef = useRef(config.serverWidget.servers); const serversRef = useRef(config.serverWidget.servers);
+25 -28
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { baseWallpapers } from './utils/baseWallpapers'; import { baseWallpapers } from './utils/baseWallpapers';
import { Wallpaper as WallpaperType } from '../types'; import { Wallpaper as WallpaperType } from '../types';
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager'; import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
import { getRandomWallpaperIndex, getWallpaperFrequencyMs } from './utils/wallpaperUtils';
interface WallpaperProps { interface WallpaperProps {
wallpaperNames: string[]; wallpaperNames: string[];
@@ -13,28 +13,18 @@ interface WallpaperProps {
wallpaperVersion: number; wallpaperVersion: number;
} }
const MIN_WALLPAPER_FREQUENCY_MS = 60 * 60 * 1000; const MAX_WALLPAPER_URL_CACHE = 3;
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 wallpaperUrlCache = new Map<string, string | undefined>(); 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> => { const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
if (!name) return undefined; if (!name) return undefined;
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name); 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; return resolved;
}; };
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => { const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined); const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
const resolvedRef = useRef(false);
useEffect(() => { useEffect(() => {
let cancelled = false;
const updateWallpaper = async () => { const updateWallpaper = async () => {
if (wallpaperNames.length === 0) { if (wallpaperNames.length === 0) {
setImageUrl(undefined); if (!cancelled) setImageUrl(undefined);
localStorage.setItem( localStorage.setItem(
'wallpaperState', 'wallpaperState',
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }), 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() ? new Date(wallpaperState.lastWallpaperChange).getTime()
: 0; : 0;
const now = Date.now(); const now = Date.now();
const freqMs = parseFrequencyToMs(wallpaperFrequency); const freqMs = getWallpaperFrequencyMs(wallpaperFrequency);
let storedIndex = let storedIndex =
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0; typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
@@ -107,6 +98,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
if (tried.has(resolvedIndex)) break; if (tried.has(resolvedIndex)) break;
tried.add(resolvedIndex); tried.add(resolvedIndex);
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]); const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
if (cancelled) return;
if (url) { if (url) {
resolvedUrl = url; resolvedUrl = url;
break; break;
@@ -114,6 +106,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length; resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
} }
if (cancelled) return;
const nextLastChange = shouldRotate const nextLastChange = shouldRotate
? new Date().toISOString() ? new Date().toISOString()
: wallpaperState.lastWallpaperChange || 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); setImageUrl(resolvedUrl);
}; };
updateWallpaper(); updateWallpaper();
return () => {
cancelled = true;
};
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]); }, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
if (!imageUrl) return null; 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 { useState, useEffect, useRef } from 'react';
import { Website } from '../types'; import { Website } from '../types';
import { getWebsiteIcon } from './utils/iconService'; import { getWebsiteIcon } from './utils/iconService';
import ModalShell from './ModalShell';
interface WebsiteEditModalProps { interface WebsiteEditModalProps {
website?: Website; website?: Website;
@@ -27,6 +28,9 @@ interface IconMetadata {
let iconMetadataCache: IconMetadata[] | null = null; 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 WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(website ? website.name : ''); const [name, setName] = useState(website ? website.name : '');
const [url, setUrl] = useState(website ? website.url : ''); 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 [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
const debounceRef = useRef<number | null>(null); const debounceRef = useRef<number | null>(null);
useEffect(() => {
return () => {
iconMetadataCache = null;
};
}, []);
const ensureIconMetadata = () => { const ensureIconMetadata = () => {
if (iconMetadataCache) { if (iconMetadataCache) {
setIconMetadata(iconMetadataCache); setIconMetadata(iconMetadataCache);
@@ -68,10 +78,10 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
filtered.push(ic); filtered.push(ic);
if (filtered.length >= 50) break; if (filtered.length >= 50) break;
} }
if (ic.colors) { if (ic.colors && typeof ic.colors === 'object') {
const colors = Object.values(ic.colors).filter(key => key !== ic.name); const colors = Object.values(ic.colors).filter(key => typeof key === 'string' && key !== ic.name);
for (const color of colors) { for (const color of colors as string[]) {
if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) { if (color.toLowerCase().includes(lowerCaseQuery)) {
filtered.push({ ...ic, name: color }); filtered.push({ ...ic, name: color });
if (filtered.length >= 50) break; 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 ( return (
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}> <ModalShell
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white"> title={edit ? 'Edit Website' : 'Add Website'}
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2> edit={edit}
<div className="flex flex-col gap-4"> onClose={onClose}
<div className="flex justify-center mb-4"> onSave={() => onSave({ id: website?.id, name, url, icon })}
{icon ? ( onDelete={edit ? onDelete : undefined}
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" /> >
) : ( <div className="flex justify-center mb-4">
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center"> {icon ? (
<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"> <img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
<circle cx="12" cy="12" r="10"></circle> ) : (
<line x1="2" y1="12" x2="22" y2="12"></line> <div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
<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 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">
</svg> <circle cx="12" cy="12" r="10"></circle>
</div> <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> </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>
</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 React, { memo, useEffect, useState } from 'react';
import { Website } from '../types'; import { Website } from '../types';
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService'; import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
interface WebsiteTileProps { interface WebsiteTileProps {
website: Website; website: Website;
@@ -10,46 +12,6 @@ interface WebsiteTileProps {
tileSize?: string; 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 WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -141,15 +103,9 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
</a> </a>
{isEditing && ( {isEditing && (
<div className="liquid-surface liquid-edit-toolbar"> <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"> <button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><ChevronLeftIcon size={14} /></button>
<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" /> <button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><PencilIcon size={14} /></button>
</svg></button> <button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><ChevronRightIcon size={14} /></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>
</div> </div>
)} )}
</div> </div>
+2 -6
View File
@@ -2,6 +2,7 @@ import React from 'react';
import Dropdown from '../Dropdown'; import Dropdown from '../Dropdown';
import ToggleSwitch from '../ToggleSwitch'; import ToggleSwitch from '../ToggleSwitch';
import { Config } from '../../types'; import { Config } from '../../types';
import { SIZE_OPTIONS } from '../utils/styleUtils';
interface ClockTabProps { interface ClockTabProps {
config: Config; config: Config;
@@ -28,12 +29,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
name="clock.size" name="clock.size"
value={config.clock.size} value={config.clock.size}
onChange={(e) => updateClock({ size: e.target.value as string })} onChange={(e) => updateClock({ size: e.target.value as string })}
options={[ options={SIZE_OPTIONS}
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/> />
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <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 React from 'react';
import Dropdown from '../Dropdown'; import Dropdown from '../Dropdown';
import { Config } from '../../types'; import { Config } from '../../types';
import { SIZE_OPTIONS } from '../utils/styleUtils';
interface GeneralTabProps { interface GeneralTabProps {
config: Config; config: Config;
@@ -25,12 +26,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
name="titleSize" name="titleSize"
value={config.titleSize} value={config.titleSize}
onChange={(e) => onChange({ titleSize: e.target.value as string })} onChange={(e) => onChange({ titleSize: e.target.value as string })}
options={[ options={SIZE_OPTIONS}
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/> />
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <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 { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import ToggleSwitch from '../ToggleSwitch'; import ToggleSwitch from '../ToggleSwitch';
import { Config, Server } from '../../types'; import { Config, Server } from '../../types';
import RangeSlider from './RangeSlider';
import { TrashIcon } from '../icons';
interface ServerWidgetTabProps { interface ServerWidgetTabProps {
config: Config; config: Config;
onChange: (updates: Partial<Config>) => void; 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 ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
const [newServerName, setNewServerName] = useState(''); const [newServerName, setNewServerName] = useState('');
const [newServerAddress, setNewServerAddress] = useState(''); const [newServerAddress, setNewServerAddress] = useState('');
@@ -60,21 +55,14 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
</div> </div>
{config.serverWidget.enabled && ( {config.serverWidget.enabled && (
<> <>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <RangeSlider
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label> label="Ping Frequency"
<div className="flex items-center gap-4"> value={config.serverWidget.pingFrequency}
<input min={5}
type="range" max={60}
min="5" valueSuffix="s"
max="60" onChange={(value) => updateServerWidget({ pingFrequency: value })}
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>
<div> <div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3> <h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
<DragDropContext onDragEnd={onDragEnd}> <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" className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Remove ${server.name}`} aria-label={`Remove ${server.name}`}
> >
<svg <TrashIcon size={16} />
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path
fillRule="evenodd"
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
/>
</svg>
</button> </button>
</div> </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 Dropdown from '../Dropdown';
import { Config, Wallpaper } from '../../types'; 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 { interface ThemeTabProps {
config: Config; config: Config;
@@ -14,31 +22,6 @@ interface ThemeTabProps {
onRandomWallpaper: () => void; 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> = ({ const ThemeTab: React.FC<ThemeTabProps> = ({
config, config,
onChange, onChange,
@@ -52,7 +35,6 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
}) => { }) => {
const [newWallpaperName, setNewWallpaperName] = useState(''); const [newWallpaperName, setNewWallpaperName] = useState('');
const [newWallpaperUrl, setNewWallpaperUrl] = useState(''); const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency); const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
const handleAddWallpaper = async () => { const handleAddWallpaper = async () => {
@@ -76,8 +58,8 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
if (!file) return; if (!file) return;
try { try {
await onAddWallpaperFile(file); await onAddWallpaperFile(file);
} catch (error: any) { } catch (error) {
alert(error?.message || 'Error adding wallpaper. Please try again.'); alert(error instanceof Error ? error.message : 'Error adding wallpaper. Please try again.');
console.error(error); console.error(error);
} }
e.target.value = ''; e.target.value = '';
@@ -96,74 +78,39 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
/> />
</div> </div>
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && ( {Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <RangeSlider
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label> label="Change Frequency"
<div className="flex items-center gap-4"> value={wallpaperFrequencyHours}
<input min={MIN_WALLPAPER_FREQUENCY_HOURS}
type="range" max={MAX_WALLPAPER_FREQUENCY_HOURS}
min={MIN_WALLPAPER_FREQUENCY_HOURS} formatValue={formatWallpaperFrequency}
max={MAX_WALLPAPER_FREQUENCY_HOURS} onChange={(value) => onChange({ wallpaperFrequency: `${value}h` })}
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>
)} )}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <RangeSlider
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label> label="Wallpaper Blur"
<div className="flex items-center gap-4"> value={config.wallpaperBlur}
<input min={0}
type="range" max={50}
min="0" valueSuffix="px"
max="50" onChange={(value) => onChange({ wallpaperBlur: value })}
value={config.wallpaperBlur} />
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })} <RangeSlider
className="liquid-range" label="Wallpaper Brightness"
style={getRangeStyle(config.wallpaperBlur, 0, 50)} value={config.wallpaperBrightness}
/> min={0}
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span> max={200}
</div> valueSuffix="%"
</div> onChange={(value) => onChange({ wallpaperBrightness: value })}
<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> <RangeSlider
<div className="flex items-center gap-4"> label="Wallpaper Opacity"
<input value={config.wallpaperOpacity}
type="range" min={1}
min="0" max={100}
max="200" valueSuffix="%"
value={config.wallpaperBrightness} onChange={(value) => onChange({ wallpaperOpacity: value })}
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>
<div> <div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3> <h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
<div className="flex flex-col gap-2"> <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" className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Delete ${wallpaper.name}`} aria-label={`Delete ${wallpaper.name}`}
> >
<svg <TrashIcon size={16} />
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path
fillRule="evenodd"
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
/>
</svg>
</button> </button>
</div> </div>
))} ))}
@@ -254,7 +188,6 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
type="file" type="file"
className="hidden" className="hidden"
onChange={handleFileUpload} onChange={handleFileUpload}
ref={fileInputRef}
/> />
</label> </label>
</div> </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 React, { memo } from 'react';
import WebsiteTile from '../WebsiteTile'; import WebsiteTile from '../WebsiteTile';
import { Category, Website } from '../../types'; import { Category, Website } from '../../types';
import { getTileSizeClass, getAlignmentClass } from '../utils/styleUtils';
import { PencilIcon, PlusIcon } from '../icons';
interface CategoryGroupProps { interface CategoryGroupProps {
category: Category; category: Category;
@@ -10,24 +12,10 @@ interface CategoryGroupProps {
setAddingWebsite: (category: Category) => void; setAddingWebsite: (category: Category) => void;
setEditingWebsite: (website: Website) => void; setEditingWebsite: (website: Website) => void;
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void; handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
getHorizontalAlignmentClass: (alignment: string) => string;
horizontalAlignment: string; horizontalAlignment: string;
tileSize?: 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> = ({ const CategoryGroup: React.FC<CategoryGroupProps> = ({
category, category,
isEditing, isEditing,
@@ -36,13 +24,12 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
setAddingWebsite, setAddingWebsite,
setEditingWebsite, setEditingWebsite,
handleMoveWebsite, handleMoveWebsite,
getHorizontalAlignmentClass,
horizontalAlignment, horizontalAlignment,
tileSize, tileSize,
}) => { }) => {
return ( return (
<div key={category.id} className="w-full"> <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> <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 && ( {isEditing && (
<button <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'}`} 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`} 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"> <PencilIcon size={14} />
<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>
)} )}
</div> </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) => ( {category.websites.map((website) => (
<WebsiteTile <WebsiteTile
key={website.id} key={website.id}
@@ -73,13 +58,10 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
{isEditing && ( {isEditing && (
<button <button
onClick={() => setAddingWebsite(category)} 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}`} 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"> <PlusIcon size={28} />
<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>
<span className="text-sm font-bold">Add</span> <span className="text-sm font-bold">Add</span>
</button> </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 { interface EditButtonProps {
isEditing: boolean; 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' : ''}`} 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'} 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"> <PencilIcon size={16} />
<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>
{isEditing ? 'Done' : ''} {isEditing ? 'Done' : ''}
</button> </button>
</div> </div>
); );
}; };
export default EditButton; export default EditButton;
+3 -49
View File
@@ -1,63 +1,17 @@
import Clock from '../Clock'; import Clock from '../Clock';
import { Config } from '../../types'; import { Config } from '../../types';
import { getTitleSizeClass } from '../utils/styleUtils';
interface HeaderProps { interface HeaderProps {
config: Config; 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 }) => { const Header: React.FC<HeaderProps> = ({ config }) => {
return ( return (
<> <>
{config.clock.enabled && ( {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"> <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>
)} )}
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}> <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, checkChromeStorageLocalAvailable,
removeWallpaperFromChromeStorageLocal, removeWallpaperFromChromeStorageLocal,
} from '../utils/StorageLocalManager'; } from '../utils/StorageLocalManager';
import { getFileNameFromUrl } from '../utils/urlUtils';
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const; const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number]; type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
@@ -44,16 +45,6 @@ const safeParse = (value: string | null): unknown => {
const toStorageString = (value: unknown): string => const toStorageString = (value: unknown): string =>
typeof value === 'string' ? value : JSON.stringify(value); typeof value === 'string' ? value : JSON.stringify(value);
const getWallpaperNameFromUrl = (url: URL): string => {
const pathName = url.pathname.split('/').filter(Boolean).pop();
if (!pathName) return url.hostname;
try {
return decodeURIComponent(pathName);
} catch {
return pathName;
}
};
const isPlainObject = (v: unknown): v is Record<string, unknown> => const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v); typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -121,7 +112,7 @@ export const ConfigurationService = {
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Wallpaper URLs must use HTTP or 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 }; return { name: finalName, url: parsedUrl.href };
}, },
+53 -74
View File
@@ -1,4 +1,6 @@
// TypeScript interface for window.chrome // TypeScript interface for window.chrome
import { getFileNameFromUrl } from './urlUtils';
declare global { declare global {
interface Window { interface Window {
chrome?: { chrome?: {
@@ -23,7 +25,6 @@ const ICON_CACHE_KEY_PREFIX = 'vision-start:icon:';
const getIconCacheKey = (sourceUrl: string): string => const getIconCacheKey = (sourceUrl: string): string =>
`${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`; `${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`;
/** /**
* Checks if chrome.storage.local is available and caches the result. * Checks if chrome.storage.local is available and caches the result.
*/ */
@@ -37,54 +38,60 @@ export function checkChromeStorageLocalAvailable(): boolean {
return isChromeStorageLocalAvailable; return isChromeStorageLocalAvailable;
} }
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> { type StorageResult = { [key: string]: string };
if (!checkChromeStorageLocalAvailable()) return null;
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) { if (!window.chrome?.storage?.local) {
resolve(null); reject(new Error('chrome.storage.local is not available'));
return; return;
} }
operation((result) => {
const key = getIconCacheKey(sourceUrl);
window.chrome.storage.local.get([key], function (result: { [key: string]: string }) {
if (window.chrome?.runtime?.lastError) { if (window.chrome?.runtime?.lastError) {
resolve(null); reject(new Error(window.chrome.runtime.lastError.message));
return; } 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> { export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
if (!checkChromeStorageLocalAvailable()) return false; if (!checkChromeStorageLocalAvailable()) return false;
try {
return new Promise<boolean>((resolve) => { await chromeLocalCall<void>((cb) =>
if (!window.chrome?.storage?.local) { window.chrome?.storage?.local?.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, cb),
resolve(false); );
return; return true;
} } catch {
return false;
window.chrome.storage.local.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, function () { }
resolve(!window.chrome?.runtime?.lastError);
});
});
} }
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> { export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
if (!checkChromeStorageLocalAvailable()) return false; if (!checkChromeStorageLocalAvailable()) return false;
try {
return new Promise<boolean>((resolve) => { await chromeLocalCall<void>((cb) =>
if (!window.chrome?.storage?.local) { window.chrome?.storage?.local?.remove(getIconCacheKey(sourceUrl), cb),
resolve(false); );
return; return true;
} } catch {
return false;
window.chrome.storage.local.remove(getIconCacheKey(sourceUrl), function () { }
resolve(!window.chrome?.runtime?.lastError);
});
});
} }
/** /**
@@ -113,22 +120,13 @@ export async function addWallpaperToChromeStorageLocal(name: string, url: string
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Wallpaper URLs must use HTTP or 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) => { await chromeLocalCall<void>((cb) =>
if (!window.chrome?.storage?.local) { window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
reject(new Error('chrome.storage.local is not available')); );
return; return finalName;
}
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);
} }
/** /**
@@ -141,19 +139,10 @@ export async function getWallpaperFromChromeStorageLocal(name: string): Promise<
if (!checkChromeStorageLocalAvailable()) { if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available'); throw new Error('chrome.storage.local is not available');
} }
return new Promise<string | null>((resolve, reject) => { const result = await chromeLocalCall<StorageResult>((cb) =>
if (window.chrome?.storage?.local) { window.chrome?.storage?.local?.get([name], cb),
window.chrome.storage.local.get([name], function (result: { [key: string]: string }) { );
if (window.chrome?.runtime?.lastError) { return result[name] || null;
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve(result[name] || null);
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
});
} }
/** /**
@@ -166,17 +155,7 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi
if (!checkChromeStorageLocalAvailable()) { if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available'); throw new Error('chrome.storage.local is not available');
} }
return new Promise<void>((resolve, reject) => { await chromeLocalCall<void>((cb) =>
if (window.chrome?.storage?.local) { window.chrome?.storage?.local?.remove(name, cb),
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'));
}
});
}
+26 -8
View File
@@ -6,10 +6,19 @@ import {
} from './StorageLocalManager'; } from './StorageLocalManager';
const MAX_CACHED_ICON_BYTES = 256 * 1024; const MAX_CACHED_ICON_BYTES = 256 * 1024;
const MAX_RESOLVED_ICONS = 50;
const resolvedIconCache = new Map<string, string>(); const resolvedIconCache = new Map<string, string>();
const iconCacheLookups = new Map<string, Promise<string | null>>(); const iconCacheLookups = new Map<string, Promise<string | null>>();
const iconCacheRequests = 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 isDataUrl = (value: string): boolean => value.startsWith('data:');
const isCacheableIconUrl = (value: string): boolean => { const isCacheableIconUrl = (value: string): boolean => {
@@ -38,9 +47,14 @@ const blobToDataUrl = (blob: Blob): Promise<string> =>
reader.readAsDataURL(blob); 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 { try {
const response = await fetch(url); const response = await fetch(targetUrl);
const html = await response.text(); const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html'); const doc = new DOMParser().parseFromString(html, 'text/html');
@@ -48,7 +62,7 @@ async function getWebsiteIcon(url: string): Promise<string> {
if (appleTouchIcon) { if (appleTouchIcon) {
const href = appleTouchIcon.getAttribute('href'); const href = appleTouchIcon.getAttribute('href');
if (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) { if (iconLink) {
const href = iconLink.getAttribute('href'); const href = iconLink.getAttribute('href');
if (href) { if (href) {
return new URL(href, url).href; return new URL(href, targetUrl).href;
} }
} }
} catch (error) { } catch (error) {
console.error('Error fetching and parsing HTML for icon:', 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> { async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
@@ -80,7 +98,7 @@ async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
const lookup = getCachedIconFromChromeStorageLocal(iconUrl) const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
.then((cachedIcon) => { .then((cachedIcon) => {
if (isValidCachedIcon(cachedIcon)) { if (isValidCachedIcon(cachedIcon)) {
resolvedIconCache.set(iconUrl, cachedIcon); rememberResolvedIcon(iconUrl, cachedIcon);
return cachedIcon; return cachedIcon;
} }
return null; return null;
@@ -121,7 +139,7 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
} }
const dataUrl = await blobToDataUrl(blob); const dataUrl = await blobToDataUrl(blob);
resolvedIconCache.set(iconUrl, dataUrl); rememberResolvedIcon(iconUrl, dataUrl);
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl); await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
return dataUrl; return dataUrl;
} catch { } 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 project_name: Vision Start
date: 2026-08-07 date: 2026-08-11
type: general_overview type: general_overview
--- ---
@@ -65,13 +65,15 @@ The startpage is composed of widgets and a configuration panel:
Performance notes: Performance notes:
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population uses ordinary CORS-enabled `fetch`, deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests. - 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. - `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
- `Clock` updates on the minute boundary (one `setTimeout``setInterval(60_000)`) instead of every second. - `Clock` updates on the minute boundary (one `setTimeout``setInterval(60_000)`) instead of every second.
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles. - `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. - `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. - 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` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`. - `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`): 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. - 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) │ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
│ ├── CategoryEditModal.tsx # Add/edit a category │ ├── CategoryEditModal.tsx # Add/edit a category
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import │ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
│ ├── ModalShell.tsx # Shared centered-modal shell (backdrop, title, footer buttons)
│ ├── ServerWidget.tsx # Bottom server status pill │ ├── ServerWidget.tsx # Bottom server status pill
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select) │ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
│ ├── ToggleSwitch.tsx # Reusable toggle switch │ ├── ToggleSwitch.tsx # Reusable toggle switch
│ ├── icons.tsx # Shared inline SVG icons (pencil, plus, trash, chevrons)
│ │ │ │
│ ├── layout/ │ ├── layout/
│ │ ├── Header.tsx # Renders Clock + Title │ │ ├── Header.tsx # Renders Clock + Title
@@ -112,7 +116,8 @@ vision-start/
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size │ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity │ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
│ │ ├── ClockTab.tsx # Clock enable/size/font/format │ │ ├── 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/ │ ├── services/
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state │ │ └── 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) │ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
│ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population │ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget) │ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper 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/ ├── public/
│ ├── favicon.ico │ ├── favicon.ico
@@ -232,7 +240,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
## 8. Notable Behaviors & Quirks ## 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. - **`@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. - **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. - **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._