data holders: private tracker of every organisation holding my address, email, phone or IBAN
Some checks failed
Deploy production / deploy (push) Has been cancelled

dataholders schema (orgs + identity) with migration and personal_app grants,
idempotent CSV import (upsert on name+login, never wipes, keeps site edits and
ticks), owner-only /trackers/data-holders with search, filters, pagination,
inline edit/add and a moving checklist per identifier
This commit is contained in:
2026-10-25 16:09:28 +00:00
parent fd4a34b495
commit 772e699655
18 changed files with 30891 additions and 1 deletions

View File

@@ -0,0 +1,10 @@
import { requireOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// Owner only (private personal data): everyone else gets a plain 404. Form actions skip
// this load, so +page.server.ts checks again, and hooks.server.ts blocks the prefix too.
export const load: LayoutServerLoad = (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {};
};

View File

@@ -0,0 +1,185 @@
import { db } from '#lib/server/db/index.js';
import { dataholderIdentity, dataholderOrgs } from '#lib/server/db/schema/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { importKey } from '#lib/server/dataholders-import.js';
import {
actions as orgActions,
addressStatuses,
isIdentityKind,
priorities
} from '#lib/dataholders.js';
import { asc, eq, sql } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
const today = () =>
new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Amsterdam' }).format(new Date());
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const [orgs, identity] = await Promise.all([
db
.select()
.from(dataholderOrgs)
.orderBy(
sql`case ${dataholderOrgs.priority} when 'must' then 0 when 'should' then 1 else 2 end`,
asc(sql`lower(${dataholderOrgs.name})`)
),
db.select().from(dataholderIdentity)
]);
return {
today: today(),
orgs,
identity: identity.map(({ kind, value, since }) => ({ kind, value, since })),
seo: { title: 'Data holders', description: 'Private.', index: false }
};
};
const str = (form: FormData, key: string, max = 2000) => {
const v = String(form.get(key) ?? '').trim();
return v ? v.slice(0, max) : null;
};
const flag = (form: FormData, key: string) => form.get(key) === 'on' || form.get(key) === 'true';
const day = (form: FormData, key: string) => {
const v = str(form, key, 10);
return v && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null;
};
const oneOf = <T extends string>(list: readonly T[], v: string | null, fallback: T): T =>
v && (list as readonly string[]).includes(v) ? (v as T) : fallback;
const orgId = (form: FormData) => {
const id = Number(form.get('id'));
return Number.isInteger(id) && id > 0 ? id : null;
};
export const actions: Actions = {
save: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const name = str(form, 'name', 200);
if (!name) return fail(400, { message: 'Name is required' });
const link = str(form, 'link', 500);
if (link && !/^https?:\/\//i.test(link))
return fail(400, { message: 'Link must start with http(s)://' });
const login = str(form, 'login', 200);
const values = {
name,
category: str(form, 'category', 80) ?? 'Other',
login,
source: str(form, 'source', 120),
domains: str(form, 'domains', 500),
holdsAddress: flag(form, 'holds_address'),
holdsEmail: flag(form, 'holds_email'),
holdsPhone: flag(form, 'holds_phone'),
holdsIban: flag(form, 'holds_iban'),
holdsBsn: flag(form, 'holds_bsn'),
holdsCard: flag(form, 'holds_card'),
holdsDob: flag(form, 'holds_dob'),
holdsIdDoc: flag(form, 'holds_id_doc'),
followsBrp: flag(form, 'follows_brp'),
dataHeld: str(form, 'data_held'),
addressOnFile: str(form, 'address_on_file', 300),
addressStatus: oneOf(addressStatuses, str(form, 'address_status'), 'unknown'),
emailOnFile: str(form, 'email_on_file', 300),
phoneOnFile: str(form, 'phone_on_file', 60),
action: oneOf(orgActions, str(form, 'action'), 'update'),
priority: oneOf(priorities, str(form, 'priority'), 'should'),
howToChange: str(form, 'how_to_change'),
link,
evidence: str(form, 'evidence', 300),
notes: str(form, 'notes'),
addressUpdatedOn: day(form, 'address_updated_on'),
emailUpdatedOn: day(form, 'email_updated_on'),
phoneUpdatedOn: day(form, 'phone_updated_on'),
ibanUpdatedOn: day(form, 'iban_updated_on'),
archived: flag(form, 'archived'),
editedAt: new Date()
};
const id = orgId(form);
if (id) {
await db.update(dataholderOrgs).set(values).where(eq(dataholderOrgs.id, id));
return { saved: id };
}
// New rows claim their import key when it is free, so a later import of the same org
// fills its empty fields instead of adding a duplicate.
const key = importKey(name, login);
const [taken] = await db
.select({ id: dataholderOrgs.id })
.from(dataholderOrgs)
.where(eq(dataholderOrgs.importKey, key));
const [row] = await db
.insert(dataholderOrgs)
.values({ ...values, source: values.source ?? 'Manual', importKey: taken ? null : key })
.returning({ id: dataholderOrgs.id });
return { saved: row.id, added: true };
},
/** Checklist tick: the org now has the current value (or the account is closed). */
tick: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const id = orgId(form);
const kind = form.get('kind');
if (!id || !isIdentityKind(kind)) return fail(400, { message: 'Invalid tick' });
const done = form.get('done') === 'true';
const close = form.get('close') === 'true';
const [current] = await db
.select()
.from(dataholderIdentity)
.where(eq(dataholderIdentity.kind, kind));
const date = done ? today() : null;
const patch: Partial<typeof dataholderOrgs.$inferInsert> = { editedAt: new Date() };
if (kind === 'address') patch.addressUpdatedOn = date;
if (kind === 'email') patch.emailUpdatedOn = date;
if (kind === 'phone') patch.phoneUpdatedOn = date;
if (kind === 'iban') patch.ibanUpdatedOn = date;
if (done && current && !close) {
if (kind === 'address')
Object.assign(patch, { addressOnFile: current.value, addressStatus: 'confirmed' });
if (kind === 'email') patch.emailOnFile = current.value;
if (kind === 'phone') patch.phoneOnFile = current.value;
}
if (close) patch.archived = done;
await db.update(dataholderOrgs).set(patch).where(eq(dataholderOrgs.id, id));
return { ticked: id };
},
archive: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const id = orgId(form);
if (!id) return fail(400, { message: 'Invalid org' });
await db
.update(dataholderOrgs)
.set({ archived: form.get('archived') === 'true', editedAt: new Date() })
.where(eq(dataholderOrgs.id, id));
return { archived: id };
},
delete: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const id = orgId(form);
if (!id) return fail(400, { message: 'Invalid org' });
await db.delete(dataholderOrgs).where(eq(dataholderOrgs.id, id));
return { deleted: id };
},
/** Set the current address/email/phone/IBAN; every org ticked before `since` becomes due. */
identity: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const kind = form.get('kind');
const value = str(form, 'value', 300);
const since = day(form, 'since') ?? today();
if (!isIdentityKind(kind)) return fail(400, { message: 'Unknown identifier' });
if (!value) {
await db.delete(dataholderIdentity).where(eq(dataholderIdentity.kind, kind));
return { identity: kind };
}
await db
.insert(dataholderIdentity)
.values({ kind, value, since })
.onConflictDoUpdate({ target: dataholderIdentity.kind, set: { value, since } });
return { identity: kind };
}
};

