<script lang="ts">
	import { enhance } from '$app/forms';
	import { goto } from '$app/navigation';
	import { navigating } from '$app/state';
	import { untrack } from 'svelte';
	import type { PageData, ActionData } from './$types';
	import type { NovelListing } from './+page.server';
	import * as m from '$lib/paraglide/messages.js';

	let { data, form }: { data: PageData; form: ActionData } = $props();

	// ── Local filter state (mirrors URL params) ──────────────────────────────
	let filterSort = $state(untrack(() => data.sort));
	let filterGenre = $state(untrack(() => data.genre));
	let filterStatus = $state(untrack(() => data.status));

	$effect(() => {
		filterSort = data.sort;
		filterGenre = data.genre;
		filterStatus = data.status;
	});

	function applyFilters() {
		const params = new URLSearchParams();
		params.set('sort', filterSort);
		params.set('genre', filterGenre);
		params.set('status', filterStatus);
		params.set('page', '1');
		goto(`/catalogue?${params.toString()}`);
	}

	let loadingSlug = $state<string | null>(null);

	$effect(() => {
		if (!navigating) loadingSlug = null;
	});

	function handleNovelClick(slug: string) {
		loadingSlug = slug;
	}

	// ── Infinite scroll state ────────────────────────────────────────────────
	let novels = $state<NovelListing[]>(untrack(() => data.novels));
	let currentPage = $state(untrack(() => data.page));
	let hasNext = $state(untrack(() => data.hasNext));
	let loadingMore = $state(false);

	let filterKey = $derived(`${data.sort}|${data.genre}|${data.status}|${data.searchQuery}`);
	let lastFilterKey = '';
	$effect(() => {
		if (filterKey !== lastFilterKey) {
			lastFilterKey = filterKey;
			novels = data.novels;
			currentPage = data.page;
			hasNext = data.hasNext;
		}
	});

	async function loadNextPage() {
		if (loadingMore || !hasNext) return;
		if (data.sort === 'rank' || data.searchQuery) return;

		loadingMore = true;
		const nextPage = currentPage + 1;
		try {
			const params = new URLSearchParams({
				page: String(nextPage),
				genre: data.genre,
				sort: data.sort,
				status: data.status
			});
			const res = await fetch(`/api/catalogue-page?${params.toString()}`);
			if (!res.ok) return;
			const body: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json();
			novels = [...novels, ...(body.novels ?? [])];
			currentPage = body.page ?? nextPage;
			hasNext = body.hasNext ?? false;
		} catch {
			// silently ignore — user can scroll again to retry
		} finally {
			loadingMore = false;
		}
	}

	// ── IntersectionObserver sentinel ────────────────────────────────────────
	let sentinel = $state<HTMLDivElement | null>(null);

	$effect(() => {
		if (!sentinel) return;
		const observer = new IntersectionObserver(
			(entries) => {
				if (entries[0].isIntersecting) loadNextPage();
			},
			{ rootMargin: '300px' }
		);
		observer.observe(sentinel);
		return () => observer.disconnect();
	});

	const FALLBACK_GENRES = [
		'action', 'adventure', 'comedy', 'drama', 'fantasy', 'harem',
		'historical', 'horror', 'isekai', 'martial-arts', 'mystery',
		'psychological', 'romance', 'sci-fi', 'system', 'xianxia'
	];
	const genres = $derived([
		{ value: 'all', label: m.catalogue_genre_all() },
		...((data.genres?.length ? data.genres : FALLBACK_GENRES).map((g: string) => ({
			value: g,
			label: g.charAt(0).toUpperCase() + g.slice(1).replace(/-/g, ' ')
		})))
	]);
	const sorts = $derived([
		{ value: 'popular', label: m.catalogue_sort_popular() },
		{ value: 'new', label: m.catalogue_sort_new() },
		{ value: 'update', label: m.catalogue_sort_updated() },
		{ value: 'top-rated', label: m.catalogue_sort_top_rated() },
		{ value: 'rank', label: m.catalogue_sort_rank() }
	]);
	const FALLBACK_STATUSES = ['ongoing', 'completed'];
	const STATUS_LABELS: Record<string, () => string> = {
		ongoing: () => m.catalogue_status_ongoing(),
		completed: () => m.catalogue_status_completed(),
	};
	const statuses = $derived([
		{ value: 'all', label: m.catalogue_status_all() },
		...((data.statuses?.length ? data.statuses : FALLBACK_STATUSES).map((s: string) => ({
			value: s,
			label: STATUS_LABELS[s]?.() ?? (s.charAt(0).toUpperCase() + s.slice(1))
		})))
	]);

	const isRankView = $derived(data.sort === 'rank');
	const isSearchView = $derived(!!data.searchQuery);

	// View toggle: 'grid' | 'list'. Persisted in localStorage.
	const VIEW_KEY = 'libnovel:browse:view';
	function savedView(): 'grid' | 'list' {
		if (data.sort === 'rank') return 'list';
		if (typeof localStorage !== 'undefined') {
			const v = localStorage.getItem(VIEW_KEY);
			if (v === 'grid' || v === 'list') return v;
		}
		return 'grid';
	}
	let view = $state<'grid' | 'list'>(savedView());
	$effect(() => {
		if (data.sort === 'rank' && view === 'grid') view = 'list';
	});
	$effect(() => {
		if (typeof localStorage !== 'undefined' && data.sort !== 'rank') {
			localStorage.setItem(VIEW_KEY, view);
		}
	});

	// Admin: per-novel scrape state (grid view)
	let scraping: Record<string, boolean> = $state({});
	let scrapeResult: Record<string, string> = $state({});

	async function scrapeNovel(novel: NovelListing) {
		scraping[novel.slug] = true;
		scrapeResult[novel.slug] = '';
		try {
			const res = await fetch('/api/scrape', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({ url: novel.url })
			});
			if (res.ok) scrapeResult[novel.slug] = 'queued';
			else if (res.status === 409) scrapeResult[novel.slug] = 'busy';
			else if (res.status === 403) scrapeResult[novel.slug] = 'forbidden';
			else scrapeResult[novel.slug] = 'error';
		} catch {
			scrapeResult[novel.slug] = 'error';
		} finally {
			scraping[novel.slug] = false;
		}
	}

	// Admin: refresh catalogue
	let refreshing = $state(false);

	// ── Collapsible filters panel ────────────────────────────────────────────
	let filtersOpen = $state(false);

	const filterSummary = $derived((() => {
		const parts: string[] = [];
		const sortLabel = sorts.find((s) => s.value === data.sort)?.label ?? data.sort;
		parts.push(sortLabel);
		if (data.genre && data.genre !== 'all') {
			const genreLabel = genres.find((g) => g.value === data.genre)?.label ?? data.genre;
			parts.push(genreLabel);
		}
		if (data.status && data.status !== 'all') {
			const statusLabel = statuses.find((s) => s.value === data.status)?.label ?? data.status;
			parts.push(statusLabel);
		}
		return parts.join(' · ');
	})());

	const hasActiveFilters = $derived(
		(data.genre && data.genre !== 'all') ||
		(data.status && data.status !== 'all') ||
		(data.sort && data.sort !== 'popular')
	);

	// ── Scroll-to-top button ─────────────────────────────────────────────────
	let showScrollTop = $state(false);
	$effect(() => {
		function onScroll() {
			showScrollTop = window.scrollY > 400;
		}
		window.addEventListener('scroll', onScroll, { passive: true });
		return () => window.removeEventListener('scroll', onScroll);
	});

	// ── Audio-available set ───────────────────────────────────────────────────
	let audioSlugs = $state<Set<string>>(new Set());
	let filterAudioOnly = $state(false);

	$effect(() => {
		fetch('/api/audio/slugs')
			.then((r) => r.json())
			.then((d: { slugs: string[] }) => { audioSlugs = new Set(d.slugs ?? []); })
			.catch(() => { /* non-critical */ });
	});

	const displayedNovels = $derived(
		filterAudioOnly ? novels.filter((n) => audioSlugs.has(n.slug)) : novels
	);
