Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ef82a1d12 | ||
|
|
7a418ee62b | ||
|
|
d4f35a4899 | ||
|
|
6559a8c015 | ||
|
|
05bfd110b8 |
@@ -569,6 +569,30 @@ func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count})
|
||||
}
|
||||
|
||||
// handleDedupChapters handles POST /api/admin/dedup-chapters/{slug}.
|
||||
// Removes duplicate chapters_idx records for a book, keeping the latest record
|
||||
// per chapter number. Returns the number of duplicate records deleted.
|
||||
func (s *Server) handleDedupChapters(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := s.deps.BookWriter.DeduplicateChapters(r.Context(), slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("dedup-chapters failed", "slug", slug, "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{
|
||||
"error": err.Error(),
|
||||
"deleted": deleted,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("dedup-chapters complete", "slug", slug, "deleted", deleted)
|
||||
writeJSON(w, 0, map[string]any{"slug": slug, "deleted": deleted})
|
||||
}
|
||||
|
||||
// ── Audio ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
|
||||
|
||||
@@ -204,6 +204,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||
|
||||
// Admin data repair endpoints
|
||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ type BookWriter interface {
|
||||
|
||||
// ChapterExists returns true if the markdown object for ref already exists.
|
||||
ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool
|
||||
|
||||
// DeduplicateChapters removes duplicate chapters_idx records for slug,
|
||||
// keeping only one record per chapter number (the one with the latest
|
||||
// updated timestamp). Returns the number of duplicate records deleted.
|
||||
DeduplicateChapters(ctx context.Context, slug string) (int, error)
|
||||
}
|
||||
|
||||
// BookReader is the read side used by the backend to serve content.
|
||||
|
||||
@@ -39,8 +39,9 @@ func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, err
|
||||
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
func (m *mockStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
// RankingStore
|
||||
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
|
||||
@@ -52,10 +53,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
|
||||
}
|
||||
|
||||
// AudioStore
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
// response. There is no 100-second Cloudflare proxy timeout because we are
|
||||
// calling the Cloudflare API directly, not routing through a Cloudflare-proxied
|
||||
// homelab tunnel.
|
||||
//
|
||||
// The aura-2-en model enforces a hard 2 000-character limit per request.
|
||||
// GenerateAudio transparently splits longer texts into sentence-boundary chunks
|
||||
// and concatenates the resulting MP3 frames.
|
||||
package cfai
|
||||
|
||||
import (
|
||||
@@ -145,6 +149,8 @@ func New(accountID, apiToken, model string) Client {
|
||||
}
|
||||
|
||||
// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
|
||||
// The aura-2-en model rejects inputs longer than 2 000 characters, so this method
|
||||
// splits the text into sentence-bounded chunks and concatenates the MP3 responses.
|
||||
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("cfai: empty text")
|
||||
@@ -154,6 +160,20 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
|
||||
speaker = "luna"
|
||||
}
|
||||
|
||||
chunks := splitText(text, 1800) // stay comfortably under the 2 000-char limit
|
||||
var combined []byte
|
||||
for _, chunk := range chunks {
|
||||
part, err := c.generateChunk(ctx, chunk, speaker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
combined = append(combined, part...)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
// generateChunk sends a single ≤2 000-character request and returns MP3 bytes.
|
||||
func (c *httpClient) generateChunk(ctx context.Context, text, speaker string) ([]byte, error) {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"text": text,
|
||||
"speaker": speaker,
|
||||
@@ -189,6 +209,87 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
|
||||
return mp3, nil
|
||||
}
|
||||
|
||||
// splitText splits src into chunks of at most maxChars characters each.
|
||||
// It tries to break at paragraph boundaries first, then at sentence-ending
|
||||
// punctuation (. ! ?), and falls back to the nearest space.
|
||||
func splitText(src string, maxChars int) []string {
|
||||
if len(src) <= maxChars {
|
||||
return []string{src}
|
||||
}
|
||||
|
||||
var chunks []string
|
||||
remaining := src
|
||||
|
||||
for len(remaining) > 0 {
|
||||
if len(remaining) <= maxChars {
|
||||
chunks = append(chunks, strings.TrimSpace(remaining))
|
||||
break
|
||||
}
|
||||
|
||||
// Search window: the first maxChars bytes of remaining.
|
||||
// Use byte length here because the API limit is in bytes/chars for ASCII;
|
||||
// for safety we operate on rune-aware slices.
|
||||
window := remaining
|
||||
if len(window) > maxChars {
|
||||
// Trim to maxChars runes (not bytes), ensuring we don't split a multi-byte char.
|
||||
window = runeSlice(remaining, maxChars)
|
||||
}
|
||||
|
||||
cut := -1
|
||||
|
||||
// 1. Prefer paragraph break (\n\n or \n).
|
||||
if i := strings.LastIndex(window, "\n\n"); i > 0 {
|
||||
cut = i + 2
|
||||
} else if i := strings.LastIndex(window, "\n"); i > 0 {
|
||||
cut = i + 1
|
||||
}
|
||||
|
||||
// 2. Fall back to sentence-ending punctuation followed by a space.
|
||||
if cut < 0 {
|
||||
for _, punct := range []string{". ", "! ", "? ", ".\n", "!\n", "?\n"} {
|
||||
if i := strings.LastIndex(window, punct); i > 0 {
|
||||
candidate := i + len(punct)
|
||||
if cut < 0 || candidate > cut {
|
||||
cut = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Last resort: nearest space.
|
||||
if cut < 0 {
|
||||
if i := strings.LastIndex(window, " "); i > 0 {
|
||||
cut = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Hard cut at maxChars runes if no boundary found.
|
||||
if cut < 0 {
|
||||
cut = len(window)
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(remaining[:cut])
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
remaining = remaining[cut:]
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
// runeSlice returns the first n runes of s as a string.
|
||||
func runeSlice(s string, n int) string {
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == n {
|
||||
return s[:i]
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// StreamAudioMP3 generates audio and wraps the MP3 bytes as an io.ReadCloser.
|
||||
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
mp3, err := c.GenerateAudio(ctx, text, voice)
|
||||
|
||||
@@ -89,6 +89,8 @@ func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.Cha
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -94,6 +94,10 @@ func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.Cha
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *stubBookWriter) DeduplicateChapters(_ context.Context, _ string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
|
||||
type stubBookReader struct {
|
||||
text string
|
||||
|
||||
@@ -130,7 +130,16 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
|
||||
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
|
||||
if postErr == nil {
|
||||
return nil
|
||||
}
|
||||
// POST failed — a concurrent writer may have inserted the same slug+number.
|
||||
// Re-fetch and fall through to PATCH (mirrors WriteMetadata retry pattern).
|
||||
items, err = s.pb.listAll(ctx, "chapters_idx", filter, "")
|
||||
if err != nil || len(items) == 0 {
|
||||
return postErr // original POST error is more informative
|
||||
}
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
@@ -139,6 +148,59 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID), payload)
|
||||
}
|
||||
|
||||
// DeduplicateChapters removes duplicate chapters_idx records for slug.
|
||||
// For each chapter number that has more than one record, it keeps the record
|
||||
// with the latest "updated" timestamp and deletes the rest.
|
||||
// Returns the number of records deleted.
|
||||
func (s *Store) DeduplicateChapters(ctx context.Context, slug string) (int, error) {
|
||||
filter := fmt.Sprintf(`slug=%q`, slug)
|
||||
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "number")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("DeduplicateChapters: list: %w", err)
|
||||
}
|
||||
|
||||
type record struct {
|
||||
ID string `json:"id"`
|
||||
Number int `json:"number"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
// Group records by chapter number.
|
||||
byNumber := make(map[int][]record)
|
||||
for _, raw := range items {
|
||||
var rec record
|
||||
if err := json.Unmarshal(raw, &rec); err != nil || rec.ID == "" {
|
||||
continue
|
||||
}
|
||||
byNumber[rec.Number] = append(byNumber[rec.Number], rec)
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, recs := range byNumber {
|
||||
if len(recs) <= 1 {
|
||||
continue
|
||||
}
|
||||
// Keep the record with the latest Updated timestamp; delete the rest.
|
||||
keep := 0
|
||||
for i := 1; i < len(recs); i++ {
|
||||
if recs[i].Updated > recs[keep].Updated {
|
||||
keep = i
|
||||
}
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i == keep {
|
||||
continue
|
||||
}
|
||||
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
|
||||
s.log.Warn("DeduplicateChapters: delete failed", "slug", slug, "number", rec.Number, "id", rec.ID, "err", delErr)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// ── BookReader ────────────────────────────────────────────────────────────────
|
||||
|
||||
type pbBook struct {
|
||||
|
||||
@@ -154,7 +154,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
|
||||
}
|
||||
const markdown = await res.text();
|
||||
html = marked(markdown) as string;
|
||||
html = await marked(markdown);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
// Don't hard-fail — show empty content with error message
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
// ── Reader settings panel ────────────────────────────────────────────────
|
||||
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
|
||||
let settingsPanelOpen = $state(false);
|
||||
let settingsTab = $state<'reading' | 'listening'>('reading');
|
||||
|
||||
const READER_THEMES = [
|
||||
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
|
||||
@@ -591,22 +592,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Floating reader settings ─────────────────────────────────────────── -->
|
||||
<!-- ── Reader settings bottom sheet ──────────────────────────────────────── -->
|
||||
{#if settingsCtx}
|
||||
<!-- Backdrop -->
|
||||
{#if settingsPanelOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => (settingsPanelOpen = false)}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Gear button -->
|
||||
<!-- Gear button — sits just above the mini-player (bottom-[4.5rem]) -->
|
||||
<button
|
||||
onclick={() => (settingsPanelOpen = !settingsPanelOpen)}
|
||||
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
|
||||
aria-label="Reader settings"
|
||||
class="fixed bottom-20 right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
|
||||
class="fixed bottom-[4.5rem] right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<svg class="w-5 h-5 {settingsPanelOpen ? 'text-(--color-brand)' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
@@ -614,231 +607,277 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Settings drawer -->
|
||||
<!-- Bottom sheet -->
|
||||
{#if settingsPanelOpen}
|
||||
<div
|
||||
class="fixed bottom-36 right-4 z-50 w-72 bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-2xl p-4 flex flex-col gap-4"
|
||||
>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Reader Settings</p>
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Theme</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each READER_THEMES as t}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) rounded-t-2xl shadow-2xl flex flex-col max-h-[80dvh]">
|
||||
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
||||
<div class="w-10 h-1 rounded-full bg-(--color-border)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-1 mx-4 mb-3 p-1 rounded-xl bg-(--color-surface-3) shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (settingsTab = 'reading')}
|
||||
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
|
||||
{settingsTab === 'reading'
|
||||
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Reading</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (settingsTab = 'listening')}
|
||||
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
|
||||
{settingsTab === 'listening'
|
||||
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Listening</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="overflow-y-auto px-4 pb-6 flex flex-col gap-0">
|
||||
|
||||
{#if settingsTab === 'reading'}
|
||||
|
||||
<!-- ── Typography group ──────────────────────────────────────── -->
|
||||
<div class="mb-1">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
|
||||
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Theme</span>
|
||||
<div class="flex flex-wrap gap-1.5 flex-1">
|
||||
{#each READER_THEMES as t}
|
||||
<button
|
||||
onclick={() => applyTheme(t.id)}
|
||||
title={t.label}
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition-colors
|
||||
{panelTheme === t.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={panelTheme === t.id}
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each READER_FONTS as f}
|
||||
<button
|
||||
onclick={() => applyFont(f.id)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{panelFont === f.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={panelFont === f.id}
|
||||
>{f.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Size -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Size</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each READER_SIZES as s}
|
||||
<button
|
||||
onclick={() => applySize(s.value)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{panelSize === s.value
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={panelSize === s.value}
|
||||
>{s.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Layout group ──────────────────────────────────────────── -->
|
||||
<div class="mt-4 mb-1">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Layout</p>
|
||||
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Read mode -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Mode</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('readMode', mode)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.readMode === mode
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.readMode === mode}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line spacing -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('lineSpacing', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.lineSpacing === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.lineSpacing === s}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Width -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Width</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('readWidth', w)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.readWidth === w
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.readWidth === w}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paragraphs -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Paragraphs</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('paraStyle', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.paraStyle === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.paraStyle === s}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Focus mode -->
|
||||
<button
|
||||
onclick={() => applyTheme(t.id)}
|
||||
title={t.label}
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{panelTheme === t.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={panelTheme === t.id}
|
||||
type="button"
|
||||
onclick={() => setLayout('focusMode', !layout.focusMode)}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{layout.focusMode ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
aria-pressed={layout.focusMode}
|
||||
>
|
||||
<span class="w-2.5 h-2.5 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||
{t.label}
|
||||
<span>Focus mode</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font family -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Font</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each READER_FONTS as f}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
|
||||
<!-- ── Listening tab ──────────────────────────────────────────── -->
|
||||
<div class="mb-1">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Player</p>
|
||||
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Player style -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('playerStyle', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.playerStyle === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.playerStyle === s}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if page.data.user}
|
||||
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-14 shrink-0">Speed</span>
|
||||
<div class="flex gap-1 flex-1">
|
||||
{#each [0.75, 1, 1.25, 1.5, 2] as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.speed = s; }}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{audioStore.speed === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={audioStore.speed === s}
|
||||
>{s}×</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<button
|
||||
onclick={() => applyFont(f.id)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{panelFont === 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={panelFont === f.id}
|
||||
type="button"
|
||||
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{audioStore.autoNext ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
{f.label}
|
||||
<span>Auto-next chapter</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{audioStore.autoNext ? 'On' : 'Off'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text size -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Text size</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each READER_SIZES as s}
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
onclick={() => applySize(s.value)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{panelSize === 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={panelSize === s.value}
|
||||
type="button"
|
||||
onclick={toggleSleepFromSettings}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
>
|
||||
{s.label}
|
||||
<span>Sleep timer</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{sleepSettingsLabel}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
|
||||
<p class="text-[11px] text-(--color-muted)/50 text-center mt-3">Changes save automatically</p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-(--color-border)"></div>
|
||||
|
||||
<!-- Read mode -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Read mode</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('readMode', mode)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.readMode === mode
|
||||
? '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={layout.readMode === mode}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line spacing -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Line spacing</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('lineSpacing', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.lineSpacing === s
|
||||
? '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={layout.lineSpacing === s}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reading width -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Width</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('readWidth', w)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.readWidth === w
|
||||
? '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={layout.readWidth === w}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paragraph style -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Paragraphs</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('paraStyle', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.paraStyle === s
|
||||
? '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={layout.paraStyle === s}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Focus mode -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('focusMode', !layout.focusMode)}
|
||||
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.focusMode
|
||||
? '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={layout.focusMode}
|
||||
>
|
||||
<span>Focus mode</span>
|
||||
<span class="opacity-60 text-xs">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
<!-- ── Listening section ─────────────────────────────────────────── -->
|
||||
<div class="border-t border-(--color-border)"></div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Listening</p>
|
||||
|
||||
<!-- Player style -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Player style</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('playerStyle', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.playerStyle === s
|
||||
? '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={layout.playerStyle === s}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if page.data.user}
|
||||
<!-- Playback speed -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Speed</p>
|
||||
<div class="flex gap-1">
|
||||
{#each [0.75, 1, 1.25, 1.5, 2] as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.speed = s; }}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{audioStore.speed === s
|
||||
? '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={audioStore.speed === s}
|
||||
>{s}×</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
|
||||
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
|
||||
{audioStore.autoNext
|
||||
? '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={audioStore.autoNext}
|
||||
>
|
||||
<span>Auto-next chapter</span>
|
||||
<span class="opacity-60">{audioStore.autoNext ? 'On' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleSleepFromSettings}
|
||||
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
|
||||
{audioStore.sleepUntil || audioStore.sleepAfterChapter
|
||||
? '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)'}"
|
||||
>
|
||||
<span>Sleep timer</span>
|
||||
<span class="opacity-60">{sleepSettingsLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-(--color-muted)/60 text-center">Changes save automatically</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user