View File

@@ -0,0 +1,907 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import {
identityKinds,
identityLabels,
isDue,
matchesCurrent,
priorityLabels,
updatedOn,
type IdentityKind
} from '#lib/dataholders.js';
import Lock from 'lucide-svelte/icons/lock';
import MapPin from 'lucide-svelte/icons/map-pin';
import Mail from 'lucide-svelte/icons/mail';
import Phone from 'lucide-svelte/icons/phone';
import Landmark from 'lucide-svelte/icons/landmark';
import Fingerprint from 'lucide-svelte/icons/fingerprint';
import CreditCard from 'lucide-svelte/icons/credit-card';
import IdCard from 'lucide-svelte/icons/id-card';
import Cake from 'lucide-svelte/icons/cake';
import Search from 'lucide-svelte/icons/search';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Archive from 'lucide-svelte/icons/archive';
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
import ExternalLink from 'lucide-svelte/icons/external-link';
import Check from 'lucide-svelte/icons/check';
import ListChecks from 'lucide-svelte/icons/list-checks';
import List from 'lucide-svelte/icons/list';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Org = (typeof data.orgs)[number];
const PAGE_SIZE = 50;
const kindIcon = { address: MapPin, email: Mail, phone: Phone, iban: Landmark };
const kindColour: Record<IdentityKind, [string, string]> = {
address: ['#fb923c', '#ea580c'],
email: ['#60a5fa', '#2563eb'],
phone: ['#34d399', '#059669'],
iban: ['#a78bfa', '#7c3aed']
};
const holdIcons = [
{ key: 'holdsAddress', label: 'Address', icon: MapPin, colour: '#f97316' },
{ key: 'holdsEmail', label: 'Email', icon: Mail, colour: '#3b82f6' },
{ key: 'holdsPhone', label: 'Phone', icon: Phone, colour: '#10b981' },
{ key: 'holdsIban', label: 'IBAN / bank', icon: Landmark, colour: '#8b5cf6' },
{ key: 'holdsBsn', label: 'BSN / tax id', icon: Fingerprint, colour: '#ef4444' },
{ key: 'holdsCard', label: 'Card', icon: CreditCard, colour: '#eab308' },
{ key: 'holdsIdDoc', label: 'ID document / photo', icon: IdCard, colour: '#06b6d4' },
{ key: 'holdsDob', label: 'Date of birth', icon: Cake, colour: '#ec4899' }
] as const;
const holdFields = [
['holds_address', 'holdsAddress', 'Address'],
['holds_email', 'holdsEmail', 'Email'],
['holds_phone', 'holdsPhone', 'Phone'],
['holds_iban', 'holdsIban', 'IBAN'],
['holds_bsn', 'holdsBsn', 'BSN'],
['holds_card', 'holdsCard', 'Card'],
['holds_id_doc', 'holdsIdDoc', 'ID doc'],
['holds_dob', 'holdsDob', 'Birth date'],
['follows_brp', 'followsBrp', 'Follows BRP']
] as const;
const identity = $derived(Object.fromEntries(data.identity.map((i) => [i.kind, i])));
// View state.
let mode = $state<'list' | 'checklist'>('list');
let kind = $state<IdentityKind>('address');
let query = $state('');
let category = $state('');
let status = $state('');
let showArchived = $state(false);
let page = $state(1);
let editing = $state<number | 'new' | null>(null);
let editingIdentity = $state<IdentityKind | null>(null);
let error = $state<string | null>(null);
const dueCount = $derived(
Object.fromEntries(
identityKinds.map((k) => [k, data.orgs.filter((o) => isDue(o, k, identity[k])).length])
) as Record<IdentityKind, number>
);
const doneSince = (o: Org, k: IdentityKind) => {
const d = updatedOn(o, k);
return !!d && !!identity[k] && d >= identity[k].since;
};
const onFile = (o: Org, k: IdentityKind) =>
({ address: o.addressOnFile, email: o.emailOnFile, phone: o.phoneOnFile, iban: null })[k];
const isCurrent = (o: Org, k: IdentityKind) =>
!!identity[k] && matchesCurrent(onFile(o, k), identity[k].value, k);
const categories = $derived.by(() => {
const counts: Record<string, number> = {};
for (const o of data.orgs)
if (showArchived || !o.archived) counts[o.category] = (counts[o.category] ?? 0) + 1;
return Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
});
const needle = $derived(query.trim().toLowerCase());
const matchesQuery = (o: Org) =>
!needle ||
[o.name, o.login, o.domains, o.category, o.notes, o.dataHeld, o.addressOnFile, o.emailOnFile]
.filter(Boolean)
.some((v) => v!.toLowerCase().includes(needle));
const filtered = $derived.by(() => {
if (mode === 'checklist') {
const rows = data.orgs.filter(
(o) =>
(isDue(o, kind, identity[kind]) || doneSince(o, kind)) &&
matchesQuery(o) &&
(!category || o.category === category)
);
// Still to do first (already priority-sorted by the server), ticked ones at the end.
return [
...rows.filter((o) => !doneSince(o, kind)),
...rows.filter((o) => doneSince(o, kind))
];
}
return data.orgs.filter((o) => {
if (o.archived && !showArchived) return false;
if (category && o.category !== category) return false;
if (!matchesQuery(o)) return false;
switch (status) {
case 'old':
return isDue(o, 'address', identity.address);
case 'old-email':
return isDue(o, 'email', identity.email);
case 'confirmed':
case 'likely':
case 'unknown':
return o.holdsAddress && o.addressStatus === status && !isCurrent(o, 'address');
case 'current':
return o.holdsAddress && isCurrent(o, 'address');
case 'todo':
return o.action !== 'none';
case 'archived':
return o.archived;
default:
return true;
}
});
});
const pages = $derived(Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)));
const shown = $derived(filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE));
const checklistTotal = $derived(
dueCount[kind] + data.orgs.filter((o) => doneSince(o, kind)).length
);
const checklistDone = $derived(data.orgs.filter((o) => doneSince(o, kind)).length);
$effect(() => {
// Any filter change jumps back to the first page.
void [mode, kind, needle, category, status, showArchived];
page = 1;
});
function openChecklist(k: IdentityKind) {
kind = k;
mode = 'checklist';
editing = null;
}
const submit: SubmitFunction =
() =>
async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') {
error = null;
editing = null;
editingIdentity = null;
} else
error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
};
const quiet: SubmitFunction =
() =>
async ({ update }) =>
update({ reset: false, invalidateAll: true });
const fmt = (d: string | null | undefined) =>
d
? new Date(`${d}T12:00:00Z`).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})
: '';
const host = (url: string) => {
try {
return new URL(url).host.replace(/^www\./, '');
} catch {
return url;
}
};
const statusLabel = (o: Org) => {
if (isCurrent(o, 'address')) return { text: 'current', cls: 'ok' };
if (o.followsBrp && !o.addressOnFile) return { text: 'follows BRP', cls: 'info' };
return (
{
confirmed: { text: 'old (confirmed)', cls: 'bad' },
likely: { text: 'likely old', cls: 'warn' },
unknown: { text: 'unknown', cls: 'muted' }
}[o.addressStatus] ?? { text: o.addressStatus, cls: 'muted' }
);
};
</script>
{#snippet tileIcon(k: IdentityKind, size = 'size-9')}
{@const Icon = kindIcon[k]}
<span
class="tile-icon grid {size} shrink-0 place-items-center rounded-xl text-white"
style="background: linear-gradient(135deg, {kindColour[k][0]}, {kindColour[k][1]})"
><Icon size={18} aria-hidden="true" /></span
>
{/snippet}
{#snippet holdsBadges(o: Org)}
<span class="flex flex-wrap items-center gap-1">
{#each holdIcons as h (h.key)}
{#if o[h.key]}
{@const Icon = h.icon}
<span
class="hold"
title={h.label}
style="color: {h.colour}; background: color-mix(in oklab, {h.colour} 14%, transparent)"
><Icon size={12} aria-label={h.label} /></span
>
{/if}
{/each}
</span>
{/snippet}
{#snippet editor(o: Org | null)}
<form method="POST" action="?/save" use:enhance={submit} class="editor grid gap-3">
{#if o}<input type="hidden" name="id" value={o.id} />{/if}
<div class="grid gap-3 sm:grid-cols-3">
<label class="field sm:col-span-2"
>Organisation *<Input name="name" value={o?.name ?? ''} required maxlength={200} /></label
>
<label class="field"
>Category<Input
name="category"
value={o?.category ?? (category || '')}
list="dh-categories"
placeholder="Other"
/></label
>
<label class="field"
>Login (username or email, never a password)<Input
name="login"
value={o?.login ?? ''}
autocomplete="off"
/></label
>
<label class="field">Link<Input name="link" type="url" value={o?.link ?? ''} /></label>
<label class="field">Domains<Input name="domains" value={o?.domains ?? ''} /></label>
</div>
<fieldset class="flex flex-wrap gap-x-4 gap-y-2 text-sm">
<legend class="mb-1 text-xs text-[var(--site-muted)]">What they hold</legend>
{#each holdFields as [name, key, label] (name)}
<label class="flex items-center gap-1.5"
><input type="checkbox" {name} checked={o ? o[key] : name === 'holds_email'} />
{label}</label
>
{/each}
</fieldset>
<label class="field"
>Other data they hold<Textarea name="data_held" rows={2} value={o?.dataHeld ?? ''} /></label
>
<div class="grid gap-3 sm:grid-cols-3">
<label class="field sm:col-span-2"
>Address on file<Input name="address_on_file" value={o?.addressOnFile ?? ''} /></label
>
<label class="field"
>How sure<NativeSelect name="address_status" value={o?.addressStatus ?? 'unknown'}>
<option value="confirmed">confirmed</option>
<option value="likely">likely</option>
<option value="unknown">unknown</option>
</NativeSelect></label
>
<label class="field"
>Email on file<Input name="email_on_file" value={o?.emailOnFile ?? ''} /></label
>
<label class="field"
>Phone on file<Input name="phone_on_file" value={o?.phoneOnFile ?? ''} /></label
>
<label class="field">Source<Input name="source" value={o?.source ?? ''} /></label>
<label class="field"
>Action<NativeSelect name="action" value={o?.action ?? 'update'}>
<option value="update">update</option>
<option value="close">close account</option>
<option value="check">check</option>
<option value="none">nothing to do</option>
</NativeSelect></label
>
<label class="field"
>Priority<NativeSelect name="priority" value={o?.priority ?? 'should'}>
<option value="must">Must-do</option>
<option value="should">Should-do</option>
<option value="nice">Nice-to-have</option>
</NativeSelect></label
>
<label class="field">Evidence<Input name="evidence" value={o?.evidence ?? ''} /></label>
</div>
<label class="field"
>How to change it<Textarea
name="how_to_change"
rows={2}
value={o?.howToChange ?? ''}
/></label
>
<label class="field">Notes<Textarea name="notes" rows={2} value={o?.notes ?? ''} /></label>
<div class="grid gap-3 sm:grid-cols-4">
{#each identityKinds as k (k)}
<label class="field"
>{identityLabels[k]} updated<Input
type="date"
name="{k}_updated_on"
value={o ? (updatedOn(o, k) ?? '') : ''}
/></label
>
{/each}
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<label class="flex items-center gap-1.5 text-sm"
><input type="checkbox" name="archived" checked={o?.archived ?? false} /> Archived (account closed)</label
>
<div class="flex gap-2">
{#if error}<p class="self-center text-sm text-red-500" role="alert">{error}</p>{/if}
<Button type="button" variant="outline" onclick={() => (editing = null)}>Cancel</Button>
<Button type="submit">{o ? 'Save' : 'Add'}</Button>
</div>
</div>
</form>
{#if o}
<form
method="POST"
action="?/delete"
use:enhance={({ cancel }) => {
if (!confirm(`Delete ${o.name} for good? Archiving keeps it.`)) cancel();
return submit({} as never);
}}
class="mt-2 text-right"
>
<input type="hidden" name="id" value={o.id} />
<button class="text-xs text-[var(--site-muted)] underline hover:text-red-500" type="submit"
>Delete for good</button
>
</form>
{/if}
{/snippet}
<datalist id="dh-categories">
{#each categories as [c] (c)}<option value={c}></option>{/each}
</datalist>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">data holders</h1>
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
<Lock size={14} aria-hidden="true" /> Private. Only you can see this page.
</p>
<p class="mt-1 max-w-2xl text-sm text-[var(--site-muted)]">
Every organisation that has my address, email, phone or bank details. When one of them
changes, set the new value below and work down the checklist.
</p>
</div>
<Button
onclick={() => {
editing = 'new';
mode = 'list';
}}><Plus size={16} /> Add organisation</Button
>
</div>
<!-- Current identifiers: each tile opens the checklist for it. -->
<section class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4" aria-label="Current details">
{#each identityKinds as k (k)}
{@const current = identity[k]}
<div class="tile relative" class:active={mode === 'checklist' && kind === k}>
{#if editingIdentity === k}
<form method="POST" action="?/identity" use:enhance={submit} class="grid gap-2">
<input type="hidden" name="kind" value={k} />
<label class="field"
>New {identityLabels[k].toLowerCase()}<Input
name="value"
value={current?.value ?? ''}
autocomplete="off"
/></label
>
<label class="field"
>Since<Input type="date" name="since" value={current?.since ?? data.today} /></label
>
<div class="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onclick={() => (editingIdentity = null)}>Cancel</Button
>
<Button size="sm" type="submit">Save</Button>
</div>
</form>
{:else}
<button
type="button"
class="stretched flex w-full items-start gap-3 text-left"
onclick={() => openChecklist(k)}
>
{@render tileIcon(k)}
<span class="min-w-0 flex-1">
<span class="block text-xs text-[var(--site-muted)]">{identityLabels[k]}</span>
<span class="block truncate font-medium" title={current?.value}
>{current?.value ?? 'not set'}</span
>
<span class="block text-xs text-[var(--site-muted)]">
{#if current}
{dueCount[k]} still to update · since {fmt(current.since)}
{:else}
set it to get a checklist
{/if}
</span>
</span>
</button>
<button
type="button"
class="icon absolute top-2 right-2 z-10"
aria-label={`Change ${identityLabels[k]}`}
onclick={() => (editingIdentity = k)}><Pencil size={14} /></button
>
{/if}
</div>
{/each}
</section>
<!-- Toolbar -->
<div class="mb-3 flex flex-wrap items-center gap-2">
<div class="seg" role="tablist" aria-label="View">
<button
type="button"
role="tab"
aria-selected={mode === 'list'}
class:on={mode === 'list'}
onclick={() => (mode = 'list')}><List size={14} /> All</button
>
<button
type="button"
role="tab"
aria-selected={mode === 'checklist'}
class:on={mode === 'checklist'}
onclick={() => (mode = 'checklist')}><ListChecks size={14} /> Checklist</button
>
</div>
{#if mode === 'checklist'}
<NativeSelect aria-label="Checklist for" bind:value={kind}>
{#each identityKinds as k (k)}<option value={k}
>{identityLabels[k]} ({dueCount[k]} to do)</option
>{/each}
</NativeSelect>
{:else}
<NativeSelect aria-label="Status" bind:value={status}>
<option value="">All statuses</option>
<option value="old">Still has my old address</option>
<option value="old-email">Still has my old email</option>
<option value="confirmed">Old address, confirmed</option>
<option value="likely">Old address, likely</option>
<option value="unknown">Address unknown</option>
<option value="current">Has my current address</option>
<option value="todo">Has something to do</option>
<option value="archived">Archived only</option>
</NativeSelect>
{/if}
<label class="search relative min-w-[14rem] flex-1">
<Search
size={14}
class="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-[var(--site-muted)]"
/>
<Input
bind:value={query}
type="search"
placeholder="Search name, login, domain, notes…"
class="pl-8"
aria-label="Search"
/>
</label>
{#if mode === 'list'}
<label class="flex items-center gap-1.5 text-sm text-[var(--site-muted)]"
><input type="checkbox" bind:checked={showArchived} /> Show archived</label
>
{/if}
</div>
<div class="mb-5 flex flex-wrap gap-1.5" aria-label="Categories">
<button type="button" class="chip" class:on={!category} onclick={() => (category = '')}
>All <small>{data.orgs.filter((o) => showArchived || !o.archived).length}</small></button
>
{#each categories as [c, n] (c)}
<button
type="button"
class="chip"
class:on={category === c}
onclick={() => (category = category === c ? '' : c)}>{c} <small>{n}</small></button
>
{/each}
</div>
{#if mode === 'checklist'}
{#if !identity[kind]}
<p class="panel text-sm">
Set your current {identityLabels[kind].toLowerCase()} in the tile above first; every organisation
that holds it then shows up here until you tick it.
</p>
{:else}
<div class="mb-4 flex items-center gap-3">
{@render tileIcon(kind, 'size-7')}
<div class="min-w-0 flex-1">
<div class="flex justify-between text-sm">
<span>Moving {identityLabels[kind].toLowerCase()} to <b>{identity[kind].value}</b></span
>
<span class="tabular-nums text-[var(--site-muted)]"
>{checklistDone} / {checklistTotal}</span
>
</div>
<div class="bar mt-1">
<span
style="width: {checklistTotal
? (checklistDone / checklistTotal) * 100
: 0}%; background: linear-gradient(90deg, {kindColour[kind][0]}, {kindColour[
kind
][1]})"
></span>
</div>
</div>
</div>
{/if}
{/if}
{#if editing === 'new'}
<div class="panel mb-3">{@render editor(null)}</div>
{/if}
<p class="mb-2 text-xs text-[var(--site-muted)]">
{filtered.length} organisation{filtered.length === 1 ? '' : 's'}{pages > 1
? ` · page ${page} of ${pages}`
: ''}
</p>
<ul class="grid gap-2">
{#each shown as o (o.id)}
{@const st = statusLabel(o)}
{@const done = mode === 'checklist' && doneSince(o, kind)}
<li class="row" class:archived={o.archived} class:done>
{#if editing === o.id}
{@render editor(o)}
{:else}
<div class="flex items-start gap-3">
{#if mode === 'checklist'}
<form method="POST" action="?/tick" use:enhance={quiet}>
<input type="hidden" name="id" value={o.id} />
<input type="hidden" name="kind" value={kind} />
<input type="hidden" name="done" value={done ? 'false' : 'true'} />
<input type="hidden" name="close" value={o.action === 'close' ? 'true' : 'false'} />
<button
type="submit"
class="tick"
class:checked={done}
aria-label={done
? `Undo ${o.name}`
: `Mark ${o.name} as ${o.action === 'close' ? 'closed' : 'updated'}`}
title={done
? 'Undo'
: o.action === 'close'
? 'Mark account closed'
: 'Mark updated'}
style="--k: {kindColour[kind][1]}"
>
{#if done}<Check size={16} />{/if}
</button>
</form>
{/if}
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="font-medium">{o.name}</span>
{#if o.link}<a class="link" href={o.link} target="_blank" rel="noopener noreferrer"
>{host(o.link)} <ExternalLink size={11} /></a
>{/if}
<span class="badge">{o.category}</span>
{#if o.priority === 'must'}<span class="badge must">{priorityLabels.must}</span
>{/if}
{#if o.action === 'close'}<span class="badge warn">close account</span>{/if}
{#if o.action === 'check'}<span class="badge info">check</span>{/if}
{#if o.archived}<span class="badge muted">archived</span>{/if}
{#if done}<span class="badge ok"
>{o.action === 'close' ? 'closed' : 'updated'} {fmt(updatedOn(o, kind))}</span
>{/if}
{@render holdsBadges(o)}
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-[var(--site-muted)]">
{#if o.login}<span title="Login">{o.login}</span>{/if}
{#if o.holdsAddress}
<span>
<span class="dot {st.cls}"></span>
{o.addressOnFile ?? 'address'} · {st.text}
</span>
{/if}
{#if o.emailOnFile && o.emailOnFile !== o.login}<span>{o.emailOnFile}</span>{/if}
{#if o.phoneOnFile}<span>{o.phoneOnFile}</span>{/if}
{#if o.source}<span>via {o.source}</span>{/if}
</div>
{#if (mode === 'checklist' && !done) || o.notes}
<div class="mt-1 text-xs">
{#if mode === 'checklist' && o.howToChange}<p>{o.howToChange}</p>{/if}
{#if o.notes}<p class="text-[var(--site-muted)]">{o.notes}</p>{/if}
</div>
{/if}
</div>
<div class="flex shrink-0 gap-1">
<button
class="icon"
type="button"
aria-label={`Edit ${o.name}`}
onclick={() => {
editing = o.id;
error = null;
}}><Pencil size={14} /></button
>
<form method="POST" action="?/archive" use:enhance={quiet}>
<input type="hidden" name="id" value={o.id} />
<input type="hidden" name="archived" value={o.archived ? 'false' : 'true'} />
<button
class="icon"
type="submit"
aria-label={o.archived ? `Restore ${o.name}` : `Archive ${o.name}`}
title={o.archived ? 'Restore' : 'Archive (account closed)'}
>
{#if o.archived}<ArchiveRestore size={14} />{:else}<Archive size={14} />{/if}
</button>
</form>
</div>
</div>
{/if}
</li>
{:else}
<li class="panel text-sm text-[var(--site-muted)]">
{mode === 'checklist' && identity[kind]
? 'Nothing left to update. 🎉'
: 'No organisations match.'}
</li>
{/each}
</ul>
{#if pages > 1}
<nav class="mt-4 flex items-center justify-center gap-1" aria-label="Pages">
<button
class="icon"
type="button"
disabled={page === 1}
onclick={() => (page -= 1)}
aria-label="Previous page"><ChevronLeft size={16} /></button
>
{#each Array.from({ length: pages }, (_, i) => i + 1) as p (p)}
<button type="button" class="chip" class:on={p === page} onclick={() => (page = p)}
>{p}</button
>
{/each}
<button
class="icon"
type="button"
disabled={page === pages}
onclick={() => (page += 1)}
aria-label="Next page"><ChevronRight size={16} /></button
>
</nav>
{/if}
</div>
<style>
.panel,
.row,
.tile {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
}
.panel {
padding: 1rem 1.25rem;
}
.row {
padding: 0.7rem 0.9rem;
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.row:hover {
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
}
.row.archived {
opacity: 0.6;
}
.row.done {
opacity: 0.7;
}
.tile {
padding: 0.85rem 1rem;
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
border-color 160ms ease,
box-shadow 160ms ease;
}
.tile:hover {
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
transform: translateY(-2px);
box-shadow: 0 10px 24px -14px rgb(0 0 0 / 0.5);
}
.tile.active {
border-color: var(--foreground);
}
.tile :global(.tile-icon) {
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tile:hover :global(.tile-icon) {
transform: scale(1.06);
}
.stretched::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
}
.field {
display: grid;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--site-muted);
}
.chip {
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
border-radius: 999px;
padding: 0.2rem 0.65rem;
font-size: 0.8rem;
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 160ms ease,
border-color 160ms ease;
}
.chip:hover {
background: color-mix(in oklab, var(--foreground) 10%, transparent);
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
transform: translateY(-1px);
}
.chip.on {
background: var(--foreground);
color: var(--background);
border-color: var(--foreground);
}
.chip small {
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
.seg {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 2px;
}
.seg button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 0.375rem;
padding: 0.3rem 0.7rem;
font-size: 0.85rem;
color: var(--site-muted);
}
.seg button.on {
background: var(--foreground);
color: var(--background);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
padding: 0.3rem;
color: var(--site-muted);
}
.icon:hover:not(:disabled) {
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
color: var(--site-text);
}
.icon:disabled {
opacity: 0.3;
}
.hold {
display: inline-grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.35rem;
}
.badge {
border-radius: 999px;
padding: 0.05rem 0.5rem;
font-size: 0.7rem;
border: 1px solid var(--site-border);
color: var(--site-muted);
}
.badge.must {
border-color: #ef4444;
color: #ef4444;
}
.badge.warn {
border-color: #f59e0b;
color: #f59e0b;
}
.badge.info {
border-color: #3b82f6;
color: #3b82f6;
}
.badge.ok {
border-color: #10b981;
color: #10b981;
}
.dot {
display: inline-block;
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
margin-right: 0.15rem;
background: #a1a1aa;
}
.dot.ok {
background: #10b981;
}
.dot.bad {
background: #ef4444;
}
.dot.warn {
background: #f59e0b;
}
.dot.info {
background: #3b82f6;
}
.link {
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 0.75rem;
color: var(--site-muted);
}
.link:hover {
color: var(--site-text);
text-decoration: underline;
}
.tick {
display: grid;
place-items: center;
width: 1.6rem;
height: 1.6rem;
margin-top: 0.05rem;
border-radius: 0.45rem;
border: 2px solid color-mix(in oklab, var(--foreground) 30%, transparent);
color: white;
transition:
transform 120ms ease,
background-color 120ms ease,
border-color 120ms ease;
}
.tick:hover {
border-color: var(--k);
transform: scale(1.06);
}
.tick.checked {
background: var(--k);
border-color: var(--k);
}
.bar {
height: 0.4rem;
border-radius: 999px;
background: color-mix(in oklab, var(--foreground) 10%, transparent);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
border-radius: inherit;
transition: width 300ms ease;
}
@media (prefers-reduced-motion: reduce) {
.tile,
.chip,
.tick {
transition: none;
}
.tile:hover,
.chip:hover {
transform: none;
}
}
</style>