Compare commits
3 Commits
v2.6.86
...
a0e705beec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0e705beec | ||
|
|
761ca83da5 | ||
|
|
48d0ae63bf |
@@ -335,6 +335,14 @@ create "notifications" '{
|
||||
{"name":"created", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "push_subscriptions" '{
|
||||
"name":"push_subscriptions","type":"base","fields":[
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"endpoint", "type":"text","required":true},
|
||||
{"name":"p256dh", "type":"text","required":true},
|
||||
{"name":"auth", "type":"text","required":true}
|
||||
]}'
|
||||
|
||||
create "ai_jobs" '{
|
||||
"name":"ai_jobs","type":"base","fields":[
|
||||
{"name":"kind", "type":"text", "required":true},
|
||||
@@ -393,6 +401,7 @@ add_field "user_settings" "font_size" "number"
|
||||
add_field "user_settings" "announce_chapter" "bool"
|
||||
add_field "user_settings" "audio_mode" "text"
|
||||
add_field "books" "archived" "bool"
|
||||
add_field "app_users" "notify_new_chapters" "bool"
|
||||
|
||||
# ── 6. Indexes ────────────────────────────────────────────────────────────────
|
||||
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
zIndex?: string;
|
||||
/** Called when a chapter row is tapped. The overlay does NOT close itself. */
|
||||
onselect: (chapterNumber: number) => void;
|
||||
/** Called when the close / chevron-down button is tapped. */
|
||||
/** Called when the close (✕) button is tapped. */
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
@@ -40,12 +40,6 @@
|
||||
)
|
||||
);
|
||||
|
||||
/** Scroll the active chapter into view instantly (no animation) when the
|
||||
* list is first rendered so the user never has to hunt for their position. */
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
search = '';
|
||||
onclose();
|
||||
@@ -73,9 +67,9 @@
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<!-- chevron-down -->
|
||||
<!-- close / ✕ -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
@@ -105,7 +99,6 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(ch.number)}
|
||||
use:scrollIfActive={ch.number === activeChapter}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === activeChapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
|
||||
193
ui/src/lib/components/CurrentlyReadingModal.svelte
Normal file
193
ui/src/lib/components/CurrentlyReadingModal.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Book {
|
||||
slug: string;
|
||||
title: string;
|
||||
cover?: string;
|
||||
author?: string;
|
||||
genres?: string[] | string;
|
||||
}
|
||||
|
||||
interface ReadingEntry {
|
||||
book: Book;
|
||||
chapter: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The slug of the book currently being read — highlighted in the list. */
|
||||
currentSlug: string;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { currentSlug, onclose }: Props = $props();
|
||||
|
||||
let entries = $state<ReadingEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// ── Fetch in-progress books on mount ──────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/home');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json() as { continue_reading: ReadingEntry[] };
|
||||
entries = data.continue_reading ?? [];
|
||||
} catch {
|
||||
error = 'Failed to load your reading list.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// ── Body scroll lock ──────────────────────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
});
|
||||
|
||||
// ── Keyboard: Escape closes ───────────────────────────────────────────────
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
|
||||
// ── Navigate to a book's current chapter ─────────────────────────────────
|
||||
function openBook(entry: ReadingEntry) {
|
||||
onclose();
|
||||
goto(`/books/${entry.book.slug}/chapters/${entry.chapter}`);
|
||||
}
|
||||
|
||||
function parseGenres(genres: string[] | string | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try { const p = JSON.parse(genres); return Array.isArray(p) ? p : []; } catch { return []; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[70] flex flex-col"
|
||||
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
|
||||
onpointerdown={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
||||
>
|
||||
<!-- Panel — matches SearchModal style -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full max-w-2xl mx-auto mt-0 sm:mt-16 flex flex-col bg-(--color-surface) sm:rounded-2xl border-b sm:border border-(--color-border) shadow-2xl overflow-hidden"
|
||||
style="max-height: 100svh;"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header row -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<svg class="w-5 h-5 text-(--color-muted) shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
<span class="flex-1 text-base font-semibold text-(--color-text)">Currently Reading</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
class="shrink-0 px-3 py-1 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain">
|
||||
|
||||
{#if loading}
|
||||
<!-- Loading skeleton -->
|
||||
<div class="px-4 pt-3 pb-4 space-y-1">
|
||||
{#each [1, 2, 3] as _}
|
||||
<div class="flex items-center gap-3 px-0 py-3 border-b border-(--color-border)/40">
|
||||
<div class="shrink-0 w-10 h-14 rounded bg-(--color-surface-3) animate-pulse"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3.5 bg-(--color-surface-3) rounded animate-pulse w-3/4"></div>
|
||||
<div class="h-3 bg-(--color-surface-3) rounded animate-pulse w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if error}
|
||||
<p class="px-5 py-8 text-sm text-center text-(--color-danger)">{error}</p>
|
||||
|
||||
{:else if entries.length === 0}
|
||||
<div class="px-5 py-12 text-center">
|
||||
<p class="text-sm font-semibold text-(--color-text) mb-1">No books in progress</p>
|
||||
<p class="text-xs text-(--color-muted)">Books you start reading will appear here.</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{#each entries as entry, i}
|
||||
{@const genres = parseGenres(entry.book.genres)}
|
||||
{@const isCurrent = entry.book.slug === currentSlug}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openBook(entry)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors border-b border-(--color-border)/40 last:border-0',
|
||||
isCurrent ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="shrink-0 w-10 h-14 rounded overflow-hidden bg-(--color-surface-2) border border-(--color-border) relative">
|
||||
{#if entry.book.cover}
|
||||
<img src={entry.book.cover} alt="" class="w-full h-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start gap-2">
|
||||
<p class={cn(
|
||||
'text-sm font-semibold leading-snug line-clamp-1 flex-1',
|
||||
isCurrent ? 'text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>
|
||||
{entry.book.title}
|
||||
</p>
|
||||
{#if isCurrent}
|
||||
<span class="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 leading-none mt-0.5">
|
||||
Now
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.book.author}
|
||||
<p class="text-xs text-(--color-muted) mt-0.5 truncate">{entry.book.author}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
<span class="text-xs text-(--color-muted)/60">Ch. {entry.chapter}</span>
|
||||
{#each genres.slice(0, 2) as g}
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{g}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chevron (dimmed for current, normal for others) -->
|
||||
<svg class={cn('w-4 h-4 shrink-0', isCurrent ? 'text-(--color-brand)/40' : 'text-(--color-muted)/40')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +98,6 @@
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
onclose();
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
@@ -434,17 +433,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapter modal (full-screen overlay) -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[80]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterModal = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
|
||||
<div class="flex-1 flex flex-col justify-end px-6 pb-6 gap-0 shrink-0 overflow-hidden" style="z-index: 2; position: relative;">
|
||||
|
||||
@@ -670,3 +658,16 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter picker rendered OUTSIDE the transformed overlay so that
|
||||
fixed inset-0 anchors to the real viewport, not the CSS-transformed
|
||||
containing block (transform: translateY breaks fixed positioning). -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[80]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterModal = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface User {
|
||||
polar_customer_id?: string;
|
||||
polar_subscription_id?: string;
|
||||
notify_new_chapters?: boolean;
|
||||
notify_new_chapters_push?: boolean;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
@@ -1582,7 +1583,10 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
|
||||
*/
|
||||
export async function updateUserNotificationPrefs(
|
||||
userId: string,
|
||||
prefs: { notify_new_chapters?: boolean }
|
||||
prefs: {
|
||||
notify_new_chapters?: boolean;
|
||||
notify_new_chapters_push?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
} catch (e) { console.error('clear notifications:', e); }
|
||||
}
|
||||
$effect(() => { if (data.user) loadNotifications(); });
|
||||
$effect(() => { if (notificationsOpen && data.user) loadNotifications(); });
|
||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
|
||||
// Close search on navigation
|
||||
|
||||
@@ -7,7 +7,7 @@ import { log } from '$lib/server/logger';
|
||||
* PATCH /api/profile
|
||||
*
|
||||
* Update mutable profile preferences (currently: notification preferences).
|
||||
* Body: { notify_new_chapters?: boolean }
|
||||
* Body: { notify_new_chapters?: boolean, notify_new_chapters_push?: boolean }
|
||||
*/
|
||||
export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
@@ -19,10 +19,13 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
error(400, 'Invalid JSON');
|
||||
}
|
||||
|
||||
const prefs: { notify_new_chapters?: boolean } = {};
|
||||
const prefs: { notify_new_chapters?: boolean; notify_new_chapters_push?: boolean } = {};
|
||||
if (typeof body.notify_new_chapters === 'boolean') {
|
||||
prefs.notify_new_chapters = body.notify_new_chapters;
|
||||
}
|
||||
if (typeof body.notify_new_chapters_push === 'boolean') {
|
||||
prefs.notify_new_chapters_push = body.notify_new_chapters_push;
|
||||
}
|
||||
|
||||
if (Object.keys(prefs).length === 0) {
|
||||
error(400, 'No valid preferences provided');
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { page } from '$app/state';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
import CurrentlyReadingModal from '$lib/components/CurrentlyReadingModal.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
@@ -22,6 +23,9 @@
|
||||
let settingsPanelOpen = $state(false);
|
||||
let settingsTab = $state<'reading' | 'listening'>('reading');
|
||||
|
||||
// ── Currently reading modal ───────────────────────────────────────────────
|
||||
let readingModalOpen = $state(false);
|
||||
|
||||
const READER_FONTS = [
|
||||
{ id: 'system', label: 'System' },
|
||||
{ id: 'serif', label: 'Serif' },
|
||||
@@ -517,6 +521,18 @@
|
||||
|
||||
<!-- Chapter heading + meta + language switcher -->
|
||||
<div class="mb-6">
|
||||
<!-- Book title — tappable, opens Currently Reading modal -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (readingModalOpen = true)}
|
||||
class="flex items-center gap-1 text-(--color-muted) hover:text-(--color-brand) text-sm font-medium transition-colors mb-1.5 max-w-full"
|
||||
title="Switch book"
|
||||
>
|
||||
<span class="truncate">{data.book.title}</span>
|
||||
<svg class="w-3.5 h-3.5 shrink-0 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-1.5">
|
||||
{m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
</p>
|
||||
@@ -1315,3 +1331,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Currently Reading modal -->
|
||||
{#if readingModalOpen}
|
||||
<CurrentlyReadingModal
|
||||
currentSlug={data.book.slug}
|
||||
onclose={() => (readingModalOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -81,6 +81,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
email,
|
||||
polarCustomerId,
|
||||
notifyNewChapters: freshUser?.notify_new_chapters ?? true,
|
||||
notifyNewChaptersPush: freshUser?.notify_new_chapters_push ?? true,
|
||||
stats: stats ?? {
|
||||
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
|
||||
booksPlanToRead: 0, booksDropped: 0, topGenres: [],
|
||||
|
||||
@@ -236,8 +236,10 @@
|
||||
let pushError = $state('');
|
||||
|
||||
// ── In-app notifications ──────────────────────────────────────────────────────
|
||||
let notifyNewChapters = $state(data.notifyNewChapters ?? true);
|
||||
let notifyNewChaptersSaving = $state(false);
|
||||
let notifyNewChapters = $state(data.notifyNewChapters ?? true);
|
||||
let notifyNewChaptersPush = $state(data.notifyNewChaptersPush ?? true);
|
||||
let notifyNewChaptersSaving = $state(false);
|
||||
let notifyNewChaptersPushSaving = $state(false);
|
||||
|
||||
async function toggleNotifyNewChapters() {
|
||||
notifyNewChaptersSaving = true;
|
||||
@@ -248,14 +250,27 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notify_new_chapters: next })
|
||||
});
|
||||
if (res.ok) {
|
||||
notifyNewChapters = next;
|
||||
}
|
||||
if (res.ok) notifyNewChapters = next;
|
||||
} catch { /* ignore */ } finally {
|
||||
notifyNewChaptersSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNotifyNewChaptersPush() {
|
||||
notifyNewChaptersPushSaving = true;
|
||||
const next = !notifyNewChaptersPush;
|
||||
try {
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notify_new_chapters_push: next })
|
||||
});
|
||||
if (res.ok) notifyNewChaptersPush = next;
|
||||
} catch { /* ignore */ } finally {
|
||||
notifyNewChaptersPushSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
@@ -802,8 +817,16 @@
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text)">Notifications</span>
|
||||
<span class="text-xs mr-2 hidden sm:inline {notifyNewChapters ? 'text-(--color-brand)' : 'text-(--color-muted)'}">
|
||||
{notifyNewChapters ? 'On' : 'Off'}
|
||||
<span class="text-xs mr-2 hidden sm:inline text-(--color-muted)">
|
||||
{#if notifyNewChapters && pushState === 'subscribed'}
|
||||
<span class="text-(--color-brand)">In-app · Push</span>
|
||||
{:else if notifyNewChapters}
|
||||
<span class="text-(--color-brand)">In-app</span>
|
||||
{:else if pushState === 'subscribed'}
|
||||
<span class="text-(--color-brand)">Push</span>
|
||||
{:else}
|
||||
Off
|
||||
{/if}
|
||||
</span>
|
||||
<svg class={cn(chevronClass, expanded === 'notifications' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
@@ -814,50 +837,24 @@
|
||||
<div class="px-5 py-5 space-y-5 bg-(--color-surface-3)/30">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Notifications</span>
|
||||
|
||||
<!-- In-app -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text)">In-app notifications</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||
{#if notifyNewChapters}
|
||||
Notified when new chapters arrive in your library.
|
||||
{:else}
|
||||
In-app new-chapter notifications are disabled.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleNotifyNewChapters}
|
||||
disabled={notifyNewChaptersSaving}
|
||||
class={cn(
|
||||
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50',
|
||||
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={notifyNewChapters}
|
||||
title={notifyNewChapters ? 'Turn off in-app notifications' : 'Turn on in-app notifications'}
|
||||
>
|
||||
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChapters ? 'translate-x-6' : 'translate-x-1')}></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Push -->
|
||||
<!-- Browser push master toggle -->
|
||||
{#if pushState !== 'unsupported'}
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text)">Push notifications</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||
<p class="text-sm font-medium text-(--color-text)">Browser push</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||
{#if pushState === 'subscribed'}
|
||||
Push enabled for new chapters in your library.
|
||||
This browser is subscribed to push notifications.
|
||||
{:else if pushState === 'denied'}
|
||||
Blocked by your browser. Change in browser settings.
|
||||
Blocked by your browser — change in browser settings.
|
||||
{:else if pushState === 'loading'}
|
||||
Updating…
|
||||
{:else}
|
||||
Get notified when new chapters arrive.
|
||||
Subscribe to receive push notifications in this browser.
|
||||
{/if}
|
||||
</p>
|
||||
{#if pushError}
|
||||
<p class="text-sm text-(--color-danger) mt-1.5">{pushError}</p>
|
||||
<p class="text-xs text-(--color-danger) mt-1">{pushError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
@@ -890,6 +887,63 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Per-category table -->
|
||||
<div class="rounded-lg border border-(--color-border) overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-4 py-2 bg-(--color-surface-3)/60 border-b border-(--color-border)">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Category</span>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-12 text-center">In-app</span>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-12 text-center">Push</span>
|
||||
</div>
|
||||
<!-- New chapters row -->
|
||||
<div class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-4 py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-(--color-text)">New chapters</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">When a book in your library gets new chapters</p>
|
||||
</div>
|
||||
<!-- In-app toggle -->
|
||||
<div class="w-12 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={notifyNewChapters}
|
||||
aria-label="In-app notifications for new chapters"
|
||||
onclick={toggleNotifyNewChapters}
|
||||
disabled={notifyNewChaptersSaving}
|
||||
class={cn(
|
||||
'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) disabled:opacity-50',
|
||||
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
|
||||
)}
|
||||
>
|
||||
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChapters ? 'translate-x-6' : 'translate-x-1')}></span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Push toggle -->
|
||||
<div class="w-12 flex justify-center">
|
||||
{#if pushState === 'unsupported'}
|
||||
<span class="text-xs text-(--color-muted)" title="Push not supported in this browser">—</span>
|
||||
{:else if pushState === 'denied'}
|
||||
<span class="text-xs text-(--color-muted)" title="Push blocked by browser">—</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={notifyNewChaptersPush && pushState === 'subscribed'}
|
||||
aria-label="Push notifications for new chapters"
|
||||
onclick={toggleNotifyNewChaptersPush}
|
||||
disabled={notifyNewChaptersPushSaving || pushState !== 'subscribed'}
|
||||
class={cn(
|
||||
'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) disabled:opacity-40',
|
||||
notifyNewChaptersPush && pushState === 'subscribed' ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
|
||||
)}
|
||||
>
|
||||
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChaptersPush && pushState === 'subscribed' ? 'translate-x-6' : 'translate-x-1')}></span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user