ez-reads 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,656 @@
1
+ import { fileURLToPath } from 'url';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+ import { execSync } from 'child_process';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const TEMPLATE_DIR = path.resolve(__dirname, '..', 'template');
8
+
9
+ /**
10
+ * Build a static site for a paper into output/<slug>/
11
+ * Preserves all previously generated sites.
12
+ * Regenerates the output/index.html library page after each build.
13
+ *
14
+ * @returns {{ slug, siteDir, sitePath, indexPath }}
15
+ */
16
+ export async function generateSite(distilledData, outputDir) {
17
+ const slug = makeSlug(distilledData.title);
18
+ const siteDir = path.join(outputDir, slug);
19
+ const tmpDir = path.join(outputDir, '.tmp-build');
20
+
21
+ try {
22
+ // Remove previous version of the same paper (if re-running) and stale temp dir
23
+ await fs.remove(siteDir);
24
+ await fs.remove(tmpDir);
25
+
26
+ // Copy template into a temp build directory
27
+ await fs.copy(TEMPLATE_DIR, tmpDir);
28
+
29
+ // Inject the distilled paper data (with Groq API key for chat assistant)
30
+ const buildData = { ...distilledData };
31
+ if (process.env.GROQ_API_KEY) {
32
+ buildData.groqApiKey = process.env.GROQ_API_KEY;
33
+ }
34
+ const dataPath = path.join(tmpDir, 'src', 'data', 'paper.json');
35
+ await fs.writeJson(dataPath, buildData, { spaces: 2 });
36
+
37
+ // Download figure images into public/ so Vite includes them in the build
38
+ if (distilledData.figures?.length > 0) {
39
+ const figDir = path.join(tmpDir, 'public', 'figures');
40
+ await fs.ensureDir(figDir);
41
+
42
+ // Migrate legacy data: re-fetch original figure URLs from ArXiv
43
+ // for figures that only have local relative paths (from previous builds)
44
+ const needsMigration = distilledData.figures.some(
45
+ (f) => !f.originalUrl && f.url && !f.url.startsWith('http')
46
+ );
47
+ if (needsMigration && distilledData.url) {
48
+ try {
49
+ const { fetchPaper } = await import('./fetcher.mjs');
50
+ const freshPaper = await fetchPaper(distilledData.url);
51
+ if (freshPaper.figures?.length > 0) {
52
+ const freshMap = new Map(freshPaper.figures.map((f) => [f.id, f.url]));
53
+ for (const fig of distilledData.figures) {
54
+ if (!fig.originalUrl && freshMap.has(fig.id)) {
55
+ fig.originalUrl = freshMap.get(fig.id);
56
+ }
57
+ }
58
+ }
59
+ } catch (_) {
60
+ // If re-fetch fails, figures will just be missing
61
+ }
62
+ }
63
+
64
+ for (const fig of distilledData.figures) {
65
+ // Determine the remote URL to download from
66
+ const remoteUrl = fig.originalUrl || fig.url;
67
+ if (!remoteUrl || !remoteUrl.startsWith('http')) continue;
68
+
69
+ // Preserve the original remote URL so rebuilds can re-download
70
+ if (!fig.originalUrl) {
71
+ fig.originalUrl = remoteUrl;
72
+ }
73
+
74
+ try {
75
+ const res = await fetch(remoteUrl, {
76
+ headers: { 'User-Agent': 'ez-reads/1.0' },
77
+ redirect: 'follow',
78
+ });
79
+ if (!res.ok) continue;
80
+ const ext = path.extname(new URL(remoteUrl).pathname) || '.png';
81
+ const filename = `${fig.id}${ext}`;
82
+ const buffer = Buffer.from(await res.arrayBuffer());
83
+ await fs.writeFile(path.join(figDir, filename), buffer);
84
+ fig.url = `./figures/${filename}`;
85
+ } catch (_) {
86
+ // Fall back to remote URL if download fails
87
+ fig.url = remoteUrl;
88
+ }
89
+ }
90
+
91
+ // Re-write paper.json with updated local figure URLs
92
+ buildData.figures = distilledData.figures;
93
+ await fs.writeJson(dataPath, buildData, { spaces: 2 });
94
+ }
95
+
96
+ // Install dependencies & build static site
97
+ execSync('npm install', {
98
+ cwd: tmpDir,
99
+ stdio: 'pipe',
100
+ timeout: 120_000,
101
+ });
102
+
103
+ execSync('npm run build', {
104
+ cwd: tmpDir,
105
+ stdio: 'pipe',
106
+ timeout: 60_000,
107
+ });
108
+
109
+ // Move built dist/ into the permanent output/<slug>/ folder
110
+ await fs.copy(path.join(tmpDir, 'dist'), siteDir);
111
+
112
+ // Post-process the built HTML:
113
+ // 1. Strip "crossorigin" attrs so the page works via file:// protocol
114
+ // 2. Set the <title> to the actual paper title
115
+ const builtHtml = path.join(siteDir, 'index.html');
116
+ let html = await fs.readFile(builtHtml, 'utf-8');
117
+ html = html.replace(/\s+crossorigin/g, '');
118
+ if (distilledData.title) {
119
+ html = html.replace(/<title>[^<]*<\/title>/, `<title>${distilledData.title}</title>`);
120
+ }
121
+ await fs.writeFile(builtHtml, html, 'utf-8');
122
+
123
+ // Save metadata alongside the built site (used by the index page)
124
+ await fs.writeJson(path.join(siteDir, 'meta.json'), {
125
+ title: distilledData.title || 'Untitled Paper',
126
+ authors: distilledData.authors || [],
127
+ tldr: distilledData.tldr || '',
128
+ url: distilledData.url || '',
129
+ slug,
130
+ color_theme: distilledData.color_theme || { primary: '#2563eb', accent: '#f59e0b' },
131
+ publishedDate: distilledData.publishedDate || null,
132
+ generatedAt: new Date().toISOString(),
133
+ }, { spaces: 2 });
134
+
135
+ // Save the full paper data so the site can be rebuilt later
136
+ await fs.writeJson(path.join(siteDir, 'data.json'), distilledData, { spaces: 2 });
137
+
138
+ } finally {
139
+ // Always clean up the temp build directory
140
+ await fs.remove(tmpDir);
141
+ }
142
+
143
+ // Regenerate the library index page
144
+ const indexPath = path.join(outputDir, 'index.html');
145
+ await generateIndex(outputDir, indexPath);
146
+
147
+ return {
148
+ slug,
149
+ siteDir,
150
+ sitePath: path.join(siteDir, 'index.html'),
151
+ indexPath,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Rebuild a single paper site from its saved data.json.
157
+ * Reads data.json from the paper's output directory, rebuilds the site,
158
+ * and regenerates the library index.
159
+ */
160
+ export async function rebuildSite(slug, outputDir) {
161
+ const siteDir = path.join(outputDir, slug);
162
+ const dataPath = path.join(siteDir, 'data.json');
163
+
164
+ if (!await fs.pathExists(dataPath)) {
165
+ throw new Error(`No data.json found for "${slug}". Cannot rebuild without full paper data.`);
166
+ }
167
+
168
+ const distilledData = await fs.readJson(dataPath);
169
+ return generateSite(distilledData, outputDir);
170
+ }
171
+
172
+ /**
173
+ * Rebuild ALL paper sites that have a data.json.
174
+ * Useful after template changes to propagate updates to all existing sites.
175
+ */
176
+ export async function rebuildAll(outputDir) {
177
+ const entries = await fs.readdir(outputDir, { withFileTypes: true });
178
+ const results = [];
179
+
180
+ for (const entry of entries) {
181
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
182
+ const dataPath = path.join(outputDir, entry.name, 'data.json');
183
+ if (await fs.pathExists(dataPath)) {
184
+ const distilledData = await fs.readJson(dataPath);
185
+ const result = await generateSite(distilledData, outputDir);
186
+ results.push(result);
187
+ }
188
+ }
189
+
190
+ return results;
191
+ }
192
+
193
+ // ---------- slug helper ----------
194
+
195
+ function makeSlug(title) {
196
+ if (!title) return `paper-${Date.now()}`;
197
+ const slug = title
198
+ .toLowerCase()
199
+ .replace(/[^a-z0-9]+/g, '-')
200
+ .replace(/^-+|-+$/g, '')
201
+ .slice(0, 64);
202
+ return slug || `paper-${Date.now()}`;
203
+ }
204
+
205
+ // ---------- index page generator ----------
206
+
207
+ export async function generateIndex(outputDir, indexPath) {
208
+ // Scan all subdirectories for meta.json files
209
+ const entries = await fs.readdir(outputDir, { withFileTypes: true });
210
+ const papers = [];
211
+
212
+ for (const entry of entries) {
213
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
214
+ const metaPath = path.join(outputDir, entry.name, 'meta.json');
215
+ if (await fs.pathExists(metaPath)) {
216
+ try {
217
+ const meta = await fs.readJson(metaPath);
218
+ papers.push(meta);
219
+ } catch (_) {
220
+ // Skip malformed meta.json
221
+ }
222
+ }
223
+ }
224
+
225
+ // Sort newest first
226
+ papers.sort((a, b) => new Date(b.generatedAt) - new Date(a.generatedAt));
227
+
228
+ const html = buildIndexHtml(papers);
229
+ await fs.writeFile(indexPath, html, 'utf-8');
230
+ }
231
+
232
+ function buildIndexHtml(papers) {
233
+ const paperCards = papers.map((p, idx) => {
234
+ // Prefer publishedDate over generatedAt
235
+ // For date-only strings (YYYY-MM-DD), parse as local time to avoid timezone shifts
236
+ const rawDate = p.publishedDate || p.generatedAt;
237
+ let date;
238
+ if (p.publishedDate && /^\d{4}-\d{2}-\d{2}$/.test(p.publishedDate)) {
239
+ const [y, m, d] = p.publishedDate.split('-').map(Number);
240
+ date = new Date(y, m - 1, d); // local time, no UTC shift
241
+ } else {
242
+ date = new Date(rawDate);
243
+ }
244
+ const formattedDate = date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
245
+ const dateStr = p.publishedDate ? `Published ${formattedDate}` : formattedDate;
246
+ const authors = (p.authors || []).slice(0, 4).join(', ')
247
+ + (p.authors?.length > 4 ? ` +${p.authors.length - 4} more` : '');
248
+ const primary = p.color_theme?.primary || '#2563eb';
249
+ const accent = p.color_theme?.accent || '#f59e0b';
250
+
251
+ return `
252
+ <a href="./${p.slug}/index.html" class="card" style="--card-primary: ${primary}; --card-accent: ${accent}; --delay: ${idx * 80}ms;"
253
+ data-title="${escapeHtml(p.title).toLowerCase()}"
254
+ data-authors="${escapeHtml(authors).toLowerCase()}"
255
+ data-tldr="${escapeHtml(p.tldr).toLowerCase()}">
256
+ <div class="card-color-bar"></div>
257
+ <div class="card-body">
258
+ <div class="card-meta">
259
+ <span class="card-date">${dateStr}</span>
260
+ <span class="card-badge" style="background: ${primary}15; color: ${primary};">${escapeHtml((p.slug || '').split('-').slice(0, 2).join(' '))}</span>
261
+ </div>
262
+ <h2 class="card-title">${escapeHtml(p.title)}</h2>
263
+ <p class="card-authors">${escapeHtml(authors)}</p>
264
+ <p class="card-tldr">${escapeHtml(p.tldr)}</p>
265
+ <div class="card-footer">
266
+ ${p.url ? `<span class="card-link" onclick="event.preventDefault(); event.stopPropagation(); window.open('${escapeHtml(p.url)}', '_blank');">View original paper &nearr;</span>` : ''}
267
+ <span class="card-arrow">
268
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M4 10h12m0 0l-4-4m4 4l-4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
269
+ </span>
270
+ </div>
271
+ </div>
272
+ </a>`;
273
+ }).join('\n');
274
+
275
+ const emptyState = papers.length === 0
276
+ ? `<div class="empty">
277
+ <div class="empty-icon">
278
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
279
+ </div>
280
+ <h2>No papers yet</h2>
281
+ <p>Run <code>npm start</code> and paste an ArXiv URL or DOI<br/>to generate your first paper site.</p>
282
+ </div>`
283
+ : '';
284
+
285
+ const searchBar = papers.length > 0
286
+ ? `<div class="search-wrap">
287
+ <svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
288
+ <input type="text" id="search" class="search" placeholder="Search papers..." autocomplete="off" />
289
+ <span class="search-count" id="searchCount">${papers.length} paper${papers.length === 1 ? '' : 's'}</span>
290
+ </div>`
291
+ : '';
292
+
293
+ return `<!DOCTYPE html>
294
+ <html lang="en">
295
+ <head>
296
+ <meta charset="UTF-8" />
297
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
298
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
299
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
300
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&family=Playfair+Display:wght@700;800;900&display=swap" rel="stylesheet" />
301
+ <title>Easy Reads | Library</title>
302
+ <style>
303
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
304
+ body {
305
+ font-family: 'Inter', system-ui, sans-serif;
306
+ background: #0c0f1a;
307
+ color: #e2e8f0;
308
+ -webkit-font-smoothing: antialiased;
309
+ min-height: 100vh;
310
+ }
311
+
312
+ /* ── header with CLI-style banner ── */
313
+ header {
314
+ background: linear-gradient(170deg, #0c0f1a 0%, #111833 50%, #0c0f1a 100%);
315
+ padding: 3rem 1.5rem 2rem;
316
+ text-align: center;
317
+ position: relative;
318
+ overflow: hidden;
319
+ }
320
+ header::before {
321
+ content: '';
322
+ position: absolute;
323
+ inset: 0;
324
+ background:
325
+ radial-gradient(ellipse 600px 300px at 50% 40%, rgba(56,189,248,0.06) 0%, transparent 70%),
326
+ radial-gradient(ellipse 400px 250px at 70% 70%, rgba(56,189,248,0.03) 0%, transparent 70%);
327
+ pointer-events: none;
328
+ }
329
+ header::after {
330
+ content: '';
331
+ position: absolute;
332
+ inset: 0;
333
+ background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
334
+ pointer-events: none;
335
+ opacity: 0.5;
336
+ }
337
+ .banner {
338
+ position: relative;
339
+ display: inline-block;
340
+ padding: 2rem 2.5rem 1.5rem;
341
+ }
342
+ .banner-title {
343
+ font-family: 'Playfair Display', Georgia, 'Times New Roman', serif;
344
+ font-size: clamp(2.8rem, 8vw, 4.5rem);
345
+ font-weight: 900;
346
+ color: #38bdf8;
347
+ letter-spacing: 0.04em;
348
+ line-height: 1.1;
349
+ text-transform: uppercase;
350
+ text-shadow: 2px 3px 6px rgba(0,0,0,0.5);
351
+ margin: 0;
352
+ }
353
+ .banner .tagline {
354
+ font-family: 'Inter', system-ui, sans-serif;
355
+ font-size: 0.92rem;
356
+ color: rgba(56,189,248,0.55);
357
+ font-weight: 500;
358
+ margin-top: 1rem;
359
+ letter-spacing: 0.08em;
360
+ text-transform: uppercase;
361
+ }
362
+ .banner .film-strip {
363
+ margin-top: 1.25rem;
364
+ display: flex;
365
+ justify-content: center;
366
+ gap: 6px;
367
+ }
368
+ .banner .film-strip span {
369
+ width: 18px;
370
+ height: 12px;
371
+ border-radius: 2px;
372
+ border: 1.5px solid rgba(56,189,248,0.2);
373
+ display: inline-block;
374
+ }
375
+ .header-meta {
376
+ position: relative;
377
+ margin-top: 1.5rem;
378
+ display: flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ gap: 1rem;
382
+ }
383
+ .count-badge {
384
+ display: inline-flex;
385
+ align-items: center;
386
+ gap: 0.4rem;
387
+ background: rgba(56,189,248,0.1);
388
+ border: 1px solid rgba(56,189,248,0.2);
389
+ padding: 0.4rem 1rem;
390
+ border-radius: 999px;
391
+ font-size: 0.82rem;
392
+ color: rgba(56,189,248,0.8);
393
+ font-weight: 600;
394
+ }
395
+ .count-badge .dot {
396
+ width: 6px; height: 6px;
397
+ border-radius: 50%;
398
+ background: #38bdf8;
399
+ animation: pulse 2s ease-in-out infinite;
400
+ }
401
+ @keyframes pulse {
402
+ 0%, 100% { opacity: 1; transform: scale(1); }
403
+ 50% { opacity: 0.4; transform: scale(0.8); }
404
+ }
405
+
406
+ /* ── search ── */
407
+ main {
408
+ max-width: 900px;
409
+ margin: 0 auto;
410
+ padding: 2rem 1.5rem 4rem;
411
+ }
412
+ .search-wrap {
413
+ position: relative;
414
+ margin-bottom: 2rem;
415
+ }
416
+ .search-icon {
417
+ position: absolute;
418
+ left: 1rem;
419
+ top: 50%;
420
+ transform: translateY(-50%);
421
+ color: #475569;
422
+ pointer-events: none;
423
+ }
424
+ .search {
425
+ width: 100%;
426
+ padding: 0.85rem 1rem 0.85rem 2.75rem;
427
+ border: 1px solid #1e293b;
428
+ border-radius: 12px;
429
+ background: #111827;
430
+ color: #e2e8f0;
431
+ font-size: 0.95rem;
432
+ font-family: inherit;
433
+ outline: none;
434
+ transition: border-color 0.2s, box-shadow 0.2s;
435
+ }
436
+ .search::placeholder { color: #475569; }
437
+ .search:focus {
438
+ border-color: #38bdf8;
439
+ box-shadow: 0 0 0 3px rgba(56,189,248,0.1);
440
+ }
441
+ .search-count {
442
+ position: absolute;
443
+ right: 1rem;
444
+ top: 50%;
445
+ transform: translateY(-50%);
446
+ font-size: 0.78rem;
447
+ color: #475569;
448
+ font-weight: 500;
449
+ pointer-events: none;
450
+ transition: color 0.2s;
451
+ }
452
+
453
+ /* ── cards ── */
454
+ .grid {
455
+ display: flex;
456
+ flex-direction: column;
457
+ gap: 1rem;
458
+ }
459
+ .card {
460
+ display: block;
461
+ background: #151b2e;
462
+ border: 1px solid #1e293b;
463
+ border-radius: 14px;
464
+ overflow: hidden;
465
+ text-decoration: none;
466
+ color: inherit;
467
+ transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
468
+ opacity: 0;
469
+ transform: translateY(16px);
470
+ animation: cardIn 0.4s ease forwards;
471
+ animation-delay: var(--delay, 0ms);
472
+ }
473
+ @keyframes cardIn {
474
+ to { opacity: 1; transform: translateY(0); }
475
+ }
476
+ .card:hover {
477
+ transform: translateY(-4px);
478
+ box-shadow: 0 16px 40px -12px rgba(0,0,0,0.5);
479
+ border-color: var(--card-primary);
480
+ }
481
+ .card.hidden {
482
+ display: none;
483
+ }
484
+ .card-color-bar {
485
+ height: 3px;
486
+ background: linear-gradient(90deg, var(--card-primary), var(--card-accent));
487
+ opacity: 0.7;
488
+ transition: opacity 0.2s;
489
+ }
490
+ .card:hover .card-color-bar { opacity: 1; }
491
+ .card-body { padding: 1.25rem 1.5rem 1.5rem; }
492
+ .card-meta {
493
+ display: flex;
494
+ align-items: center;
495
+ gap: 0.75rem;
496
+ margin-bottom: 0.65rem;
497
+ }
498
+ .card-date {
499
+ font-size: 0.75rem;
500
+ color: #64748b;
501
+ font-weight: 500;
502
+ }
503
+ .card-badge {
504
+ font-size: 0.68rem;
505
+ font-weight: 600;
506
+ padding: 0.15rem 0.6rem;
507
+ border-radius: 999px;
508
+ text-transform: capitalize;
509
+ }
510
+ .card-title {
511
+ font-size: 1.2rem;
512
+ font-weight: 700;
513
+ line-height: 1.4;
514
+ margin-bottom: 0.3rem;
515
+ color: #f1f5f9;
516
+ transition: color 0.2s;
517
+ }
518
+ .card:hover .card-title { color: var(--card-primary); }
519
+ .card-authors {
520
+ font-size: 0.82rem;
521
+ color: #64748b;
522
+ margin-bottom: 0.75rem;
523
+ }
524
+ .card-tldr {
525
+ font-size: 0.88rem;
526
+ color: #94a3b8;
527
+ line-height: 1.65;
528
+ margin-bottom: 1rem;
529
+ }
530
+ .card-footer {
531
+ display: flex;
532
+ align-items: center;
533
+ justify-content: space-between;
534
+ }
535
+ .card-link {
536
+ font-size: 0.78rem;
537
+ color: var(--card-primary);
538
+ font-weight: 600;
539
+ cursor: pointer;
540
+ opacity: 0.8;
541
+ transition: opacity 0.2s;
542
+ }
543
+ .card-link:hover { opacity: 1; text-decoration: underline; }
544
+ .card-arrow {
545
+ color: #334155;
546
+ transition: color 0.2s, transform 0.2s;
547
+ display: flex;
548
+ }
549
+ .card:hover .card-arrow {
550
+ color: var(--card-primary);
551
+ transform: translateX(4px);
552
+ }
553
+
554
+ /* ── no results ── */
555
+ .no-results {
556
+ text-align: center;
557
+ padding: 3rem 1rem;
558
+ color: #475569;
559
+ display: none;
560
+ }
561
+ .no-results.visible { display: block; }
562
+ .no-results p { font-size: 0.95rem; }
563
+
564
+ /* ── empty state ── */
565
+ .empty {
566
+ text-align: center;
567
+ padding: 5rem 2rem;
568
+ color: #475569;
569
+ }
570
+ .empty-icon { margin-bottom: 1.5rem; opacity: 0.5; }
571
+ .empty h2 { font-size: 1.3rem; color: #94a3b8; margin-bottom: 0.75rem; font-weight: 700; }
572
+ .empty p { font-size: 0.95rem; line-height: 1.7; color: #64748b; }
573
+ .empty code {
574
+ background: #1e293b;
575
+ color: #38bdf8;
576
+ padding: 0.2rem 0.6rem;
577
+ border-radius: 6px;
578
+ font-size: 0.85rem;
579
+ font-family: 'JetBrains Mono', monospace;
580
+ }
581
+
582
+ /* ── footer ── */
583
+ footer {
584
+ text-align: center;
585
+ padding: 2rem;
586
+ color: #334155;
587
+ font-size: 0.8rem;
588
+ }
589
+ </style>
590
+ </head>
591
+ <body>
592
+ <header>
593
+ <div class="banner">
594
+ <h1 class="banner-title">Easy<br>Reads</h1>
595
+ <div class="tagline">Make beautiful sites from research papers.</div>
596
+ <div class="film-strip"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
597
+ </div>
598
+ ${papers.length > 0 ? `
599
+ <div class="header-meta">
600
+ <div class="count-badge"><span class="dot"></span> ${papers.length} paper${papers.length === 1 ? '' : 's'} in library</div>
601
+ </div>` : ''}
602
+ </header>
603
+ <main>
604
+ ${searchBar}
605
+ ${emptyState}
606
+ <div class="grid" id="grid">
607
+ ${paperCards}
608
+ </div>
609
+ <div class="no-results" id="noResults">
610
+ <p>No papers match your search.</p>
611
+ </div>
612
+ </main>
613
+ <footer>Easy Reads</footer>
614
+ <script>
615
+ const search = document.getElementById('search');
616
+ if (search) {
617
+ const cards = document.querySelectorAll('.card');
618
+ const noResults = document.getElementById('noResults');
619
+ const countEl = document.getElementById('searchCount');
620
+ const total = cards.length;
621
+
622
+ search.addEventListener('input', () => {
623
+ const q = search.value.toLowerCase().trim();
624
+ let visible = 0;
625
+ cards.forEach((card, i) => {
626
+ const text = (card.dataset.title || '') + ' ' + (card.dataset.authors || '') + ' ' + (card.dataset.tldr || '');
627
+ const show = !q || text.includes(q);
628
+ card.classList.toggle('hidden', !show);
629
+ if (show) {
630
+ card.style.setProperty('--delay', (visible * 60) + 'ms');
631
+ card.style.animation = 'none';
632
+ card.offsetHeight; /* reflow */
633
+ card.style.animation = '';
634
+ visible++;
635
+ }
636
+ });
637
+ noResults.classList.toggle('visible', visible === 0 && q.length > 0);
638
+ countEl.textContent = q
639
+ ? visible + ' of ' + total
640
+ : total + ' paper' + (total === 1 ? '' : 's');
641
+ });
642
+ }
643
+ </script>
644
+ </body>
645
+ </html>`;
646
+ }
647
+
648
+ function escapeHtml(str) {
649
+ if (!str) return '';
650
+ return str
651
+ .replace(/&/g, '&amp;')
652
+ .replace(/</g, '&lt;')
653
+ .replace(/>/g, '&gt;')
654
+ .replace(/"/g, '&quot;')
655
+ .replace(/'/g, '&#039;');
656
+ }
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
7
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
9
+ <title>Paper Site</title>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="./src/main.jsx"></script>
14
+ </body>
15
+ </html>