Files
minstrel/web/src/lib/components/NotificationSettings.svelte
T
bvandeusenandClaude Opus 5.5 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:

- svelte.config.js is gone. Its options move into sveltekit() in
  vite.config.ts, exported as kitOptions so vitest.config.ts runs the
  same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
  src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
  ^8.0.12, svelte-check ^4.7.5.

By hand, from the codemod's list of non-automated tasks:

- goto's replaceState option is now replace; keepFocus becomes
  reset: false. For the search typeahead, reset: false also stops the
  scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
  base/assets, which kit 3 removed, and mock refreshAll in place of
  invalidateAll.
- The other flagged files only read page.url or goto internal routes,
  so they needed no change.

TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.

With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:50:39 -04:00

133 lines
4.7 KiB
Svelte

<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import {
NOTIFICATION_KIND_LABELS,
createNotificationSettingsQuery,
putNotificationSettings,
type NotificationChannel,
type NotificationKindSetting,
type NotificationSettingChange,
type NotificationSettings
} from '#lib/api/notifications.js';
import { qk } from '#lib/api/queries.js';
import { user } from '#lib/auth/store.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Per-user notification settings (M489): one row per kind, a toggle per
// channel. Toggles are optimistic; a failed save puts the switch back.
const client = useQueryClient();
const queryStore = createNotificationSettingsQuery();
const settings = $derived($queryStore.data as NotificationSettings | undefined);
const forEveryone = $derived((settings?.kinds ?? []).filter((k) => !k.admin_only));
const forAdmins = $derived((settings?.kinds ?? []).filter((k) => k.admin_only));
const channels: { key: NotificationChannel; label: string }[] = [
{ key: 'inbox', label: 'Inbox' },
{ key: 'phone', label: 'Phone' },
{ key: 'email', label: 'Email' }
];
// Bumped by every local change, so a save resolving after a newer toggle
// does not overwrite it, and a failed save only reverts when nothing newer
// happened (snippet #5106's generation counter).
let generation = 0;
function commit(next: NotificationSettings) {
client.setQueryData(qk.notificationSettings(), next);
}
function withChannel(
k: NotificationKindSetting,
channel: NotificationChannel,
value: boolean
): NotificationKindSetting {
const next = { ...k };
next[channel] = value;
return next;
}
async function toggle(row: NotificationKindSetting, channel: NotificationChannel) {
if (!settings) return;
const prev = settings;
const value = !row[channel];
const gen = ++generation;
commit({
...prev,
kinds: prev.kinds.map((k) => (k.kind === row.kind ? withChannel(k, channel, value) : k))
});
const change: NotificationSettingChange = { kind: row.kind };
change[channel] = value;
try {
const saved = await putNotificationSettings([change]);
if (gen === generation) commit(saved);
} catch {
if (gen === generation) commit(prev);
pushToast("Couldn't save that setting. Try again in a moment.", 'error');
}
}
// Phone and email ride on the inbox: with the inbox off, nothing is stored
// to send. Their own choices are kept and come back when the inbox does.
function disabled(row: NotificationKindSetting, channel: NotificationChannel): boolean {
if (channel === 'inbox') return false;
if (!row.inbox) return true;
return channel === 'email' && !settings?.email_available;
}
function label(kind: string): string {
return NOTIFICATION_KIND_LABELS[kind] ?? kind;
}
</script>
{#snippet rows(list: NotificationKindSetting[])}
<div class="grid grid-cols-[1fr_repeat(3,3.5rem)] items-center gap-y-1 text-sm">
<span></span>
{#each channels as c (c.key)}
<span class="text-center text-xs text-text-secondary">{c.label}</span>
{/each}
{#each list as row (row.kind)}
<span class="py-1 text-text-primary">{label(row.kind)}</span>
{#each channels as c (c.key)}
<span class="flex justify-center">
<input
type="checkbox"
class="h-4 w-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{label(row.kind)}: {c.label}"
checked={row[c.key] && !disabled(row, c.key)}
disabled={disabled(row, c.key)}
onchange={() => toggle(row, c.key)}
/>
</span>
{/each}
{/each}
</div>
{/snippet}
<section id="notifications" class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Notifications</h2>
{#if !settings}
<p class="text-sm text-text-secondary">Loading…</p>
{:else}
{@render rows(forEveryone)}
{#if forAdmins.length > 0}
<h3 class="pt-2 text-sm font-medium text-text-secondary">Library health</h3>
{@render rows(forAdmins)}
{/if}
{#if !settings.email_available}
<p class="text-sm text-text-secondary" data-testid="email-unavailable">
{#if settings.email_unavailable_reason === 'no_address'}
Email is off: <a href="#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
{:else if user.value?.is_admin}
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
{:else}
Email is off: this server doesn't send email.
{/if}
</p>
{/if}
{/if}
</section>