From 158018768964626242cecc7129b1c71d41388330 Mon Sep 17 00:00:00 2001 From: Jose Henrique Date: Fri, 3 Jul 2026 14:28:26 -0300 Subject: [PATCH] big performance changes --- App.tsx | 156 +++++++++-------- Dockerfile | 1 + components/Clock.tsx | 23 ++- components/EditModal.tsx | 255 ---------------------------- components/ServerWidget.tsx | 63 ++++--- components/Wallpaper.tsx | 76 +++++---- components/WebsiteEditModal.tsx | 73 +++++--- components/WebsiteTile.tsx | 6 +- components/layout/CategoryGroup.tsx | 20 +-- components/layout/Header.tsx | 2 + components/utils/jsping.js | 28 ++- index.css | 6 +- nginx.conf | 13 ++ project-context.md | 20 ++- 14 files changed, 298 insertions(+), 444 deletions(-) delete mode 100644 components/EditModal.tsx create mode 100644 nginx.conf diff --git a/App.tsx b/App.tsx index a0e88b2..a464048 100644 --- a/App.tsx +++ b/App.tsx @@ -1,10 +1,7 @@ -import { useState, useEffect } from 'react'; -import ConfigurationModal from './components/ConfigurationModal'; +import { useState, useEffect, useCallback, lazy, Suspense } from 'react'; import ServerWidget from './components/ServerWidget'; import { DEFAULT_CATEGORIES } from './constants'; import { Category, Website, Config } from './types'; -import WebsiteEditModal from './components/WebsiteEditModal'; -import CategoryEditModal from './components/CategoryEditModal'; import Header from './components/layout/Header'; import EditButton from './components/layout/EditButton'; import ConfigurationButton from './components/layout/ConfigurationButton'; @@ -12,6 +9,36 @@ import CategoryGroup from './components/layout/CategoryGroup'; import Wallpaper from './components/Wallpaper'; import { ConfigurationService } from './components/services/ConfigurationService'; +const ConfigurationModal = lazy(() => import('./components/ConfigurationModal')); +const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal')); +const CategoryEditModal = lazy(() => import('./components/CategoryEditModal')); + +const getAlignmentClass = (alignment: string) => { + switch (alignment) { + case 'top': + return 'justify-start'; + case 'middle': + return 'justify-center'; + case 'bottom': + return 'justify-end'; + default: + return 'justify-center'; + } +}; + +const getHorizontalAlignmentClass = (alignment: string) => { + switch (alignment) { + case 'left': + return 'justify-start'; + case 'middle': + return 'justify-center'; + case 'right': + return 'justify-end'; + default: + return 'justify-center'; + } +}; + const App: React.FC = () => { const [categories, setCategories] = useState(() => { try { @@ -45,16 +72,16 @@ const App: React.FC = () => { } }, [categories]); - const handleSaveConfig = (newConfig: Config) => { + const handleSaveConfig = useCallback((newConfig: Config) => { setConfig(newConfig); setIsConfigModalOpen(false); - }; + }, []); - const handleWallpaperChange = (newConfig: Partial) => { + const handleWallpaperChange = useCallback((newConfig: Partial) => { setConfig(prev => ({ ...prev, ...newConfig })); - }; + }, []); - const handleNextWallpaper = () => { + const handleNextWallpaper = useCallback(() => { const names = config.currentWallpapers; if (names.length === 0) return; try { @@ -73,9 +100,9 @@ const App: React.FC = () => { console.error('Error advancing wallpaper state', error); } setWallpaperVersion(v => v + 1); - }; + }, [config.currentWallpapers]); - const handleSaveWebsite = (website: Partial) => { + const handleSaveWebsite = useCallback((website: Partial) => { if (editingWebsite) { const idToUpdate = website.id ?? editingWebsite.id; const newCategories = categories.map(category => ({ @@ -102,9 +129,9 @@ const App: React.FC = () => { setCategories(newCategories); setAddingWebsite(null); } - }; + }, [editingWebsite, addingWebsite, categories]); - const handleSaveCategory = (name: string) => { + const handleSaveCategory = useCallback((name: string) => { if (editingCategory) { const newCategories = categories.map(category => category.id === editingCategory.id ? { ...category, name } : category @@ -120,9 +147,9 @@ const App: React.FC = () => { } setEditingCategory(null); setIsCategoryModalOpen(false); - }; + }, [editingCategory, categories]); - const handleDeleteWebsite = () => { + const handleDeleteWebsite = useCallback(() => { if (!editingWebsite) return; const newCategories = categories.map(category => ({ @@ -131,18 +158,18 @@ const App: React.FC = () => { })); setCategories(newCategories); setEditingWebsite(null); - }; + }, [editingWebsite, categories]); - const handleDeleteCategory = () => { + const handleDeleteCategory = useCallback(() => { if (!editingCategory) return; const newCategories = categories.filter(c => c.id !== editingCategory.id); setCategories(newCategories); setEditingCategory(null); setIsCategoryModalOpen(false); - }; + }, [editingCategory, categories]); - const handleMoveWebsite = (website: Website, direction: 'left' | 'right') => { + const handleMoveWebsite = useCallback((website: Website, direction: 'left' | 'right') => { const categoryIndex = categories.findIndex(cat => cat.websites.some(w => w.id === website.id)); if (categoryIndex === -1) return; @@ -169,33 +196,7 @@ const App: React.FC = () => { } setCategories(newCategories); - }; - - 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'; - } - }; + }, [categories]); return (
{ setEditingWebsite={setEditingWebsite} handleMoveWebsite={handleMoveWebsite} getHorizontalAlignmentClass={getHorizontalAlignmentClass} - config={config} + horizontalAlignment={config.horizontalAlignment} + tileSize={config.tileSize} /> ))} {isEditing && ( @@ -250,39 +252,45 @@ const App: React.FC = () => { {config.serverWidget.enabled && } {(editingWebsite || addingWebsite) && ( - { - setEditingWebsite(null); - setAddingWebsite(null); - }} - onSave={handleSaveWebsite} - onDelete={handleDeleteWebsite} - /> + + { + setEditingWebsite(null); + setAddingWebsite(null); + }} + onSave={handleSaveWebsite} + onDelete={handleDeleteWebsite} + /> + )} {isCategoryModalOpen && ( - { - setEditingCategory(null); - setIsCategoryModalOpen(false); - }} - onSave={handleSaveCategory} - onDelete={handleDeleteCategory} - /> + + { + setEditingCategory(null); + setIsCategoryModalOpen(false); + }} + onSave={handleSaveCategory} + onDelete={handleDeleteCategory} + /> + )} {isConfigModalOpen && ( - setIsConfigModalOpen(false)} - onSave={handleSaveConfig} - onWallpaperChange={handleWallpaperChange} - onNextWallpaper={handleNextWallpaper} - /> + + setIsConfigModalOpen(false)} + onSave={handleSaveConfig} + onWallpaperChange={handleWallpaperChange} + onNextWallpaper={handleNextWallpaper} + /> + )}
); diff --git a/Dockerfile b/Dockerfile index fa42768..fda45b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,5 +10,6 @@ RUN npm run build FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY manifest.json /usr/share/nginx/html/ +COPY nginx.conf /etc/nginx/conf.d/gzip.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/components/Clock.tsx b/components/Clock.tsx index 339bdeb..c9010f8 100644 --- a/components/Clock.tsx +++ b/components/Clock.tsx @@ -16,9 +16,26 @@ const Clock: React.FC = ({ config, getClockSizeClass }) => { const [time, setTime] = useState(new Date()); useEffect(() => { - const timerId = setInterval(() => setTime(new Date()), 1000); - return () => clearInterval(timerId); - }, []); + if (!config.clock.enabled) return; + + const scheduleNext = () => { + const now = new Date(); + const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); + timeoutId = window.setTimeout(() => { + setTime(new Date()); + intervalId = window.setInterval(() => setTime(new Date()), 60_000); + }, msToNextMinute); + }; + + let timeoutId = 0; + let intervalId = 0; + scheduleNext(); + + return () => { + if (timeoutId) window.clearTimeout(timeoutId); + if (intervalId) window.clearInterval(intervalId); + }; + }, [config.clock.enabled]); if (!config.clock.enabled) { return null; diff --git a/components/EditModal.tsx b/components/EditModal.tsx deleted file mode 100644 index c736186..0000000 --- a/components/EditModal.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'; -import { getWebsiteIcon } from './utils/iconService'; -import { Category, Website } from '../types'; -import IconPicker from './IconPicker'; -import { icons } from 'lucide-react'; - -interface EditModalProps { - categories: Category[]; - onClose: () => void; - onSave: (categories: Category[]) => void; -} - -const EditModal: React.FC = ({ categories, onClose, onSave }) => { - const [localCategories, setLocalCategories] = useState(categories); - const [selectedCategoryId, setSelectedCategoryId] = useState(categories[0]?.id || null); - const [newCategoryName, setNewCategoryName] = useState(''); - const [newWebsite, setNewWebsite] = useState({ name: '', url: '', icon: '' }); - const [showIconPicker, setShowIconPicker] = useState(false); - const modalRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (modalRef.current && !modalRef.current.contains(event.target as Node)) { - onClose(); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [onClose]); - - const handleOnDragEnd = (result: DropResult) => { - if (!result.destination) return; - - const { source, destination } = result; - - if (source.droppableId === destination.droppableId) { - const category = localCategories.find(cat => cat.id === source.droppableId); - if (category) { - const items = Array.from(category.websites); - const [reorderedItem] = items.splice(source.index, 1); - items.splice(destination.index, 0, reorderedItem); - const updatedCategories = localCategories.map(cat => - cat.id === category.id ? { ...cat, websites: items } : cat - ); - setLocalCategories(updatedCategories); - } - } else { - const sourceCategory = localCategories.find(cat => cat.id === source.droppableId); - const destCategory = localCategories.find(cat => cat.id === destination.droppableId); - if (sourceCategory && destCategory) { - const sourceItems = Array.from(sourceCategory.websites); - const [movedItem] = sourceItems.splice(source.index, 1); - const destItems = Array.from(destCategory.websites); - destItems.splice(destination.index, 0, { ...movedItem, categoryId: destCategory.id }); - - const updatedCategories = localCategories.map(cat => { - if (cat.id === sourceCategory.id) return { ...cat, websites: sourceItems }; - if (cat.id === destCategory.id) return { ...cat, websites: destItems }; - return cat; - }); - setLocalCategories(updatedCategories); - } - } - }; - - const handleAddCategory = () => { - if (newCategoryName.trim() === '') return; - const newCategory: Category = { - id: Date.now().toString(), - name: newCategoryName, - websites: [], - }; - setLocalCategories([...localCategories, newCategory]); - setNewCategoryName(''); - }; - - const handleRemoveCategory = (id: string) => { - const updatedCategories = localCategories.filter(cat => cat.id !== id); - setLocalCategories(updatedCategories); - if (selectedCategoryId === id) { - setSelectedCategoryId(updatedCategories[0]?.id || null); - } - }; - - const handleAddWebsite = async () => { - if (!selectedCategoryId || !newWebsite.name || !newWebsite.url) return; - - let icon = newWebsite.icon; - if (!icon || !Object.keys(icons).includes(icon)) { - icon = await getWebsiteIcon(newWebsite.url); - } - - const newWebsiteData: Website = { - id: Date.now().toString(), - name: newWebsite.name, - url: newWebsite.url, - icon, - categoryId: selectedCategoryId, - }; - - const updatedCategories = localCategories.map(cat => { - if (cat.id === selectedCategoryId) { - return { ...cat, websites: [...cat.websites, newWebsiteData] }; - } - return cat; - }); - - setLocalCategories(updatedCategories); - setNewWebsite({ name: '', url: '', icon: '' }); - }; - - const handleRemoveWebsite = (categoryId: string, websiteId: string) => { - const updatedCategories = localCategories.map(cat => { - if (cat.id === categoryId) { - return { ...cat, websites: cat.websites.filter(web => web.id !== websiteId) }; - } - return cat; - }); - setLocalCategories(updatedCategories); - }; - - const selectedCategory = localCategories.find(cat => cat.id === selectedCategoryId); - - return ( -
-
-

Edit Bookmarks

-
-
-

Categories

-
- {localCategories.map(category => ( -
setSelectedCategoryId(category.id)} - > - {category.name} - -
- ))} -
-
- setNewCategoryName(e.target.value)} - className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400" - /> - -
-
-
-

