-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLibrary.tsx
More file actions
615 lines (578 loc) · 21.3 KB
/
Copy pathLibrary.tsx
File metadata and controls
615 lines (578 loc) · 21.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
import { useEffect, useMemo, useState } from "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";
import { Mockingjay } from "./Mockingjay";
import { EmberField } from "./EmberField";
interface Props {
docs: CachedDoc[];
/** Bookmark/highlight/note counts per doc key, for the shelf badges. */
annCounts: Map<string, number>;
loading: boolean;
progress: ImportProgress;
error: string | null;
warning: string | null;
onFile: (f: File) => void;
onOpen: (doc: CachedDoc) => void;
onRemove: (key: string) => void;
onRename: (key: string, title: string) => void;
}
interface ShelfItem {
doc: CachedDoc;
pages: number;
pct: number;
finished: boolean;
lastOpened: number;
marks: number;
}
/** Chromium's install-prompt event (not yet in lib.dom). */
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
/**
* "Add to Home Screen" support: stash the deferred beforeinstallprompt event
* and expose an explicit install action for the nav button.
*/
function useInstallPrompt() {
const [evt, setEvt] = useState<BeforeInstallPromptEvent | null>(null);
useEffect(() => {
const onPrompt = (e: Event) => {
e.preventDefault();
setEvt(e as BeforeInstallPromptEvent);
};
const onInstalled = () => setEvt(null);
window.addEventListener("beforeinstallprompt", onPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
return {
available: !!evt,
install: async () => {
if (!evt) return;
setEvt(null);
await evt.prompt();
},
};
}
export function Library({
docs,
annCounts,
loading,
progress,
error,
warning,
onFile,
onOpen,
onRemove,
onRename,
}: Props) {
const installer = useInstallPrompt();
// Build view models (progress + last-opened) and sort by most recent.
const items = useMemo<ShelfItem[]>(() => {
return docs
.map((doc) => {
const prog = loadProgress(doc.key);
const pages = doc.pages.length;
const pct = prog ? Math.round((prog.pageNumber / Math.max(1, prog.total)) * 100) : 0;
return {
doc,
pages,
pct,
finished: pct >= 100,
lastOpened: prog?.updatedAt ?? doc.savedAt,
marks: annCounts.get(doc.key) ?? 0,
};
})
.sort((a, b) => b.lastOpened - a.lastOpened);
}, [docs, annCounts]);
const continueItem = items.find((it) => it.pct > 0 && !it.finished) ?? null;
return (
<div className="min-h-screen relative overflow-hidden">
{/* Ember atmospherics — slow-drifting glow blobs + a field of rising sparks */}
<div className="pointer-events-none absolute inset-0 -z-10">
<div
className="absolute -top-32 left-1/2 w-[900px] h-[900px] rounded-full opacity-30 blur-3xl animate-drift-a"
style={{ background: "radial-gradient(circle, var(--ember) 0%, transparent 60%)" }}
/>
<div
className="absolute bottom-0 right-0 w-[500px] h-[500px] rounded-full opacity-20 blur-3xl animate-drift-b"
style={{ background: "radial-gradient(circle, var(--accent) 0%, transparent 60%)" }}
/>
<div
className="absolute top-1/3 -left-40 w-[420px] h-[420px] rounded-full opacity-[0.12] blur-3xl animate-drift-b"
style={{
background: "radial-gradient(circle, var(--ember) 0%, transparent 65%)",
animationDelay: "-6s",
}}
/>
</div>
<EmberField />
{/* Nav */}
<nav className="px-6 sm:px-10 py-6 flex items-center justify-between max-w-7xl mx-auto">
<div className="flex items-center gap-2.5">
<Mockingjay className="w-7 h-7 pin-glow" />
<span className="font-serif text-xl font-semibold tracking-wide">WeReadPDF</span>
</div>
<div className="flex items-center gap-3 sm:gap-4">
{installer.available && (
<button
onClick={installer.install}
aria-label="Install app"
title="Install app"
className="flex items-center gap-1.5 rounded-md border border-ember/40 p-2 sm:px-3 sm:py-1.5 text-xs uppercase tracking-[0.15em] text-ember hover:bg-ember/10 transition-colors"
>
<Download className="w-3 h-3" />
<span className="hidden sm:inline">Install app</span>
</button>
)}
<span className="hidden items-center gap-1.5 text-xs uppercase tracking-[0.2em] text-muted-foreground sm:flex">
<Lock className="w-3 h-3" /> District-local
</span>
</div>
</nav>
{items.length === 0 ? (
<EmptyState
loading={loading}
progress={progress}
error={error}
warning={warning}
onFile={onFile}
/>
) : (
<Shelf
items={items}
continueItem={continueItem}
loading={loading}
progress={progress}
error={error}
warning={warning}
onFile={onFile}
onOpen={onOpen}
onRemove={onRemove}
onRename={onRename}
/>
)}
<footer className="px-6 sm:px-10 py-10 border-t border-border/40 text-center text-xs text-muted-foreground tracking-wider">
<span className="font-serif text-sm font-semibold tracking-wide">WeReadPDF</span> — read in
your own district. May the odds be ever in your favor.
</footer>
</div>
);
}
// ---------------------------------------------------------------------------
// Populated shelf
// ---------------------------------------------------------------------------
function Shelf({
items,
continueItem,
loading,
progress,
error,
warning,
onFile,
onOpen,
onRemove,
onRename,
}: {
items: ShelfItem[];
continueItem: ShelfItem | null;
loading: boolean;
progress: ImportProgress;
error: string | null;
warning: string | null;
onFile: (f: File) => void;
onOpen: (doc: CachedDoc) => void;
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>
<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>
{/* 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>
)}
{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 — 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>
)}
{warning && <p className="mt-6 text-sm text-amber-400/80">{warning}</p>}
</section>
);
}
function ContinueHero({ item, onResume }: { item: ShelfItem; onResume: () => void }) {
return (
<button
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} 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>
{item.doc.author && (
<p className="truncate text-sm text-muted-foreground">by {item.doc.author}</p>
)}
<div className="mt-4 flex items-center gap-3">
<KindleBar pct={item.pct} />
<span className="shrink-0 text-xs text-ember">{item.pct}% survived</span>
</div>
</div>
</button>
);
}
function BookCard({
item,
onOpen,
onRemove,
onRename,
}: {
item: ShelfItem;
onOpen: () => void;
onRemove: () => void;
onRename: (title: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(item.doc.title);
const [confirmRemove, setConfirmRemove] = useState(false);
function commitRename() {
const next = draft.trim();
if (next && next !== item.doc.title) onRename(next);
setEditing(false);
}
return (
<div className="group relative flex flex-col rounded-lg border border-border/60 bg-card/40 p-5 backdrop-blur transition-all hover:border-ember/40 hover:bg-card/70">
<button
onClick={onOpen}
disabled={editing}
className="flex flex-1 items-start gap-4 text-left"
aria-label={`Open ${item.doc.title}`}
>
<BookCover title={item.doc.title} cover={item.doc.cover} />
<div className="min-w-0 flex-1">
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") setEditing(false);
}}
onClick={(e) => e.preventDefault()}
className="w-full bg-input/50 border border-ember/40 rounded px-2 py-1 text-sm focus:outline-none focus:border-ember"
/>
) : (
<p className="font-serif text-base leading-snug text-foreground line-clamp-2">
{item.doc.title}
</p>
)}
{item.doc.author && !editing && (
<p className="mt-1 truncate text-xs text-muted-foreground">by {item.doc.author}</p>
)}
<p className="mt-1 text-[11px] text-muted-foreground">
{item.pages} {item.pages === 1 ? "page" : "pages"} · {relativeTime(item.lastOpened)}
{item.marks > 0 && ` · ${item.marks} ${item.marks === 1 ? "mark" : "marks"}`}
</p>
</div>
</button>
<div className="mt-4 flex items-center gap-3">
<KindleBar pct={item.pct} />
<span className="shrink-0 text-[11px] text-ember">
{item.finished ? "Victor" : `${item.pct}% survived`}
</span>
</div>
{/* Per-book actions */}
<div className="absolute right-3 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{editing ? (
<>
<IconButton label="Save name" onClick={commitRename}>
<Check className="w-3.5 h-3.5" />
</IconButton>
<IconButton label="Cancel" onClick={() => setEditing(false)}>
<X className="w-3.5 h-3.5" />
</IconButton>
</>
) : confirmRemove ? (
<>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
Remove?
</span>
<IconButton label="Confirm remove" danger onClick={onRemove}>
<Check className="w-3.5 h-3.5" />
</IconButton>
<IconButton label="Keep book" onClick={() => setConfirmRemove(false)}>
<X className="w-3.5 h-3.5" />
</IconButton>
</>
) : (
<>
<IconButton
label="Rename"
onClick={() => {
setDraft(item.doc.title);
setEditing(true);
}}
>
<Pencil className="w-3.5 h-3.5" />
</IconButton>
<IconButton label="Remove" danger onClick={() => setConfirmRemove(true)}>
<Trash2 className="w-3.5 h-3.5" />
</IconButton>
</>
)}
</div>
</div>
);
}
function IconButton({
label,
onClick,
children,
danger = false,
}: {
label: string;
onClick: () => void;
children: React.ReactNode;
danger?: boolean;
}) {
return (
<button
onClick={onClick}
aria-label={label}
title={label}
className={`rounded-md bg-background/70 p-1.5 backdrop-blur transition-colors ${
danger
? "text-muted-foreground hover:text-destructive"
: "text-muted-foreground hover:text-ember"
}`}
>
{children}
</button>
);
}
function BookCover({
title,
cover,
large = false,
}: {
title: string;
cover?: string;
large?: boolean;
}) {
const initial = title.trim().charAt(0).toUpperCase() || "?";
return (
<div
className={`relative flex shrink-0 items-center justify-center overflow-hidden rounded border border-ember/20 ${
large ? "h-24 w-16" : "h-16 w-11"
}`}
style={{ background: "linear-gradient(150deg, var(--card) 0%, rgba(0,0,0,0.4) 100%)" }}
>
{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>
);
}
function KindleBar({ pct }: { pct: number }) {
return (
<div className="h-1 flex-1 overflow-hidden rounded bg-border/40">
<div
className="h-full bg-gradient-to-r from-ember to-accent transition-all"
style={{
width: `${Math.min(100, Math.max(0, pct))}%`,
boxShadow: "0 0 10px var(--ember-glow)",
}}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state — the landing hero, shown when the shelf is bare.
// ---------------------------------------------------------------------------
function EmptyState({
loading,
progress,
error,
warning,
onFile,
}: {
loading: boolean;
progress: ImportProgress;
error: string | null;
warning: string | null;
onFile: (f: File) => void;
}) {
return (
<>
<section className="relative px-6 sm:px-10 pt-12 sm:pt-20 pb-20 max-w-4xl mx-auto text-center">
{/* The Mockingjay, smouldering and breathing over the page */}
<Mockingjay className="pointer-events-none absolute left-1/2 top-0 -z-10 w-[440px] animate-pin-breathe" />
<p className="text-xs uppercase tracking-[0.4em] text-ember mb-6 animate-fade-up">
— Welcome, tribute —
</p>
<h1
className="font-display font-black tracking-tight text-5xl sm:text-7xl leading-[1.05] animate-fade-up"
style={{ animationDelay: "0.1s" }}
>
May the words be
<br />
<span className="text-shimmer">ever in your favor.</span>
</h1>
<p
className="mt-8 text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto font-serif italic animate-fade-up"
style={{ animationDelay: "0.2s" }}
>
PDFs were built for paper. WeReadPDF reaps them into clean, flowing text you can actually
read on any screen — every PDF read in your own private arena.
</p>
<div className="relative mt-12 animate-fade-up" style={{ animationDelay: "0.3s" }}>
{/* Ember halo pooling behind the drop target */}
<div
className="pointer-events-none absolute left-1/2 top-1/2 -z-10 h-72 w-[34rem] max-w-full -translate-x-1/2 -translate-y-1/2 rounded-full opacity-40 blur-3xl"
style={{ background: "radial-gradient(circle, var(--ember-glow) 0%, transparent 70%)" }}
/>
<DropZone loading={loading} progress={progress} error={error} onFile={onFile} />
</div>
{warning && <p className="mt-4 text-sm text-amber-400/80 text-center">{warning}</p>}
<p className="mt-6 text-xs text-muted-foreground tracking-wide">
Files never leave your device. No upload. No account. No trace.
</p>
</section>
<section className="px-6 sm:px-10 py-20 max-w-6xl mx-auto">
<div className="grid sm:grid-cols-3 gap-6">
{FEATURES.map((f, i) => (
<div
key={i}
className="group relative overflow-hidden p-8 rounded-lg border border-border/60 bg-card/40 backdrop-blur transition-all duration-500 hover:-translate-y-1 hover:border-ember/50 hover:bg-card/80 hover:shadow-[0_24px_60px_-30px_var(--ember-glow)] animate-fade-up"
style={{ animationDelay: `${0.15 * i}s` }}
>
{/* Ember filament that ignites along the top edge on hover */}
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-ember to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<div className="mb-5 flex items-center justify-between">
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-ember/30 bg-ember/5 transition-colors group-hover:border-ember/60 group-hover:bg-ember/10">
<f.icon className="w-5 h-5 text-ember group-hover:animate-flicker" />
</span>
<span className="font-display text-2xl text-ember/20 transition-colors group-hover:text-ember/40">
{String(i + 1).padStart(2, "0")}
</span>
</div>
<h3 className="font-display uppercase tracking-[0.2em] text-sm mb-3">{f.title}</h3>
<p className="text-sm text-muted-foreground font-serif leading-relaxed">{f.body}</p>
</div>
))}
</div>
</section>
</>
);
}
const FEATURES = [
{
icon: BookOpen,
title: "The Arena",
body: "Flowing text styled like a real book — screen-sized pages you turn with a tap. Font, width, spacing, and theme all bend to your will.",
},
{
icon: Type,
title: "Tribute Typography",
body: "Garamond serif or Inter sans. Sizes from intimate to grand. Tune every letter like a weapon before the Games.",
},
{
icon: Lock,
title: "Sealed in Your District",
body: "Every page is reaped in your browser. Nothing is uploaded. Nothing is tracked. No Capitol watching.",
},
];
// Coarse "time ago" — good enough for a shelf, no dependency needed.
function relativeTime(ts: number): string {
const diff = Date.now() - ts;
const min = Math.floor(diff / 60000);
if (min < 1) return "just now";
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 7) return `${day}d ago`;
const wk = Math.floor(day / 7);
if (wk < 5) return `${wk}w ago`;
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}