</script>

<svelte:head>
	<title>{m.catalogue_page_title()}</title>
</svelte:head>

<!-- Header -->
<div class="mb-4">
	<h1 class="text-2xl font-bold text-(--color-text)">{m.catalogue_heading()}</h1>
	<p class="text-(--color-muted) text-sm mt-1">
		{#if isSearchView}
			{m.catalogue_search_results({ n: String(novels.length), s: novels.length !== 1 ? 's' : '', q: data.searchQuery })}
			{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
				<span class="text-(--color-muted) text-xs ml-1">{m.catalogue_search_local_count({ local: String(data.searchLocalCount), remote: String(data.searchRemoteCount) })}</span>
			{/if}
		{:else if isRankView}
			{#if novels.length > 0}
				{m.catalogue_rank_ranked({ n: String(novels.length) })}
			{:else}
				{m.catalogue_rank_no_data_body()}
			{/if}
		{:else}
			{m.catalogue_browse_source()}
		{/if}
	</p>
</div>

<!-- Admin flash messages -->
{#if form}
	{#if form.status === 'queued'}
		<div class="mb-4 px-4 py-3 rounded bg-(--color-success)/10 border border-(--color-success)/40 text-(--color-success) text-sm">
			{m.catalogue_scrape_queued_flash()}
		</div>
	{:else if form.status === 'busy'}
		<div class="mb-4 px-4 py-3 rounded bg-(--color-brand)/10 border border-(--color-brand)/40 text-(--color-brand) text-sm">
			{m.catalogue_scrape_busy_flash()}
		</div>
	{:else if form.status === 'error'}
		<div class="mb-4 px-4 py-3 rounded bg-(--color-danger)/10 border border-(--color-danger) text-(--color-danger) text-sm">
			{m.catalogue_scrape_error_flash()}
		</div>
	{/if}
{/if}

<!-- Toolbar: search + filter toggle + view toggle + admin refresh -->
<div class="flex gap-2 mb-3">
	<!-- Search (grows to fill available space) -->
	<form method="GET" action="/catalogue" class="flex flex-1 gap-2 min-w-0">
		<input
			type="search"
			name="q"
			value={data.searchQuery}
			placeholder={m.catalogue_search_placeholder()}
			class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) placeholder-zinc-500"
		/>
		<button
			type="submit"
			class="px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors whitespace-nowrap"
		>
			{m.catalogue_search_button()}
		</button>
		{#if data.searchQuery}
			<a
				href="/catalogue"
				class="px-3 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors whitespace-nowrap"
			>
				{m.catalogue_clear_filters()}
			</a>
		{/if}
	</form>

	<!-- Filters toggle button -->
	<button
		type="button"
		onclick={() => (filtersOpen = !filtersOpen)}
		aria-expanded={filtersOpen}
		class="relative flex items-center gap-1.5 px-3 py-2 rounded border text-sm font-medium transition-colors whitespace-nowrap
			{filtersOpen
				? 'bg-(--color-surface-3) border-zinc-500 text-(--color-text)'
				: 'bg-(--color-surface-2) border-(--color-border) text-(--color-text) hover:border-zinc-500 hover:text-(--color-text)'}"
	>
		<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
			<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
				d="M3 4h18M7 8h10M11 12h2M9 16h6" />
		</svg>
		<span class="hidden sm:inline">{m.catalogue_filters_label()}</span>
		{#if hasActiveFilters}
			<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-(--color-brand)"></span>
		{/if}
	</button>

	<!-- View toggle -->
	<div class="flex items-center bg-(--color-surface-2) border border-(--color-border) rounded overflow-hidden shrink-0">
		<button
			onclick={() => (view = 'grid')}
			title={m.catalogue_view_grid()}
			class="px-2.5 py-2 transition-colors {view === 'grid'
				? 'bg-(--color-surface-3) text-(--color-text)'
				: 'text-(--color-muted) hover:text-(--color-text)'}"
		>
			<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
			</svg>
		</button>
		<button
			onclick={() => (view = 'list')}
			title={m.catalogue_view_list()}
			class="px-2.5 py-2 transition-colors {view === 'list'
				? 'bg-(--color-surface-3) text-(--color-text)'
				: 'text-(--color-muted) hover:text-(--color-text)'}"
		>
			<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="M4 6h16M4 10h16M4 14h16M4 18h16" />
			</svg>
		</button>
	</div>

	<!-- Audio-only filter toggle -->
	{#if audioSlugs.size > 0}
		<button
			onclick={() => (filterAudioOnly = !filterAudioOnly)}
			title="Show only books with audio"
			class="flex items-center gap-1.5 px-2.5 py-2 rounded border text-sm transition-colors shrink-0
				{filterAudioOnly
					? 'bg-(--color-brand)/15 border-(--color-brand)/50 text-(--color-brand)'
					: 'bg-(--color-surface-2) border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500'}"
		>
			<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="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M9 10.5a2.5 2.5 0 000 3" />
			</svg>
			<span class="hidden sm:inline text-xs font-medium">Audio</span>
		</button>
	{/if}

	<!-- Admin: refresh catalogue -->
	{#if data.isAdmin}
		<form
			method="POST"
			action="?/refresh"
			use:enhance={() => {
				refreshing = true;
				return async ({ update }) => {
					await update();
					refreshing = false;
				};
			}}
		>
			<button
				type="submit"
				disabled={refreshing}
				class="hidden sm:block px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
			>
				{refreshing ? m.catalogue_refreshing() : m.catalogue_refresh()}
			</button>
		</form>
	{/if}
</div>

<!-- Active filter summary (shown when panel is closed and filters are active) -->
{#if !filtersOpen && hasActiveFilters}
	<p class="text-xs text-(--color-muted) mb-3">
		<span class="text-(--color-muted)">{filterSummary}</span>
		<a href="/catalogue" class="ml-2 text-(--color-muted) hover:text-(--color-muted) underline underline-offset-2">{m.catalogue_clear_filters().toLowerCase()}</a>
	</p>
{/if}

<!-- Collapsible filter panel -->
{#if filtersOpen}
	<!-- Admin refresh (mobile only) -->
	{#if data.isAdmin}
		<form
			method="POST"
			action="?/refresh"
			use:enhance={() => {
				refreshing = true;
				return async ({ update }) => {
					await update();
					refreshing = false;
				};
			}}
			class="sm:hidden mb-2"
		>
			<button
				type="submit"
				disabled={refreshing}
				class="w-full px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
			>
				{refreshing ? m.catalogue_refreshing() : m.catalogue_refresh_mobile()}
			</button>
		</form>
	{/if}

	<form method="GET" action="/catalogue" class="mb-4 p-3 rounded-lg bg-(--color-surface-2)/60 border border-(--color-border) flex flex-col gap-3">
		<input type="hidden" name="page" value="1" />

		<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
			<div class="flex flex-col gap-1">
				<label for="filter-sort" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_sort()}</label>
				<select
					id="filter-sort"
					name="sort"
					bind:value={filterSort}
					class="bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) w-full"
				>
					{#each sorts as s}
						<option value={s.value} selected={s.value === filterSort}>{s.label}</option>
					{/each}
				</select>
			</div>

			<div class="flex flex-col gap-1">
				<label for="filter-genre" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_genre()}</label>
				<select
					id="filter-genre"
					name="genre"
					bind:value={filterGenre}
					disabled={isRankView}
					class="bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) disabled:opacity-40 disabled:cursor-not-allowed w-full"
				>
					{#each genres as g}
						<option value={g.value} selected={g.value === filterGenre}>{g.label}</option>
					{/each}
				</select>
			</div>

			<div class="flex flex-col gap-1">
				<label for="filter-status" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_status()}</label>
				<select
					id="filter-status"
					name="status"
					bind:value={filterStatus}
					disabled={isRankView}
					class="bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) disabled:opacity-40 disabled:cursor-not-allowed w-full"
				>
					{#each statuses as st}
						<option value={st.value} selected={st.value === filterStatus}>{st.label}</option>
					{/each}
				</select>
			</div>
		</div>

		{#if isRankView}
			<p class="text-xs text-(--color-muted) italic">{m.catalogue_filter_rank_note()}</p>
		{/if}

		<div class="flex gap-2 justify-end">
			<a href="/catalogue" class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors">
				{m.catalogue_reset()}
			</a>
			<button
				type="button"
				onclick={applyFilters}
				class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
			>
				{m.catalogue_apply()}
			</button>
		</div>
	</form>
{/if}

<!-- Content -->
{#if displayedNovels.length === 0}
	<div class="text-center py-20 text-(--color-muted)">
		<p class="text-lg">{isSearchView ? m.catalogue_no_results_search() : isRankView ? m.catalogue_rank_no_data() : filterAudioOnly ? 'No books with audio found.' : m.catalogue_no_results()}</p>
		<p class="text-sm mt-2">
			{#if isSearchView}
				{m.catalogue_no_results_try()}
			{:else if isRankView}
				{#if data.isAdmin}
					{m.catalogue_rank_run_scrape_admin()}
				{:else}
					{m.catalogue_rank_run_scrape_user()}
				{/if}
			{:else if filterAudioOnly}
				<button onclick={() => (filterAudioOnly = false)} class="text-(--color-brand) hover:underline">Clear audio filter</button>
			{:else}
				{m.catalogue_no_results_filters()}
			{/if}
		</p>
	</div>

{:else if view === 'grid'}
	<!-- ── Grid view ─────────────────────────────────────────────────────── -->
	<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
		{#each displayedNovels as novel}
			{@const isLoading = loadingSlug === novel.slug}
			<a
				href="/books/{novel.slug}"
				onclick={() => handleNovelClick(novel.slug)}
				class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) border transition-colors relative
					{isLoading ? 'border-(--color-brand)/60' : 'border-(--color-border) hover:border-zinc-500'}"
			>
				<!-- Cover -->
				<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
					{#if novel.cover}
						<img
							src={novel.cover}
							alt={novel.title}
							class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
							loading="lazy"
						/>
					{:else}
						<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
							<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
								<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
									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>
						</div>
					{/if}
					{#if novel.rank}
						<span class="absolute top-1 left-1 text-xs px-1.5 py-0.5 rounded bg-(--color-surface)/80 text-(--color-brand) font-bold">
							{novel.rank}
						</span>
					{/if}
					{#if novel.rating}
						<span class="absolute top-1 right-1 text-xs px-1.5 py-0.5 rounded bg-(--color-surface)/80 text-(--color-text)">
							{novel.rating}
						</span>
					{/if}
					<!-- Audio badge -->
					{#if audioSlugs.has(novel.slug)}
						<span class="absolute bottom-1.5 left-1.5 flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded bg-(--color-brand)/80 text-(--color-surface) font-medium" title="Audio available">
							<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
								<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M9 10.5a2.5 2.5 0 000 3" />
							</svg>
						</span>
					{/if}
					<!-- Loading overlay -->
					{#if isLoading}
						<div class="absolute inset-0 bg-(--color-surface)/70 flex items-center justify-center">
							<svg class="w-8 h-8 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
								<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
								<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
							</svg>
						</div>
					{/if}
				</div>

				<!-- Info -->
				<div class="p-2 flex flex-col gap-1 flex-1">
					<h2 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{novel.title}</h2>
					{#if novel.author}
						<p class="text-xs text-(--color-muted) truncate">{novel.author}</p>
					{:else if novel.chapters}
						<p class="text-xs text-(--color-muted) truncate">{novel.chapters}</p>
					{/if}

					<!-- Admin: per-novel scrape button -->
					{#if data.isAdmin && novel.url}
						<div class="mt-auto pt-1">
							{#if scrapeResult[novel.slug] === 'queued'}
								<span class="text-xs text-emerald-400 font-medium">{m.catalogue_scrape_queued_badge()}</span>
							{:else if scrapeResult[novel.slug] === 'busy'}
								<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_badge()}</span>
							{:else if scrapeResult[novel.slug] === 'forbidden'}
								<span class="text-xs text-(--color-danger) font-medium">{m.catalogue_scrape_forbidden_badge()}</span>
							{:else if scrapeResult[novel.slug] === 'error'}
								<span class="text-xs text-(--color-danger) font-medium">{m.common_error()}</span>
							{:else}
								<button
									onclick={(e) => { e.preventDefault(); scrapeNovel(novel); }}
									disabled={scraping[novel.slug]}
									class="w-full text-xs px-2 py-1 rounded bg-(--color-brand) text-(--color-surface) font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
								>
									{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
								</button>
							{/if}
						</div>
					{/if}
				</div>
			</a>
		{/each}
	</div>

{:else}
	<!-- ── List view ─────────────────────────────────────────────────────── -->
	<div class="flex flex-col gap-2">
		{#each displayedNovels as novel}
			{@const isLoading = loadingSlug === novel.slug}
			<div
				class="flex items-center gap-4 bg-(--color-surface-2) border rounded-lg px-4 py-3 transition-colors
					{isLoading ? 'border-(--color-brand)/60' : 'border-(--color-border) hover:border-zinc-500'}"
			>
				<!-- Rank / index -->
				{#if novel.rank}
					<span class="text-(--color-brand) font-bold text-sm w-8 shrink-0 text-right">{novel.rank}</span>
				{/if}

				<!-- Cover thumbnail -->
				<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-(--color-surface) relative">
					{#if novel.cover}
						<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
					{:else}
						<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
							<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
								<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
									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>
						</div>
					{/if}
					{#if isLoading}
						<div class="absolute inset-0 bg-(--color-surface)/70 flex items-center justify-center">
							<svg class="w-4 h-4 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
								<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
								<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
							</svg>
						</div>
					{/if}
				</div>

				<!-- Title + meta -->
				<div class="flex-1 min-w-0">
					{#if novel.slug}
						<a
							href="/books/{novel.slug}"
							onclick={() => handleNovelClick(novel.slug)}
							class="text-sm font-semibold transition-colors line-clamp-1
								{isLoading ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
						>
							{novel.title}
						</a>
					{:else}
						<span class="text-sm font-semibold text-(--color-text) line-clamp-1">{novel.title}</span>
					{/if}
					<div class="flex items-center gap-2 mt-0.5 flex-wrap">
						{#if novel.author}
							<span class="text-xs text-(--color-muted)">{novel.author}</span>
						{/if}
						{#if novel.status}
							<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-text)">{novel.status}</span>
						{:else if novel.chapters}
							<span class="text-xs text-(--color-muted)">{novel.chapters}</span>
						{/if}
						{#if novel.rating}
							<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">★ {novel.rating}</span>
						{/if}
						{#if audioSlugs.has(novel.slug)}
							<span class="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 font-medium">
								<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M9 10.5a2.5 2.5 0 000 3" />
								</svg>
								Audio
							</span>
						{/if}
						{#if novel.genres?.length}
							{#each novel.genres.slice(0, 3) as genre}
								<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
							{/each}
						{/if}
					</div>
				</div>

				<!-- Admin: per-novel scrape button (list view) -->
				{#if data.isAdmin && novel.url}
					<div class="shrink-0">
						{#if scrapeResult[novel.slug] === 'queued'}
							<span class="text-xs text-emerald-400 font-medium">{m.catalogue_scrape_queued_badge()}</span>
						{:else if scrapeResult[novel.slug] === 'busy'}
							<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_list()}</span>
						{:else if scrapeResult[novel.slug] === 'error'}
							<span class="text-xs text-(--color-danger) font-medium">{m.common_error()}</span>
						{:else}
							<button
								onclick={() => scrapeNovel(novel)}
								disabled={scraping[novel.slug]}
								class="text-xs px-2.5 py-1 rounded bg-(--color-brand) text-(--color-surface) font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
							>
								{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
							</button>
						{/if}
					</div>
				{/if}

				<!-- External link -->
				{#if novel.source_url || novel.url}
					<a
						href={novel.source_url ?? novel.url}
						target="_blank"
						rel="noopener noreferrer"
						class="shrink-0 text-(--color-muted) hover:text-(--color-text) transition-colors"
						title="Open on novelfire.net"
					>
						<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
						</svg>
					</a>
				{/if}
			</div>
		{/each}
	</div>
{/if}

<!-- Infinite scroll sentinel (browse mode only — not rank, not search) -->
{#if !isRankView && !isSearchView}
	{#if hasNext}
		<div bind:this={sentinel} class="h-px mt-8"></div>
	{/if}

	{#if loadingMore}
		<div class="flex justify-center py-8">
			<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
				<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
				<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
			</svg>
		</div>
	{:else if !hasNext && novels.length > 0}
		<p class="text-center text-(--color-muted) text-xs mt-8 pb-4">{m.catalogue_all_loaded()}</p>
	{/if}
{/if}

<!-- Scroll-to-top button -->
{#if showScrollTop}
	<button
		onclick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
		class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-text) shadow-lg hover:bg-(--color-surface-3) hover:text-(--color-text) transition-colors"
		title={m.catalogue_scroll_top()}
		aria-label={m.catalogue_scroll_top()}
	>
		<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="M5 15l7-7 7 7" />
		</svg>
	</button>
{/if}
