Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function App() {
pages: extracted.pages,
outline: extracted.outline,
structure: extracted.structure,
cover: extracted.cover,
wordCount: extracted.wordCount,
savedAt: Date.now(),
};
Expand Down
126 changes: 95 additions & 31 deletions src/components/Library.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Lock, BookOpen, Type, Pencil, Trash2, Check, X, Download } from "lucide-react";
import { Lock, BookOpen, Type, Pencil, Trash2, Check, X, Download, Search } from "lucide-react";
import { loadProgress, type CachedDoc } from "@/lib/reader-store";
import type { ImportProgress } from "./App";
import { DropZone } from "./DropZone";
Expand Down Expand Up @@ -203,35 +203,83 @@ function Shelf({
onRemove: (key: string) => void;
onRename: (key: string, title: string) => void;
}) {
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();

// Once the shelf grows past a handful of books, a search field earns its keep.
// Keep it mounted while a query is active even if the (filtered/deleted) list
// shrinks back below the threshold, so the filter can always be cleared.
const showSearch = items.length > 6 || q !== "";

const filtered = useMemo(() => {
if (!q) return items;
return items.filter(
(it) =>
it.doc.title.toLowerCase().includes(q) ||
(it.doc.author?.toLowerCase().includes(q) ?? false),
);
}, [items, q]);

return (
<section className="px-6 sm:px-10 pb-20 max-w-6xl mx-auto">
<p className="text-xs uppercase tracking-[0.4em] text-ember mb-2">— Read in private —</p>
<h1 className="font-display font-black tracking-tight text-3xl sm:text-5xl mb-10">
Your <span className="text-shimmer">library</span>
</h1>
<div className="mb-10 flex flex-wrap items-end justify-between gap-4">
<h1 className="font-display font-black tracking-tight text-3xl sm:text-5xl">
Your <span className="text-shimmer">library</span>
</h1>
{showSearch && (
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search title or author"
aria-label="Search your library"
className="w-full rounded-md border border-border/60 bg-card/40 py-2 pl-9 pr-3 text-sm text-foreground backdrop-blur transition-colors placeholder:text-muted-foreground focus:border-ember/60 focus:outline-none"
/>
</div>
)}
</div>

{continueItem && (
{/* The "continue reading" hero is a global shortcut, not a search result. */}
{continueItem && !q && (
<div className="mb-12">
<ContinueHero item={continueItem} onResume={() => onOpen(continueItem.doc)} />
</div>
)}

<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<BookCard
key={item.doc.key}
item={item}
onOpen={() => onOpen(item.doc)}
onRemove={() => onRemove(item.doc.key)}
onRename={(title) => onRename(item.doc.key, title)}
/>
))}
{filtered.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No books match “{query.trim()}”.
</p>
) : (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((item) => (
<BookCard
key={item.doc.key}
item={item}
onOpen={() => onOpen(item.doc)}
onRemove={() => onRemove(item.doc.key)}
onRename={(title) => onRename(item.doc.key, title)}
/>
))}

{/* Add-another tile lives in the grid flow. */}
<div className="min-h-[150px]">
<DropZone compact loading={loading} progress={progress} error={error} onFile={onFile} />
{/* Add-another tile lives in the grid flow — but stays out of the way
while the reader is filtering an existing shelf. */}
{!q && (
<div className="min-h-[150px]">
<DropZone
compact
loading={loading}
progress={progress}
error={error}
onFile={onFile}
/>
</div>
)}
</div>
</div>
)}

