7 Commits
Author SHA1 Message Date
ivanch d391dc7135 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
2026-08-12 00:25:13 +00:00
ivanch 4885fd68c1 fixing pipeline
Build Pull Request / Build Pull Request Archive (pull_request) Successful in 14s
Build Pull Request / Capture Pull Request Screenshots (pull_request) Successful in 1m3s
Build Pull Request / Publish Pull Request Screenshot Preview (pull_request) Successful in 1s
2026-08-11 19:55:29 -03:00
ivanch 31de8265d9 general fixes & enhancements 2026-08-11 19:08:16 -03:00
ivanch e08853fe54 making wallpapers random
Build and Release to Staging / Build Vision Start (push) Successful in 1m16s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m36s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 8s
2026-08-10 10:05:04 -03:00
ivanch acd8369285 Merge branch 'feat/icon-cache'
Build and Release to Staging / Build Vision Start (push) Successful in 1m14s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m40s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 12s
2026-08-07 12:53:51 -03:00
ivanch 0ecf0eed62 fixing wallpaper issue 2026-08-07 12:53:39 -03:00
ivanch 7635471ed6 adding icon cache 2026-08-07 12:45:12 -03:00
29 changed files with 925 additions and 741 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
+10 -37
View File
@@ -8,37 +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 App: React.FC = () => { const App: React.FC = () => {
const [categories, setCategories] = useState<Category[]>(() => { const [categories, setCategories] = useState<Category[]>(() => {
try { try {
@@ -81,23 +58,23 @@ const App: React.FC = () => {
setConfig(prev => ({ ...prev, ...newConfig })); setConfig(prev => ({ ...prev, ...newConfig }));
}, []); }, []);
const handleNextWallpaper = useCallback(() => { const handleRandomWallpaper = useCallback(() => {
const names = config.currentWallpapers; const names = config.currentWallpapers;
if (names.length === 0) return; if (names.length === 0) return;
try { try {
const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}'); const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0; const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0;
const safeCurrent = current < 0 || current >= names.length ? 0 : current; const safeCurrent = current < 0 || current >= names.length ? 0 : current;
const nextIndex = (safeCurrent + 1) % names.length; const randomIndex = getRandomWallpaperIndex(names.length, safeCurrent);
localStorage.setItem( localStorage.setItem(
'wallpaperState', 'wallpaperState',
JSON.stringify({ JSON.stringify({
lastWallpaperChange: new Date().toISOString(), lastWallpaperChange: new Date().toISOString(),
currentIndex: nextIndex, currentIndex: randomIndex,
}), }),
); );
} catch (error) { } catch (error) {
console.error('Error advancing wallpaper state', error); console.error('Error randomizing wallpaper state', error);
} }
setWallpaperVersion(v => v + 1); setWallpaperVersion(v => v + 1);
}, [config.currentWallpapers]); }, [config.currentWallpapers]);
@@ -218,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);
@@ -233,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>
@@ -282,7 +255,7 @@ const App: React.FC = () => {
onClose={() => setIsConfigModalOpen(false)} onClose={() => setIsConfigModalOpen(false)}
onSave={handleSaveConfig} onSave={handleSaveConfig}
onWallpaperChange={handleWallpaperChange} onWallpaperChange={handleWallpaperChange}
onNextWallpaper={handleNextWallpaper} onRandomWallpaper={handleRandomWallpaper}
/> />
</Suspense> </Suspense>
)} )}
+1 -1
View File
@@ -63,7 +63,7 @@ npm run dev
* [x] Multiple Wallpapers * [x] Multiple Wallpapers
* [x] Remake icons * [x] Remake icons
* [/] Increase offline compatibility (might not be possible) * [/] Increase offline compatibility (might not be possible)
- [x] Use chrome.storage.local for user wallpapers -- this one is - [x] Use chrome.storage.local for uploaded wallpaper files; remote wallpapers stay as URLs
- [ ] Use chrome.storage.local for some logos -- a bit hard - [ ] Use chrome.storage.local for some logos -- a bit hard
- Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local - Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local
* Dynamic Weather Widget * Dynamic Weather Widget
+16 -37
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,44 +13,22 @@ 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>
); );
}; };
+2 -2
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(() => {
+10 -17
View File
@@ -13,7 +13,7 @@ interface ConfigurationModalProps {
onSave: (config: Config) => void; onSave: (config: Config) => void;
currentConfig: Config; currentConfig: Config;
onWallpaperChange: (newConfig: Partial<Config>) => void; onWallpaperChange: (newConfig: Partial<Config>) => void;
onNextWallpaper: () => void; onRandomWallpaper: () => void;
} }
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
@@ -21,14 +21,13 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
onSave, onSave,
currentConfig, currentConfig,
onWallpaperChange, onWallpaperChange,
onNextWallpaper, onRandomWallpaper,
}) => { }) => {
const [config, setConfig] = useState<Config>(currentConfig); const [config, setConfig] = useState<Config>(currentConfig);
const [activeTab, setActiveTab] = useState('general'); const [activeTab, setActiveTab] = useState('general');
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'
}`} }`}
@@ -177,7 +170,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
onAddWallpaper={handleAddWallpaper} onAddWallpaper={handleAddWallpaper}
onAddWallpaperFile={handleAddWallpaperFile} onAddWallpaperFile={handleAddWallpaperFile}
onDeleteWallpaper={handleDeleteWallpaper} onDeleteWallpaper={handleDeleteWallpaper}
onNextWallpaper={onNextWallpaper} onRandomWallpaper={onRandomWallpaper}
/> />
)} )}
{activeTab === 'clock' && ( {activeTab === 'clock' && (
+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);
+32 -31
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,22 +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 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);
@@ -43,16 +39,14 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
JSON.parse(localStorage.getItem('userWallpapers') || '[]'); JSON.parse(localStorage.getItem('userWallpapers') || '[]');
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name); const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
if (foundInUser) { if (foundInUser) {
try { resolved = foundInUser.url || foundInUser.base64;
const wallpaperData = await getWallpaperFromChromeStorageLocal(name); if (!resolved) {
if (wallpaperData && wallpaperData.startsWith('http')) { try {
resolved = wallpaperData; resolved = (await getWallpaperFromChromeStorageLocal(name)) || undefined;
} else { } catch (error) {
resolved = wallpaperData || undefined; console.error('Error getting wallpaper from chrome storage', error);
resolved = undefined;
} }
} catch (error) {
console.error('Error getting wallpaper from chrome storage', error);
resolved = undefined;
} }
} }
} catch (error) { } catch (error) {
@@ -61,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 }),
@@ -85,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;
@@ -93,7 +88,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
const shouldRotate = now - lastChange >= freqMs; const shouldRotate = now - lastChange >= freqMs;
let resolvedIndex = shouldRotate let resolvedIndex = shouldRotate
? (storedIndex + 1) % wallpaperNames.length ? getRandomWallpaperIndex(wallpaperNames.length, storedIndex)
: storedIndex; : storedIndex;
const tried = new Set<number>(); const tried = new Set<number>();
@@ -103,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;
@@ -110,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();
@@ -122,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;
+86 -103
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,108 +106,81 @@ 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>
); );
}; };
+55 -51
View File
@@ -1,5 +1,8 @@
import React, { memo, 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 { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
interface WebsiteTileProps { interface WebsiteTileProps {
website: Website; website: Website;
@@ -9,49 +12,49 @@ 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);
const [iconSource, setIconSource] = useState<string | null>(null);
const [usingCachedIcon, setUsingCachedIcon] = useState(false);
useEffect(() => {
let cancelled = false;
setIconSource(null);
setUsingCachedIcon(false);
const loadIcon = async () => {
const cachedIcon = await getCachedWebsiteIcon(website.icon);
if (cancelled) return;
if (cachedIcon) {
setIconSource(cachedIcon);
setUsingCachedIcon(cachedIcon !== website.icon);
return;
}
setIconSource(website.icon);
const newlyCachedIcon = await cacheWebsiteIcon(website.icon);
if (!cancelled && newlyCachedIcon) {
setIconSource(newlyCachedIcon);
setUsingCachedIcon(newlyCachedIcon !== website.icon);
}
};
void loadIcon();
return () => {
cancelled = true;
};
}, [website.icon]);
const handleIconError = () => {
if (!usingCachedIcon) return;
setIconSource(website.icon);
setUsingCachedIcon(false);
void removeCachedWebsiteIcon(website.icon);
};
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
if (isEditing) { if (isEditing) {
@@ -84,7 +87,14 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
)} )}
<div className={`relative z-10 flex items-center transition-all duration-200 ease-ios ${isLoading ? 'translate-y-5 gap-2' : 'flex-col gap-3'}`}> <div className={`relative z-10 flex items-center transition-all duration-200 ease-ios ${isLoading ? 'translate-y-5 gap-2' : 'flex-col gap-3'}`}>
<div className={`transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)] ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}> <div className={`transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)] ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
<img src={website.icon} alt={`${website.name} icon`} className="object-contain w-full h-full" /> {iconSource && (
<img
src={iconSource}
alt={`${website.name} icon`}
className="object-contain w-full h-full"
onError={handleIconError}
/>
)}
</div> </div>
<span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}> <span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}>
{website.name} {website.name}
@@ -93,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;
+11 -36
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>
)} )}
+128 -193
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;
@@ -11,34 +19,9 @@ interface ThemeTabProps {
onAddWallpaper: (name: string, url: string) => Promise<void>; onAddWallpaper: (name: string, url: string) => Promise<void>;
onAddWallpaperFile: (file: File) => Promise<void>; onAddWallpaperFile: (file: File) => Promise<void>;
onDeleteWallpaper: (wallpaper: Wallpaper) => Promise<void>; onDeleteWallpaper: (wallpaper: Wallpaper) => Promise<void>;
onNextWallpaper: () => 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,
@@ -48,11 +31,10 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
onAddWallpaper, onAddWallpaper,
onAddWallpaperFile, onAddWallpaperFile,
onDeleteWallpaper, onDeleteWallpaper,
onNextWallpaper, onRandomWallpaper,
}) => { }) => {
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 () => {
@@ -62,7 +44,11 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
setNewWallpaperName(''); setNewWallpaperName('');
setNewWallpaperUrl(''); setNewWallpaperUrl('');
} catch (error) { } catch (error) {
alert('Error adding wallpaper. Please check the URL and try again.'); alert(
error instanceof Error
? error.message
: 'Error adding wallpaper. Please check the URL and try again.',
);
console.error(error); console.error(error);
} }
}; };
@@ -72,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 = '';
@@ -92,176 +78,125 @@ 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}
min={MIN_WALLPAPER_FREQUENCY_HOURS}
max={MAX_WALLPAPER_FREQUENCY_HOURS}
formatValue={formatWallpaperFrequency}
onChange={(value) => onChange({ wallpaperFrequency: `${value}h` })}
/>
)}
<RangeSlider
label="Wallpaper Blur"
value={config.wallpaperBlur}
min={0}
max={50}
valueSuffix="px"
onChange={(value) => onChange({ wallpaperBlur: value })}
/>
<RangeSlider
label="Wallpaper Brightness"
value={config.wallpaperBrightness}
min={0}
max={200}
valueSuffix="%"
onChange={(value) => onChange({ wallpaperBrightness: value })}
/>
<RangeSlider
label="Wallpaper Opacity"
value={config.wallpaperOpacity}
min={1}
max={100}
valueSuffix="%"
onChange={(value) => onChange({ wallpaperOpacity: value })}
/>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
<div className="flex flex-col gap-2">
{userWallpapers.map((wallpaper) => (
<div
key={wallpaper.name}
className="liquid-surface flex items-center justify-between rounded-xl p-2.5"
>
<span className="truncate">{wallpaper.name}</span>
<button
onClick={() => onDeleteWallpaper(wallpaper)}
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Delete ${wallpaper.name}`}
>
<TrashIcon size={16} />
</button>
</div>
))}
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="Wallpaper Name (optional for URLs)"
value={newWallpaperName}
onChange={(e) => setNewWallpaperName(e.target.value)}
className="liquid-input p-2.5"
/>
<div className="flex flex-col gap-2 sm:flex-row">
<input <input
type="range" type="text"
min={MIN_WALLPAPER_FREQUENCY_HOURS} placeholder="Image URL"
max={MAX_WALLPAPER_FREQUENCY_HOURS} value={newWallpaperUrl}
step="1" onChange={(e) => setNewWallpaperUrl(e.target.value)}
value={wallpaperFrequencyHours} className="liquid-input p-2.5"
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"> <button
{formatWallpaperFrequency(wallpaperFrequencyHours)} onClick={handleAddWallpaper}
</span> className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
>
Add
</button>
</div> </div>
</div> {chromeStorageAvailable && (
)} <div className="flex items-center justify-center w-full">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <label
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label> htmlFor="file-upload"
<div className="flex items-center gap-4"> className="liquid-surface liquid-ghost-tile flex flex-col items-center justify-center w-full h-32 cursor-pointer transition-all duration-200 ease-ios"
<input >
type="range" <div className="flex flex-col items-center justify-center pt-5 pb-6">
min="0" <svg
max="50" className="w-8 h-8 mb-4 text-gray-400"
value={config.wallpaperBlur} aria-hidden="true"
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })} xmlns="http://www.w3.org/2000/svg"
className="liquid-range" fill="none"
style={getRangeStyle(config.wallpaperBlur, 0, 50)} viewBox="0 0 20 16"
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="200"
value={config.wallpaperBrightness}
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1"
max="100"
value={config.wallpaperOpacity}
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
</div>
</div>
{chromeStorageAvailable && (
<>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
<div className="flex flex-col gap-2">
{userWallpapers.map((wallpaper) => (
<div
key={wallpaper.name}
className="liquid-surface flex items-center justify-between rounded-xl p-2.5"
>
<span className="truncate">{wallpaper.name}</span>
<button
onClick={() => onDeleteWallpaper(wallpaper)}
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Delete ${wallpaper.name}`}
> >
<svg <path
xmlns="http://www.w3.org/2000/svg" stroke="currentColor"
width="16" strokeLinecap="round"
height="16" strokeLinejoin="round"
fill="currentColor" strokeWidth="2"
className="bi bi-trash" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
viewBox="0 0 16 16" />
> </svg>
<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" /> <p className="mb-2 text-sm text-gray-400">
<path <span className="font-semibold">Click to upload</span> or drag and drop
fillRule="evenodd" </p>
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" <p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
/>
</svg>
</button>
</div> </div>
))}
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="Wallpaper Name (optional for URLs)"
value={newWallpaperName}
onChange={(e) => setNewWallpaperName(e.target.value)}
className="liquid-input p-2.5"
/>
<div className="flex flex-col gap-2 sm:flex-row">
<input <input
type="text" id="file-upload"
placeholder="Image URL" type="file"
value={newWallpaperUrl} className="hidden"
onChange={(e) => setNewWallpaperUrl(e.target.value)} onChange={handleFileUpload}
className="liquid-input p-2.5"
/> />
<button </label>
onClick={handleAddWallpaper}
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
>
Add
</button>
</div>
<div className="flex items-center justify-center w-full">
<label
htmlFor="file-upload"
className="liquid-surface liquid-ghost-tile flex flex-col items-center justify-center w-full h-32 cursor-pointer transition-all duration-200 ease-ios"
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg
className="w-8 h-8 mb-4 text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 16"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
/>
</svg>
<p className="mb-2 text-sm text-gray-400">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
</div>
<input
id="file-upload"
type="file"
className="hidden"
onChange={handleFileUpload}
ref={fileInputRef}
/>
</label>
</div>
</div> </div>
</div> )}
</> </div>
)} </div>
<div className="flex justify-center pt-2"> <div className="flex justify-center pt-2">
<button <button
onClick={onNextWallpaper} onClick={onRandomWallpaper}
disabled={config.currentWallpapers.length === 0} disabled={config.currentWallpapers.length === 0}
className="liquid-surface liquid-control liquid-focus disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold py-2 px-4 rounded-2xl" className="liquid-surface liquid-control liquid-focus disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold py-2 px-4 rounded-2xl"
> >
@@ -274,7 +209,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
> >
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM4.5 7.5a.5.5 0 0 1 .5-.5h5.379L8.646 5.354a.5.5 0 1 1 .708-.708l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 0 1-.708-.708L10.379 8H5a.5.5 0 0 1-.5-.5z" /> <path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM4.5 7.5a.5.5 0 0 1 .5-.5h5.379L8.646 5.354a.5.5 0 1 1 .708-.708l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 0 1-.708-.708L10.379 8H5a.5.5 0 0 1-.5-.5z" />
</svg> </svg>
Next Wallpaper Random Wallpaper
</button> </button>
</div> </div>
</div> </div>
+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>
);
+7 -25
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>
)} )}
+2 -4
View File
@@ -1,4 +1,4 @@
import { PencilIcon } from '../icons';
interface EditButtonProps { interface EditButtonProps {
isEditing: boolean; isEditing: boolean;
@@ -13,9 +13,7 @@ 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>
+2 -48
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`}>
+15 -2
View File
@@ -1,8 +1,10 @@
import { Config, Wallpaper } from '../../types'; import { Config, Wallpaper } from '../../types';
import { import {
addWallpaperToChromeStorageLocal, addWallpaperToChromeStorageLocal,
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];
@@ -100,8 +102,18 @@ export const ConfigurationService = {
}, },
async addWallpaper(name: string, url: string): Promise<Wallpaper> { async addWallpaper(name: string, url: string): Promise<Wallpaper> {
const finalName = await addWallpaperToChromeStorageLocal(name, url); const trimmedUrl = url.trim();
return { name: finalName }; let parsedUrl: URL;
try {
parsedUrl = new URL(trimmedUrl);
} catch {
throw new Error('Please enter a valid wallpaper URL.');
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
}
const finalName = name.trim() || getFileNameFromUrl(parsedUrl) || 'Wallpaper';
return { name: finalName, url: parsedUrl.href };
}, },
async addWallpaperFile(file: File): Promise<Wallpaper> { async addWallpaperFile(file: File): Promise<Wallpaper> {
@@ -130,6 +142,7 @@ export const ConfigurationService = {
}, },
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> { async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
if (wallpaper.url || wallpaper.base64 || !checkChromeStorageLocalAvailable()) return;
await removeWallpaperFromChromeStorageLocal(wallpaper.name); await removeWallpaperFromChromeStorageLocal(wallpaper.name);
}, },
+88 -91
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?: {
@@ -18,6 +20,10 @@ declare global {
let isChromeStorageLocalAvailable: boolean | null = null; let isChromeStorageLocalAvailable: boolean | null = null;
const ICON_CACHE_KEY_PREFIX = 'vision-start:icon:';
const getIconCacheKey = (sourceUrl: string): string =>
`${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`;
/** /**
* Checks if chrome.storage.local is available and caches the result. * Checks if chrome.storage.local is available and caches the result.
@@ -32,85 +38,95 @@ export function checkChromeStorageLocalAvailable(): boolean {
return isChromeStorageLocalAvailable; return isChromeStorageLocalAvailable;
} }
type StorageResult = { [key: string]: string };
const chromeLocalCall = <T>(
operation: (callback: (result: T) => void) => void,
): Promise<T> =>
new Promise<T>((resolve, reject) => {
if (!window.chrome?.storage?.local) {
reject(new Error('chrome.storage.local is not available'));
return;
}
operation((result) => {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve(result);
}
});
});
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
if (!checkChromeStorageLocalAvailable()) return null;
try {
const key = getIconCacheKey(sourceUrl);
const result = await chromeLocalCall<StorageResult>((cb) =>
window.chrome?.storage?.local?.get([key], cb),
);
return result[key] || null;
} catch {
return null;
}
}
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
if (!checkChromeStorageLocalAvailable()) return false;
try {
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, cb),
);
return true;
} catch {
return false;
}
}
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
if (!checkChromeStorageLocalAvailable()) return false;
try {
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.remove(getIconCacheKey(sourceUrl), cb),
);
return true;
} catch {
return false;
}
}
/** /**
* Adds a new wallpaper to chrome.storage.local. * Adds a new wallpaper to chrome.storage.local.
* If the URL is fetchable, it will be stored as base64 and the name will be derived from the URL. * File uploads are stored as base64 while remote wallpapers remain URLs.
* If the URL is not fetchable (e.g., CORS), it will be stored as a URL and the provided name will be used.
* @param name Wallpaper name (string), used as a fallback. * @param name Wallpaper name (string), used as a fallback.
* @param url Wallpaper image URL (string) or base64 data URL. * @param url Wallpaper image URL (string) or base64 data URL.
* @returns Promise<string> The name under which the wallpaper was stored. * @returns Promise<string> The name under which the wallpaper was stored.
* @throws Error if chrome.storage.local is unavailable or if a name is not provided for a non-fetchable URL. * @throws Error if chrome.storage.local is unavailable or the wallpaper data is invalid.
*/ */
export async function addWallpaperToChromeStorageLocal(name: string, url: string): Promise<string> { export async function addWallpaperToChromeStorageLocal(name: string, url: string): Promise<string> {
if (!checkChromeStorageLocalAvailable()) { if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available'); throw new Error('chrome.storage.local is not available');
} }
let finalName = name.trim();
if (url.startsWith('data:')) { if (url.startsWith('data:')) {
// This is a base64 encoded image from a file upload. if (!finalName) throw new Error('A name is required for an uploaded wallpaper.');
// The name is the file name. } else {
return new Promise<void>((resolve, reject) => { let parsedUrl: URL;
if (window.chrome?.storage?.local) { try {
window.chrome.storage.local.set({ [name]: url }, function () { parsedUrl = new URL(url);
if (window.chrome?.runtime?.lastError) { } catch {
reject(new Error(window.chrome.runtime.lastError.message)); throw new Error('Please enter a valid wallpaper URL.');
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
}).then(() => name);
}
// This is a URL. Let's try to fetch it.
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch image');
const imageBlob = await response.blob();
const reader = new FileReader();
const base64 = await new Promise<string>((resolve, reject) => {
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(imageBlob);
});
// If successful, use the filename from URL as the name.
const finalName = url.substring(url.lastIndexOf('/') + 1).replace(/[?#].*$/, '') || name;
return new Promise<void>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.set({ [finalName]: base64 }, 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'));
}
}).then(() => finalName);
} catch (error) {
// If fetch fails (e.g., CORS), store the URL directly with the user-provided name.
console.warn('Could not fetch wallpaper, storing URL instead. Error:', error);
if (!name) {
throw new Error("A name for the wallpaper is required when the URL can't be accessed.");
} }
return new Promise<void>((resolve, reject) => { if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
if (window.chrome?.storage?.local) { throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
window.chrome.storage.local.set({ [name]: url }, function () { }
if (window.chrome?.runtime?.lastError) { finalName = finalName || getFileNameFromUrl(parsedUrl);
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
}).then(() => name);
} }
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
);
return finalName;
} }
/** /**
@@ -123,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'));
}
});
} }
/** /**
@@ -148,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'));
}
});
} }
+143 -7
View File
@@ -1,7 +1,60 @@
import {
checkChromeStorageLocalAvailable,
getCachedIconFromChromeStorageLocal,
removeCachedIconFromChromeStorageLocal,
saveCachedIconToChromeStorageLocal,
} from './StorageLocalManager';
async function getWebsiteIcon(url: string): Promise<string> { const MAX_CACHED_ICON_BYTES = 256 * 1024;
const MAX_RESOLVED_ICONS = 50;
const resolvedIconCache = new Map<string, string>();
const iconCacheLookups = new Map<string, Promise<string | null>>();
const iconCacheRequests = new Map<string, Promise<string | null>>();
const rememberResolvedIcon = (iconUrl: string, dataUrl: string): void => {
resolvedIconCache.set(iconUrl, dataUrl);
if (resolvedIconCache.size > MAX_RESOLVED_ICONS) {
const oldest = resolvedIconCache.keys().next().value;
if (oldest !== undefined) resolvedIconCache.delete(oldest);
}
};
const isDataUrl = (value: string): boolean => value.startsWith('data:');
const isCacheableIconUrl = (value: string): boolean => {
try { try {
const response = await fetch(url); const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
const isValidCachedIcon = (value: string | null): value is string =>
typeof value === 'string' && isDataUrl(value);
const blobToDataUrl = (blob: Blob): Promise<string> =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Could not convert icon to a data URL'));
}
};
reader.onerror = () => reject(reader.error || new Error('Could not read icon data'));
reader.readAsDataURL(blob);
});
async function getWebsiteIcon(rawUrl: string): Promise<string> {
let targetUrl = rawUrl.trim();
if (targetUrl && !/^https?:\/\//i.test(targetUrl)) {
targetUrl = `https://${targetUrl}`;
}
try {
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');
@@ -9,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;
} }
} }
@@ -17,15 +70,98 @@ 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`;
}
} }
export { getWebsiteIcon }; async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
if (isDataUrl(iconUrl)) return iconUrl;
const inMemoryIcon = resolvedIconCache.get(iconUrl);
if (inMemoryIcon) return inMemoryIcon;
if (!isCacheableIconUrl(iconUrl) || !checkChromeStorageLocalAvailable()) return null;
const existingLookup = iconCacheLookups.get(iconUrl);
if (existingLookup) return existingLookup;
const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
.then((cachedIcon) => {
if (isValidCachedIcon(cachedIcon)) {
rememberResolvedIcon(iconUrl, cachedIcon);
return cachedIcon;
}
return null;
})
.catch(() => null)
.finally(() => {
iconCacheLookups.delete(iconUrl);
});
iconCacheLookups.set(iconUrl, lookup);
return lookup;
}
async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
if (!isCacheableIconUrl(iconUrl) || !checkChromeStorageLocalAvailable()) return null;
const inMemoryIcon = resolvedIconCache.get(iconUrl);
if (inMemoryIcon) return inMemoryIcon;
const existingRequest = iconCacheRequests.get(iconUrl);
if (existingRequest) return existingRequest;
const request = (async () => {
const cachedIcon = await getCachedWebsiteIcon(iconUrl);
if (cachedIcon) return cachedIcon;
try {
const response = await fetch(iconUrl, { mode: 'cors' });
if (!response.ok || response.type === 'opaque') return null;
const blob = await response.blob();
const contentType = (blob.type || response.headers.get('content-type') || '')
.split(';', 1)[0]
.trim()
.toLowerCase();
if (!contentType.startsWith('image/') || blob.size === 0 || blob.size > MAX_CACHED_ICON_BYTES) {
return null;
}
const dataUrl = await blobToDataUrl(blob);
rememberResolvedIcon(iconUrl, dataUrl);
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
return dataUrl;
} catch {
return null;
}
})().finally(() => {
iconCacheRequests.delete(iconUrl);
});
iconCacheRequests.set(iconUrl, request);
return request;
}
async function removeCachedWebsiteIcon(iconUrl: string): Promise<void> {
resolvedIconCache.delete(iconUrl);
iconCacheLookups.delete(iconUrl);
await removeCachedIconFromChromeStorageLocal(iconUrl);
}
export {
cacheWebsiteIcon,
getCachedWebsiteIcon,
getWebsiteIcon,
removeCachedWebsiteIcon,
};
+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;
};
+33 -18
View File
@@ -1,6 +1,6 @@
--- ---
project_name: Vision Start project_name: Vision Start
date: 2026-07-08 date: 2026-08-11
type: general_overview type: general_overview
--- ---
@@ -55,22 +55,25 @@ The startpage is composed of widgets and a configuration panel:
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format. - **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
- **Title** — Optional big header title (text + size configurable). - **Title** — Optional big header title (text + size configurable).
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency. - **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Supports rotating through multiple wallpapers at an hourly cadence selected by slider (`1h``48h`). - **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Randomly rotates through multiple wallpapers at an hourly cadence selected by slider (`1h``48h`).
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`). - Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure. - User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL. Remote URLs remain lightweight entries in the `userWallpapers` index; uploaded image data is stored in `chrome.storage.local` when available.
- **Icon library & auto-fetch** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service. - **Icon library, auto-fetch & cache** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable.
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page). - **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles. - **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`. - **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
Performance notes: Performance notes:
- 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. - 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. `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.
@@ -97,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
@@ -111,16 +116,20 @@ 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
│ │ │ │
│ └── utils/ │ └── utils/
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs) │ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
│ ├── iconService.ts # getWebsiteIcon: fetch HTML, parse apple-touch-icon/icon, fallback to Google favicons │ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget) │ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper fetch/base64/URL storage ── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper and icon cache storage
│ ├── 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
@@ -155,6 +164,8 @@ vision-start/
## 5. Data Model & State ## 5. Data Model & State
The icon service also resolves website icon URLs through the persistent CORS-safe cache used by `WebsiteTile`; this cache is separate from the `Website` data model.
The shape of all persisted data lives in `types.ts`: The shape of all persisted data lives in `types.ts`:
- **`Website`** — `id`, `name`, `url`, `icon`, `categoryId` - **`Website`** — `id`, `name`, `url`, `icon`, `categoryId`
@@ -171,11 +182,12 @@ Storage layout (browser-side):
|---|---|---| |---|---|---|
| `config` | `localStorage` | The full `Config` JSON | | `config` | `localStorage` | The full `Config` JSON |
| `categories` | `localStorage` | `Category[]` JSON | | `categories` | `localStorage` | `Category[]` JSON |
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names) | | `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) |
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation | | `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
| `<wallpaperName>` | `chrome.storage.local` (when available) | base64 (or URL on CORS failure) image data | | `<wallpaperName>` | `chrome.storage.local` (when available) | Base64 image data for uploaded wallpaper files and legacy URL wallpapers |
| `vision-start:icon:<encoded-source-url>` | `chrome.storage.local` (when available) | CORS-readable website icon data URL, capped at 256KiB |
Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`. Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`. Remote wallpaper URLs are included through `userWallpapers`; uploaded image data and rebuildable icon cache entries are not included.
--- ---
@@ -191,7 +203,8 @@ Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaper
- One `<CategoryGroup>` per category (renders its `<WebsiteTile>`s and, in edit mode, add/edit/move controls). - One `<CategoryGroup>` per category (renders its `<WebsiteTile>`s and, in edit mode, add/edit/move controls).
- Optional `<ServerWidget>` if enabled. - Optional `<ServerWidget>` if enabled.
- Conditionally one of: `<WebsiteEditModal>`, `<CategoryEditModal>`, `<ConfigurationModal>`. - Conditionally one of: `<WebsiteEditModal>`, `<CategoryEditModal>`, `<ConfigurationModal>`.
5. Edit / configuration interactions update central `App` state; the existing `useEffect`s persist it. 5. Each `<WebsiteTile>` checks the icon cache before loading the external URL, then asynchronously populates the cache on a miss.
6. Edit / configuration interactions update central `App` state; the existing `useEffect`s persist it.
--- ---
@@ -227,11 +240,12 @@ 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. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws. - **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`, advances the index if the frequency window has elapsed, and writes it 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 "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) 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.
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable. - **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only CORS-readable `http`/`https` image responses no larger than 256KiB are cached.
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation. - **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.). - **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`. - **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
@@ -253,10 +267,11 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` | | Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` |
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` | | Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` | | chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
| Website icon cache | `components/utils/iconService.ts`, `components/WebsiteTile.tsx`, `components/utils/StorageLocalManager.ts` |
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) | | Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
| Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` | | Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` |
| Docker build | `Dockerfile` | | Docker build | `Dockerfile` |
--- ---
_Last updated: 2026-07-10. 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._