feat: universal search modal
- New SearchModal.svelte: full-screen modal with blurred backdrop
- Live results as you type (300ms debounce, min 2 chars)
- Local vs Novelfire badge on each result card (cover + title + author +
genres + chapter count)
- Local/remote counts shown in result header
- 'See all in catalogue' shortcut button + footer repeat link
- Recent searches (localStorage, max 8, per-item remove + clear all)
- Genre suggestion chips shown when query is empty or no results found
- Keyboard navigation: ArrowUp/Down to select, Enter to open, Escape to close
- Body scroll lock while open
- +layout.svelte:
- Imports SearchModal, adds searchOpen state
- Search icon button in nav header (hidden on chapter reader pages)
- Global keyboard shortcut: '/' or Cmd/Ctrl+K opens modal
- Shortcut ignored when focused in input/textarea or on chapter pages
- Modal not shown while ListeningMode is open
- Auto-closes on route change
This commit is contained in:
437
ui/src/lib/components/SearchModal.svelte
Normal file
437
ui/src/lib/components/SearchModal.svelte
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
let { onclose }: Props = $props();
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
interface SearchResult {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
cover?: string;
|
||||||
|
author?: string;
|
||||||
|
genres?: string[];
|
||||||
|
status?: string;
|
||||||
|
chapters?: string; // e.g. "42 chapters"
|
||||||
|
url?: string; // novelfire source url — present for remote results
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
results: SearchResult[];
|
||||||
|
local_count: number;
|
||||||
|
remote_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
const RECENTS_KEY = 'search_recents_v1';
|
||||||
|
const MAX_RECENTS = 8;
|
||||||
|
|
||||||
|
function loadRecents(): string[] {
|
||||||
|
if (!browser) return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENTS_KEY);
|
||||||
|
if (raw) return JSON.parse(raw) as string[];
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRecents(list: string[]) {
|
||||||
|
if (!browser) return;
|
||||||
|
try { localStorage.setItem(RECENTS_KEY, JSON.stringify(list)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let recents = $state<string[]>(loadRecents());
|
||||||
|
let query = $state('');
|
||||||
|
let results = $state<SearchResult[]>([]);
|
||||||
|
let localCount = $state(0);
|
||||||
|
let remoteCount = $state(0);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
// For keyboard navigation through results
|
||||||
|
let selectedIdx = $state(-1);
|
||||||
|
|
||||||
|
// Input element ref for autofocus
|
||||||
|
let inputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// ── Autofocus + body scroll lock ──────────────────────────────────────────
|
||||||
|
$effect(() => {
|
||||||
|
if (inputEl) inputEl.focus();
|
||||||
|
if (browser) {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Keyboard shortcuts (global): Escape closes ────────────────────────────
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { onclose(); return; }
|
||||||
|
|
||||||
|
const total = visibleResults.length;
|
||||||
|
if (total === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIdx = (selectedIdx + 1) % total;
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIdx = (selectedIdx - 1 + total) % total;
|
||||||
|
} else if (e.key === 'Enter' && selectedIdx >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateTo(visibleResults[selectedIdx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Debounced search ──────────────────────────────────────────────────────
|
||||||
|
let debounceTimer = 0;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const q = query.trim();
|
||||||
|
selectedIdx = -1;
|
||||||
|
|
||||||
|
if (q.length < 2) {
|
||||||
|
results = [];
|
||||||
|
localCount = 0;
|
||||||
|
remoteCount = 0;
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data: SearchResponse = await res.json();
|
||||||
|
results = data.results ?? [];
|
||||||
|
localCount = data.local_count ?? 0;
|
||||||
|
remoteCount = data.remote_count ?? 0;
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Search failed. Please try again.';
|
||||||
|
results = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}, 300) as unknown as number;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Results visible in the list — same as results (no client-side filter needed)
|
||||||
|
const visibleResults = $derived(results);
|
||||||
|
|
||||||
|
// ── Genre suggestions shown when query is empty ───────────────────────────
|
||||||
|
const GENRE_SUGGESTIONS = [
|
||||||
|
'Fantasy', 'Action', 'Romance', 'Cultivation', 'System',
|
||||||
|
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Navigation helpers ────────────────────────────────────────────────────
|
||||||
|
function navigateTo(r: SearchResult) {
|
||||||
|
pushRecent(query.trim());
|
||||||
|
goto(`/books/${r.slug}`);
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchGenre(genre: string) {
|
||||||
|
goto(`/catalogue?genre=${encodeURIComponent(genre)}`);
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitQuery() {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
pushRecent(q);
|
||||||
|
goto(`/catalogue?q=${encodeURIComponent(q)}`);
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recent searches ───────────────────────────────────────────────────────
|
||||||
|
function pushRecent(q: string) {
|
||||||
|
if (!q || q.length < 2) return;
|
||||||
|
const next = [q, ...recents.filter(r => r.toLowerCase() !== q.toLowerCase())].slice(0, MAX_RECENTS);
|
||||||
|
recents = next;
|
||||||
|
saveRecents(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRecent(q: string) {
|
||||||
|
const next = recents.filter(r => r !== q);
|
||||||
|
recents = next;
|
||||||
|
saveRecents(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllRecents() {
|
||||||
|
recents = [];
|
||||||
|
saveRecents([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRecent(q: string) {
|
||||||
|
query = q;
|
||||||
|
if (inputEl) inputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
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 []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRemote = (r: SearchResult) => r.url != null && r.url.includes('novelfire');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte:window for global keyboard handling -->
|
||||||
|
<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(); }}
|
||||||
|
>
|
||||||
|
<!-- Modal panel — slides down from top -->
|
||||||
|
<!-- 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; sm:max-height: calc(100svh - 8rem);"
|
||||||
|
onpointerdown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<!-- ── Search input row ──────────────────────────────────────────────── -->
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||||
|
<!-- Search icon -->
|
||||||
|
{#if loading}
|
||||||
|
<svg class="w-5 h-5 text-(--color-brand) animate-spin shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={query}
|
||||||
|
type="search"
|
||||||
|
placeholder="Search books, authors, genres…"
|
||||||
|
class="flex-1 bg-transparent text-(--color-text) placeholder:text-(--color-muted) text-base focus:outline-none min-w-0"
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submitQuery(); } }}
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if query}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { query = ''; inputEl?.focus(); }}
|
||||||
|
class="shrink-0 p-1 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<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 search"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Scrollable body ───────────────────────────────────────────────── -->
|
||||||
|
<div class="flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
|
||||||
|
<!-- ── Error state ─────────────────────────────────────────────── -->
|
||||||
|
{#if error}
|
||||||
|
<p class="px-5 py-8 text-sm text-center text-(--color-danger)">{error}</p>
|
||||||
|
|
||||||
|
<!-- ── Results ─────────────────────────────────────────────────── -->
|
||||||
|
{:else if visibleResults.length > 0}
|
||||||
|
<!-- Result count + "see all" hint -->
|
||||||
|
<div class="flex items-center justify-between px-4 pt-3 pb-1.5">
|
||||||
|
<p class="text-xs text-(--color-muted)">
|
||||||
|
{#if localCount > 0 && remoteCount > 0}
|
||||||
|
<span class="text-(--color-text) font-medium">{localCount}</span> in library
|
||||||
|
· <span class="text-(--color-text) font-medium">{remoteCount}</span> from Novelfire
|
||||||
|
{:else if localCount > 0}
|
||||||
|
<span class="text-(--color-text) font-medium">{localCount}</span> in library
|
||||||
|
{:else}
|
||||||
|
<span class="text-(--color-text) font-medium">{remoteCount}</span> from Novelfire
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<!-- "All results in catalogue" shortcut -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={submitQuery}
|
||||||
|
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||||
|
>
|
||||||
|
See all in catalogue →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each visibleResults as r, i}
|
||||||
|
{@const genres = parseGenres(r.genres)}
|
||||||
|
{@const remote = isRemote(r)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => navigateTo(r)}
|
||||||
|
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',
|
||||||
|
selectedIdx === i ? 'bg-(--color-surface-2)' : '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)">
|
||||||
|
{#if r.cover}
|
||||||
|
<img src={r.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="text-sm font-semibold text-(--color-text) leading-snug line-clamp-1 flex-1">
|
||||||
|
{r.title}
|
||||||
|
</p>
|
||||||
|
{#if remote}
|
||||||
|
<span class="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) leading-none mt-0.5">
|
||||||
|
Novelfire
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if r.author}
|
||||||
|
<p class="text-xs text-(--color-muted) mt-0.5 truncate">{r.author}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||||
|
{#if r.chapters}
|
||||||
|
<span class="text-xs text-(--color-muted)/60">{r.chapters}</span>
|
||||||
|
{/if}
|
||||||
|
{#if r.status}
|
||||||
|
<span class="text-xs text-(--color-muted)/60 capitalize">{r.status}</span>
|
||||||
|
{/if}
|
||||||
|
{#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 -->
|
||||||
|
<svg class="w-4 h-4 text-(--color-muted)/40 shrink-0" 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}
|
||||||
|
|
||||||
|
<!-- "See all results" footer button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={submitQuery}
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-4 py-4 text-sm text-(--color-brand) hover:bg-(--color-surface-2) transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
See all results for "{query.trim()}"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ── No results ───────────────────────────────────────────────── -->
|
||||||
|
{:else if query.trim().length >= 2 && !loading}
|
||||||
|
<div class="px-5 py-10 text-center">
|
||||||
|
<p class="text-sm font-semibold text-(--color-text) mb-1">No results for "{query.trim()}"</p>
|
||||||
|
<p class="text-xs text-(--color-muted) mb-5">Try a different title, author, or browse by genre below.</p>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center">
|
||||||
|
{#each GENRE_SUGGESTIONS as genre}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => searchGenre(genre)}
|
||||||
|
class="px-3 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/50 transition-colors"
|
||||||
|
>
|
||||||
|
{genre}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Empty state (query too short or empty) ───────────────────── -->
|
||||||
|
{:else if query.trim().length === 0}
|
||||||
|
<!-- Recent searches -->
|
||||||
|
{#if recents.length > 0}
|
||||||
|
<div class="px-4 pt-4 pb-2">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Recent</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clearAllRecents}
|
||||||
|
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#each recents as r}
|
||||||
|
<div class="flex items-center gap-2 group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => applyRecent(r)}
|
||||||
|
class="flex-1 flex items-center gap-2.5 px-1 py-2 rounded-lg text-sm text-(--color-text) hover:bg-(--color-surface-2) transition-colors text-left"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 text-(--color-muted)/50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeRecent(r)}
|
||||||
|
class="shrink-0 p-1 rounded text-(--color-muted)/40 hover:text-(--color-muted) opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
<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="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="mx-4 my-2 border-t border-(--color-border)/60"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Genre suggestions -->
|
||||||
|
<div class="px-4 pt-3 pb-5">
|
||||||
|
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider mb-3">Browse by genre</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each GENRE_SUGGESTIONS as genre}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => searchGenre(genre)}
|
||||||
|
class="px-3 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/50 transition-colors"
|
||||||
|
>
|
||||||
|
{genre}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
||||||
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
||||||
|
import SearchModal from '$lib/components/SearchModal.svelte';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||||
@@ -19,6 +20,15 @@
|
|||||||
// Mobile nav drawer state
|
// Mobile nav drawer state
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
|
|
||||||
|
// Universal search
|
||||||
|
let searchOpen = $state(false);
|
||||||
|
|
||||||
|
// Close search on navigation
|
||||||
|
$effect(() => {
|
||||||
|
void page.url.pathname;
|
||||||
|
searchOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
// Desktop dropdown menus
|
// Desktop dropdown menus
|
||||||
let userMenuOpen = $state(false);
|
let userMenuOpen = $state(false);
|
||||||
let langMenuOpen = $state(false);
|
let langMenuOpen = $state(false);
|
||||||
@@ -434,6 +444,20 @@
|
|||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<!-- Universal search button (hidden on chapter/reader pages) -->
|
||||||
|
{#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
|
||||||
|
title="Search (/ or ⌘K)"
|
||||||
|
aria-label="Search books"
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<!-- Theme dropdown (desktop) -->
|
<!-- Theme dropdown (desktop) -->
|
||||||
<div class="hidden sm:block relative">
|
<div class="hidden sm:block relative">
|
||||||
<button
|
<button
|
||||||
@@ -961,3 +985,22 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Universal search modal — shown from anywhere except focus mode / listening mode -->
|
||||||
|
{#if searchOpen && !listeningModeOpen}
|
||||||
|
<SearchModal onclose={() => { searchOpen = false; }} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(e) => {
|
||||||
|
// Don't intercept when typing in an input/textarea
|
||||||
|
const tag = (e.target as HTMLElement).tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) return;
|
||||||
|
// Don't open on chapter reader pages
|
||||||
|
if (/\/books\/[^/]+\/chapters\//.test(page.url.pathname)) return;
|
||||||
|
if (searchOpen) return;
|
||||||
|
// `/` key or Cmd/Ctrl+K
|
||||||
|
if (e.key === '/' || ((e.metaKey || e.ctrlKey) && e.key === 'k')) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchOpen = true;
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
|||||||
Reference in New Issue
Block a user