feat(web): add /settings page with ListenBrainz section

Adds api.put helper, ListenBrainz API module with query/mutation
helpers, a /settings route with token + enable/disable UI, and 5
tests. Adds Settings to the Shell nav.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 15:45:42 -04:00
parent 46b23424c2
commit 13e12d97ae
5 changed files with 250 additions and 1 deletions
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import type { CreateQueryResult, CreateMutationResult } from '@tanstack/svelte-query';
import {
createLBStatusQuery,
createTokenMutation,
createEnabledMutation,
type LBStatus
} from '$lib/api/listenbrainz';
const queryClient = useQueryClient();
const status = createLBStatusQuery() as CreateQueryResult<LBStatus>;
const tokenMutation = createTokenMutation(queryClient);
const enabledMutation = createEnabledMutation(queryClient);
let tokenInput = $state('');
function saveToken() {
const t = tokenInput.trim();
if (!t) return;
$tokenMutation.mutate(t, { onSuccess: () => { tokenInput = ''; } });
}
function clearToken() {
$tokenMutation.mutate('');
}
function toggleEnabled() {
if (!$status.data) return;
$enabledMutation.mutate(!$status.data.enabled);
}
</script>
<div class="space-y-6">
<h1 class="text-2xl font-semibold">Settings</h1>
<section class="space-y-4 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">ListenBrainz</h2>
{#if $status.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if $status.isError}
<p class="text-text-secondary">Couldn't load status.</p>
{:else if $status.data}
<div class="space-y-3">
<label class="block text-sm">
<span class="block text-text-secondary mb-1">User token</span>
{#if $status.data.token_set}
<div class="flex items-center gap-2">
<span class="text-text-primary">••••••••••• (set)</span>
<button
type="button"
onclick={clearToken}
class="text-sm text-accent hover:underline"
disabled={$tokenMutation.isPending}
>clear</button>
</div>
{:else}
<div class="flex gap-2">
<input
type="password"
bind:value={tokenInput}
placeholder="paste your LB token"
class="flex-1 rounded border border-border bg-background px-2 py-1 text-sm"
/>
<button
type="button"
onclick={saveToken}
disabled={!tokenInput.trim() || $tokenMutation.isPending}
class="rounded bg-accent px-3 py-1 text-sm text-background disabled:opacity-50"
>Save</button>
</div>
{/if}
</label>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={$status.data.enabled}
disabled={!$status.data.token_set || $enabledMutation.isPending}
onchange={toggleEnabled}
/>
<span>Send my plays to ListenBrainz</span>
</label>
{#if $status.data.last_scrobbled_at}
<p class="text-sm text-text-secondary">
Last scrobbled: {new Date($status.data.last_scrobbled_at).toLocaleString()}
</p>
{/if}
<p class="text-xs text-text-secondary">
Get a token at <a href="https://listenbrainz.org/profile/" target="_blank" rel="noopener" class="text-accent hover:underline">listenbrainz.org/profile</a>.
Tokens are stored unencrypted in this server's database — treat as sensitive.
</p>
</div>
{/if}
</section>
</div>