Websites

- {selectedCategory && ( - - - {(provided) => ( -
    - {selectedCategory.websites.map((website, index) => ( - - {(provided) => ( -
  • -
    - {Object.keys(icons).includes(website.icon) ? ( - React.createElement(icons[website.icon as keyof typeof icons], { className: "h-8 w-8 mr-4" }) - ) : ( - {website.name} - )} - {website.name} -
    - -
  • - )} -
    - ))} - {provided.placeholder} -
- )} -
-
- )} -
-

Add New Bookmark

-
- setNewWebsite({ ...newWebsite, name: e.target.value })} - className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400" - /> - setNewWebsite({ ...newWebsite, url: e.target.value })} - className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400" - /> -
- setNewWebsite({ ...newWebsite, icon: e.target.value })} - className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full" - /> - -
- {showIconPicker && ( - { - setNewWebsite({ ...newWebsite, icon: iconName }); - setShowIconPicker(false); - }} - /> - )} - -
-
-
-
-
- - -
-
-
- ); -}; - -export default EditModal; diff --git a/components/ServerWidget.tsx b/components/ServerWidget.tsx index f0dbf4d..fc65026 100644 --- a/components/ServerWidget.tsx +++ b/components/ServerWidget.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Server } from '../types'; import ping from './utils/jsping.js'; @@ -12,45 +12,64 @@ interface ServerWidgetProps { }; } +const getStatusColor = (status: string) => { + switch (status) { + case 'online': + return 'bg-green-500'; + case 'offline': + return 'bg-red-500'; + default: + return 'bg-gray-500'; + } +}; + const ServerWidget: React.FC = ({ config }) => { const [serverStatus, setServerStatus] = useState>({}); + const serversRef = useRef(config.serverWidget.servers); + serversRef.current = config.serverWidget.servers; + + const serversSignature = config.serverWidget.servers + .map(s => `${s.id}:${s.address}`) + .join('|'); + const serverCount = config.serverWidget.servers.length; useEffect(() => { + if (!config.serverWidget.enabled) return; + const pingServers = () => { - config.serverWidget.servers.forEach((server) => { - setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'pending' })); + const pending: Record = {}; + for (const s of serversRef.current) pending[s.id] = 'pending'; + setServerStatus(prev => ({ ...prev, ...pending })); + + serversRef.current.forEach((server) => { ping(server.address) .then(() => { - setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'online' })); + setServerStatus(prev => + prev[server.id] === 'online' ? prev : { ...prev, [server.id]: 'online' } + ); }) .catch(() => { - setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'offline' })); + setServerStatus(prev => + prev[server.id] === 'offline' ? prev : { ...prev, [server.id]: 'offline' } + ); }); }); }; - if (config.serverWidget.enabled) { - pingServers(); - const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000); - return () => clearInterval(interval); - } - }, [config.serverWidget.enabled, config.serverWidget.servers, config.serverWidget.pingFrequency]); + pingServers(); + const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000); + return () => clearInterval(interval); + }, [ + config.serverWidget.enabled, + config.serverWidget.pingFrequency, + serversSignature, + serverCount, + ]); if (!config.serverWidget.enabled) { return null; } - const getStatusColor = (status: string) => { - switch (status) { - case 'online': - return 'bg-green-500'; - case 'offline': - return 'bg-red-500'; - default: - return 'bg-gray-500'; - } - }; - return (
{ + if (!freq) return 24 * 60 * 60 * 1000; // default 1 day + const match = freq.match(/(\d+)(h|d)/); + if (!match) return 24 * 60 * 60 * 1000; + const value = parseInt(match[1], 10); + const unit = match[2]; + if (unit === 'h') return value * 60 * 60 * 1000; + if (unit === 'd') return value * 24 * 60 * 60 * 1000; + return 24 * 60 * 60 * 1000; +}; + +const wallpaperUrlCache = new Map(); + const getWallpaperUrlByName = async (name: string): Promise => { if (!name) return undefined; + if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name); + + let resolved: string | undefined; const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name); if (foundInBase) { - return foundInBase.url || foundInBase.base64; - } - - try { - const storedUserWallpapers: WallpaperType[] = - JSON.parse(localStorage.getItem('userWallpapers') || '[]'); - const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name); - if (foundInUser) { - try { - const wallpaperData = await getWallpaperFromChromeStorageLocal(name); - if (wallpaperData && wallpaperData.startsWith('http')) { - return wallpaperData; + resolved = foundInBase.url || foundInBase.base64; + } else { + try { + const storedUserWallpapers: WallpaperType[] = + JSON.parse(localStorage.getItem('userWallpapers') || '[]'); + const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name); + if (foundInUser) { + try { + const wallpaperData = await getWallpaperFromChromeStorageLocal(name); + if (wallpaperData && wallpaperData.startsWith('http')) { + resolved = wallpaperData; + } else { + resolved = wallpaperData || undefined; + } + } catch (error) { + console.error('Error getting wallpaper from chrome storage', error); + resolved = undefined; } - return wallpaperData || undefined; - } catch (error) { - console.error('Error getting wallpaper from chrome storage', error); - return undefined; } + } catch (error) { + console.error('Error reading userWallpapers from localStorage', error); + resolved = undefined; } - } catch (error) { - console.error('Error reading userWallpapers from localStorage', error); } - return undefined; + wallpaperUrlCache.set(name, resolved); + return resolved; }; const Wallpaper: React.FC = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => { const [imageUrl, setImageUrl] = useState(undefined); - - // Helper to parse wallpaperFrequency string to ms - const parseFrequencyToMs = (freq: string): number => { - if (!freq) return 24 * 60 * 60 * 1000; // default 1 day - const match = freq.match(/(\d+)(h|d)/); - if (!match) return 24 * 60 * 60 * 1000; - const value = parseInt(match[1], 10); - const unit = match[2]; - if (unit === 'h') return value * 60 * 60 * 1000; - if (unit === 'd') return value * 24 * 60 * 60 * 1000; - return 24 * 60 * 60 * 1000; - }; + const resolvedRef = useRef(false); useEffect(() => { const updateWallpaper = async () => { @@ -111,6 +119,7 @@ const Wallpaper: React.FC = ({ wallpaperNames, blur, brightness, }), ); + resolvedRef.current = true; setImageUrl(resolvedUrl); }; updateWallpaper(); @@ -120,14 +129,13 @@ const Wallpaper: React.FC = ({ wallpaperNames, blur, brightness, return (
diff --git a/components/WebsiteEditModal.tsx b/components/WebsiteEditModal.tsx index ea580d7..8afac65 100644 --- a/components/WebsiteEditModal.tsx +++ b/components/WebsiteEditModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Website } from '../types'; import { getWebsiteIcon } from './utils/iconService'; @@ -22,47 +22,64 @@ interface IconMetadata { name: string; }; }; - colors: any; // this can be anything I guess + colors: any; } +let iconMetadataCache: IconMetadata[] | null = null; + const WebsiteEditModal: React.FC = ({ website, edit, onClose, onSave, onDelete }) => { const [name, setName] = useState(website ? website.name : ''); const [url, setUrl] = useState(website ? website.url : ''); const [icon, setIcon] = useState(website ? website.icon : ''); const [iconQuery, setIconQuery] = useState(''); - const [iconMetadata, setIconMetadata] = useState([]); const [filteredIcons, setFilteredIcons] = useState([]); + const [iconMetadata, setIconMetadata] = useState([]); + const [iconsFetched, setIconsFetched] = useState(false); + const debounceRef = useRef(null); - useEffect(() => { - fetch('/icon-metadata.json') + const ensureIconMetadata = () => { + if (iconMetadataCache || iconsFetched) return; + setIconsFetched(true); + fetch('/icon-metadata.json', { cache: 'force-cache' }) .then(response => response.json()) .then(data => { - const iconsArray = Object.entries(data).map(([name, details]) => ({ + const iconsArray: IconMetadata[] = Object.entries(data).map(([name, details]) => ({ name, - ...details - })); - // Expand colors into separate entries - iconsArray.forEach(icon => { - if (icon.colors) { - const colors = Object.values(icon.colors).filter(key => key !== icon.name); - for (const color of colors) { - const newIcon = { ...icon }; - newIcon.name = color; - iconsArray.push(newIcon); - } - } - }); + ...(details as object), + })) as IconMetadata[]; + iconMetadataCache = iconsArray; setIconMetadata(iconsArray); - }); - }, []); + }) + .catch(err => console.error('Failed to load icon metadata', err)); + }; useEffect(() => { - if (iconQuery && Array.isArray(iconMetadata)) { - const lowerCaseQuery = iconQuery.toLowerCase(); - const filtered = iconMetadata - .filter(icon => icon.name.toLowerCase().includes(lowerCaseQuery)) - .slice(0, 50); - setFilteredIcons(filtered); + if (iconQuery && Array.isArray(iconMetadata) && iconMetadata.length > 0) { + if (debounceRef.current) window.clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout(() => { + const lowerCaseQuery = iconQuery.toLowerCase(); + const filtered: IconMetadata[] = []; + for (const ic of iconMetadata) { + if (ic.name.toLowerCase().includes(lowerCaseQuery)) { + filtered.push(ic); + if (filtered.length >= 50) break; + } + if (ic.colors) { + const colors = Object.values(ic.colors).filter(key => key !== ic.name); + for (const color of colors) { + if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) { + filtered.push({ ...ic, name: color }); + if (filtered.length >= 50) break; + } + } + if (filtered.length >= 50) break; + } + } + setFilteredIcons(filtered); + }, 150); + return () => { + if (debounceRef.current) window.clearTimeout(debounceRef.current); + }; } else { setFilteredIcons([]); } @@ -76,7 +93,6 @@ const WebsiteEditModal: React.FC = ({ website, edit, onCl }; const handleSave = () => { - console.log({ id: website?.id, name, url, icon }); onSave({ id: website?.id, name, url, icon }); }; @@ -128,6 +144,7 @@ const WebsiteEditModal: React.FC = ({ website, edit, onCl setIcon(e.target.value); setIconQuery(e.target.value); }} + onFocus={ensureIconMetadata} className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full" /> {filteredIcons.length > 0 && ( diff --git a/components/WebsiteTile.tsx b/components/WebsiteTile.tsx index c8e261c..fe56c94 100644 --- a/components/WebsiteTile.tsx +++ b/components/WebsiteTile.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { memo, useState } from 'react'; import { Website } from '../types'; interface WebsiteTileProps { @@ -23,7 +23,6 @@ const getTileSizeClass = (size: string | undefined) => { }; -// Returns normal icon size in px const getIconPixelSize = (size: string | undefined): number => { switch (size) { case 'small': @@ -37,7 +36,6 @@ const getIconPixelSize = (size: string | undefined): number => { } }; -// Returns loading icon size in px const getIconLoadingPixelSize = (size: string | undefined): number => { switch (size) { case 'small': @@ -115,4 +113,4 @@ const WebsiteTile: React.FC = ({ website, isEditing, onEdit, o ); }; -export default WebsiteTile; +export default memo(WebsiteTile); diff --git a/components/layout/CategoryGroup.tsx b/components/layout/CategoryGroup.tsx index 1339f89..db61596 100644 --- a/components/layout/CategoryGroup.tsx +++ b/components/layout/CategoryGroup.tsx @@ -1,3 +1,4 @@ +import React, { memo } from 'react'; import WebsiteTile from '../WebsiteTile'; import { Category, Website } from '../../types'; @@ -10,10 +11,8 @@ interface CategoryGroupProps { setEditingWebsite: (website: Website) => void; handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void; getHorizontalAlignmentClass: (alignment: string) => string; - config: { - horizontalAlignment: string; - tileSize?: string; - }; + horizontalAlignment: string; + tileSize?: string; } const CategoryGroup: React.FC = ({ @@ -25,12 +24,13 @@ const CategoryGroup: React.FC = ({ setEditingWebsite, handleMoveWebsite, getHorizontalAlignmentClass, - config, + horizontalAlignment, + tileSize, }) => { return (
-
-

