Files
website/src/lib/link-icons.ts

127 lines
5.2 KiB
TypeScript

// 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,
// - otherwise a coloured monogram.
import marks from './link-icons.json';
export type LinkIcon =
| { kind: 'svg'; body: string; color: string }
| { kind: 'img'; src: string }
| { kind: 'letter'; letter: string; color: string };
type Rule = {
/** Host suffix ("google.com" matches mail.google.com), or exact host with a leading "=". */
host: string;
port?: string;
path?: string;
si?: string;
color?: string; // brand colour; omitted = currentColor (black/white marks)
img?: string;
};
// First match wins, so specific rules go before broad ones.
export const RULES: Rule[] = [
{ host: 'mail.google.com', si: 'gmail', color: '#EA4335' },
{ host: 'calendar.google.com', si: 'googlecalendar', color: '#4285F4' },
{ host: 'drive.google.com', si: 'googledrive', color: '#1FA463' },
{ host: 'photos.google.com', si: 'googlephotos', color: '#FBBC04' },
{ host: 'maps.google.com', si: 'googlemaps', color: '#34A853' },
{ host: 'google.com', path: '/maps', si: 'googlemaps', color: '#34A853' },
{ host: 'whatsapp.com', si: 'whatsapp', color: '#25D366' },
{ host: 'discord.com', si: 'discord', color: '#5865F2' },
{ host: 'x.com', si: 'x' },
{ host: 'twitter.com', si: 'x' },
{ host: 'grok.com', img: 'grok.png' },
{ host: 'ticktick.com', si: 'ticktick', color: '#4772FA' },
{ host: 'spotify.com', si: 'spotify', color: '#1ED760' },
{ host: 'last.fm', si: 'lastdotfm', color: '#D51007' },
{ host: 'youtube.com', si: 'youtube', color: '#FF0000' },
{ host: 'twitch.tv', si: 'twitch', color: '#9146FF' },
{ host: 'osu.ppy.sh', si: 'osu', color: '#FF66AA' },
{ host: 'steampowered.com', si: 'steam' },
{ host: 'steamcommunity.com', si: 'steam' },
{ host: 'reddit.com', si: 'reddit', color: '#FF4500' },
{ host: 'linkedin.com', si: 'linkedin', color: '#0A66C2' },
{ host: 'claude.ai', si: 'claude', color: '#D97757' },
{ host: 'revolut.com', si: 'revolut' },
{ host: 'moneybird.com', img: 'moneybird.png' },
{ host: 'belastingdienst.nl', img: 'belastingdienst.png' },
{ host: 'megekko.nl', img: 'megekko.png' },
{ host: 'tweakers.net', img: 'tweakers.png' },
{ host: 'funda.nl', img: 'funda.png' },
{ host: 'pararius.nl', img: 'pararius.png' },
{ host: 'mijndak.nl', img: 'mijndak.png' },
{ host: 'ns.nl', img: 'ns.png' },
{ host: 'buienradar.nl', img: 'buienradar.png' },
// Self-hosted on stan-ovh
{ host: 'git.stanrunge.dev', si: 'gitea', color: '#609926' },
{ host: 'photos.stanrunge.dev', si: 'immich', color: '#4250AF' },
{ host: 'analytics.stanrunge.dev', si: 'umami' },
{ host: 'pgadmin.stanrunge.dev', si: 'postgresql', color: '#4169E1' },
{ host: 'logs.vash.software', si: 'docker', color: '#2496ED' },
{ host: 'dev.stanrunge.dev', si: 'svelte', color: '#FF3E00' },
{ host: 'stuhee.stanrunge.dev', img: 'stuhee.svg' },
{ host: 'vash.software', img: 'vash.ico' },
{ host: '=stanrunge.dev', img: '/favicon-32x32.png' },
{ host: 'ts.net', port: '19999', si: 'netdata', color: '#00AB44' },
{ host: 'ts.net', port: '9443', si: 'portainer', color: '#13BEF9' },
{ host: 'ntfy.sh', si: 'ntfy', color: '#3CB4A0' },
// Dev + infra
{ host: 'github.com', si: 'github' },
{ host: 'cursor.com', si: 'cursor' },
{ host: 'wix.com', si: 'wix' },
{ host: 'svelte.dev', si: 'svelte', color: '#FF3E00' },
{ host: 'drizzle.team', si: 'drizzle', color: '#C5F74F' },
{ host: 'cloudflare.com', si: 'cloudflare', color: '#F38020' },
{ host: 'ovh.com', si: 'ovh', color: '#4F8CF7' },
{ host: 'ovhcloud.com', si: 'ovh', color: '#4F8CF7' },
{ host: 'hetzner.com', si: 'hetzner', color: '#D50C2D' },
{ host: 'hetzner.cloud', si: 'hetzner', color: '#D50C2D' },
{ host: 'tailscale.com', si: 'tailscale' },
{ host: 'backblaze.com', si: 'backblaze', color: '#E21E29' },
{ host: '1password.com', si: '1password', color: '#3B66BC' },
{ host: 'squarespace.com', si: 'squarespace' }
];
export const iconFile = (img: string) => (img.startsWith('/') ? img : `/link-icons/${img}`);
function parse(url: string) {
try {
const u = new URL(url);
return { host: u.hostname.replace(/^www\./, '').toLowerCase(), port: u.port, path: u.pathname };
} catch {
return null;
}
}
export function ruleFor(url: string): Rule | null {
const u = parse(url);
if (!u) return null;
return (
RULES.find((r) => {
const hostOk = r.host.startsWith('=')
? u.host === r.host.slice(1)
: u.host === r.host || u.host.endsWith(`.${r.host}`);
return hostOk && (!r.port || r.port === u.port) && (!r.path || u.path.startsWith(r.path));
}) ?? null
);
}
const marksBySlug = marks as Record<string, string>;
export function linkIcon(url: string, title = ''): LinkIcon {
const r = ruleFor(url);
if (r?.si && marksBySlug[r.si])
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;
let h = 0;
for (const c of host) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return {
kind: 'letter',
letter: (title.trim()[0] ?? host[0] ?? '?').toUpperCase(),
color: `hsl(${h % 360} 70% 55%)`
};
}