Compare commits

...

3 Commits

Author SHA1 Message Date
Admin
28fee7aee3 feat: fix recently-updated section + hideable home sections
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m28s
Release / Docker / runner (push) Successful in 2m28s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Fix "Recently Updated" showing stale books: replace meta_updated
sorting (only changes on metadata writes) with chapters_idx sorted
by -created, so the section now reflects actual chapter activity.

Add per-section show/hide toggles on the home page, persisted in
localStorage via Svelte 5 $state. Each section header gets a small
hide button; hidden sections appear as restore chips above the footer.
Toggleable: Recently Updated, Browse by Genre, From Following.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:09:48 +05:00
Admin
a888d9a0f5 fix: clean up book detail mobile layout + make genre tags linkable
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 49s
Release / Docker / caddy (push) Successful in 53s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m22s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Mobile action area was cluttered with Continue, Start ch.1, bookmark,
stars, and shelf dropdown all competing in a single unstructured row.

- Split mobile CTAs into two clear rows:
  Row 1: primary read button(s) only (Continue / Start from ch.1)
  Row 2: bookmark icon + shelf dropdown + star rating inline
- 'Start from ch.1' no longer stretches to flex-1 when Continue is
  present — it's a compact secondary button instead
- Stars and shelf dropdown moved out of the CTA row into their own line

Genre tags were plain <span> elements with no interaction. Tapping
'fantasy' or 'action' now navigates to /catalogue?genre=fantasy,
pre-selecting the genre filter on the catalogue page.
2026-04-03 21:34:38 +05:00
Admin
ac7b686fba fix: don't save settings immediately after login
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m34s
Release / Docker / ui (push) Successful in 1m56s
Release / Gitea Release (push) Successful in 20s
The save-settings $effect was firing on the initial data load because
settingsApplied was set to true synchronously in the apply effect, then
currentTheme/fontFamily/fontSize were written in the same tick — causing
the save effect to immediately fire with uninitialized default values
(theme: "", fontFamily: "", fontSize: 0), producing a 400 error.

- Add settingsDirty flag, set via setTimeout(0) after initial apply so
  the save effect is blocked for the first load and only runs on real
  user-driven changes
- Also accept empty string / 0 as 'not provided' in PUT /api/settings
  validation as a defensive backstop
2026-04-03 21:07:01 +05:00
6 changed files with 215 additions and 88 deletions

View File

