Compare commits
6 Commits
a0e705beec
...
v2.6.88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed9eeb6262 | ||
|
|
e6f7f7297d | ||
|
|
93cc0b6eb0 | ||
|
|
6af5a4966f | ||
|
|
14388e8186 | ||
|
|
5cebbb1692 |
@@ -233,6 +233,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
var allResults []proposedChapterTitle
|
||||
chaptersDone := resumeFrom
|
||||
firstEvent := true
|
||||
for i, batch := range batches {
|
||||
@@ -287,6 +288,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
NewTitle: p.Title,
|
||||
})
|
||||
}
|
||||
allResults = append(allResults, result...)
|
||||
chaptersDone += len(batch)
|
||||
|
||||
if jobID != "" && s.deps.AIJobStore != nil {
|
||||
@@ -310,16 +312,20 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
sseWrite(evt)
|
||||
}
|
||||
|
||||
// Mark job as done in PB.
|
||||
// Mark job as done in PB, persisting results so the Review button works.
|
||||
if jobID != "" && s.deps.AIJobStore != nil {
|
||||
status := domain.TaskStatusDone
|
||||
if jobCtx.Err() != nil {
|
||||
status = domain.TaskStatusCancelled
|
||||
}
|
||||
resultsJSON, _ := json.Marshal(allResults)
|
||||
finalPayload := fmt.Sprintf(`{"pattern":%q,"slug":%q,"results":%s}`,
|
||||
req.Pattern, req.Slug, string(resultsJSON))
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||
"status": string(status),
|
||||
"items_done": chaptersDone,
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
"payload": finalPayload,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { Voice } from '$lib/types';
|
||||
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
|
||||
|
||||
@@ -229,6 +230,7 @@
|
||||
<!-- Full-screen listening mode overlay -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
transition:fly={{ y: '100%', duration: 320, opacity: 1 }}
|
||||
bind:this={overlayEl}
|
||||
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
|
||||
style="
|
||||
|
||||
@@ -223,26 +223,13 @@
|
||||
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"
|
||||
class="flex-1 bg-transparent text-(--color-text) placeholder:text-(--color-muted) text-base focus:outline-none min-w-0 [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
||||
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}
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface Book {
|
||||
source_url: string;
|
||||
ranking: number;
|
||||
meta_updated: string;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface ChapterIdx {
|
||||
@@ -659,11 +660,16 @@ function libraryFilter(sessionId: string, userId?: string): string {
|
||||
|
||||
/** Returns all slugs the user has explicitly saved to their library. */
|
||||
export async function getSavedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const cacheKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`;
|
||||
const cached = await cache.get<string[]>(cacheKey);
|
||||
if (cached) return new Set(cached);
|
||||
const rows = await listAll<UserLibraryEntry>(
|
||||
'user_library',
|
||||
libraryFilter(sessionId, userId)
|
||||
);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
const slugs = rows.map((r) => r.slug);
|
||||
await cache.set(cacheKey, slugs, SAVED_SLUGS_TTL);
|
||||
return new Set(slugs);
|
||||
}
|
||||
|
||||
/** Returns whether a specific slug is saved. */
|
||||
@@ -710,7 +716,11 @@ export async function saveBook(
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body });
|
||||
return;
|
||||
}
|
||||
// Invalidate saved-slugs cache so the next discover load excludes this book.
|
||||
const savedKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`;
|
||||
await cache.invalidate(savedKey);
|
||||
}
|
||||
|
||||
/** Remove a book from the user's library. */
|
||||
@@ -2151,12 +2161,27 @@ function discoveryFilter(sessionId: string, userId?: string): string {
|
||||
return `session_id="${sessionId}"`;
|
||||
}
|
||||
|
||||
/** Cache TTL (seconds) for per-user voted/saved slug sets. Short — changes on every swipe. */
|
||||
const VOTED_SLUGS_TTL = 30;
|
||||
const SAVED_SLUGS_TTL = 60;
|
||||
|
||||
export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const cacheKey = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`;
|
||||
const cached = await cache.get<string[]>(cacheKey);
|
||||
if (cached) return new Set(cached);
|
||||
const rows = await listAll<DiscoveryVote>(
|
||||
'discovery_votes',
|
||||
discoveryFilter(sessionId, userId)
|
||||
).catch(() => [] as DiscoveryVote[]);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
const slugs = rows.map((r) => r.slug);
|
||||
await cache.set(cacheKey, slugs, VOTED_SLUGS_TTL);
|
||||
return new Set(slugs);
|
||||
}
|
||||
|
||||
/** Invalidate the voted-slugs cache entry after a vote is recorded. */
|
||||
async function invalidateVotedSlugsCache(sessionId: string, userId?: string): Promise<void> {
|
||||
const key = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`;
|
||||
await cache.invalidate(key);
|
||||
}
|
||||
|
||||
export async function upsertDiscoveryVote(
|
||||
@@ -2179,6 +2204,7 @@ export async function upsertDiscoveryVote(
|
||||
const res = await pbPost('/api/collections/discovery_votes/records', payload);
|
||||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
|
||||
}
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
|
||||
@@ -2189,6 +2215,7 @@ export async function clearDiscoveryVotes(sessionId: string, userId?: string): P
|
||||
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
|
||||
)
|
||||
);
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
// ─── Ratings ──────────────────────────────────────────────────────────────────
|
||||
@@ -2283,10 +2310,13 @@ export async function getBooksForDiscovery(
|
||||
userId?: string,
|
||||
prefs?: DiscoveryPrefs
|
||||
): Promise<Book[]> {
|
||||
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
|
||||
// Fetch all 4 independent data sources in parallel — previously getAllRatings
|
||||
// ran sequentially after the first group, adding it to the critical path.
|
||||
const [allBooks, votedSlugs, savedSlugs, ratingRows] = await Promise.all([
|
||||
listBooks(),
|
||||
getVotedSlugs(sessionId, userId),
|
||||
getSavedSlugs(sessionId, userId)
|
||||
getSavedSlugs(sessionId, userId),
|
||||
getAllRatings(),
|
||||
]);
|
||||
|
||||
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
|
||||
@@ -2305,10 +2335,7 @@ export async function getBooksForDiscovery(
|
||||
if (sf.length >= 3) candidates = sf;
|
||||
}
|
||||
|
||||
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
|
||||
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
|
||||
// appear — they're just pushed further back via a stable sort before shuffle.
|
||||
const ratingRows = await getAllRatings();
|
||||
// Build slug→avg rating map
|
||||
const ratingMap = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of ratingRows) {
|
||||
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
||||
@@ -2384,6 +2411,7 @@ export async function undoDiscoveryVote(
|
||||
if (row) {
|
||||
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
|
||||
}
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
// ─── User stats ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1137,12 +1137,10 @@
|
||||
<!-- Listening mode — mounted at root level, independent of audioStore.active,
|
||||
so closing/pausing audio never tears it down and loses context. -->
|
||||
{#if listeningModeOpen}
|
||||
<div transition:fly={{ y: '100%', duration: 320, opacity: 1 }} style="pointer-events: none;">
|
||||
<ListeningMode
|
||||
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
||||
openChapters={listeningModeChapters}
|
||||
/>
|
||||
</div>
|
||||
<ListeningMode
|
||||
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
||||
openChapters={listeningModeChapters}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Universal search modal — shown from anywhere except focus mode / listening mode -->
|
||||
|
||||
34
ui/src/routes/api/admin/books/[slug]/archive/+server.ts
Normal file
34
ui/src/routes/api/admin/books/[slug]/archive/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* PATCH /api/admin/books/[slug]/archive
|
||||
* PATCH /api/admin/books/[slug]/unarchive (action param: ?action=unarchive)
|
||||
*
|
||||
* Admin-only proxy. Soft-deletes (archives) or restores a book.
|
||||
* Returns { slug, status: "archived" | "active" }.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, url, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
const action = url.searchParams.get('action') === 'unarchive' ? 'unarchive' : 'archive';
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}/${action}`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/books/archive', 'backend proxy error', { slug, action, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
34
ui/src/routes/api/admin/books/[slug]/delete/+server.ts
Normal file
34
ui/src/routes/api/admin/books/[slug]/delete/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* DELETE /api/admin/books/[slug]/delete
|
||||
*
|
||||
* Admin-only proxy. Permanently removes a book and all its data:
|
||||
* PocketBase records, MinIO objects, and the Meilisearch document.
|
||||
* This operation is irreversible — use the archive endpoint for soft-deletion.
|
||||
* Returns { slug, status: "deleted" }.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/books/delete', 'backend proxy error', { slug, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
@@ -95,7 +95,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
total_chapters: preview.meta.total_chapters,
|
||||
source_url: preview.meta.source_url,
|
||||
ranking: 0,
|
||||
meta_updated: ''
|
||||
meta_updated: '',
|
||||
archived: false
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -616,6 +616,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: archive / delete ───────────────────────────────────────────────
|
||||
let archiveStatus = $state<'idle' | 'busy' | 'done' | 'error'>('idle');
|
||||
let deleteStatus = $state<'idle' | 'busy' | 'confirm' | 'done' | 'error'>('idle');
|
||||
let bookArchived = $state(data.book?.archived ?? false);
|
||||
|
||||
async function toggleArchive() {
|
||||
const slug = data.book?.slug;
|
||||
if (!slug) return;
|
||||
archiveStatus = 'busy';
|
||||
const action = bookArchived ? 'unarchive' : 'archive';
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/books/${encodeURIComponent(slug)}/archive?action=${action}`,
|
||||
{ method: 'PATCH' }
|
||||
);
|
||||
if (res.ok) {
|
||||
bookArchived = !bookArchived;
|
||||
archiveStatus = 'done';
|
||||
setTimeout(() => { archiveStatus = 'idle'; }, 3000);
|
||||
} else {
|
||||
archiveStatus = 'error';
|
||||
}
|
||||
} catch {
|
||||
archiveStatus = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBook() {
|
||||
const slug = data.book?.slug;
|
||||
if (!slug) return;
|
||||
deleteStatus = 'busy';
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/books/${encodeURIComponent(slug)}/delete`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (res.ok) {
|
||||
deleteStatus = 'done';
|
||||
// Navigate away — book no longer exists
|
||||
setTimeout(() => { goto('/admin/catalogue-tools'); }, 1500);
|
||||
} else {
|
||||
deleteStatus = 'error';
|
||||
}
|
||||
} catch {
|
||||
deleteStatus = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
// ── "More like this" ─────────────────────────────────────────────────────
|
||||
interface SimilarBook { slug: string; title: string; cover: string | null; author: string | null }
|
||||
let similarBooks = $state<SimilarBook[]>([]);
|
||||
@@ -1525,10 +1573,83 @@
|
||||
{/if}
|
||||
{#if audioError}
|
||||
<span class="text-xs text-(--color-muted)">{audioError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-(--color-border)" />
|
||||
|
||||
<!-- Archive / Delete -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Danger Zone</p>
|
||||
|
||||
<!-- Archive / Unarchive -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={toggleArchive}
|
||||
disabled={archiveStatus === 'busy'}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium border transition-colors
|
||||
{archiveStatus === 'busy'
|
||||
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed border-(--color-border)'
|
||||
: bookArchived
|
||||
? 'bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 border-amber-500/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2) border-(--color-border)'}"
|
||||
>
|
||||
{#if archiveStatus === 'busy'}
|
||||
<svg class="w-3 h-3 animate-spin" 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 if bookArchived}
|
||||
<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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
|
||||
{:else}
|
||||
<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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
|
||||
{/if}
|
||||
{bookArchived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
{#if archiveStatus === 'done'}
|
||||
<span class="text-xs text-green-400">{bookArchived ? 'Book archived — hidden from search.' : 'Book restored — visible again.'}</span>
|
||||
{:else if archiveStatus === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">Action failed.</span>
|
||||
{:else if bookArchived}
|
||||
<span class="text-xs text-amber-400/70">This book is archived and hidden from all users.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hard delete -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
{#if deleteStatus === 'confirm'}
|
||||
<span class="text-xs text-(--color-danger)">This will permanently delete all chapters, audio, and cover. Cannot be undone.</span>
|
||||
<button
|
||||
onclick={deleteBook}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 border border-red-600/30 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Confirm delete
|
||||
</button>
|
||||
<button onclick={() => { deleteStatus = 'idle'; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Cancel</button>
|
||||
{:else if deleteStatus === 'busy'}
|
||||
<button disabled class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed border border-(--color-border)">
|
||||
<svg class="w-3 h-3 animate-spin" 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>
|
||||
Deleting…
|
||||
</button>
|
||||
{:else if deleteStatus === 'done'}
|
||||
<span class="text-xs text-green-400">Book deleted. Redirecting…</span>
|
||||
{:else if deleteStatus === 'error'}
|
||||
<button onclick={() => { deleteStatus = 'confirm'; }} class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-danger) hover:bg-(--color-surface-2) border border-(--color-border) 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Delete failed — retry?
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { deleteStatus = 'confirm'; }}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-danger) hover:bg-red-600/10 border border-(--color-border) 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Delete book
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -68,9 +68,10 @@
|
||||
focusMode: boolean;
|
||||
playerStyle: PlayerStyle;
|
||||
pageLines: PageLines;
|
||||
showSidebar: boolean;
|
||||
}
|
||||
|
||||
const LAYOUT_KEY = 'reader_layout_v2';
|
||||
const LAYOUT_KEY = 'reader_layout_v3';
|
||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
||||
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
||||
/**
|
||||
@@ -79,7 +80,7 @@
|
||||
* shorter so fewer lines fit per page; More (+4rem) grows it for more lines.
|
||||
*/
|
||||
const PAGE_LINES_OFFSET: Record<PageLines, string> = { less: '4rem', normal: '0rem', more: '-4rem' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard', pageLines: 'normal' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard', pageLines: 'normal', showSidebar: true };
|
||||
|
||||
function loadLayout(): LayoutPrefs {
|
||||
if (!browser) return DEFAULT_LAYOUT;
|
||||
@@ -466,6 +467,12 @@
|
||||
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Two-column grid wrapper (sidebar activates at xl when enabled) ──────── -->
|
||||
<div class="{layout.showSidebar && !layout.focusMode ? 'xl:grid xl:grid-cols-[1fr_18rem] xl:gap-10 xl:items-start' : ''}">
|
||||
|
||||
<!-- ── Main reading column ────────────────────────────────────────────────── -->
|
||||
<div>
|
||||
|
||||
<!-- ── Top navigation (hidden in focus mode) ─────────────────────────────── -->
|
||||
{#if !layout.focusMode}
|
||||
<div class="flex items-center justify-between mb-8 gap-2">
|
||||
@@ -864,6 +871,169 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div><!-- end main column -->
|
||||
|
||||
<!-- ── Sidebar (xl+, hidden in focus mode, toggled via settings) ─────────── -->
|
||||
{#if layout.showSidebar && !layout.focusMode}
|
||||
<aside class="hidden xl:block">
|
||||
<div class="sticky top-24 flex flex-col gap-4">
|
||||
|
||||
<!-- Card 1: Book cover + info -->
|
||||
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) overflow-hidden">
|
||||
{#if data.book.cover}
|
||||
<a href="/books/{data.book.slug}" tabindex="-1" aria-hidden="true">
|
||||
<img
|
||||
src={data.book.cover}
|
||||
alt={data.book.title}
|
||||
class="w-full aspect-[2/3] object-cover"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
<div class="px-3 py-3 flex flex-col gap-2">
|
||||
<a
|
||||
href="/books/{data.book.slug}"
|
||||
class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors leading-snug line-clamp-2"
|
||||
>
|
||||
{data.book.title}
|
||||
</a>
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<span class="tabular-nums">Ch. {data.chapter.number}</span>
|
||||
{#if data.chapters.length > 0}
|
||||
<span class="opacity-40">·</span>
|
||||
<span class="tabular-nums">{data.chapters.length} chapters</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if wordCount > 0}
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<span class="tabular-nums">{wordCount.toLocaleString()} words</span>
|
||||
<span class="opacity-40">·</span>
|
||||
<span>~{Math.max(1, Math.round(wordCount / 200))} min</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Reading progress -->
|
||||
{#if data.chapters.length > 1}
|
||||
{@const progressPct = Math.round((data.chapter.number / data.chapters.length) * 100)}
|
||||
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) px-4 py-3">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Progress</p>
|
||||
<div class="flex items-center justify-between text-xs text-(--color-muted) mb-1.5">
|
||||
<span>Chapter {data.chapter.number} of {data.chapters.length}</span>
|
||||
<span class="tabular-nums font-medium text-(--color-brand)">{progressPct}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-(--color-surface-3) overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-(--color-brand) transition-all"
|
||||
style="width: {progressPct}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if layout.readMode === 'scroll' && scrollProgress > 0}
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<span>Page scroll</span>
|
||||
<div class="flex-1 h-1 rounded-full bg-(--color-surface-3) overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-(--color-brand)/50 transition-all"
|
||||
style="width: {Math.round(scrollProgress * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="tabular-nums">{Math.round(scrollProgress * 100)}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if layout.readMode === 'paginated' && totalPages > 1}
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<span>Page</span>
|
||||
<div class="flex-1 h-1 rounded-full bg-(--color-surface-3) overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-(--color-brand)/50 transition-all"
|
||||
style="width: {Math.round(((pageIndex + 1) / totalPages) * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="tabular-nums">{pageIndex + 1}/{totalPages}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Card 3: Chapter ToC -->
|
||||
{#if data.chapters.length > 0}
|
||||
{@const tocChapters = data.chapters}
|
||||
{@const currentIdx = tocChapters.findIndex(c => c.number === data.chapter.number)}
|
||||
{@const windowStart = Math.max(0, currentIdx - 3)}
|
||||
{@const windowEnd = Math.min(tocChapters.length, windowStart + 10)}
|
||||
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-2.5 border-b border-(--color-border)">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider">Chapters</p>
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters"
|
||||
class="text-[10px] text-(--color-brand) hover:underline"
|
||||
>All {tocChapters.length}</a>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-(--color-border)/50 max-h-64 overflow-y-auto">
|
||||
{#each tocChapters.slice(windowStart, windowEnd) as ch}
|
||||
{@const isCurrent = ch.number === data.chapter.number}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{ch.number}"
|
||||
class="flex items-start gap-2 px-3 py-2 text-xs transition-colors
|
||||
{isCurrent
|
||||
? 'bg-(--color-brand)/10 text-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
<span class="shrink-0 tabular-nums w-6 text-right opacity-60">{ch.number}</span>
|
||||
<span class="truncate leading-snug">{ch.title || `Chapter ${ch.number}`}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Card 4: Chapter navigation -->
|
||||
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) px-3 py-3 flex flex-col gap-2">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-0.5">Navigate</p>
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
<span class="truncate">Chapter {data.prev}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="flex items-center gap-2 px-3 py-2 text-xs text-(--color-muted)/30">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
First chapter
|
||||
</span>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-(--color-brand) bg-(--color-brand)/10 hover:bg-(--color-brand)/20 transition-colors font-medium"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 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>
|
||||
<span class="truncate">Chapter {data.next}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="flex items-center gap-2 px-3 py-2 text-xs text-(--color-muted)/30">
|
||||
<svg class="w-3.5 h-3.5 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>
|
||||
Last chapter
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
</div><!-- end grid wrapper -->
|
||||
|
||||
<!-- ── Scroll mode floating nav buttons ──────────────────────────────────── -->
|
||||
{#if layout.readMode === 'scroll' && !layout.focusMode}
|
||||
{@const atTop = scrollProgress <= 0.01}
|
||||
@@ -1227,6 +1397,17 @@
|
||||
<span class="text-(--color-muted) text-[11px]">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('showSidebar', !layout.showSidebar)}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{layout.showSidebar ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
aria-pressed={layout.showSidebar}
|
||||
>
|
||||
<span>Sidebar</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{layout.showSidebar ? 'On — ToC, progress & nav' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user