feat(profile): auto-save settings, merge sections, fix font/size preview
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Successful in 33s
Release / Docker / caddy (push) Failing after 10s
CI / UI (push) Successful in 52s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 40s
CI / UI (pull_request) Successful in 53s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / ui (push) Successful in 5m2s
Release / Gitea Release (push) Has been skipped
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Successful in 33s
Release / Docker / caddy (push) Failing after 10s
CI / UI (push) Successful in 52s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 40s
CI / UI (pull_request) Successful in 53s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / ui (push) Successful in 5m2s
Release / Gitea Release (push) Has been skipped
- Remove both "Save settings" buttons; all settings now auto-save with 800ms debounce and show a transient "✓ Saved" indicator - Apply theme, font family, and font size to context immediately on change so the preview is live without waiting for the save - Merge Appearance + Reading settings into a single Preferences card with dividers — fewer sections, less visual noise - Pro users see a compact subscription row; free users see upgrade CTAs - Speed label splits value and units (shows "1.5x" separately in brand color) for cleaner readout - Auto-advance toggle gains a subtitle description Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { invalidateAll } from '$app/navigation';
|
|
||||||
import { untrack, getContext } from 'svelte';
|
import { untrack, getContext } from 'svelte';
|
||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData, ActionData } from './$types';
|
||||||
import { audioStore } from '$lib/audio.svelte';
|
import { audioStore } from '$lib/audio.svelte';
|
||||||
@@ -16,14 +15,12 @@
|
|||||||
let avatarError = $state('');
|
let avatarError = $state('');
|
||||||
let fileInput: HTMLInputElement | null = null;
|
let fileInput: HTMLInputElement | null = null;
|
||||||
|
|
||||||
// Crop modal state
|
|
||||||
let cropFile = $state<File | null>(null);
|
let cropFile = $state<File | null>(null);
|
||||||
|
|
||||||
function handleAvatarChange(e: Event) {
|
function handleAvatarChange(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
// Reset input so the same file can be re-selected after cancel
|
|
||||||
if (fileInput) fileInput.value = '';
|
if (fileInput) fileInput.value = '';
|
||||||
cropFile = file;
|
cropFile = file;
|
||||||
}
|
}
|
||||||
@@ -33,7 +30,6 @@
|
|||||||
avatarUploading = true;
|
avatarUploading = true;
|
||||||
avatarError = '';
|
avatarError = '';
|
||||||
try {
|
try {
|
||||||
// POST raw bytes to the SvelteKit server, which proxies to MinIO internally.
|
|
||||||
const res = await fetch('/api/profile/avatar', {
|
const res = await fetch('/api/profile/avatar', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': mimeType },
|
headers: { 'Content-Type': mimeType },
|
||||||
@@ -57,95 +53,101 @@
|
|||||||
cropFile = null;
|
cropFile = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings ────────────────────────────────────────────────────────────────
|
// ── Voices ───────────────────────────────────────────────────────────────────
|
||||||
let voices = $state<Voice[]>([]);
|
let voices = $state<Voice[]>([]);
|
||||||
let voicesLoaded = $state(false);
|
let voicesLoaded = $state(false);
|
||||||
|
|
||||||
// Derived: voices grouped by engine
|
|
||||||
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
||||||
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
||||||
|
|
||||||
// Load voices on mount
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
fetch('/api/voices')
|
fetch('/api/voices')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d: { voices: Voice[] }) => {
|
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
|
||||||
voices = d.voices ?? [];
|
.catch(() => { voicesLoaded = true; });
|
||||||
voicesLoaded = true;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
voicesLoaded = true;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mirror from audioStore so sliders feel live
|
// ── Settings state ───────────────────────────────────────────────────────────
|
||||||
let voice = $state(audioStore.voice);
|
let voice = $state(audioStore.voice);
|
||||||
let speed = $state(audioStore.speed);
|
let speed = $state(audioStore.speed);
|
||||||
let autoNext = $state(audioStore.autoNext);
|
let autoNext = $state(audioStore.autoNext);
|
||||||
|
|
||||||
// Keep in sync when layout changes them externally
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
voice = audioStore.voice;
|
voice = audioStore.voice;
|
||||||
speed = audioStore.speed;
|
speed = audioStore.speed;
|
||||||
autoNext = audioStore.autoNext;
|
autoNext = audioStore.autoNext;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Theme + Font ─────────────────────────────────────────────────────────────
|
|
||||||
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
|
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
|
||||||
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
|
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
|
||||||
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
|
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
|
||||||
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
|
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
|
||||||
|
|
||||||
const THEMES: { id: string; label: () => string; swatch: string }[] = [
|
const THEMES: { id: string; label: () => string; swatch: string }[] = [
|
||||||
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
|
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
|
||||||
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
|
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
|
||||||
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
|
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FONTS = [
|
const FONTS = [
|
||||||
{ id: 'system', label: () => m.profile_font_system() },
|
{ id: 'system', label: () => m.profile_font_system() },
|
||||||
{ id: 'serif', label: () => m.profile_font_serif() },
|
{ id: 'serif', label: () => m.profile_font_serif() },
|
||||||
{ id: 'mono', label: () => m.profile_font_mono() },
|
{ id: 'mono', label: () => m.profile_font_mono() },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FONT_SIZES = [
|
const FONT_SIZES = [
|
||||||
{ value: 0.9, label: () => m.profile_text_size_sm() },
|
{ value: 0.9, label: () => m.profile_text_size_sm() },
|
||||||
{ value: 1.0, label: () => m.profile_text_size_md() },
|
{ value: 1.0, label: () => m.profile_text_size_md() },
|
||||||
{ value: 1.15, label: () => m.profile_text_size_lg() },
|
{ value: 1.15, label: () => m.profile_text_size_lg() },
|
||||||
{ value: 1.3, label: () => m.profile_text_size_xl() },
|
{ value: 1.3, label: () => m.profile_text_size_xl() },
|
||||||
];
|
];
|
||||||
|
|
||||||
let settingsSaving = $state(false);
|
// ── Auto-save ────────────────────────────────────────────────────────────────
|
||||||
let settingsSaved = $state(false);
|
type SaveStatus = 'idle' | 'saving' | 'saved';
|
||||||
|
let saveStatus = $state<SaveStatus>('idle');
|
||||||
|
let saveTimer = 0;
|
||||||
|
let savedTimer = 0;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
async function saveSettings() {
|
$effect(() => {
|
||||||
settingsSaving = true;
|
// Read all settings deps to subscribe
|
||||||
settingsSaved = false;
|
const t = selectedTheme;
|
||||||
try {
|
const ff = selectedFontFamily;
|
||||||
await fetch('/api/settings', {
|
const fs = selectedFontSize;
|
||||||
method: 'PUT',
|
const v = voice;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const sp = speed;
|
||||||
body: JSON.stringify({ autoNext, voice, speed, theme: selectedTheme, fontFamily: selectedFontFamily, fontSize: selectedFontSize })
|
const an = autoNext;
|
||||||
});
|
|
||||||
// Sync to audioStore so the player picks up changes immediately
|
// Apply context immediately (font/theme previews live without waiting for save)
|
||||||
audioStore.autoNext = autoNext;
|
if (settingsCtx) {
|
||||||
audioStore.voice = voice;
|
settingsCtx.current = t;
|
||||||
audioStore.speed = speed;
|
settingsCtx.fontFamily = ff;
|
||||||
// Apply theme + font live via context
|
settingsCtx.fontSize = fs;
|
||||||
if (settingsCtx) {
|
|
||||||
settingsCtx.current = selectedTheme;
|
|
||||||
settingsCtx.fontFamily = selectedFontFamily;
|
|
||||||
settingsCtx.fontSize = selectedFontSize;
|
|
||||||
}
|
|
||||||
await invalidateAll();
|
|
||||||
settingsSaved = true;
|
|
||||||
setTimeout(() => (settingsSaved = false), 2500);
|
|
||||||
} finally {
|
|
||||||
settingsSaving = false;
|
|
||||||
}
|
}
|
||||||
}
|
audioStore.voice = v;
|
||||||
|
audioStore.autoNext = an;
|
||||||
|
|
||||||
// ── Sessions ────────────────────────────────────────────────────────────────
|
if (!initialized) { initialized = true; return; }
|
||||||
|
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(async () => {
|
||||||
|
saveStatus = 'saving';
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ autoNext: an, voice: v, speed: sp, theme: t, fontFamily: ff, fontSize: fs })
|
||||||
|
});
|
||||||
|
saveStatus = 'saved';
|
||||||
|
clearTimeout(savedTimer);
|
||||||
|
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
|
||||||
|
} catch {
|
||||||
|
saveStatus = 'idle';
|
||||||
|
}
|
||||||
|
}, 800) as unknown as number;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sessions ─────────────────────────────────────────────────────────────────
|
||||||
type Session = {
|
type Session = {
|
||||||
id: string;
|
id: string;
|
||||||
user_agent: string;
|
user_agent: string;
|
||||||
@@ -164,19 +166,12 @@
|
|||||||
revokeError = '';
|
revokeError = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
|
||||||
if (!res.ok) {
|
if (!res.ok) { revokeError = 'Failed to end session. Please try again.'; return; }
|
||||||
revokeError = 'Failed to end session. Please try again.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session.is_current) {
|
if (session.is_current) {
|
||||||
// Ended our own session — submit the logout form to clear the cookie
|
|
||||||
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
|
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
|
||||||
if (logoutForm) {
|
if (logoutForm) logoutForm.submit();
|
||||||
logoutForm.submit();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Remove from local list
|
|
||||||
sessions = sessions.filter((s) => s.id !== session.id);
|
sessions = sessions.filter((s) => s.id !== session.id);
|
||||||
} catch {
|
} catch {
|
||||||
revokeError = 'Network error. Please try again.';
|
revokeError = 'Network error. Please try again.';
|
||||||
@@ -188,18 +183,12 @@
|
|||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
try {
|
try {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
||||||
dateStyle: 'medium',
|
} catch { return iso; }
|
||||||
timeStyle: 'short'
|
|
||||||
}).format(new Date(iso));
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUA(ua: string): string {
|
function parseUA(ua: string): string {
|
||||||
if (!ua) return 'Unknown browser';
|
if (!ua) return 'Unknown browser';
|
||||||
// Very lightweight UA display — just show the most meaningful part
|
|
||||||
if (/Mobile/i.test(ua)) {
|
if (/Mobile/i.test(ua)) {
|
||||||
const match = ua.match(/\(([^)]+)\)/);
|
const match = ua.match(/\(([^)]+)\)/);
|
||||||
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
|
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
|
||||||
@@ -218,24 +207,20 @@
|
|||||||
|
|
||||||
{#if cropFile && browser}
|
{#if cropFile && browser}
|
||||||
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
|
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
|
||||||
<AvatarCropModal
|
<AvatarCropModal file={cropFile} onconfirm={handleCropConfirm} oncancel={handleCropCancel} />
|
||||||
file={cropFile}
|
|
||||||
onconfirm={handleCropConfirm}
|
|
||||||
oncancel={handleCropCancel}
|
|
||||||
/>
|
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Hidden logout form used when user ends their own session -->
|
|
||||||
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
||||||
|
|
||||||
<div class="max-w-xl mx-auto space-y-10">
|
<div class="max-w-2xl mx-auto space-y-6 pb-12">
|
||||||
<div class="flex items-center gap-5">
|
|
||||||
<!-- Avatar -->
|
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="flex items-center gap-5 pt-2">
|
||||||
<div class="relative shrink-0">
|
<div class="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
onclick={() => fileInput?.click()}
|
onclick={() => fileInput?.click()}
|
||||||
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none focus:ring-(--color-brand)"
|
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none"
|
||||||
title={m.profile_change_avatar()}
|
title={m.profile_change_avatar()}
|
||||||
disabled={avatarUploading}
|
disabled={avatarUploading}
|
||||||
>
|
>
|
||||||
@@ -248,7 +233,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Hover overlay -->
|
|
||||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{#if avatarUploading}
|
{#if avatarUploading}
|
||||||
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
@@ -263,96 +247,92 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
|
||||||
bind:this={fileInput}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp"
|
|
||||||
class="hidden"
|
|
||||||
onchange={handleAvatarChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold text-(--color-text)">{data.user.username}</h1>
|
|
||||||
<p class="text-(--color-muted) text-sm mt-0.5 capitalize">{data.user.role}</p>
|
|
||||||
{#if avatarError}
|
|
||||||
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
|
|
||||||
{:else}
|
|
||||||
<p class="text-(--color-muted) text-xs mt-1">{m.profile_click_to_change()}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Subscription ─────────────────────────────────────────────────────── -->
|
|
||||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
|
||||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
|
|
||||||
{#if data.isPro}
|
|
||||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 tracking-wide uppercase">
|
|
||||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
|
|
||||||
</svg>
|
|
||||||
{m.profile_plan_pro()}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
|
|
||||||
{m.profile_plan_free()}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.isPro}
|
<div class="min-w-0">
|
||||||
<p class="text-sm text-(--color-text)">{m.profile_pro_active()}</p>
|
<h1 class="text-2xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
|
||||||
<p class="text-sm text-(--color-muted)">{m.profile_pro_perks()}</p>
|
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
<a
|
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
|
||||||
href="https://polar.sh/libnovel"
|
{#if data.isPro}
|
||||||
target="_blank"
|
<span class="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
|
||||||
rel="noopener noreferrer"
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
||||||
class="inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline"
|
{m.profile_plan_pro()}
|
||||||
>
|
</span>
|
||||||
{m.profile_manage_subscription()}
|
{/if}
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold text-(--color-text) mb-3">{m.profile_upgrade_heading()}</p>
|
|
||||||
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<a
|
|
||||||
href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
|
|
||||||
</svg>
|
|
||||||
{m.profile_upgrade_monthly()}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors"
|
|
||||||
>
|
|
||||||
{m.profile_upgrade_annual()}
|
|
||||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if avatarError}
|
||||||
|
<p class="text-(--color-danger) text-xs mt-1.5">{avatarError}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-(--color-muted) text-xs mt-1.5">{m.profile_click_to_change()}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
|
||||||
|
{#if !data.isPro}
|
||||||
|
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
|
||||||
|
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
|
||||||
|
{m.profile_plan_free()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 pt-5 border-t border-(--color-border)">
|
||||||
|
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
|
||||||
|
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<a href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||||
|
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
||||||
|
{m.profile_upgrade_monthly()}
|
||||||
|
</a>
|
||||||
|
<a href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors">
|
||||||
|
{m.profile_upgrade_annual()}
|
||||||
|
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{:else}
|
||||||
|
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
|
||||||
|
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
|
||||||
|
</div>
|
||||||
|
<a href="https://polar.sh/libnovel" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
|
||||||
|
{m.profile_manage_subscription()}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ── Appearance ────────────────────────────────────────────────────────── -->
|
<!-- ── Preferences ──────────────────────────────────────────────────────────── -->
|
||||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
|
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
|
||||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_appearance_heading()}</h2>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<!-- Section header with auto-save indicator -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4">
|
||||||
|
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
|
||||||
|
<span class="text-xs transition-all duration-300 {saveStatus === 'saving' ? 'text-(--color-muted)' : saveStatus === 'saved' ? 'text-(--color-success)' : 'opacity-0 pointer-events-none'}">
|
||||||
|
{#if saveStatus === 'saving'}
|
||||||
|
{m.profile_saving()}…
|
||||||
|
{:else if saveStatus === 'saved'}
|
||||||
|
✓ {m.profile_saved()}
|
||||||
|
{:else}
|
||||||
|
{m.profile_saved()}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme -->
|
||||||
|
<div class="px-6 py-5 space-y-3">
|
||||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
||||||
<div class="flex gap-3 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
{#each THEMES as t}
|
{#each THEMES as t}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -363,26 +343,25 @@
|
|||||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||||
aria-pressed={selectedTheme === t.id}
|
aria-pressed={selectedTheme === t.id}
|
||||||
>
|
>
|
||||||
<span class="w-3.5 h-3.5 rounded-full flex-shrink-0" style="background: {t.swatch};"></span>
|
<span class="w-3 h-3 rounded-full shrink-0" style="background: {t.swatch};"></span>
|
||||||
{t.label()}
|
{t.label()}
|
||||||
{#if selectedTheme === t.id}
|
|
||||||
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<!-- Font family -->
|
||||||
|
<div class="px-6 py-5 space-y-3">
|
||||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
|
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
{#each FONTS as f}
|
{#each FONTS as f}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (selectedFontFamily = f.id)}
|
onclick={() => (selectedFontFamily = f.id)}
|
||||||
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors {selectedFontFamily === f.id ? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)' : 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||||
|
{selectedFontFamily === f.id
|
||||||
|
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||||
|
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||||
aria-pressed={selectedFontFamily === f.id}
|
aria-pressed={selectedFontFamily === f.id}
|
||||||
>
|
>
|
||||||
{f.label()}
|
{f.label()}
|
||||||
@@ -391,14 +370,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<!-- Text size -->
|
||||||
|
<div class="px-6 py-5 space-y-3">
|
||||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
|
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
{#each FONT_SIZES as s}
|
{#each FONT_SIZES as s}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (selectedFontSize = s.value)}
|
onclick={() => (selectedFontSize = s.value)}
|
||||||
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors {selectedFontSize === s.value ? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)' : 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||||
|
{selectedFontSize === s.value
|
||||||
|
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||||
|
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||||
aria-pressed={selectedFontSize === s.value}
|
aria-pressed={selectedFontSize === s.value}
|
||||||
>
|
>
|
||||||
{s.label()}
|
{s.label()}
|
||||||
@@ -407,115 +390,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 pt-1">
|
<!-- TTS voice -->
|
||||||
<button
|
<div class="px-6 py-5 space-y-3">
|
||||||
onclick={saveSettings}
|
|
||||||
disabled={settingsSaving}
|
|
||||||
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
|
|
||||||
</button>
|
|
||||||
{#if settingsSaved}
|
|
||||||
<span class="text-sm text-(--color-success)">{m.profile_saved()}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
|
||||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
|
|
||||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_reading_heading()}</h2>
|
|
||||||
|
|
||||||
<!-- Voice -->
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
|
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
|
||||||
{#if !voicesLoaded}
|
{#if !voicesLoaded}
|
||||||
<div class="h-9 bg-(--color-surface-3) rounded animate-pulse"></div>
|
<div class="h-9 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
|
||||||
{:else if voices.length === 0}
|
{:else if voices.length === 0}
|
||||||
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
|
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
|
||||||
<option>{m.common_loading()}</option>
|
<option>{m.common_loading()}</option>
|
||||||
</select>
|
</select>
|
||||||
{:else}
|
{:else}
|
||||||
<select
|
<select id="voice-select" bind:value={voice}
|
||||||
id="voice-select"
|
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)">
|
||||||
bind:value={voice}
|
|
||||||
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
|
||||||
>
|
|
||||||
{#if kokoroVoices.length > 0}
|
{#if kokoroVoices.length > 0}
|
||||||
<optgroup label="Kokoro (GPU)">
|
<optgroup label="Kokoro (GPU)">
|
||||||
{#each kokoroVoices as v}
|
{#each kokoroVoices as v}<option value={v.id}>{v.id}</option>{/each}
|
||||||
<option value={v.id}>{v.id}</option>
|
|
||||||
{/each}
|
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{/if}
|
{/if}
|
||||||
{#if pocketVoices.length > 0}
|
{#if pocketVoices.length > 0}
|
||||||
<optgroup label="Pocket TTS (CPU)">
|
<optgroup label="Pocket TTS (CPU)">
|
||||||
{#each pocketVoices as v}
|
{#each pocketVoices as v}<option value={v.id}>{v.id}</option>{/each}
|
||||||
<option value={v.id}>{v.id}</option>
|
|
||||||
{/each}
|
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{/if}
|
{/if}
|
||||||
</select>
|
</select>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Speed -->
|
<!-- Playback speed -->
|
||||||
<div class="space-y-1.5">
|
<div class="px-6 py-5 space-y-3">
|
||||||
<label class="block text-sm font-medium text-(--color-text)" for="speed-range">
|
<div class="flex items-center justify-between">
|
||||||
{m.profile_playback_speed({ speed: speed.toFixed(1) })}
|
<label class="text-sm font-medium text-(--color-text)" for="speed-range">{m.profile_playback_speed({ speed: '' })}</label>
|
||||||
</label>
|
<span class="text-sm font-mono text-(--color-brand)">{speed.toFixed(1)}x</span>
|
||||||
<input
|
</div>
|
||||||
id="speed-range"
|
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1" bind:value={speed}
|
||||||
type="range"
|
style="accent-color: var(--color-brand);" class="w-full" />
|
||||||
min="0.5"
|
|
||||||
max="3.0"
|
|
||||||
step="0.1"
|
|
||||||
bind:value={speed}
|
|
||||||
style="accent-color: var(--color-brand);"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<div class="flex justify-between text-xs text-(--color-muted)">
|
<div class="flex justify-between text-xs text-(--color-muted)">
|
||||||
<span>0.5x</span>
|
<span>0.5x</span><span>3.0x</span>
|
||||||
<span>3.0x</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto-next toggle -->
|
<!-- Auto-advance -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="px-6 py-5 flex items-center justify-between">
|
||||||
<span class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</span>
|
<div>
|
||||||
|
<p class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</p>
|
||||||
|
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={autoNext}
|
aria-checked={autoNext}
|
||||||
onclick={() => (autoNext = !autoNext)}
|
onclick={() => (autoNext = !autoNext)}
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
|
class="shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'}"
|
||||||
>
|
>
|
||||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span>
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 pt-1">
|
|
||||||
<button
|
|
||||||
onclick={saveSettings}
|
|
||||||
disabled={settingsSaving}
|
|
||||||
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
|
|
||||||
</button>
|
|
||||||
{#if settingsSaved}
|
|
||||||
<span class="text-sm text-(--color-success)">{m.profile_saved()}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
|
<!-- ── Active sessions ──────────────────────────────────────────────────────── -->
|
||||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
|
<div>
|
||||||
<p class="text-sm text-(--color-muted)">{m.profile_session_unrecognised()}</p>
|
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
|
||||||
|
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_session_unrecognised()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if revokeError}
|
{#if revokeError}
|
||||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
|
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
|
||||||
{revokeError}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if sessions.length === 0}
|
{#if sessions.length === 0}
|
||||||
@@ -547,7 +488,7 @@
|
|||||||
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
||||||
{session.is_current
|
{session.is_current
|
||||||
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
|
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
|
||||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-3)'}"
|
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-2)'}"
|
||||||
>
|
>
|
||||||
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
|
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
|
||||||
</button>
|
</button>
|
||||||
@@ -556,4 +497,5 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user