{category.name}

+
+

{category.name}

{isEditing && ( )}
-
+
{category.websites.map((website) => ( = ({ isEditing={isEditing} onEdit={setEditingWebsite} onMove={handleMoveWebsite} - tileSize={config.tileSize} + tileSize={tileSize} /> ))} {isEditing && ( @@ -72,4 +72,4 @@ const CategoryGroup: React.FC = ({ ); }; -export default CategoryGroup; +export default memo(CategoryGroup); diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 9131138..88575fe 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -50,6 +50,8 @@ const getSubtitleSizeClass = (size: string) => { } }; +export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass }; + const Header: React.FC = ({ config }) => { return ( <> diff --git a/components/utils/jsping.js b/components/utils/jsping.js index 791ef7a..8a824ce 100644 --- a/components/utils/jsping.js +++ b/components/utils/jsping.js @@ -1,24 +1,38 @@ function request_image(url) { return new Promise(function(resolve, reject) { var img = new Image(); - img.onload = function() { resolve(img); }; - img.onerror = function() { reject(url); }; + var settled = false; + function settleOk() { + if (settled) return; + settled = true; + clearTimeout(failTimer); + img.onload = img.onerror = null; + resolve(img); + } + function settleFail() { + if (settled) return; + settled = true; + clearTimeout(failTimer); + img.onload = img.onerror = null; + reject(url); + } + img.onload = settleOk; + img.onerror = settleFail; img.src = url + '?random-no-cache=' + Math.floor((1 + Math.random()) * 0x10000).toString(16); + var failTimer = setTimeout(settleFail, 5000); }); } function ping(url, multiplier) { return new Promise(function(resolve, reject) { var start = (new Date()).getTime(); - var response = function() { + var response = function() { var delta = ((new Date()).getTime() - start); delta *= (multiplier || 1); - resolve(delta); + resolve(delta); }; request_image(url).then(response).catch(response); - - setTimeout(function() { reject(Error('Timeout')); }, 5000); }); } -export default ping; \ No newline at end of file +export default ping; diff --git a/index.css b/index.css index cc8232e..37efef4 100644 --- a/index.css +++ b/index.css @@ -3,4 +3,8 @@ @theme { --ease-ios: cubic-bezier(0.25, 0.46, 0.45, 0.94); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); -} \ No newline at end of file +} + +.wallpaper-transition { + transition: filter 0.3s ease, opacity 0.3s ease; +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..6db0d18 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,13 @@ +gzip on; +gzip_comp_level 6; +gzip_min_length 256; +gzip_proxied any; +gzip_vary on; +gzip_types + application/json + application/javascript + application/xml + text/css + text/plain + text/xml + image/svg+xml; diff --git a/project-context.md b/project-context.md index 5dfc42e..7773c3a 100644 --- a/project-context.md +++ b/project-context.md @@ -36,7 +36,7 @@ Live instances / artifacts: | Bundler / dev server | Vite 6 | | Styling | Tailwind CSS v4 (`@tailwindcss/vite` plugin + `@tailwindcss/postcss` + `autoprefixer`) | | Drag & drop | `@hello-pangea/dnd` 18 | -| Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle) | +| Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle; modals code-split into separate JS chunks via `React.lazy`) | | Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` | | Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-.zip` | | CI/CD | Gitea Actions workflows (`.gitea/workflows/`) | @@ -62,6 +62,15 @@ The startpage is composed of widgets and a configuration panel: - **Edit mode** — Toggle via the top-left pencil button; reveals per-tile move/edit buttons, per-category edit buttons, and "add" buttons. - **Glassmorphism design language** — Translucent surfaces, `backdrop-blur`, subtle borders, and custom iOS-like easing tokens (`ease-ios`, `ease-spring`) defined in `index.css`. +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. +- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile. +- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second. +- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles. +- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings. +- Icon metadata (`/icon-metadata.json`) is module-level cached, fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront. +- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map` and its CSS transition lives in the static `.wallpaper-transition` class in `index.css`. + 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. @@ -86,7 +95,6 @@ vision-start/ │ ├── WebsiteTile.tsx # Individual bookmark tile + loading/edit controls │ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside) │ ├── CategoryEditModal.tsx # Add/edit a category -│ ├── EditModal.tsx # Legacy drag-and-drop editor (imports IconPicker & lucide-react; not wired into App.tsx) │ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import │ ├── ServerWidget.tsx # Bottom server status pill │ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select) @@ -126,7 +134,8 @@ vision-start/ │ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging │ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod │ -├── Dockerfile # Node 22 build → nginx serving dist/ +├── Dockerfile # Node 22 build → nginx serving dist/ (with gzip via nginx.conf) +├── nginx.conf # nginx gzip config (JSON/JS/CSS/SVG/XML), mounted into container ├── .dockerignore ├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc. ├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer @@ -211,9 +220,8 @@ External assets fetched at build time by `scripts/prepare_release.sh`: ## 8. Notable Behaviors & Quirks -- **`EditModal.tsx` is not wired up.** It imports `./IconPicker` and `lucide-react`, neither of which exist in `package.json` or this tree. It appears to be a legacy/abandoned editor superseded by `WebsiteEditModal.tsx` + `CategoryEditModal.tsx`. Treat it as dead code unless reintended. -- **`EditModal` references `lucide-react` and `IconPicker`** which are **not** in `package.json`; do not rely on them. -- **`@hello-pangea/dnd`** is used only inside `EditModal.tsx` (legacy) and `ServerWidgetTab.tsx` (server reorder). `WebsiteTile` moves tile via simple left/right buttons, not drag-and-drop. +- **`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. +- **`@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 via simple left/right buttons, 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. - **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 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. - **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.