From e4631e74860f044cacabe491a82b2f62a4d1bd19 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 12 Apr 2026 10:21:20 +0500 Subject: [PATCH] refactor: profile page grouped menu layout inspired by iOS settings style --- ui/src/routes/profile/+page.svelte | 1085 +++++++++++++++------------- 1 file changed, 596 insertions(+), 489 deletions(-) diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index e797cc2..3cebe8d 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -84,10 +84,6 @@ function handleCropCancel() { cropFile = null; } // ── Settings state ──────────────────────────────────────────────────────────── - // All changes are written directly into audioStore / theme context. - // The layout's debounced $effect owns the single PUT /api/settings call. - // We only maintain a local saveStatus indicator here. - const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme'); let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber')); let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system')); @@ -118,7 +114,6 @@ { value: 1.3, label: () => m.profile_text_size_xl() }, ]; - // Local save-status indicator — layout's effect does the actual debounced save. type SaveStatus = 'idle' | 'saving' | 'saved'; let saveStatus = $state('idle'); let savedTimer = 0; @@ -133,8 +128,6 @@ }, 900) as unknown as number; } - // Propagate all settings changes into audioStore / context immediately. - // Layout effect watches these and persists to the server (debounced 800ms). $effect(() => { const t = selectedTheme; const ff = selectedFontFamily; @@ -160,8 +153,13 @@ $effect(() => { if (settingsCtx) settingsCtx.fontFamily = selectedFontFamily; }); $effect(() => { if (settingsCtx) settingsCtx.fontSize = selectedFontSize; }); - // ── Tab ────────────────────────────────────────────────────────────────────── - let activeTab = $state<'profile' | 'stats' | 'history'>('profile'); + // ── Expanded section state ──────────────────────────────────────────────────── + type Section = 'history' | 'stats' | 'appearance' | 'playback' | 'notifications' | 'subscription' | 'sessions' | 'danger' | null; + let expanded = $state
(null); + + function toggle(section: Section) { + expanded = expanded === section ? null : section; + } // ── Sessions ───────────────────────────────────────────────────────────────── type Session = { @@ -197,7 +195,6 @@ } // ── Danger zone ────────────────────────────────────────────────────────────── - let deleteConfirmOpen = $state(false); let deleteConfirmText = $state(''); let deleting = $state(false); let deleteError = $state(''); @@ -265,7 +262,6 @@ pushState = 'unsupported'; return; } - // Check current permission / subscription state (async () => { const perm = Notification.permission; if (perm === 'denied') { pushState = 'denied'; return; } @@ -280,15 +276,12 @@ pushError = ''; pushState = 'loading'; try { - // Fetch VAPID public key from backend const keyRes = await fetch('/api/push-subscriptions/vapid-public-key'); if (!keyRes.ok) { pushError = 'Push notifications are not configured on this server.'; pushState = 'default'; return; } const { public_key } = await keyRes.json() as { public_key: string }; - // Register / get existing service worker const reg = await navigator.serviceWorker.ready; - // Subscribe with VAPID const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(public_key), @@ -334,7 +327,6 @@ } } - /** Convert a base64url VAPID public key to a Uint8Array for PushManager.subscribe(). */ function urlBase64ToUint8Array(base64String: string): ArrayBuffer { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); @@ -356,6 +348,9 @@ if (/Edg\/(\d+)/i.test(ua)) return `Edge ${ua.match(/Edg\/(\d+)/i)![1]}`; return ua.slice(0, 48) + (ua.length > 48 ? '…' : ''); } + + // ── Chevron helper ──────────────────────────────────────────────────────────── + const chevronClass = 'w-4 h-4 text-(--color-muted) shrink-0 transition-transform duration-200'; @@ -370,9 +365,9 @@ -
+
- + {#if justSubscribed}

Welcome to Pro!

@@ -381,11 +376,11 @@ {/if} -
+
-
-

{data.user.username}

-
+
+

{data.user.username}

+
{data.user.role} {#if data.isPro} @@ -423,195 +421,300 @@ {/if}
{#if avatarError} -

{avatarError}

- {:else} -

{m.profile_click_to_change()}

+

{avatarError}

{/if}
- -
- {#each (['profile', 'stats', 'history'] as const) as tab} - - {/each} -
+ +
- {#if activeTab === 'profile'} - - - {#if !data.isPro} -
-
-
-

{m.profile_subscription_heading()}

-

{m.profile_free_limits()}

-
- - {m.profile_plan_free()} + + + + + + -
-
-

{m.profile_upgrade_heading()}

-

{m.profile_upgrade_desc()} See plans →

- {#if checkoutError} -

{checkoutError}

- {/if} -
- - -
-
-
- {:else} -
-
-

{m.profile_pro_active()}

-

{m.profile_pro_perks()}

-
- - {m.profile_manage_subscription()} → + Library + + + -
- {/if} - -
- - -
-

Preferences

- - {#if saveStatus === 'saving'}{m.profile_saving()}… - {:else if saveStatus === 'saved'}✓ {m.profile_saved()} - {:else}{m.profile_saved()}{/if} + +
+ Stats + + + + - -
-

{m.profile_theme_label()}

-
- {#each THEMES as t, i} - {#if i === 6} - - {/if} - + {#if expanded === 'stats'} +
+
+ {#each [ + { label: 'Chapters Read', value: data.stats.totalChaptersRead }, + { label: 'Completed', value: data.stats.booksCompleted }, + { label: 'Reading', value: data.stats.booksReading }, + { label: 'Plan to Read', value: data.stats.booksPlanToRead }, + ] as stat} +
+

{stat.value}

+

{stat.label}

+
{/each}
-
- - -
-

{m.profile_font_family()}

-
- {#each FONTS as f} - +
+
+

{data.stats.streak}

+

day streak

+
+
+

+ {data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'} +

+

avg rating

+
+
+ {#if data.stats.topGenres.length > 0} +
+ {#each data.stats.topGenres as genre, i} + + {genre} + {/each}
+ {/if}
+ {/if} - -
-

{m.profile_text_size()}

-
- {#each FONT_SIZES as s} - - {/each} -
+ + + + {#if expanded === 'history'} + + {/if} - -
+
+ + +
+ + + + + {#if expanded === 'appearance'} +
- - {audioStore.speed.toFixed(1)}x + Appearance + + {#if saveStatus === 'saving'}{m.profile_saving()} + {:else if saveStatus === 'saved'}✓ {m.profile_saved()} + {:else}{m.profile_saved()}{/if} +
- -
- 0.5x3.0x + + +
+

{m.profile_theme_label()}

+
+ {#each THEMES as t, i} + {#if i === 6} + + {/if} + + {/each} +
+
+ + +
+

{m.profile_font_family()}

+
+ {#each FONTS as f} + + {/each} +
+
+ + +
+

{m.profile_text_size()}

+
+ {#each FONT_SIZES as s} + + {/each} +
+ {/if} - -
-

Playback

+ + + + {#if expanded === 'playback'} +
+ Playback + + +
+
+ + {audioStore.speed.toFixed(1)}x +
+ +
+ 0.5x3.0x +
+
@@ -685,325 +788,329 @@
- -
- - -
-
-

{m.profile_sessions_heading()}

-

{m.profile_session_unrecognised()}

-
- - {#if revokeError} -
{revokeError}
{/if} - {#if sessions.length === 0} -

{m.profile_no_sessions()}

- {:else} -
    - {#each sessions as session (session.id)} -
  • -
    -
    - {parseUA(session.user_agent)} - {#if session.is_current} - {m.profile_session_this()} - {/if} -
    - {#if session.ip} -

    {session.ip}

    - {/if} -

    - {m.profile_session_signed_in({ date: formatDate(session.created_at) })} - {#if session.last_seen && session.last_seen !== session.created_at} - {m.profile_session_last_seen({ date: formatDate(session.last_seen) })} - {/if} -

    -
    - -
  • - {/each} -
- {/if} -
- - -
-
-
-

In-app notifications

-

- {#if notifyNewChapters} - You'll receive a notification when new chapters are added to books in your library. - {:else} - In-app new-chapter notifications are disabled. - {/if} -

-
- -
-
- - - {#if pushState !== 'unsupported'} -
-
-
-

Push notifications

-

- {#if pushState === 'subscribed'} - You'll receive a push notification when new chapters are added to books in your library. - {:else if pushState === 'denied'} - Notifications are blocked by your browser. Change the permission in your browser settings. - {:else} - Get notified when new chapters arrive for books in your library. - {/if} -

- {#if pushError} -

{pushError}

- {/if} -
-
- {#if pushState === 'subscribed'} - - {:else if pushState === 'denied'} - Blocked - {:else} - - {/if} -
-
-
- {/if} - - -
+ - {#if deleteConfirmOpen} -
-
-

Delete account

-

- This permanently deletes your account, reading history, settings, and all associated data. This action cannot be undone. + {#if expanded === 'notifications'} +

+ Notifications + + +
+
+

In-app notifications

+

+ {#if notifyNewChapters} + Notified when new chapters arrive in your library. + {:else} + In-app new-chapter notifications are disabled. + {/if}

- -
- - -
- - {#if deleteError} -

{deleteError}

- {/if} -
- {/if} -
- {/if} - - {#if activeTab === 'stats'} -
- - -
-

Reading Overview

-
- {#each [ - { label: 'Chapters Read', value: data.stats.totalChaptersRead }, - { label: 'Completed', value: data.stats.booksCompleted }, - { label: 'Reading', value: data.stats.booksReading }, - { label: 'Plan to Read', value: data.stats.booksPlanToRead }, - ] as stat} -
-

{stat.value}

-

{stat.label}

-
- {/each} -
-
- - -
-

Activity

-
-
-

{data.stats.streak}

-

day streak

-
-
-

- {data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'} -

-

avg rating given

-
-
-
- - - {#if data.stats.topGenres.length > 0} -
-

Favourite Genres

-
- {#each data.stats.topGenres as genre, i} - - {genre} - - {/each} -
-
- {/if} - - {#if data.stats.booksDropped > 0} -

- {data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''} — - revisit your library -

- {/if} - -
- {/if} - - {#if activeTab === 'history'} - + {/if} + +
+ + +
+ + + + + {#if expanded === 'subscription'} +
+ {#if data.isPro} +
+
+

{m.profile_pro_active()}

+

{m.profile_pro_perks()}

+
+ + {m.profile_manage_subscription()} → + +
+ {:else} +
+
+
+

{m.profile_free_limits()}

+
+
+
+

{m.profile_upgrade_heading()}

+

{m.profile_upgrade_desc()} See plans →

+ {#if checkoutError} +

{checkoutError}

+ {/if} +
+ + +
+
+
+ {/if} +
+ {/if} + + + + + {#if expanded === 'sessions'} +
+

{m.profile_session_unrecognised()}

+ + {#if revokeError} +
{revokeError}
+ {/if} + + {#if sessions.length === 0} +

{m.profile_no_sessions()}

+ {:else} +
    + {#each sessions as session (session.id)} +
  • +
    +
    + {parseUA(session.user_agent)} + {#if session.is_current} + {m.profile_session_this()} + {/if} +
    + {#if session.ip} +

    {session.ip}

    + {/if} +

    + {m.profile_session_signed_in({ date: formatDate(session.created_at) })} + {#if session.last_seen && session.last_seen !== session.created_at} + {m.profile_session_last_seen({ date: formatDate(session.last_seen) })} + {/if} +

    +
    + +
  • + {/each} +
+ {/if} +
+ {/if} + +
+ + +
+ + + {#if expanded === 'danger'} +
+
+

Delete account

+

+ This permanently deletes your account, reading history, settings, and all associated data. This action cannot be undone. +

+
+ +
+ + +
+ + {#if deleteError} +

{deleteError}

+ {/if} + + +
{/if}
- {/if}