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>
133 lines
4.7 KiB
Svelte
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>
|