import { useState, useEffect, useRef } from 'react'; import { Website } from '../types'; import { getWebsiteIcon } from './utils/iconService'; import { normalizeWebsiteUrl } from './utils/urlUtils'; import ModalShell from './ModalShell'; interface WebsiteEditModalProps { website?: Website; edit: boolean; onClose: () => void; onSave: (website: Partial) => void; onDelete: () => void; } interface IconMetadata { name: string; base: string; colors: string[]; } const getIconPickUrl = (iconData: IconMetadata): string => `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`; const WebsiteEditModal: React.FC = ({ website, edit, onClose, onSave, onDelete }) => { const [name, setName] = useState(website ? website.name : ''); const [url, setUrl] = useState(website ? website.url : ''); const [icon, setIcon] = useState(website ? website.icon : ''); const [iconQuery, setIconQuery] = useState(''); const [filteredIcons, setFilteredIcons] = useState([]); const [iconMetadata, setIconMetadata] = useState([]); const metadataRequest = useRef(null); const debounceRef = useRef(null); useEffect(() => { return () => { metadataRequest.current?.abort(); if (debounceRef.current !== null) window.clearTimeout(debounceRef.current); }; }, []); const ensureIconMetadata = () => { if (iconMetadata.length > 0 || metadataRequest.current) return; const controller = new AbortController(); metadataRequest.current = controller; const timeout = window.setTimeout(() => controller.abort(), 10_000); fetch(new URL('icon-metadata.json', document.baseURI).href, { cache: 'force-cache', signal: controller.signal }) .then(response => { if (!response.ok) throw new Error('Icon metadata is unavailable'); return response.json(); }) .then(data => { if (controller.signal.aborted) return; if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid icon metadata'); const iconsArray: IconMetadata[] = Object.entries(data).flatMap(([name, details]) => { if (!details || typeof details !== 'object' || !('base' in details) || typeof details.base !== 'string') return []; const colors = 'colors' in details && details.colors && typeof details.colors === 'object' ? Object.values(details.colors).filter((color): color is string => typeof color === 'string' && color !== name) : []; return [{ name, base: details.base, colors }]; }); setIconMetadata(iconsArray); }) .catch(err => { if (!controller.signal.aborted) console.error('Failed to load icon metadata', err); }) .finally(() => { window.clearTimeout(timeout); if (metadataRequest.current === controller) metadataRequest.current = null; }); }; useEffect(() => { 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.length > 0) { for (const color of ic.colors) { if (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([]); } }, [iconQuery, iconMetadata]); const handleSave = () => { const normalizedUrl = normalizeWebsiteUrl(url); if (!normalizedUrl) { alert('Please enter a valid website URL (http, https, ftp, or mailto).'); return; } onSave({ id: website?.id, name: name.trim(), url: normalizedUrl, icon }); }; const fetchIcon = async () => { if (url) { const fetchedIcon = await getWebsiteIcon(url); setIcon(fetchedIcon); } }; return (
{icon ? ( Website Icon ) : (
)}
setName(e.target.value)} className="liquid-input p-3" /> setUrl(e.target.value)} className="liquid-input p-3" />
{ setIcon(e.target.value); setIconQuery(e.target.value); }} onFocus={ensureIconMetadata} className="liquid-input p-3" /> {filteredIcons.length > 0 && (
{filteredIcons.map((iconData, index) => (
{ setIcon(getIconPickUrl(iconData)); setIconQuery(''); setFilteredIcons([]); }} className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20" > {iconData.name} {iconData.name}
))}
)}
); }; export default WebsiteEditModal;