{warning && <p className="mt-6 text-sm text-amber-400/80">{warning}</p>}
</section>
Expand All @@ -244,7 +292,7 @@ function ContinueHero({ item, onResume }: { item: ShelfItem; onResume: () => voi
onClick={onResume}
className="group flex w-full items-center gap-5 rounded-lg border border-ember/30 bg-card/40 p-6 text-left backdrop-blur transition-all hover:border-ember/60 hover:bg-card/70 ember-glow"
>
<BookCover title={item.doc.title} large />
<BookCover title={item.doc.title} cover={item.doc.cover} large />
<div className="min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-[0.3em] text-ember/70">Return to the arena</p>
<p className="mt-1 truncate font-serif text-2xl text-foreground">{item.doc.title}</p>
Expand Down Expand Up @@ -289,7 +337,7 @@ function BookCard({
className="flex flex-1 items-start gap-4 text-left"
aria-label={`Open ${item.doc.title}`}
>
<BookCover title={item.doc.title} />
<BookCover title={item.doc.title} cover={item.doc.cover} />
<div className="min-w-0 flex-1">
{editing ? (
<input
Expand Down Expand Up @@ -396,7 +444,15 @@ function IconButton({
);
}

function BookCover({ title, large = false }: { title: string; large?: boolean }) {
function BookCover({
title,
cover,
large = false,
}: {
title: string;
cover?: string;
large?: boolean;
}) {
const initial = title.trim().charAt(0).toUpperCase() || "?";
return (
<div
Expand All @@ -405,15 +461,23 @@ function BookCover({ title, large = false }: { title: string; large?: boolean })
}`}
style={{ background: "linear-gradient(150deg, var(--card) 0%, rgba(0,0,0,0.4) 100%)" }}
>
<div
className="pointer-events-none absolute inset-0 opacity-40"
style={{
background: "radial-gradient(circle at 30% 20%, var(--ember) 0%, transparent 70%)",
}}
/>
<span className={`relative font-display text-ember ${large ? "text-2xl" : "text-lg"}`}>
{initial}
</span>
{cover ? (
// The real page-1 thumbnail. `alt=""` — the title sits right beside it,
// so the cover is decorative for a screen reader.
<img src={cover} alt="" className="absolute inset-0 h-full w-full object-cover" />
) : (
<>
<div
className="pointer-events-none absolute inset-0 opacity-40"
style={{
background: "radial-gradient(circle at 30% 20%, var(--ember) 0%, transparent 70%)",
}}
/>
<span className={`relative font-display text-ember ${large ? "text-2xl" : "text-lg"}`}>
{initial}
</span>
</>
)}
</div>
);
}
Expand Down
38 changes: 37 additions & 1 deletion src/lib/pdf-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export interface ExtractedDoc {
outline: Array<{ title: string; pageNumber: number }>;
/** Rich, kind-aware table of contents derived by the structure cascade. */
structure: StructureNode[];
/** Page-1 thumbnail as a JPEG data URL, for the library shelf. Optional:
* rendering is best-effort and may be absent (no DOM, render failure). */
cover?: string;
}

/** Which stage of the import pipeline a progress tick belongs to. */
Expand Down Expand Up @@ -65,6 +68,11 @@ export async function extractPdf(
const loadingTask = pdfjs.getDocument({ data: buf });
const pdf = await loadingTask.promise;

// Render the first page to a small thumbnail up front — it's the book's cover
// on the shelf. Best-effort: a failure just leaves `cover` undefined and the
// shelf falls back to the title-initial tile.
const cover = await renderCover(pdf);

// Pass 1: rebuild each page's physical lines in reading order. Pages with a
// column layout are re-ordered geometrically (left column before right);
// everything else trusts the content-stream order, which preserves things
Expand Down Expand Up @@ -145,7 +153,35 @@ export async function extractPdf(
fileName: file.name,
});

return { title, author, pages, fullText, wordCount, outline, structure };
return { title, author, pages, fullText, wordCount, outline, structure, cover };
}

/**
* Render page 1 to a compact JPEG data URL for the shelf cover. Sized to ~360px
* on the long edge — crisp on a retina card while staying a few tens of KB in
* IndexedDB. Best-effort: returns undefined off-DOM or on any render failure.
*/
async function renderCover(pdf: PdfJsType.PDFDocumentProxy): Promise<string | undefined> {
if (typeof document === "undefined") return undefined;
let canvas: HTMLCanvasElement | null = null;
try {
const page = await pdf.getPage(1);
const base = page.getViewport({ scale: 1 });
const scale = Math.min(2, 360 / Math.max(base.width, base.height, 1));
const viewport = page.getViewport({ scale });
canvas = document.createElement("canvas");
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
await page.render({ canvas, viewport }).promise;
// Prefer JPEG for size; browsers without JPEG canvas export fall back to
// PNG, which is still a usable cover (the page is tiny), so accept either.
const url = canvas.toDataURL("image/jpeg", 0.72);
return url.startsWith("data:image/") ? url : undefined;
} catch {
return undefined;
} finally {
if (canvas) canvas.width = canvas.height = 0; // release the bitmap eagerly
}
}

// ---------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions src/lib/reader-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ export interface CachedDoc {
/** Kind-aware contents from the structure cascade. Optional: docs cached
* before this field shipped fall back to `outline`. */
structure?: StructureNode[];
/** Page-1 thumbnail (JPEG data URL) shown on the shelf. Optional: docs cached
* before covers shipped, or whose render failed, fall back to a letter tile. */
cover?: string;
wordCount: number;
savedAt: number;
}
Expand Down
Loading