start: collapsible link groups (remembered per browser), links filter (f, Enter opens first match), brand marks + saved favicons for bookmarked sites
Some checks failed
Deploy production / deploy (push) Has been cancelled
49
src/lib/link-favicons.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import marks from './link-icons.json';
|
import marks from './link-icons.json';
|
||||||
import { RULES, iconFile, linkIcon } from './link-icons';
|
import { RULES, faviconHosts, iconFile, linkIcon } from './link-icons';
|
||||||
|
|
||||||
describe('link icons', () => {
|
describe('link icons', () => {
|
||||||
it('picks brand marks by host, port and path', () => {
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// Icons for /start link and service tiles, picked from the URL (no hotlinking):
|
// 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
|
// - 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),
|
// (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/<host>.png),
|
||||||
// - otherwise a coloured monogram.
|
// - otherwise a coloured monogram.
|
||||||
import marks from './link-icons.json';
|
import marks from './link-icons.json';
|
||||||
|
import savedFavicons from './link-favicons.json';
|
||||||
|
|
||||||
export type LinkIcon =
|
export type LinkIcon =
|
||||||
| { kind: 'svg'; body: string; color: string }
|
| { kind: 'svg'; body: string; color: string }
|
||||||
@@ -81,7 +83,86 @@ export const RULES: Rule[] = [
|
|||||||
{ host: 'tailscale.com', si: 'tailscale' },
|
{ host: 'tailscale.com', si: 'tailscale' },
|
||||||
{ host: 'backblaze.com', si: 'backblaze', color: '#E21E29' },
|
{ host: 'backblaze.com', si: 'backblaze', color: '#E21E29' },
|
||||||
{ host: '1password.com', si: '1password', color: '#3B66BC' },
|
{ 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}`);
|
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<string, string>;
|
const marksBySlug = marks as Record<string, string>;
|
||||||
|
export const faviconHosts = new Set<string>(savedFavicons);
|
||||||
|
|
||||||
export function linkIcon(url: string, title = ''): LinkIcon {
|
export function linkIcon(url: string, title = ''): LinkIcon {
|
||||||
const r = ruleFor(url);
|
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' };
|
return { kind: 'svg', body: marksBySlug[r.si], color: r.color ?? 'currentColor' };
|
||||||
if (r?.img) return { kind: 'img', src: iconFile(r.img) };
|
if (r?.img) return { kind: 'img', src: iconFile(r.img) };
|
||||||
const host = parse(url)?.host ?? url;
|
const host = parse(url)?.host ?? url;
|
||||||
|
if (faviconHosts.has(host)) return { kind: 'img', src: iconFile(`${host}.png`) };
|
||||||
let h = 0;
|
let h = 0;
|
||||||
for (const c of host) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
for (const c of host) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,6 +12,19 @@
|
|||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let editId = $state<number | null>(null);
|
let editId = $state<number | null>(null);
|
||||||
let searchEl = $state<HTMLInputElement>();
|
let searchEl = $state<HTMLInputElement>();
|
||||||
|
let linkFilter = $state('');
|
||||||
|
let linkFilterEl = $state<HTMLInputElement>();
|
||||||
|
// 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<Record<string, boolean>>({});
|
||||||
|
$effect(() => {
|
||||||
|
try {
|
||||||
|
openGroups = JSON.parse(localStorage.getItem(OPEN_KEY) ?? '{}');
|
||||||
|
} catch {
|
||||||
|
openGroups = {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const trackers = [
|
const trackers = [
|
||||||
{ name: 'Tasks', href: '/trackers/tasks' },
|
{ name: 'Tasks', href: '/trackers/tasks' },
|
||||||
@@ -51,6 +64,46 @@
|
|||||||
return groups;
|
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 };
|
type Hit = { name: string; href: string; kind: string };
|
||||||
const everything = $derived<Hit[]>([
|
const everything = $derived<Hit[]>([
|
||||||
...data.links.map((l) => ({ name: l.title, href: l.url, kind: l.section })),
|
...data.links.map((l) => ({ name: l.title, href: l.url, kind: l.section })),
|
||||||
@@ -94,6 +147,9 @@
|
|||||||
if (e.key === '/' && !typing) {
|
if (e.key === '/' && !typing) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
searchEl?.focus();
|
searchEl?.focus();
|
||||||
|
} else if (e.key === 'f' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
linkFilterEl?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,15 +433,49 @@
|
|||||||
<!-- Personal links -->
|
<!-- Personal links -->
|
||||||
<section class="card links">
|
<section class="card links">
|
||||||
<h2>
|
<h2>
|
||||||
Links
|
Links <small class="count">{data.links.length}</small>
|
||||||
|
<input
|
||||||
|
class="filter"
|
||||||
|
bind:this={linkFilterEl}
|
||||||
|
bind:value={linkFilter}
|
||||||
|
onkeydown={onFilterKey}
|
||||||
|
type="search"
|
||||||
|
placeholder="Filter links (f)"
|
||||||
|
aria-label="Filter links"
|
||||||
|
/>
|
||||||
<button class="ghost" onclick={() => ((editing = !editing), (editId = null))}
|
<button class="ghost" onclick={() => ((editing = !editing), (editId = null))}
|
||||||
>{editing ? 'done' : 'edit'}</button
|
>{editing ? 'done' : 'edit'}</button
|
||||||
>
|
>
|
||||||
</h2>
|
</h2>
|
||||||
{#if form?.message}<p class="bad sub" role="alert">{form.message}</p>{/if}
|
{#if form?.message}<p class="bad sub" role="alert">{form.message}</p>{/if}
|
||||||
{#each sections as [section, links] (section)}
|
{#if !editing}
|
||||||
|
{#each shownSections as [section, links] (section)}
|
||||||
|
{@const open = filtering || isOpen(section)}
|
||||||
|
<details
|
||||||
|
class="group"
|
||||||
|
{open}
|
||||||
|
ontoggle={(e) => toggleGroup(section, e.currentTarget.open)}
|
||||||
|
>
|
||||||
|
<summary><span>{section}</span><small>{links.length}</small></summary>
|
||||||
|
{#if open}
|
||||||
|
<ul class="tiles icons">
|
||||||
|
{#each links as l (l.id)}
|
||||||
|
<li>
|
||||||
|
<a href={l.url} title={l.url}
|
||||||
|
>{@render icon(l.url, l.title)}<span class="label">{l.title}</span></a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">No links match “{linkFilter}”.</p>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{#each editing ? sections : [] as [section, links] (section)}
|
||||||
{#if sections.length > 1}<h3>{section}</h3>{/if}
|
{#if sections.length > 1}<h3>{section}</h3>{/if}
|
||||||
<ul class={editing ? 'list' : 'tiles icons'}>
|
<ul class="list">
|
||||||
{#each links as l (l.id)}
|
{#each links as l (l.id)}
|
||||||
<li>
|
<li>
|
||||||
{#if editing && editId === l.id}
|
{#if editing && editId === l.id}
|
||||||
@@ -407,10 +497,6 @@
|
|||||||
<input type="hidden" name="id" value={l.id} />
|
<input type="hidden" name="id" value={l.id} />
|
||||||
<button class="ghost bad" aria-label="Delete {l.title}">✕</button>
|
<button class="ghost bad" aria-label="Delete {l.title}">✕</button>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
|
||||||
<a href={l.url} title={l.url}
|
|
||||||
>{@render icon(l.url, l.title)}<span class="label">{l.title}</span></a
|
|
||||||
>
|
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -827,6 +913,60 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: var(--site-muted);
|
border-color: var(--site-muted);
|
||||||
}
|
}
|
||||||
|
.links h2 .count {
|
||||||
|
margin-left: 0;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.links .filter {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--site-text);
|
||||||
|
background: var(--site-bg, transparent);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
.links .filter:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.group > summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--site-muted);
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
}
|
||||||
|
.group > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.group > summary::before {
|
||||||
|
content: '▸';
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
.group[open] > summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.group > summary:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.group > summary small {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
.tiles.icons {
|
.tiles.icons {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
static/link-icons/1337xx.to.png
Normal file
|
After Width: | Height: | Size: 554 B |
BIN
static/link-icons/8kun.top.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/link-icons/ad.nl.png
Normal file
|
After Width: | Height: | Size: 709 B |
BIN
static/link-icons/aeon.co.png
Normal file
|
After Width: | Height: | Size: 380 B |
BIN
static/link-icons/aljazeera.com.png
Normal file
|
After Width: | Height: | Size: 738 B |
BIN
static/link-icons/apnews.com.png
Normal file
|
After Width: | Height: | Size: 600 B |
BIN
static/link-icons/beta.leetify.com.png
Normal file
|
After Width: | Height: | Size: 745 B |
BIN
static/link-icons/bloomberg.com.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
static/link-icons/bogleheads.org.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
static/link-icons/bsmg.wiki.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
static/link-icons/coe.psu.ac.th.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/link-icons/coolors.co.png
Normal file
|
After Width: | Height: | Size: 817 B |
BIN
static/link-icons/curacao.nu.png
Normal file
|
After Width: | Height: | Size: 779 B |
BIN
static/link-icons/desmos.com.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/link-icons/devdocs.io.png
Normal file
|
After Width: | Height: | Size: 959 B |
BIN
static/link-icons/en.yna.co.kr.png
Normal file
|
After Width: | Height: | Size: 916 B |
BIN
static/link-icons/endchan.org.png
Normal file
|
After Width: | Height: | Size: 155 B |
BIN
static/link-icons/euronews.com.png
Normal file
|
After Width: | Height: | Size: 490 B |
BIN
static/link-icons/ft.com.png
Normal file
|
After Width: | Height: | Size: 373 B |
BIN
static/link-icons/iep.utm.edu.png
Normal file
|
After Width: | Height: | Size: 687 B |
BIN
static/link-icons/koreaherald.com.png
Normal file
|
After Width: | Height: | Size: 259 B |
BIN
static/link-icons/koreatimes.co.kr.png
Normal file
|
After Width: | Height: | Size: 738 B |
BIN
static/link-icons/lesswrong.com.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/link-icons/macvendors.com.png
Normal file
|
After Width: | Height: | Size: 767 B |
BIN
static/link-icons/marinetraffic.com.png
Normal file
|
After Width: | Height: | Size: 680 B |
BIN
static/link-icons/nodeschool.io.png
Normal file
|
After Width: | Height: | Size: 792 B |
BIN
static/link-icons/nos.nl.png
Normal file
|
After Width: | Height: | Size: 618 B |
BIN
static/link-icons/npr.org.png
Normal file
|
After Width: | Height: | Size: 406 B |
BIN
static/link-icons/nu.nl.png
Normal file
|
After Width: | Height: | Size: 914 B |
BIN
static/link-icons/ocw.mit.edu.png
Normal file
|
After Width: | Height: | Size: 265 B |
BIN
static/link-icons/overapi.com.png
Normal file
|
After Width: | Height: | Size: 567 B |
BIN
static/link-icons/pikminwiki.com.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/link-icons/plato.stanford.edu.png
Normal file
|
After Width: | Height: | Size: 320 B |
BIN
static/link-icons/platomania.nl.png
Normal file
|
After Width: | Height: | Size: 661 B |
BIN
static/link-icons/projecteuler.net.png
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
static/link-icons/random-ize.com.png
Normal file
|
After Width: | Height: | Size: 522 B |
BIN
static/link-icons/reuters.com.png
Normal file
|
After Width: | Height: | Size: 739 B |
BIN
static/link-icons/secure.e-boekhouden.nl.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
static/link-icons/systemd.io.png
Normal file
|
After Width: | Height: | Size: 320 B |
BIN
static/link-icons/telegraaf.nl.png
Normal file
|
After Width: | Height: | Size: 928 B |
BIN
static/link-icons/theverge.com.png
Normal file
|
After Width: | Height: | Size: 557 B |
BIN
static/link-icons/tradingeconomics.com.png
Normal file
|
After Width: | Height: | Size: 354 B |
BIN
static/link-icons/tutorial.math.lamar.edu.png
Normal file
|
After Width: | Height: | Size: 266 B |
BIN
static/link-icons/uboachan.net.png
Normal file
|
After Width: | Height: | Size: 503 B |
BIN
static/link-icons/volkskrant.nl.png
Normal file
|
After Width: | Height: | Size: 991 B |
BIN
static/link-icons/wiki.kerbalspaceprogram.com.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
static/link-icons/wsj.com.png
Normal file
|
After Width: | Height: | Size: 847 B |