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

This commit is contained in:
2026-10-25 13:41:59 +00:00
parent 628e5cb9b3
commit 7b5faf6e74
52 changed files with 368 additions and 11 deletions

View File

@@ -12,6 +12,19 @@
let editing = $state(false);
let editId = $state<number | null>(null);
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 = [
{ 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<Hit[]>([
...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 @@
<!-- Personal links -->
<section class="card links">
<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))}
>{editing ? 'done' : 'edit'}</button
>
</h2>
{#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}
<ul class={editing ? 'list' : 'tiles icons'}>
<ul class="list">
{#each links as l (l.id)}
<li>
{#if editing && editId === l.id}
@@ -407,10 +497,6 @@
<input type="hidden" name="id" value={l.id} />
<button class="ghost bad" aria-label="Delete {l.title}">✕</button>
</form>
{:else}
<a href={l.url} title={l.url}
>{@render icon(l.url, l.title)}<span class="label">{l.title}</span></a
>
{/if}
</li>
{/each}
@@ -827,6 +913,60 @@
transform: translateY(-1px);
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 {
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
}