@@ -299,7 +299,8 @@ export async function invalidateBooksCache(): Promise<void> {
await Promise.all([
cache.invalidate(BOOKS_CACHE_KEY),
cache.invalidate(HOME_STATS_CACHE_KEY),
cache.invalidatePattern('books:recent:*')
cache.invalidatePattern('books:recent:*'),
cache.invalidatePattern('books:recently-updated:*')
]);
}
@@ -312,10 +313,46 @@ export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, '', '-meta_updated');
await cache.set(key, books, 5 * 60); // 5 minutes
await cache.set(key, books, 5 * 60);
return books;
}
/**
* Books with the most recently added chapters, ordered by chapter insertion time.
* Queries chapters_idx sorted by -created, deduplicates by slug, then loads books.
* This correctly reflects actual chapter activity, unlike meta_updated on books.
*/
export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
const key = `books:recently-updated:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
// Fetch enough recent chapter rows to find `limit` distinct books
const rows = await listN<{ slug: string; created: string }>(
'chapters_idx', limit * 25, '', '-created'
);
const seen = new Set<string>();
const slugs: string[] = [];
for (const row of rows) {
if (!seen.has(row.slug)) {
seen.add(row.slug);
slugs.push(row.slug);
if (slugs.length >= limit) break;
}
}
if (!slugs.length) return [];
const books = await getBooksBySlugs(new Set(slugs));
// Restore recency order (getBooksBySlugs returns in title sort order)
const bookMap = new Map(books.map((b) => [b.slug, b]));
const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));
await cache.set(key, ordered, 5 * 60);
return ordered;
}
export interface HomeStats {
totalBooks: number;
totalChapters: number;

View File

@@ -88,6 +88,7 @@
// Apply persisted settings once on mount (server-loaded data).
// Use a derived to react to future invalidateAll() re-loads too.
let settingsApplied = false;
let settingsDirty = false; // true only after the first apply completes
$effect(() => {
if (data.settings) {
if (!settingsApplied) {
@@ -100,6 +101,9 @@
currentTheme = data.settings.theme ?? 'amber';
currentFontFamily = data.settings.fontFamily ?? 'system';
currentFontSize = data.settings.fontSize ?? 1.0;
// Mark dirty only after the synchronous apply is done so the save
// effect doesn't fire for this initial load.
setTimeout(() => { settingsDirty = true; }, 0);
}
});
@@ -114,8 +118,9 @@
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
// Skip saving until settings have been applied from the server
if (!settingsApplied) return;
// Skip saving until settings have been applied from the server AND
// at least one user-driven change has occurred after that.
if (!settingsDirty) return;
clearTimeout(settingsSaveTimer);
settingsSaveTimer = setTimeout(() => {

View File

@@ -1,7 +1,7 @@
import type { PageServerLoad } from './$types';
import {
getBooksBySlugs,
recentlyAddedBooks,
recentlyUpdatedBooks,
allProgress,
getHomeStats,
getSubscriptionFeed
@@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ locals }) => {
try {
[recentBooks, progressList, stats] = await Promise.all([
recentlyAddedBooks(8),
recentlyUpdatedBooks(8),
allProgress(locals.sessionId, locals.user?.id),
getHomeStats()
]);

View File

@@ -1,9 +1,47 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
// ── Section visibility (localStorage, Svelte 5 runes) ────────────────────────
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following';
const SECTIONS_KEY = 'home_sections_v1';
const SECTION_LABELS: Record<SectionId, string> = {
'recently-updated': 'Recently Updated',
'browse-genre': 'Browse by Genre',
'from-following': 'From Following',
};
function loadHidden(): Set<SectionId> {
if (!browser) return new Set();
try {
const raw = localStorage.getItem(SECTIONS_KEY);
if (raw) return new Set(JSON.parse(raw) as SectionId[]);
} catch { /* ignore */ }
return new Set();
}
let hidden = $state<Set<SectionId>>(loadHidden());
function hide(id: SectionId) {
hidden = new Set([...hidden, id]);
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...hidden]));
}
function restore(id: SectionId) {
const next = new Set(hidden);
next.delete(id);
hidden = next;
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...next]));
}
const hiddenList = $derived(
(Object.keys(SECTION_LABELS) as SectionId[]).filter((id) => hidden.has(id))
);
function parseGenres(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
@@ -121,10 +159,19 @@
{/if}
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
{#if !hidden.has('browse-genre')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('browse-genre')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
{#each GENRES as genre}
@@ -135,13 +182,22 @@
{/each}
</div>
</section>
{/if}
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
{#if dedupedRecent.length > 0}
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('recently-updated')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each dedupedRecent as { book, count }}
@@ -182,10 +238,16 @@
{/if}
<!-- ── From Following ────────────────────────────────────────────────────────── -->
{#if data.subscriptionFeed.length > 0}
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
<button type="button" onclick={() => hide('from-following')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.subscriptionFeed as { book, readerUsername }}
@@ -221,6 +283,23 @@
</div>
{/if}
<!-- ── Hidden sections restore ───────────────────────────────────────────────── -->
{#if hiddenList.length > 0}
<div class="mb-6 flex flex-wrap items-center gap-2">
<span class="text-xs text-(--color-muted)">Hidden:</span>
{#each hiddenList as id}
<button type="button" onclick={() => restore(id)}
class="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full border border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 transition-colors">
<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 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{SECTION_LABELS[id]}
</button>
{/each}
</div>
{/if}
<!-- ── Stats footer ──────────────────────────────────────────────────────────── -->
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted)">
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>

View File

@@ -43,27 +43,27 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, 'Invalid body — expected { autoNext, voice, speed }');
}
// theme is optional — if provided it must be a known value
// theme is optional — if provided (and non-empty) it must be a known value
const validThemes = ['amber', 'slate', 'rose', 'light', 'light-slate', 'light-rose'];
if (body.theme !== undefined && !validThemes.includes(body.theme)) {
if (body.theme !== undefined && body.theme !== '' && !validThemes.includes(body.theme)) {
error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`);
}
// locale is optional — if provided it must be a known value
// locale is optional — if provided (and non-empty) it must be a known value
const validLocales = ['en', 'ru', 'id', 'pt', 'fr'];
if (body.locale !== undefined && !validLocales.includes(body.locale)) {
if (body.locale !== undefined && body.locale !== '' && !validLocales.includes(body.locale)) {
error(400, `Invalid locale — must be one of: ${validLocales.join(', ')}`);
}
// fontFamily is optional — if provided it must be a known value
// fontFamily is optional — if provided (and non-empty) it must be a known value
const validFontFamilies = ['system', 'serif', 'mono'];
if (body.fontFamily !== undefined && !validFontFamilies.includes(body.fontFamily)) {
if (body.fontFamily !== undefined && body.fontFamily !== '' && !validFontFamilies.includes(body.fontFamily)) {
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
}
// fontSize is optional — if provided it must be one of the valid steps
// fontSize is optional — if provided (and non-zero) it must be one of the valid steps
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
if (body.fontSize !== undefined && body.fontSize !== 0 && !validFontSizes.includes(body.fontSize)) {
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
}

View File

@@ -234,7 +234,10 @@
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) border border-(--color-border)">{book.status}</span>
{/if}
{#each genres as genre}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{genre}</span>
<a
href="/catalogue?genre={encodeURIComponent(genre)}"
class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-text) transition-colors"
>{genre}</a>
{/each}
{#if data.readersThisWeek && data.readersThisWeek > 0}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) flex items-center gap-1">
@@ -348,65 +351,82 @@
</div>
<!-- CTA buttons — mobile only -->
<div class="flex sm:hidden gap-2 items-center">
{#if data.lastChapter}
<a
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
>
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
</a>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{book.slug}/chapters/1"
class="flex-1 text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
>
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) 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="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
>
{#if saving}
<svg class="w-4 h-4 animate-spin" 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>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<div class="flex sm:hidden flex-col gap-2 mt-3">
<!-- Row 1: primary read button(s) -->
<div class="flex gap-2">
{#if data.lastChapter}
<a
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
>
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
</a>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{book.slug}/chapters/1"
class="text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
: 'flex-1 bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
>
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
</div>
<!-- Row 2: bookmark + shelf + stars -->
<div class="flex items-center gap-2 flex-wrap">
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-9 h-9 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
>
<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="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
class="flex items-center justify-center w-9 h-9 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
>
{#if saving}
<svg class="w-4 h-4 animate-spin" 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>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<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="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
{#if saved}
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer flex-shrink-0"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
{/if}
<!-- Ratings + shelf — mobile -->
<div class="flex sm:hidden items-center gap-3 flex-wrap mt-1">
<StarRating
rating={userRating}
avg={ratingAvg.avg}
@@ -414,20 +434,6 @@
onrate={rate}
size="sm"
/>
{#if saved}
<div class="relative">
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
</div>
{/if}
</div>
</div>
</div>