diff --git a/AGENTS.md b/AGENTS.md index 9ad0f95..02c2361 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,8 +97,8 @@ A "feature" in this project typically means: a new widget, a new settings option - **Runtime note:** Preact is the actual runtime (`@preact/preset-vite`), but types and imports use `react`/`@types/react`. Do not introduce React-only APIs without verifying Preact compatibility. - **Path alias:** `@/*` maps to the project root (see `tsconfig.json` `paths`). Source files currently use relative imports (`./`, `../`) — match the surrounding file. - **No comments** in committed code unless explicitly requested. -- **Build vs. dev:** Prefer verifying with `npm run build` rather than `npm run dev`. The only npm scripts are `dev`, `build`, `preview`. -- **No lint/typecheck script is configured.** Verify TS correctness manually (e.g. `npx tsc --noEmit`), and confirm `npm run build` succeeds before considering a task complete. +- **Build vs. dev:** Prefer verifying with `npm run build` rather than `npm run dev`. npm scripts: `dev`, `build`, `preview`, `typecheck`, `test`, `capture:screenshots`. +- **Verification:** run `npm run typecheck` and `npm run build`, then `npm test` (Playwright regression suite; needs `npx playwright install chromium`) before considering a task complete. CI runs the type check on PRs and pushes to `main`. - **Don't touch `public/icon-metadata.json`** — it's gitignored and fetched at release-build time by `scripts/prepare_release.sh`. Don't commit a local copy. - **Don't reintroduce dead code:** `components/EditModal.tsx` imports `lucide-react` and `./IconPicker`, neither of which is a dependency. It is not wired into the app. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, and add the icon picker inline (as in `WebsiteEditModal.tsx`) rather than reviving `IconPicker`. - **External asset URLs:** any new icons/css/JS pulled from CDNs (e.g. `cdn.jsdelivr.net`) must work offline-or-not without crashing — always provide a fallback (see how `iconService.ts` falls back to Google's S2 favicon service). diff --git a/App.tsx b/App.tsx index 4f1441d..a2f8c83 100644 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, lazy, Suspense } from 'react'; +import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'; import ServerWidget from './components/ServerWidget'; import { Category, Website, Config } from './types'; import Header from './components/layout/Header'; @@ -11,6 +11,7 @@ import { getAlignmentClass } from './components/utils/styleUtils'; import { getRandomWallpaperIndex, loadWallpaperState, saveWallpaperState } from './components/utils/wallpaperUtils'; import { PlusIcon } from './components/icons'; import { createId } from './components/utils/idUtils'; +import { pruneCachedIconsFromChromeStorageLocal } from './components/utils/StorageLocalManager'; const ConfigurationModal = lazy(() => import('./components/ConfigurationModal')); const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal')); @@ -26,6 +27,16 @@ const App: React.FC = () => { const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); const [config, setConfig] = useState(() => ConfigurationService.loadConfig()); const [wallpaperVersion, setWallpaperVersion] = useState(0); + const categoriesRef = useRef(categories); + categoriesRef.current = categories; + + useEffect(() => { + const timer = setTimeout(() => { + const icons = categoriesRef.current.flatMap(category => category.websites.map(website => website.icon)); + void pruneCachedIconsFromChromeStorageLocal(icons); + }, 5000); + return () => clearTimeout(timer); + }, []); useEffect(() => { ConfigurationService.saveConfig(config); @@ -117,6 +128,8 @@ const App: React.FC = () => { const handleDeleteCategory = useCallback(() => { if (!editingCategory) return; + const websiteCount = editingCategory.websites.length; + if (websiteCount > 0 && !confirm(`Delete "${editingCategory.name}" and its ${websiteCount} ${websiteCount === 1 ? 'website' : 'websites'}?`)) return; setCategories(previous => previous.filter(c => c.id !== editingCategory.id)); setEditingCategory(null); diff --git a/components/CategoryEditModal.tsx b/components/CategoryEditModal.tsx index eb6bb76..45aae94 100644 --- a/components/CategoryEditModal.tsx +++ b/components/CategoryEditModal.tsx @@ -18,7 +18,8 @@ const CategoryEditModal: React.FC = ({ category, edit, o title={edit ? 'Edit Category' : 'Add Category'} edit={edit} onClose={onClose} - onSave={() => onSave(name)} + onSave={() => onSave(name.trim())} + canSave={name.trim() !== ''} onDelete={edit ? onDelete : undefined} > = ({ closeTimer.current = setTimeout(onClose, 250); }; + const handleCloseRef = useRef(handleClose); + handleCloseRef.current = handleClose; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleCloseRef.current(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + const handleConfigChange = (updates: Partial) => { setConfig((prev) => ({ ...prev, ...updates })); }; @@ -139,7 +150,7 @@ const ConfigurationModal: React.FC = ({ ]; return ( -
+
= ({ }`} >
-

Configuration

+

Configuration

{tabs.map((tab) => ( diff --git a/components/ModalShell.tsx b/components/ModalShell.tsx index 70e7610..add76d4 100644 --- a/components/ModalShell.tsx +++ b/components/ModalShell.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useId, useRef } from 'react'; interface ModalShellProps { title: string; @@ -6,10 +6,23 @@ interface ModalShellProps { onClose: () => void; onSave: () => void; onDelete?: () => void; + canSave?: boolean; children: React.ReactNode; } -const ModalShell: React.FC = ({ title, edit, onClose, onSave, onDelete, children }) => { +const ModalShell: React.FC = ({ title, edit, onClose, onSave, onDelete, canSave = true, children }) => { + const titleId = useId(); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCloseRef.current(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); @@ -18,8 +31,8 @@ const ModalShell: React.FC = ({ title, edit, onClose, onSave, o return (
-
-

{title}

+
+

{title}

{children}
@@ -30,7 +43,11 @@ const ModalShell: React.FC = ({ title, edit, onClose, onSave, o )}
-