From 7b5faf6e7479d2d287ce8ddf51466b2aabb8f8bd Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sun, 25 Oct 2026 13:41:59 +0000 Subject: [PATCH] start: collapsible link groups (remembered per browser), links filter (f, Enter opens first match), brand marks + saved favicons for bookmarked sites --- src/lib/link-favicons.json | 49 ++++++ src/lib/link-icons.json | 72 +++++++- src/lib/link-icons.test.ts | 17 +- src/lib/link-icons.ts | 87 +++++++++- src/routes/start/+page.svelte | 154 +++++++++++++++++- static/link-icons/1337xx.to.png | Bin 0 -> 554 bytes static/link-icons/8kun.top.png | Bin 0 -> 3453 bytes static/link-icons/ad.nl.png | Bin 0 -> 709 bytes static/link-icons/aeon.co.png | Bin 0 -> 380 bytes static/link-icons/aljazeera.com.png | Bin 0 -> 738 bytes static/link-icons/apnews.com.png | Bin 0 -> 600 bytes static/link-icons/beta.leetify.com.png | Bin 0 -> 745 bytes static/link-icons/bloomberg.com.png | Bin 0 -> 2412 bytes static/link-icons/bogleheads.org.png | Bin 0 -> 1819 bytes static/link-icons/bsmg.wiki.png | Bin 0 -> 1852 bytes static/link-icons/coe.psu.ac.th.png | Bin 0 -> 1992 bytes static/link-icons/coolors.co.png | Bin 0 -> 817 bytes static/link-icons/curacao.nu.png | Bin 0 -> 779 bytes static/link-icons/desmos.com.png | Bin 0 -> 1204 bytes static/link-icons/devdocs.io.png | Bin 0 -> 959 bytes static/link-icons/en.yna.co.kr.png | Bin 0 -> 916 bytes static/link-icons/endchan.org.png | Bin 0 -> 155 bytes static/link-icons/euronews.com.png | Bin 0 -> 490 bytes static/link-icons/ft.com.png | Bin 0 -> 373 bytes static/link-icons/iep.utm.edu.png | Bin 0 -> 687 bytes static/link-icons/koreaherald.com.png | Bin 0 -> 259 bytes static/link-icons/koreatimes.co.kr.png | Bin 0 -> 738 bytes static/link-icons/lesswrong.com.png | Bin 0 -> 1286 bytes static/link-icons/macvendors.com.png | Bin 0 -> 767 bytes static/link-icons/marinetraffic.com.png | Bin 0 -> 680 bytes static/link-icons/nodeschool.io.png | Bin 0 -> 792 bytes static/link-icons/nos.nl.png | Bin 0 -> 618 bytes static/link-icons/npr.org.png | Bin 0 -> 406 bytes static/link-icons/nu.nl.png | Bin 0 -> 914 bytes static/link-icons/ocw.mit.edu.png | Bin 0 -> 265 bytes static/link-icons/overapi.com.png | Bin 0 -> 567 bytes static/link-icons/pikminwiki.com.png | Bin 0 -> 1180 bytes static/link-icons/plato.stanford.edu.png | Bin 0 -> 320 bytes static/link-icons/platomania.nl.png | Bin 0 -> 661 bytes static/link-icons/projecteuler.net.png | Bin 0 -> 766 bytes static/link-icons/random-ize.com.png | Bin 0 -> 522 bytes static/link-icons/reuters.com.png | Bin 0 -> 739 bytes static/link-icons/secure.e-boekhouden.nl.png | Bin 0 -> 2320 bytes static/link-icons/systemd.io.png | Bin 0 -> 320 bytes static/link-icons/telegraaf.nl.png | Bin 0 -> 928 bytes static/link-icons/theverge.com.png | Bin 0 -> 557 bytes static/link-icons/tradingeconomics.com.png | Bin 0 -> 354 bytes static/link-icons/tutorial.math.lamar.edu.png | Bin 0 -> 266 bytes static/link-icons/uboachan.net.png | Bin 0 -> 503 bytes static/link-icons/volkskrant.nl.png | Bin 0 -> 991 bytes .../wiki.kerbalspaceprogram.com.png | Bin 0 -> 3989 bytes static/link-icons/wsj.com.png | Bin 0 -> 847 bytes 52 files changed, 368 insertions(+), 11 deletions(-) create mode 100644 src/lib/link-favicons.json create mode 100644 static/link-icons/1337xx.to.png create mode 100644 static/link-icons/8kun.top.png create mode 100644 static/link-icons/ad.nl.png create mode 100644 static/link-icons/aeon.co.png create mode 100644 static/link-icons/aljazeera.com.png create mode 100644 static/link-icons/apnews.com.png create mode 100644 static/link-icons/beta.leetify.com.png create mode 100644 static/link-icons/bloomberg.com.png create mode 100644 static/link-icons/bogleheads.org.png create mode 100644 static/link-icons/bsmg.wiki.png create mode 100644 static/link-icons/coe.psu.ac.th.png create mode 100644 static/link-icons/coolors.co.png create mode 100644 static/link-icons/curacao.nu.png create mode 100644 static/link-icons/desmos.com.png create mode 100644 static/link-icons/devdocs.io.png create mode 100644 static/link-icons/en.yna.co.kr.png create mode 100644 static/link-icons/endchan.org.png create mode 100644 static/link-icons/euronews.com.png create mode 100644 static/link-icons/ft.com.png create mode 100644 static/link-icons/iep.utm.edu.png create mode 100644 static/link-icons/koreaherald.com.png create mode 100644 static/link-icons/koreatimes.co.kr.png create mode 100644 static/link-icons/lesswrong.com.png create mode 100644 static/link-icons/macvendors.com.png create mode 100644 static/link-icons/marinetraffic.com.png create mode 100644 static/link-icons/nodeschool.io.png create mode 100644 static/link-icons/nos.nl.png create mode 100644 static/link-icons/npr.org.png create mode 100644 static/link-icons/nu.nl.png create mode 100644 static/link-icons/ocw.mit.edu.png create mode 100644 static/link-icons/overapi.com.png create mode 100644 static/link-icons/pikminwiki.com.png create mode 100644 static/link-icons/plato.stanford.edu.png create mode 100644 static/link-icons/platomania.nl.png create mode 100644 static/link-icons/projecteuler.net.png create mode 100644 static/link-icons/random-ize.com.png create mode 100644 static/link-icons/reuters.com.png create mode 100644 static/link-icons/secure.e-boekhouden.nl.png create mode 100644 static/link-icons/systemd.io.png create mode 100644 static/link-icons/telegraaf.nl.png create mode 100644 static/link-icons/theverge.com.png create mode 100644 static/link-icons/tradingeconomics.com.png create mode 100644 static/link-icons/tutorial.math.lamar.edu.png create mode 100644 static/link-icons/uboachan.net.png create mode 100644 static/link-icons/volkskrant.nl.png create mode 100644 static/link-icons/wiki.kerbalspaceprogram.com.png create mode 100644 static/link-icons/wsj.com.png diff --git a/src/lib/link-favicons.json b/src/lib/link-favicons.json new file mode 100644 index 0000000..62b6612 --- /dev/null +++ b/src/lib/link-favicons.json @@ -0,0 +1,49 @@ +[ + "1337xx.to", + "8kun.top", + "ad.nl", + "aeon.co", + "aljazeera.com", + "apnews.com", + "beta.leetify.com", + "bloomberg.com", + "bogleheads.org", + "bsmg.wiki", + "coe.psu.ac.th", + "coolors.co", + "curacao.nu", + "desmos.com", + "devdocs.io", + "en.yna.co.kr", + "endchan.org", + "euronews.com", + "ft.com", + "iep.utm.edu", + "koreaherald.com", + "koreatimes.co.kr", + "lesswrong.com", + "macvendors.com", + "marinetraffic.com", + "nodeschool.io", + "nos.nl", + "npr.org", + "nu.nl", + "ocw.mit.edu", + "overapi.com", + "pikminwiki.com", + "plato.stanford.edu", + "platomania.nl", + "projecteuler.net", + "random-ize.com", + "reuters.com", + "secure.e-boekhouden.nl", + "systemd.io", + "telegraaf.nl", + "theverge.com", + "tradingeconomics.com", + "tutorial.math.lamar.edu", + "uboachan.net", + "volkskrant.nl", + "wiki.kerbalspaceprogram.com", + "wsj.com" +] diff --git a/src/lib/link-icons.json b/src/lib/link-icons.json index da0b00c..8e472c0 100644 --- a/src/lib/link-icons.json +++ b/src/lib/link-icons.json @@ -37,5 +37,75 @@ "tailscale": "", "backblaze": "", "1password": "", - "squarespace": "" + "squarespace": "", + "googleanalytics": "", + "googlegemini": "", + "googlenews": "", + "googlecloud": "", + "openai": "", + "perplexity": "", + "mistralai": "", + "huggingface": "", + "kaggle": "", + "sentry": "", + "hubspot": "", + "amazonwebservices": "", + "microsoftoffice": "", + "mdnwebdocs": "", + "typescript": "", + "bun": "", + "nodedotjs": "", + "vite": "", + "tailwindcss": "", + "shadcnui": "", + "eslint": "", + "react": "", + "vuedotjs": "", + "nuxt": "", + "astro": "", + "solid": "", + "sass": "", + "html5": "", + "css": "", + "nginx": "", + "terraform": "", + "redis": "", + "prometheus": "", + "git": "", + "stripe": "", + "rust": "", + "go": "", + "python": "", + "php": "", + "swift": "", + "pytorch": "", + "archlinux": "", + "nixos": "", + "gnubash": "", + "linux": "", + "tmux": "", + "visualstudiocode": "", + "zedindustries": "", + "owasp": "", + "levelsdotfyi": "", + "glassdoor": "", + "ycombinator": "", + "lobsters": "", + "4chan": "", + "exercism": "", + "leetcode": "", + "roadmapdotsh": "", + "wolfram": "", + "plex": "", + "netflix": "", + "curseforge": "", + "bbc": "", + "theguardian": "", + "cnn": "", + "axios": "", + "vox": "", + "thewashingtonpost": "", + "newyorktimes": "", + "nintendo": "", + "pokemon": "" } diff --git a/src/lib/link-icons.test.ts b/src/lib/link-icons.test.ts index f68c94d..cbf4207 100644 --- a/src/lib/link-icons.test.ts +++ b/src/lib/link-icons.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { existsSync } from 'node:fs'; import marks from './link-icons.json'; -import { RULES, iconFile, linkIcon } from './link-icons'; +import { RULES, faviconHosts, iconFile, linkIcon } from './link-icons'; describe('link icons', () => { it('picks brand marks by host, port and path', () => { @@ -39,3 +39,18 @@ describe('link icons', () => { } }); }); + +describe('bookmark favicons', () => { + it('serves saved favicons by host and has every file', () => { + expect(linkIcon('https://nos.nl/')).toEqual({ kind: 'img', src: '/link-icons/nos.nl.png' }); + for (const h of faviconHosts) expect(existsSync(`static/link-icons/${h}.png`), h).toBe(true); + }); + + it('prefers brand marks for bookmarked dev sites', () => { + expect(linkIcon('https://developers.cloudflare.com/')).toMatchObject({ color: '#F38020' }); + expect(linkIcon('https://analytics.google.com/analytics/web/')).toMatchObject({ + color: '#E37400' + }); + expect(linkIcon('https://netflix.com/browse')).toMatchObject({ color: '#E50914' }); + }); +}); diff --git a/src/lib/link-icons.ts b/src/lib/link-icons.ts index b4faf67..4373299 100644 --- a/src/lib/link-icons.ts +++ b/src/lib/link-icons.ts @@ -1,9 +1,11 @@ // Icons for /start link and service tiles, picked from the URL (no hotlinking): // - brand marks from simple-icons, trimmed into link-icons.json by scripts/link-icons.ts // (run `bun scripts/link-icons.ts` after adding a `si` rule below), -// - favicons saved under static/link-icons/ for brands simple-icons doesn't have, +// - favicons saved under static/link-icons/ for brands simple-icons doesn't have (named +// rules below, or by host: link-favicons.json lists static/link-icons/.png), // - otherwise a coloured monogram. import marks from './link-icons.json'; +import savedFavicons from './link-favicons.json'; export type LinkIcon = | { kind: 'svg'; body: string; color: string } @@ -81,7 +83,86 @@ export const RULES: Rule[] = [ { host: 'tailscale.com', si: 'tailscale' }, { host: 'backblaze.com', si: 'backblaze', color: '#E21E29' }, { host: '1password.com', si: '1password', color: '#3B66BC' }, - { host: 'squarespace.com', si: 'squarespace' } + { host: 'squarespace.com', si: 'squarespace' }, + // From Stan's browser bookmarks (Oct 2026) + { host: 'analytics.google.com', si: 'googleanalytics', color: '#E37400' }, + { host: 'gemini.google.com', si: 'googlegemini', color: '#8E75B2' }, + { host: 'news.google.com', si: 'googlenews', color: '#4285F4' }, + { host: 'console.cloud.google.com', si: 'googlecloud', color: '#4285F4' }, + { host: 'chatgpt.com', si: 'openai' }, + { host: 'openai.com', si: 'openai' }, + { host: 'perplexity.ai', si: 'perplexity', color: '#1FB8CD' }, + { host: 'mistral.ai', si: 'mistralai', color: '#FA520F' }, + { host: 'huggingface.co', si: 'huggingface', color: '#FFD21E' }, + { host: 'kaggle.com', si: 'kaggle', color: '#20BEFF' }, + { host: 'sentry.io', si: 'sentry' }, + { host: 'hubspot.com', si: 'hubspot', color: '#FF7A59' }, + { host: 'netdata.cloud', si: 'netdata', color: '#00AB44' }, + { host: 'awsapps.com', si: 'amazonwebservices', color: '#FF9900' }, + { host: 'aws.amazon.com', si: 'amazonwebservices', color: '#FF9900' }, + { host: 'office.com', si: 'microsoftoffice', color: '#D83B01' }, + { host: 'developer.mozilla.org', si: 'mdnwebdocs' }, + { host: 'typescriptlang.org', si: 'typescript', color: '#3178C6' }, + { host: 'bun.com', si: 'bun' }, + { host: 'nodejs.org', si: 'nodedotjs', color: '#5FA04E' }, + { host: 'vite.dev', si: 'vite', color: '#9135FF' }, + { host: 'tailwindcss.com', si: 'tailwindcss', color: '#06B6D4' }, + { host: 'ui.shadcn.com', si: 'shadcnui' }, + { host: 'eslint.org', si: 'eslint', color: '#4B32C3' }, + { host: 'react.dev', si: 'react', color: '#61DAFB' }, + { host: 'reactnative.dev', si: 'react', color: '#61DAFB' }, + { host: 'vuejs.org', si: 'vuedotjs', color: '#4FC08D' }, + { host: 'nuxt.com', si: 'nuxt', color: '#00DC82' }, + { host: 'astro.build', si: 'astro', color: '#BC52EE' }, + { host: 'solidjs.com', si: 'solid', color: '#5A8BD6' }, + { host: 'sass-lang.com', si: 'sass', color: '#CC6699' }, + { host: 'html.spec.whatwg.org', si: 'html5', color: '#E34F26' }, + { host: 'w3.org', si: 'css', color: '#9B6BD6' }, + { host: 'nginx.org', si: 'nginx', color: '#009639' }, + { host: 'docker.com', si: 'docker', color: '#2496ED' }, + { host: 'hashicorp.com', si: 'terraform', color: '#844FBA' }, + { host: 'postgresql.org', si: 'postgresql', color: '#4169E1' }, + { host: 'redis.io', si: 'redis', color: '#FF4438' }, + { host: 'prometheus.io', si: 'prometheus', color: '#E6522C' }, + { host: 'git-scm.com', si: 'git', color: '#F03C2E' }, + { host: 'stripe.com', si: 'stripe', color: '#635BFF' }, + { host: 'rust-lang.org', si: 'rust' }, + { host: 'go.dev', si: 'go', color: '#00ADD8' }, + { host: 'python.org', si: 'python', color: '#3776AB' }, + { host: 'php.net', si: 'php', color: '#777BB4' }, + { host: 'swift.org', si: 'swift', color: '#F05138' }, + { host: 'pytorch.org', si: 'pytorch', color: '#EE4C2C' }, + { host: 'archlinux.org', si: 'archlinux', color: '#1793D1' }, + { host: 'nixos.wiki', si: 'nixos', color: '#5277C3' }, + { host: 'gnu.org', si: 'gnubash', color: '#4EAA25' }, + { host: 'man7.org', si: 'linux', color: '#FCC624' }, + { host: 'man.openbsd.org', si: 'tmux', color: '#1BB91F' }, + { host: 'code.visualstudio.com', si: 'visualstudiocode', color: '#007ACC' }, + { host: 'zed.dev', si: 'zedindustries', color: '#2F7CF6' }, + { host: 'owasp.org', si: 'owasp' }, + { host: 'levels.fyi', si: 'levelsdotfyi', color: '#788B95' }, + { host: 'glassdoor.nl', si: 'glassdoor', color: '#00A162' }, + { host: 'news.ycombinator.com', si: 'ycombinator', color: '#F0652F' }, + { host: 'lobste.rs', si: 'lobsters', color: '#AC130D' }, + { host: '4chan.org', si: '4chan', color: '#3A9A3A' }, + { host: 'exercism.org', si: 'exercism', color: '#009CAB' }, + { host: 'leetcode.com', si: 'leetcode', color: '#FFA116' }, + { host: 'roadmap.sh', si: 'roadmapdotsh' }, + { host: 'wolframalpha.com', si: 'wolfram', color: '#DD1100' }, + { host: 'plex.tv', si: 'plex', color: '#EBAF00' }, + { host: 'netflix.com', si: 'netflix', color: '#E50914' }, + { host: 'curseforge.com', si: 'curseforge', color: '#F16436' }, + { host: 'bbc.com', si: 'bbc' }, + { host: 'theguardian.com', si: 'theguardian' }, + { host: 'cnn.com', si: 'cnn', color: '#CC0000' }, + { host: 'axios.com', si: 'axios', color: '#7B5CF0' }, + { host: 'vox.com', si: 'vox', color: '#DA074A' }, + { host: 'washingtonpost.com', si: 'thewashingtonpost' }, + { host: 'nytimes.com', si: 'newyorktimes' }, + { host: 'mariowiki.com', si: 'nintendo', color: '#E60012' }, + { host: 'bulbagarden.net', si: 'pokemon', color: '#FFCB05' }, + { host: 'pokemmo.com', si: 'pokemon', color: '#FFCB05' }, + { host: 'cloudflareaccess.com', si: 'cloudflare', color: '#F38020' } ]; export const iconFile = (img: string) => (img.startsWith('/') ? img : `/link-icons/${img}`); @@ -109,6 +190,7 @@ export function ruleFor(url: string): Rule | null { } const marksBySlug = marks as Record; +export const faviconHosts = new Set(savedFavicons); export function linkIcon(url: string, title = ''): LinkIcon { const r = ruleFor(url); @@ -116,6 +198,7 @@ export function linkIcon(url: string, title = ''): LinkIcon { return { kind: 'svg', body: marksBySlug[r.si], color: r.color ?? 'currentColor' }; if (r?.img) return { kind: 'img', src: iconFile(r.img) }; const host = parse(url)?.host ?? url; + if (faviconHosts.has(host)) return { kind: 'img', src: iconFile(`${host}.png`) }; let h = 0; for (const c of host) h = (h * 31 + c.charCodeAt(0)) >>> 0; return { diff --git a/src/routes/start/+page.svelte b/src/routes/start/+page.svelte index 6fb8200..a1357e2 100644 --- a/src/routes/start/+page.svelte +++ b/src/routes/start/+page.svelte @@ -12,6 +12,19 @@ let editing = $state(false); let editId = $state(null); let searchEl = $state(); + let linkFilter = $state(''); + let linkFilterEl = $state(); + // Link groups: collapsible, open/closed remembered per browser; these start expanded. + const DEFAULT_OPEN = ['daily', 'self-hosted', 'dev', 'infra', 'money']; + const OPEN_KEY = 'start:links:open'; + let openGroups = $state>({}); + $effect(() => { + try { + openGroups = JSON.parse(localStorage.getItem(OPEN_KEY) ?? '{}'); + } catch { + openGroups = {}; + } + }); const trackers = [ { name: 'Tasks', href: '/trackers/tasks' }, @@ -51,6 +64,46 @@ return groups; }); + const filtering = $derived(linkFilter.trim() !== ''); + const shownSections = $derived.by(() => { + const q = linkFilter.trim().toLowerCase(); + if (!q) return sections; + return sections + .map( + ([section, links]) => + [ + section, + section.includes(q) + ? links + : links.filter( + (l) => l.title.toLowerCase().includes(q) || l.url.toLowerCase().includes(q) + ) + ] as [string, typeof data.links] + ) + .filter(([, links]) => links.length); + }); + const isOpen = (section: string) => openGroups[section] ?? DEFAULT_OPEN.includes(section); + function toggleGroup(section: string, open: boolean) { + if (filtering || isOpen(section) === open) return; + openGroups = { ...openGroups, [section]: open }; + try { + localStorage.setItem(OPEN_KEY, JSON.stringify(openGroups)); + } catch { + // private mode: just don't remember + } + } + function onFilterKey(e: KeyboardEvent) { + if (e.key === 'Escape') { + linkFilter = ''; + linkFilterEl?.blur(); + } else if (e.key === 'Enter') { + const first = shownSections[0]?.[1][0]; + if (!first) return; + if (e.metaKey || e.ctrlKey) window.open(first.url, '_blank'); + else location.href = first.url; + } + } + type Hit = { name: string; href: string; kind: string }; const everything = $derived([ ...data.links.map((l) => ({ name: l.title, href: l.url, kind: l.section })), @@ -94,6 +147,9 @@ if (e.key === '/' && !typing) { e.preventDefault(); searchEl?.focus(); + } else if (e.key === 'f' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + linkFilterEl?.focus(); } } @@ -377,15 +433,49 @@