Files
minstrel/web/src/routes/settings/+page.svelte
T

344 lines
13 KiB
Svelte

<script lang="ts">
import { pageTitle } from '$lib/branding';
import { useQueryClient } from '@tanstack/svelte-query';
import type { CreateQueryResult, CreateMutationResult } from '@tanstack/svelte-query';
import {
createLBStatusQuery,
createTokenMutation,
createEnabledMutation,
type LBStatus
} from '$lib/api/listenbrainz';
import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte';
import {
updateProfile,
changePassword,
getAPIToken,
regenerateAPIToken
} from '$lib/api/me';
import { errCode } from '$lib/api/errors';
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);
}
// Toast -------------------------------------------------------------------
let toast = $state<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | undefined;
function showToast(msg: string) {
toast = msg;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast = null; }, 4000);
}
// Profile card ------------------------------------------------------------
let profileForm = $state<{ displayName: string; email: string }>({
displayName: '',
email: '',
});
let profileSaving = $state(false);
async function onSaveProfile() {
profileSaving = true;
try {
await updateProfile({
display_name: profileForm.displayName,
email: profileForm.email,
});
showToast('Profile saved.');
} catch (e: unknown) {
const code = errCode(e);
if (code === 'email_taken') showToast('That email is already in use.');
else if (code === 'email_invalid') showToast('Email format is invalid.');
else showToast(`Save failed: ${code}`);
} finally {
profileSaving = false;
}
}
// Password card -----------------------------------------------------------
let passwordForm = $state<{ current: string; new: string; confirm: string }>({
current: '', new: '', confirm: '',
});
let passwordSaving = $state(false);
async function onSavePassword(e: SubmitEvent) {
e.preventDefault();
if (passwordForm.new !== passwordForm.confirm) {
showToast('New passwords do not match.');
return;
}
passwordSaving = true;
try {
await changePassword(passwordForm.current, passwordForm.new);
showToast('Password changed.');
passwordForm = { current: '', new: '', confirm: '' };
} catch (e: unknown) {
const code = errCode(e);
if (code === 'wrong_password') showToast('Current password is incorrect.');
else if (code === 'password_too_short') showToast('Password must be at least 8 characters.');
else showToast(`Change failed: ${code}`);
} finally {
passwordSaving = false;
}
}
// API Token card ----------------------------------------------------------
let apiToken = $state<string | null>(null);
let tokenSaving = $state(false);
let confirmRegen = $state(false);
let regenTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
getAPIToken().then(r => { apiToken = r.api_token; }).catch(() => {});
});
async function copyToken() {
if (!apiToken) return;
try {
await navigator.clipboard.writeText(apiToken);
showToast('Token copied to clipboard.');
} catch {
showToast('Copy failed.');
}
}
async function onRegenerateToken() {
if (!confirmRegen) {
confirmRegen = true;
if (regenTimer) clearTimeout(regenTimer);
regenTimer = setTimeout(() => { confirmRegen = false; }, 5000);
return;
}
if (regenTimer) clearTimeout(regenTimer);
confirmRegen = false;
tokenSaving = true;
try {
const r = await regenerateAPIToken();
apiToken = r.api_token;
showToast('API token regenerated.');
} catch (e: unknown) {
showToast(`Regenerate failed: ${errCode(e)}`);
} finally {
tokenSaving = false;
}
}
</script>
<svelte:head><title>{pageTitle('Settings')}</title></svelte:head>
{#if toast}
<div data-testid="toast" role="status"
class="fixed bottom-4 right-4 z-50 rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow">
{toast}
</div>
{/if}
<div class="space-y-6">
<h1 class="text-2xl font-semibold">Settings</h1>
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Appearance</h2>
<fieldset class="space-y-2">
<legend class="block text-sm text-text-secondary">Theme</legend>
<div class="flex gap-2">
{#each ['dark', 'light', 'system'] as opt (opt)}
<label
class="flex cursor-pointer items-center gap-2 rounded border border-border px-3 py-1.5 text-sm
{theme.value === opt ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'}"
>
<input
type="radio"
name="theme"
value={opt}
checked={theme.value === opt}
onchange={() => setTheme(opt as ThemePreference)}
class="sr-only"
/>
<span class="capitalize">{opt}</span>
</label>
{/each}
</div>
</fieldset>
<p class="text-xs text-text-secondary">
System follows your device's light/dark preference.
</p>
</section>
<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>
<!-- Profile card -->
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Profile</h2>
<p class="text-sm text-text-secondary">
Your display name and recovery email.
</p>
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
<label class="text-sm text-text-secondary" for="profile-displayname">Display name</label>
<input id="profile-displayname" type="text" maxlength="64"
bind:value={profileForm.displayName}
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent" />
<label class="text-sm text-text-secondary" for="profile-email">Email</label>
<input id="profile-email" type="email" autocomplete="email"
bind:value={profileForm.email}
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent" />
</div>
<button type="button" disabled={profileSaving}
onclick={onSaveProfile}
class="rounded bg-accent px-3 py-2 text-sm font-medium text-background disabled:opacity-60">
{profileSaving ? 'Saving…' : 'Save profile'}
</button>
</section>
<!-- Password card -->
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Password</h2>
<form onsubmit={onSavePassword} class="space-y-3">
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
<label class="text-sm text-text-secondary" for="password-current">Current password</label>
<input id="password-current" type="password" required minlength="8"
autocomplete="current-password"
bind:value={passwordForm.current}
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent" />
<label class="text-sm text-text-secondary" for="password-new">New password</label>
<input id="password-new" type="password" required minlength="8"
autocomplete="new-password"
bind:value={passwordForm.new}
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent" />
<label class="text-sm text-text-secondary" for="password-confirm">Confirm</label>
<input id="password-confirm" type="password" required minlength="8"
bind:value={passwordForm.confirm}
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent" />
</div>
<button type="submit" disabled={passwordSaving}
class="rounded bg-accent px-3 py-2 text-sm font-medium text-background disabled:opacity-60">
{passwordSaving ? 'Saving…' : 'Change password'}
</button>
</form>
</section>
<!-- API Token card -->
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">API Token</h2>
<p class="text-sm text-text-secondary">
Used by Subsonic clients (DSub, Symfonium, etc.) to authenticate to your library.
Regenerating invalidates clients that have the old token cached.
</p>
{#if apiToken}
<code class="block break-all rounded bg-background p-2 text-xs">
{apiToken}
</code>
{/if}
<div class="flex gap-2">
<button type="button" onclick={copyToken}
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
Copy
</button>
<button type="button" disabled={tokenSaving}
onclick={onRegenerateToken}
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{confirmRegen ? 'Click again to confirm' : (tokenSaving ? 'Regenerating…' : 'Regenerate')}
</button>
</div>
</section>
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Library</h2>
<ul class="space-y-2 text-sm">
<li>
<a href="/library/hidden" class="text-accent hover:underline">Hidden tracks</a>
<span class="text-text-secondary"> — tracks you've flagged as bad rips, wrong tags, or otherwise unplayable.</span>
</li>
</ul>
</section>
</div>