linkpress 0.1.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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/dist/ai.d.ts +31 -0
  4. package/dist/ai.d.ts.map +1 -0
  5. package/dist/ai.js +428 -0
  6. package/dist/ai.js.map +1 -0
  7. package/dist/commands/add.d.ts +3 -0
  8. package/dist/commands/add.d.ts.map +1 -0
  9. package/dist/commands/add.js +38 -0
  10. package/dist/commands/add.js.map +1 -0
  11. package/dist/commands/clear.d.ts +3 -0
  12. package/dist/commands/clear.d.ts.map +1 -0
  13. package/dist/commands/clear.js +25 -0
  14. package/dist/commands/clear.js.map +1 -0
  15. package/dist/commands/generate.d.ts +3 -0
  16. package/dist/commands/generate.d.ts.map +1 -0
  17. package/dist/commands/generate.js +38 -0
  18. package/dist/commands/generate.js.map +1 -0
  19. package/dist/commands/index.d.ts +9 -0
  20. package/dist/commands/index.d.ts.map +1 -0
  21. package/dist/commands/index.js +9 -0
  22. package/dist/commands/index.js.map +1 -0
  23. package/dist/commands/init.d.ts +3 -0
  24. package/dist/commands/init.d.ts.map +1 -0
  25. package/dist/commands/init.js +129 -0
  26. package/dist/commands/init.js.map +1 -0
  27. package/dist/commands/list.d.ts +3 -0
  28. package/dist/commands/list.d.ts.map +1 -0
  29. package/dist/commands/list.js +33 -0
  30. package/dist/commands/list.js.map +1 -0
  31. package/dist/commands/serve.d.ts +3 -0
  32. package/dist/commands/serve.d.ts.map +1 -0
  33. package/dist/commands/serve.js +122 -0
  34. package/dist/commands/serve.js.map +1 -0
  35. package/dist/commands/source.d.ts +3 -0
  36. package/dist/commands/source.d.ts.map +1 -0
  37. package/dist/commands/source.js +48 -0
  38. package/dist/commands/source.js.map +1 -0
  39. package/dist/commands/sync.d.ts +3 -0
  40. package/dist/commands/sync.d.ts.map +1 -0
  41. package/dist/commands/sync.js +24 -0
  42. package/dist/commands/sync.js.map +1 -0
  43. package/dist/config.d.ts +8 -0
  44. package/dist/config.d.ts.map +1 -0
  45. package/dist/config.js +47 -0
  46. package/dist/config.js.map +1 -0
  47. package/dist/db.d.ts +14 -0
  48. package/dist/db.d.ts.map +1 -0
  49. package/dist/db.js +140 -0
  50. package/dist/db.js.map +1 -0
  51. package/dist/i18n.d.ts +4 -0
  52. package/dist/i18n.d.ts.map +1 -0
  53. package/dist/i18n.js +139 -0
  54. package/dist/i18n.js.map +1 -0
  55. package/dist/index.d.ts +3 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +17 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/magazine.d.ts +6 -0
  60. package/dist/magazine.d.ts.map +1 -0
  61. package/dist/magazine.js +1751 -0
  62. package/dist/magazine.js.map +1 -0
  63. package/dist/process.d.ts +16 -0
  64. package/dist/process.d.ts.map +1 -0
  65. package/dist/process.js +77 -0
  66. package/dist/process.js.map +1 -0
  67. package/dist/scraper.d.ts +11 -0
  68. package/dist/scraper.d.ts.map +1 -0
  69. package/dist/scraper.js +159 -0
  70. package/dist/scraper.js.map +1 -0
  71. package/dist/slack/auth.d.ts +6 -0
  72. package/dist/slack/auth.d.ts.map +1 -0
  73. package/dist/slack/auth.js +373 -0
  74. package/dist/slack/auth.js.map +1 -0
  75. package/dist/slack/browser-auth.d.ts +6 -0
  76. package/dist/slack/browser-auth.d.ts.map +1 -0
  77. package/dist/slack/browser-auth.js +236 -0
  78. package/dist/slack/browser-auth.js.map +1 -0
  79. package/dist/slack/client.d.ts +45 -0
  80. package/dist/slack/client.d.ts.map +1 -0
  81. package/dist/slack/client.js +98 -0
  82. package/dist/slack/client.js.map +1 -0
  83. package/dist/slack/index.d.ts +6 -0
  84. package/dist/slack/index.d.ts.map +1 -0
  85. package/dist/slack/index.js +4 -0
  86. package/dist/slack/index.js.map +1 -0
  87. package/dist/slack/sync.d.ts +12 -0
  88. package/dist/slack/sync.d.ts.map +1 -0
  89. package/dist/slack/sync.js +182 -0
  90. package/dist/slack/sync.js.map +1 -0
  91. package/dist/types.d.ts +64 -0
  92. package/dist/types.d.ts.map +1 -0
  93. package/dist/types.js +2 -0
  94. package/dist/types.js.map +1 -0
  95. package/dist/utils.d.ts +3 -0
  96. package/dist/utils.d.ts.map +1 -0
  97. package/dist/utils.js +15 -0
  98. package/dist/utils.js.map +1 -0
  99. package/package.json +71 -0
@@ -0,0 +1,1751 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { loadConfig, ensureConfigDir } from './config.js';
4
+ import { getAllArticles } from './db.js';
5
+ import { parseSummary } from './ai.js';
6
+ export function generateMagazine(options = {}) {
7
+ const config = loadConfig();
8
+ const articles = getAllArticles(options.limit || 500);
9
+ const processedArticles = articles.filter(a => a.processedAt);
10
+ const outputDir = options.outputDir || config.output.directory;
11
+ ensureConfigDir();
12
+ if (!fs.existsSync(outputDir)) {
13
+ fs.mkdirSync(outputDir, { recursive: true });
14
+ }
15
+ const html = renderMagazineHtml(processedArticles);
16
+ const outputPath = path.join(outputDir, 'index.html');
17
+ fs.writeFileSync(outputPath, html, 'utf-8');
18
+ return outputPath;
19
+ }
20
+ function renderMagazineHtml(articles) {
21
+ const now = new Date();
22
+ const dateStr = now.toLocaleDateString('en-US', {
23
+ year: 'numeric',
24
+ month: 'long',
25
+ day: 'numeric'
26
+ });
27
+ const tagCounts = new Map();
28
+ articles.forEach(a => {
29
+ a.tags.forEach(tag => {
30
+ tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
31
+ });
32
+ });
33
+ const topTags = [...tagCounts.entries()]
34
+ .sort((a, b) => b[1] - a[1])
35
+ .slice(0, 8);
36
+ const unreadArticles = articles.filter(a => !a.readAt);
37
+ const readArticles = articles.filter(a => a.readAt);
38
+ const featuredArticle = unreadArticles[0];
39
+ const remainingUnread = unreadArticles.slice(1);
40
+ const unreadReadingTime = unreadArticles.reduce((sum, a) => sum + (a.readingTimeMinutes || 0), 0);
41
+ const issueStats = {
42
+ totalArticles: unreadArticles.length,
43
+ totalReadingTime: unreadReadingTime,
44
+ };
45
+ const featuredHtml = featuredArticle ? renderFeaturedCard(featuredArticle, issueStats) : '';
46
+ const unreadCards = remainingUnread.map((article, idx) => renderArticleCard(article, idx)).join('\n');
47
+ const readCards = readArticles.map((article, idx) => renderArticleCard(article, idx)).join('\n');
48
+ const tagFilters = topTags.map(([tag]) => `<button class="tag-btn" data-tag="${tag}">${tag}</button>`).join('\n');
49
+ return `<!DOCTYPE html>
50
+ <html lang="ko" data-theme="light">
51
+ <head>
52
+ <meta charset="UTF-8">
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
54
+ <title>LinkPress — Tech Feed</title>
55
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Crect width='200' height='200' fill='%230a0a0b' rx='24'/%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%2317ead9'/%3E%3Cstop offset='100%25' stop-color='%236078ea'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpolygon fill='url(%23g)' points='180,180 90,180 90,172 172,172 172,28 28,28 28,90 20,90 20,20 180,20'/%3E%3Cpath fill='url(%23g)' d='M95 50L95 110L125 110L125 100L108 100L108 50Z'/%3E%3C/svg%3E">
56
+ <link rel="preconnect" href="https://cdn.jsdelivr.net">
57
+ <link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css" rel="stylesheet">
58
+ <style>
59
+ :root, [data-theme="dark"] {
60
+ --bg-deep: #0a0a0b;
61
+ --bg-surface: #111113;
62
+ --bg-elevated: #18181b;
63
+ --bg-inset: rgba(255, 255, 255, 0.03);
64
+ --text-primary: #fafaf9;
65
+ --text-secondary: #a8a29e;
66
+ --text-muted: #57534e;
67
+ --accent: #f97316;
68
+ --accent-subtle: rgba(249, 115, 22, 0.12);
69
+ --border: rgba(255, 255, 255, 0.06);
70
+ --border-hover: rgba(255, 255, 255, 0.12);
71
+ --card-shadow: rgba(0, 0, 0, 0.25);
72
+ --card-shadow-hover: rgba(0, 0, 0, 0.6);
73
+ --font-main: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
74
+ --font-mono: 'Pretendard', monospace;
75
+ }
76
+
77
+ [data-theme="light"] {
78
+ --bg-deep: #f5f5f4;
79
+ --bg-surface: #ffffff;
80
+ --bg-elevated: #fafaf9;
81
+ --bg-inset: #f0f0ef;
82
+ --text-primary: #1a1a1a;
83
+ --text-secondary: #525252;
84
+ --text-muted: #a3a3a3;
85
+ --accent: #ea580c;
86
+ --accent-subtle: rgba(234, 88, 12, 0.1);
87
+ --border: rgba(0, 0, 0, 0.1);
88
+ --border-hover: rgba(0, 0, 0, 0.2);
89
+ --card-shadow: rgba(0, 0, 0, 0.08);
90
+ --card-shadow-hover: rgba(0, 0, 0, 0.15);
91
+ }
92
+
93
+ * { box-sizing: border-box; margin: 0; padding: 0; }
94
+
95
+ html {
96
+ scroll-behavior: smooth;
97
+ }
98
+
99
+ body {
100
+ font-family: var(--font-main);
101
+ background: var(--bg-deep);
102
+ color: var(--text-primary);
103
+ min-height: 100vh;
104
+ line-height: 1.7;
105
+ overflow-x: hidden;
106
+ transition: background 0.3s ease, color 0.3s ease;
107
+ }
108
+
109
+ [data-theme="dark"] body::before {
110
+ content: '';
111
+ position: fixed;
112
+ top: 0;
113
+ left: 0;
114
+ width: 100%;
115
+ height: 100%;
116
+ opacity: 0.03;
117
+ pointer-events: none;
118
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
119
+ z-index: 1000;
120
+ }
121
+
122
+ [data-theme="light"] body::before {
123
+ display: none;
124
+ }
125
+
126
+ /* Masthead */
127
+ .masthead {
128
+ padding: 2rem 3rem;
129
+ border-bottom: 1px solid var(--border);
130
+ display: flex;
131
+ justify-content: space-between;
132
+ align-items: flex-end;
133
+ position: relative;
134
+ }
135
+
136
+ .masthead::after {
137
+ content: '';
138
+ position: absolute;
139
+ bottom: 0;
140
+ left: 3rem;
141
+ width: 60px;
142
+ height: 3px;
143
+ background: var(--accent);
144
+ }
145
+
146
+ .brand {
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: 0.25rem;
150
+ }
151
+
152
+ .brand-name {
153
+ font-family: var(--font-main);
154
+ font-size: 0.7rem;
155
+ font-weight: 600;
156
+ letter-spacing: 0.2em;
157
+ text-transform: uppercase;
158
+ color: var(--text-muted);
159
+ }
160
+
161
+ .issue-title {
162
+ font-family: var(--font-main);
163
+ font-size: 2.5rem;
164
+ font-weight: 300;
165
+ letter-spacing: -0.03em;
166
+ line-height: 1;
167
+ }
168
+
169
+ .masthead-right {
170
+ display: flex;
171
+ align-items: flex-end;
172
+ gap: 1.5rem;
173
+ }
174
+
175
+ .theme-toggle {
176
+ background: var(--bg-surface);
177
+ border: 1px solid var(--border);
178
+ border-radius: 8px;
179
+ padding: 0.5rem;
180
+ cursor: pointer;
181
+ display: flex;
182
+ align-items: center;
183
+ justify-content: center;
184
+ transition: all 0.2s;
185
+ color: var(--text-secondary);
186
+ }
187
+
188
+ .theme-toggle:hover {
189
+ border-color: var(--accent);
190
+ color: var(--accent);
191
+ }
192
+
193
+ .theme-toggle svg {
194
+ width: 18px;
195
+ height: 18px;
196
+ }
197
+
198
+ .theme-toggle .icon-sun,
199
+ [data-theme="light"] .theme-toggle .icon-moon {
200
+ display: none;
201
+ }
202
+
203
+ [data-theme="light"] .theme-toggle .icon-sun {
204
+ display: block;
205
+ }
206
+
207
+ .theme-toggle .icon-moon {
208
+ display: block;
209
+ }
210
+
211
+ .masthead-meta {
212
+ text-align: right;
213
+ font-family: var(--font-mono);
214
+ font-size: 0.7rem;
215
+ color: var(--text-muted);
216
+ line-height: 1.8;
217
+ }
218
+
219
+ .masthead-meta strong {
220
+ color: var(--text-secondary);
221
+ font-weight: 500;
222
+ }
223
+
224
+ /* Hero Section */
225
+ .hero {
226
+ display: grid;
227
+ grid-template-columns: 1fr 380px;
228
+ gap: 0;
229
+ min-height: 70vh;
230
+ border-bottom: 1px solid var(--border);
231
+ }
232
+
233
+ .hero-main {
234
+ padding: 4rem 3rem;
235
+ display: flex;
236
+ flex-direction: column;
237
+ justify-content: space-between;
238
+ border-right: 1px solid var(--border);
239
+ position: relative;
240
+ }
241
+
242
+ .hero-main::before {
243
+ content: 'FEATURED';
244
+ position: absolute;
245
+ top: 4rem;
246
+ left: 3rem;
247
+ font-family: var(--font-mono);
248
+ font-size: 0.65rem;
249
+ font-weight: 500;
250
+ letter-spacing: 0.15em;
251
+ color: var(--accent);
252
+ padding: 0.35rem 0.75rem;
253
+ background: var(--accent-subtle);
254
+ border: 1px solid rgba(249, 115, 22, 0.25);
255
+ }
256
+
257
+ .hero-content {
258
+ margin-top: 5rem;
259
+ }
260
+
261
+ .hero-headline {
262
+ font-family: var(--font-main);
263
+ font-size: clamp(2.5rem, 5vw, 4rem);
264
+ font-weight: 400;
265
+ line-height: 1.1;
266
+ letter-spacing: -0.02em;
267
+ margin-bottom: 1.5rem;
268
+ max-width: 90%;
269
+ }
270
+
271
+ .hero-headline a {
272
+ color: inherit;
273
+ text-decoration: none;
274
+ background-image: linear-gradient(var(--accent), var(--accent));
275
+ background-size: 0 2px;
276
+ background-position: 0 100%;
277
+ background-repeat: no-repeat;
278
+ transition: background-size 0.4s ease;
279
+ }
280
+
281
+ .hero-headline a:hover {
282
+ background-size: 100% 2px;
283
+ }
284
+
285
+ .hero-excerpt {
286
+ font-size: 1.15rem;
287
+ color: var(--text-secondary);
288
+ line-height: 1.7;
289
+ max-width: 600px;
290
+ font-style: italic;
291
+ }
292
+
293
+ .hero-footer {
294
+ display: flex;
295
+ align-items: center;
296
+ gap: 2rem;
297
+ padding-top: 2rem;
298
+ border-top: 1px solid var(--border);
299
+ }
300
+
301
+ .hero-meta {
302
+ font-family: var(--font-mono);
303
+ font-size: 0.75rem;
304
+ color: var(--text-muted);
305
+ }
306
+
307
+ .hero-meta span {
308
+ color: var(--text-secondary);
309
+ }
310
+
311
+ .hero-tags {
312
+ display: flex;
313
+ gap: 0.5rem;
314
+ }
315
+
316
+ .hero-tag {
317
+ font-family: var(--font-mono);
318
+ font-size: 0.7rem;
319
+ color: var(--text-secondary);
320
+ padding: 0.35rem 0.7rem;
321
+ background: var(--bg-deep);
322
+ border: 1px solid var(--border);
323
+ border-radius: 6px;
324
+ text-transform: lowercase;
325
+ letter-spacing: 0.02em;
326
+ transition: all 0.2s;
327
+ }
328
+
329
+ .hero-tag:hover {
330
+ border-color: var(--accent);
331
+ color: var(--accent);
332
+ }
333
+
334
+ /* Hero Sidebar */
335
+ .hero-sidebar {
336
+ background: var(--bg-surface);
337
+ padding: 2rem;
338
+ display: flex;
339
+ flex-direction: column;
340
+ }
341
+
342
+ .sidebar-header {
343
+ font-family: var(--font-mono);
344
+ font-size: 0.65rem;
345
+ font-weight: 500;
346
+ letter-spacing: 0.15em;
347
+ text-transform: uppercase;
348
+ color: var(--text-muted);
349
+ margin-bottom: 1.5rem;
350
+ padding-bottom: 1rem;
351
+ border-bottom: 1px solid var(--border);
352
+ }
353
+
354
+ .stats-grid {
355
+ display: grid;
356
+ grid-template-columns: 1fr 1fr;
357
+ gap: 1.5rem;
358
+ margin-bottom: 2rem;
359
+ }
360
+
361
+ .stat-item {
362
+ padding: 1rem;
363
+ background: var(--bg-elevated);
364
+ position: relative;
365
+ }
366
+
367
+ .stat-item::before {
368
+ content: '';
369
+ position: absolute;
370
+ top: 0;
371
+ left: 0;
372
+ width: 2px;
373
+ height: 100%;
374
+ background: var(--accent);
375
+ opacity: 0;
376
+ transition: opacity 0.3s;
377
+ }
378
+
379
+ .stat-item:hover::before {
380
+ opacity: 1;
381
+ }
382
+
383
+ .stat-number {
384
+ font-family: var(--font-main);
385
+ font-size: 2rem;
386
+ font-weight: 600;
387
+ color: var(--text-primary);
388
+ line-height: 1;
389
+ margin-bottom: 0.25rem;
390
+ }
391
+
392
+ .stat-label {
393
+ font-family: var(--font-mono);
394
+ font-size: 0.65rem;
395
+ color: var(--text-muted);
396
+ text-transform: uppercase;
397
+ letter-spacing: 0.1em;
398
+ }
399
+
400
+ /* Filters & Tabs */
401
+ .filters-section {
402
+ padding: 1.5rem 3rem;
403
+ border-bottom: 1px solid var(--border);
404
+ background: var(--bg-surface);
405
+ }
406
+
407
+ .filters-row {
408
+ display: flex;
409
+ align-items: center;
410
+ justify-content: space-between;
411
+ gap: 1rem;
412
+ }
413
+
414
+ .tab-buttons {
415
+ display: flex;
416
+ gap: 0.5rem;
417
+ }
418
+
419
+ .tab-btn {
420
+ font-family: var(--font-mono);
421
+ font-size: 0.8rem;
422
+ font-weight: 500;
423
+ padding: 0.6rem 1.2rem;
424
+ background: transparent;
425
+ border: 1px solid var(--border);
426
+ border-radius: 8px;
427
+ color: var(--text-secondary);
428
+ cursor: pointer;
429
+ transition: all 0.2s;
430
+ display: flex;
431
+ align-items: center;
432
+ gap: 0.5rem;
433
+ }
434
+
435
+ .tab-btn:hover {
436
+ background: var(--bg-elevated);
437
+ border-color: var(--border-hover);
438
+ }
439
+
440
+ .tab-btn.active {
441
+ background: var(--accent-subtle);
442
+ border-color: var(--accent);
443
+ color: var(--accent);
444
+ }
445
+
446
+ .tab-count {
447
+ font-size: 0.7rem;
448
+ padding: 0.15rem 0.5rem;
449
+ background: var(--bg-deep);
450
+ border-radius: 10px;
451
+ color: var(--text-muted);
452
+ }
453
+
454
+ .tab-btn.active .tab-count {
455
+ background: rgba(249, 115, 22, 0.2);
456
+ color: var(--accent);
457
+ }
458
+
459
+ .tag-filters {
460
+ display: flex;
461
+ align-items: center;
462
+ gap: 0.75rem;
463
+ overflow-x: auto;
464
+ scrollbar-width: none;
465
+ }
466
+
467
+ .tag-filters::-webkit-scrollbar {
468
+ display: none;
469
+ }
470
+
471
+ .filter-label {
472
+ font-family: var(--font-mono);
473
+ font-size: 0.65rem;
474
+ color: var(--text-muted);
475
+ text-transform: uppercase;
476
+ letter-spacing: 0.1em;
477
+ white-space: nowrap;
478
+ }
479
+
480
+ .tag-btn {
481
+ font-family: var(--font-mono);
482
+ font-size: 0.7rem;
483
+ padding: 0.35rem 0.7rem;
484
+ background: var(--bg-deep);
485
+ border: 1px solid var(--border);
486
+ border-radius: 6px;
487
+ color: var(--text-secondary);
488
+ cursor: pointer;
489
+ transition: all 0.2s;
490
+ white-space: nowrap;
491
+ }
492
+
493
+ .tag-btn:hover, .tag-btn.active {
494
+ background: var(--bg-elevated);
495
+ border-color: var(--accent);
496
+ color: var(--accent);
497
+ }
498
+
499
+ .hidden {
500
+ display: none !important;
501
+ }
502
+
503
+ /* Articles Grid - Magazine Style 2-Column Layout */
504
+ .articles-section {
505
+ padding: 4rem 5rem;
506
+ max-width: 1400px;
507
+ margin: 0 auto;
508
+ }
509
+
510
+ .section-header {
511
+ display: flex;
512
+ justify-content: space-between;
513
+ align-items: flex-end;
514
+ margin-bottom: 3rem;
515
+ padding-bottom: 1.5rem;
516
+ border-bottom: 1px solid var(--border);
517
+ }
518
+
519
+ .section-title {
520
+ font-family: var(--font-main);
521
+ font-size: 0.75rem;
522
+ font-weight: 600;
523
+ letter-spacing: 0.2em;
524
+ text-transform: uppercase;
525
+ color: var(--text-muted);
526
+ }
527
+
528
+ .section-count {
529
+ font-family: var(--font-mono);
530
+ font-size: 0.75rem;
531
+ color: var(--text-muted);
532
+ }
533
+
534
+ .articles-grid {
535
+ display: grid;
536
+ grid-template-columns: repeat(2, 1fr);
537
+ gap: 3rem 2.5rem;
538
+ }
539
+
540
+ .article-card {
541
+ background: var(--bg-surface);
542
+ display: flex;
543
+ flex-direction: column;
544
+ min-height: auto;
545
+ position: relative;
546
+ border: 1px solid var(--border);
547
+ border-radius: 16px;
548
+ transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
549
+ box-shadow: 0 4px 20px var(--card-shadow);
550
+ overflow: hidden;
551
+ }
552
+
553
+ .article-card:not(.has-image) {
554
+ padding: 2.5rem;
555
+ }
556
+
557
+ .article-card.has-image .article-content {
558
+ padding: 1.5rem 2rem 2rem;
559
+ }
560
+
561
+ .article-card:hover {
562
+ background: var(--bg-elevated);
563
+ border-color: rgba(249, 115, 22, 0.3);
564
+ transform: translateY(-6px);
565
+ box-shadow: 0 20px 50px var(--card-shadow-hover), 0 0 0 1px rgba(249, 115, 22, 0.1);
566
+ }
567
+
568
+ .article-card::before {
569
+ content: attr(data-index);
570
+ font-family: var(--font-mono);
571
+ font-size: 0.7rem;
572
+ color: var(--text-muted);
573
+ position: absolute;
574
+ top: 1.5rem;
575
+ right: 1.5rem;
576
+ opacity: 0.4;
577
+ z-index: 1;
578
+ }
579
+
580
+ .article-card.has-image::before {
581
+ background: var(--bg-surface);
582
+ padding: 0.25rem 0.5rem;
583
+ border-radius: 4px;
584
+ }
585
+
586
+ .article-image {
587
+ width: 100%;
588
+ height: 200px;
589
+ overflow: hidden;
590
+ background: var(--bg-inset);
591
+ }
592
+
593
+ .article-image img {
594
+ width: 100%;
595
+ height: 100%;
596
+ object-fit: cover;
597
+ object-position: top;
598
+ transition: transform 0.3s ease;
599
+ }
600
+
601
+ .article-image.error {
602
+ display: none;
603
+ }
604
+
605
+ .article-card.has-image:has(.article-image.error) {
606
+ padding: 2.5rem;
607
+ }
608
+
609
+ .article-card.has-image:has(.article-image.error) .article-content {
610
+ padding: 0;
611
+ }
612
+
613
+ .article-card:hover .article-image img {
614
+ transform: scale(1.05);
615
+ }
616
+
617
+ .article-content {
618
+ display: flex;
619
+ flex-direction: column;
620
+ flex-grow: 1;
621
+ }
622
+
623
+ .article-meta-row {
624
+ display: flex;
625
+ align-items: center;
626
+ gap: 1rem;
627
+ margin-bottom: 1.25rem;
628
+ }
629
+
630
+ .article-difficulty {
631
+ font-family: var(--font-mono);
632
+ font-size: 0.75rem;
633
+ font-weight: 700;
634
+ letter-spacing: 0.1em;
635
+ text-transform: uppercase;
636
+ display: inline-flex;
637
+ align-items: center;
638
+ gap: 0.4rem;
639
+ padding: 0.5rem 0.9rem;
640
+ border-radius: 6px;
641
+ }
642
+
643
+ .difficulty-beginner {
644
+ color: #22c55e;
645
+ background: rgba(34, 197, 94, 0.2);
646
+ border: 1px solid rgba(34, 197, 94, 0.4);
647
+ text-shadow: 0 0 20px rgba(34, 197, 94, 0.5);
648
+ }
649
+ .difficulty-intermediate {
650
+ color: #facc15;
651
+ background: rgba(250, 204, 21, 0.2);
652
+ border: 1px solid rgba(250, 204, 21, 0.4);
653
+ text-shadow: 0 0 20px rgba(250, 204, 21, 0.5);
654
+ }
655
+ .difficulty-advanced {
656
+ color: #ef4444;
657
+ background: rgba(239, 68, 68, 0.2);
658
+ border: 1px solid rgba(239, 68, 68, 0.4);
659
+ text-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
660
+ }
661
+
662
+ .article-headline {
663
+ font-family: var(--font-main);
664
+ font-size: 1.5rem;
665
+ font-weight: 600;
666
+ line-height: 1.4;
667
+ margin-bottom: 1rem;
668
+ letter-spacing: -0.01em;
669
+ }
670
+
671
+ .article-headline a {
672
+ color: var(--text-primary);
673
+ text-decoration: none;
674
+ transition: color 0.2s;
675
+ }
676
+
677
+ .article-headline a:hover {
678
+ color: var(--accent);
679
+ }
680
+
681
+ .article-tldr {
682
+ font-size: 1rem;
683
+ color: var(--text-secondary);
684
+ line-height: 1.75;
685
+ margin-bottom: 1.5rem;
686
+ }
687
+
688
+ .article-key-points {
689
+ margin-bottom: 1.25rem;
690
+ padding: 1.25rem;
691
+ background: var(--bg-inset);
692
+ border-radius: 10px;
693
+ border: 1px solid var(--border);
694
+ }
695
+
696
+ .key-point-item {
697
+ font-family: var(--font-main);
698
+ font-size: 0.9rem;
699
+ color: var(--text-secondary);
700
+ padding: 0.5rem 0;
701
+ padding-left: 1.5rem;
702
+ position: relative;
703
+ line-height: 1.6;
704
+ }
705
+
706
+ .key-point-item::before {
707
+ content: '→';
708
+ position: absolute;
709
+ left: 0;
710
+ color: var(--accent);
711
+ font-size: 0.85rem;
712
+ }
713
+
714
+ .article-why-matters {
715
+ background: var(--bg-inset);
716
+ padding: 1rem 1.25rem;
717
+ margin-bottom: 1.25rem;
718
+ border-radius: 10px;
719
+ border: 1px dashed var(--border);
720
+ }
721
+
722
+ .why-label {
723
+ font-family: var(--font-mono);
724
+ font-size: 0.65rem;
725
+ color: var(--text-muted);
726
+ text-transform: uppercase;
727
+ letter-spacing: 0.12em;
728
+ margin-bottom: 0.4rem;
729
+ font-weight: 500;
730
+ }
731
+
732
+ .why-text {
733
+ font-size: 0.9rem;
734
+ color: var(--text-secondary);
735
+ line-height: 1.6;
736
+ }
737
+
738
+ .article-quote {
739
+ color: var(--text-secondary);
740
+ font-size: 0.9rem;
741
+ padding: 1rem 1.25rem;
742
+ background: var(--bg-inset);
743
+ border: 1px solid var(--border);
744
+ border-radius: 8px;
745
+ margin-bottom: 1.25rem;
746
+ line-height: 1.6;
747
+ position: relative;
748
+ margin-left: 0.5rem;
749
+ }
750
+
751
+ .article-quote::before {
752
+ content: '"';
753
+ position: absolute;
754
+ left: -0.25rem;
755
+ top: 0.25rem;
756
+ font-size: 2rem;
757
+ color: var(--text-muted);
758
+ opacity: 0.5;
759
+ font-family: Georgia, serif;
760
+ line-height: 1;
761
+ }
762
+
763
+ .article-footer {
764
+ margin-top: auto;
765
+ padding-top: 1.25rem;
766
+ border-top: 1px solid var(--border);
767
+ display: flex;
768
+ justify-content: space-between;
769
+ align-items: center;
770
+ }
771
+
772
+ .article-source-info {
773
+ display: flex;
774
+ align-items: center;
775
+ gap: 0.5rem;
776
+ }
777
+
778
+ .article-source-label {
779
+ font-family: var(--font-mono);
780
+ font-size: 0.7rem;
781
+ font-weight: 600;
782
+ color: var(--accent);
783
+ background: var(--accent-subtle);
784
+ padding: 0.25rem 0.5rem;
785
+ border-radius: 4px;
786
+ text-transform: uppercase;
787
+ letter-spacing: 0.05em;
788
+ }
789
+
790
+ .article-source-divider {
791
+ color: var(--text-muted);
792
+ }
793
+
794
+ .article-source {
795
+ font-family: var(--font-mono);
796
+ font-size: 0.75rem;
797
+ color: var(--text-muted);
798
+ }
799
+
800
+ .article-reading-time {
801
+ font-family: var(--font-mono);
802
+ font-size: 0.75rem;
803
+ color: var(--text-muted);
804
+ background: var(--bg-inset);
805
+ border: 1px solid var(--border);
806
+ padding: 0.35rem 0.65rem;
807
+ border-radius: 6px;
808
+ }
809
+
810
+ .article-tags {
811
+ display: flex;
812
+ flex-wrap: wrap;
813
+ gap: 0.5rem;
814
+ margin-top: 1rem;
815
+ }
816
+
817
+ .article-tag {
818
+ font-family: var(--font-mono);
819
+ font-size: 0.7rem;
820
+ color: var(--text-secondary);
821
+ padding: 0.35rem 0.7rem;
822
+ background: var(--bg-deep);
823
+ border-radius: 6px;
824
+ border: 1px solid var(--border);
825
+ transition: all 0.2s;
826
+ }
827
+
828
+ .article-tag:hover {
829
+ border-color: var(--accent);
830
+ color: var(--accent);
831
+ }
832
+
833
+ .read-toggle-btn {
834
+ position: absolute;
835
+ top: 1rem;
836
+ left: 1rem;
837
+ width: 28px;
838
+ height: 28px;
839
+ border-radius: 6px;
840
+ border: 2px solid var(--border);
841
+ background: var(--bg-surface);
842
+ cursor: pointer;
843
+ display: flex;
844
+ align-items: center;
845
+ justify-content: center;
846
+ transition: all 0.2s;
847
+ z-index: 2;
848
+ }
849
+
850
+ .read-toggle-btn::after {
851
+ content: 'Mark as read';
852
+ position: absolute;
853
+ left: 100%;
854
+ margin-left: 8px;
855
+ padding: 4px 8px;
856
+ background: var(--bg-elevated);
857
+ border: 1px solid var(--border);
858
+ border-radius: 4px;
859
+ font-family: var(--font-mono);
860
+ font-size: 0.65rem;
861
+ color: var(--text-secondary);
862
+ white-space: nowrap;
863
+ opacity: 0;
864
+ pointer-events: none;
865
+ transition: opacity 0.2s;
866
+ }
867
+
868
+ .read-toggle-btn:hover::after {
869
+ opacity: 1;
870
+ }
871
+
872
+ .read-toggle-btn:hover {
873
+ border-color: var(--accent);
874
+ background: var(--accent-subtle);
875
+ }
876
+
877
+ .read-toggle-btn svg {
878
+ width: 14px;
879
+ height: 14px;
880
+ color: var(--text-muted);
881
+ opacity: 0;
882
+ transition: opacity 0.2s;
883
+ }
884
+
885
+ .read-toggle-btn:hover svg {
886
+ opacity: 0.6;
887
+ color: var(--accent);
888
+ }
889
+
890
+ .article-card[data-read="true"] .read-toggle-btn {
891
+ border-color: var(--accent);
892
+ background: var(--accent);
893
+ }
894
+
895
+ .article-card[data-read="true"] .read-toggle-btn::after {
896
+ content: 'Mark as unread';
897
+ }
898
+
899
+ .article-card[data-read="true"] .read-toggle-btn svg {
900
+ color: white;
901
+ opacity: 1;
902
+ }
903
+
904
+ .article-card[data-read="true"] .read-toggle-btn:hover {
905
+ background: var(--accent-subtle);
906
+ border-color: var(--accent);
907
+ }
908
+
909
+ .article-card[data-read="true"] .read-toggle-btn:hover svg {
910
+ color: var(--accent);
911
+ }
912
+
913
+ /* Footer */
914
+ footer {
915
+ padding: 4rem 3rem;
916
+ border-top: 1px solid var(--border);
917
+ display: flex;
918
+ justify-content: space-between;
919
+ align-items: center;
920
+ }
921
+
922
+ .footer-brand {
923
+ font-family: var(--font-main);
924
+ font-size: 0.7rem;
925
+ letter-spacing: 0.15em;
926
+ text-transform: uppercase;
927
+ color: var(--text-muted);
928
+ }
929
+
930
+ .footer-brand a {
931
+ color: var(--accent);
932
+ text-decoration: none;
933
+ }
934
+
935
+ .footer-brand a:hover {
936
+ text-decoration: underline;
937
+ }
938
+
939
+ .footer-info {
940
+ font-family: var(--font-mono);
941
+ font-size: 0.65rem;
942
+ color: var(--text-muted);
943
+ }
944
+
945
+ /* Animations */
946
+ @keyframes fadeInUp {
947
+ from {
948
+ opacity: 0;
949
+ transform: translateY(20px);
950
+ }
951
+ to {
952
+ opacity: 1;
953
+ transform: translateY(0);
954
+ }
955
+ }
956
+
957
+ .article-card {
958
+ animation: fadeInUp 0.6s ease forwards;
959
+ opacity: 0;
960
+ }
961
+
962
+ .article-card:nth-child(1) { animation-delay: 0.1s; }
963
+ .article-card:nth-child(2) { animation-delay: 0.2s; }
964
+ .article-card:nth-child(3) { animation-delay: 0.3s; }
965
+ .article-card:nth-child(4) { animation-delay: 0.4s; }
966
+ .article-card:nth-child(5) { animation-delay: 0.5s; }
967
+ .article-card:nth-child(6) { animation-delay: 0.6s; }
968
+
969
+ /* Responsive */
970
+ @media (max-width: 1100px) {
971
+ .articles-section {
972
+ padding: 3rem;
973
+ }
974
+
975
+ .articles-grid {
976
+ gap: 2.5rem 2rem;
977
+ }
978
+
979
+ .article-card {
980
+ padding: 2rem;
981
+ }
982
+
983
+ .article-headline {
984
+ font-size: 1.35rem;
985
+ }
986
+ }
987
+
988
+ @media (max-width: 900px) {
989
+ .hero {
990
+ grid-template-columns: 1fr;
991
+ min-height: auto;
992
+ }
993
+
994
+ .hero-main {
995
+ border-right: none;
996
+ border-bottom: 1px solid var(--border);
997
+ }
998
+
999
+ .hero-sidebar {
1000
+ padding: 2rem 3rem;
1001
+ }
1002
+
1003
+ .stats-grid {
1004
+ grid-template-columns: repeat(4, 1fr);
1005
+ }
1006
+
1007
+ .articles-grid {
1008
+ grid-template-columns: 1fr;
1009
+ gap: 2rem;
1010
+ }
1011
+
1012
+ .article-card {
1013
+ max-width: 640px;
1014
+ }
1015
+ }
1016
+
1017
+ @media (max-width: 768px) {
1018
+ .masthead {
1019
+ flex-direction: column;
1020
+ align-items: flex-start;
1021
+ gap: 1rem;
1022
+ padding: 1.5rem 1.5rem;
1023
+ }
1024
+
1025
+ .masthead::after {
1026
+ left: 1.5rem;
1027
+ }
1028
+
1029
+ .masthead-meta {
1030
+ text-align: left;
1031
+ }
1032
+
1033
+ .hero {
1034
+ display: block;
1035
+ }
1036
+
1037
+ .hero-main {
1038
+ padding: 1.5rem;
1039
+ border-bottom: 1px solid var(--border);
1040
+ }
1041
+
1042
+ .hero-main::before {
1043
+ top: 1.5rem;
1044
+ left: 1.5rem;
1045
+ font-size: 0.6rem;
1046
+ padding: 0.25rem 0.5rem;
1047
+ }
1048
+
1049
+ .hero-content {
1050
+ margin-top: 2.5rem;
1051
+ }
1052
+
1053
+ .hero-headline {
1054
+ font-size: 1.5rem;
1055
+ margin-bottom: 1rem;
1056
+ }
1057
+
1058
+ .hero-excerpt {
1059
+ font-size: 1rem;
1060
+ display: -webkit-box;
1061
+ -webkit-line-clamp: 3;
1062
+ -webkit-box-orient: vertical;
1063
+ overflow: hidden;
1064
+ }
1065
+
1066
+ .hero-footer {
1067
+ flex-direction: column;
1068
+ align-items: flex-start;
1069
+ gap: 1rem;
1070
+ padding-top: 1rem;
1071
+ }
1072
+
1073
+ .hero-sidebar {
1074
+ padding: 1.5rem;
1075
+ display: flex;
1076
+ flex-direction: row;
1077
+ align-items: center;
1078
+ gap: 1.5rem;
1079
+ }
1080
+
1081
+ .sidebar-header {
1082
+ margin-bottom: 0;
1083
+ padding-bottom: 0;
1084
+ border-bottom: none;
1085
+ white-space: nowrap;
1086
+ }
1087
+
1088
+ .stats-grid {
1089
+ display: flex;
1090
+ gap: 1.5rem;
1091
+ margin-bottom: 0;
1092
+ }
1093
+
1094
+ .stat-item {
1095
+ padding: 0;
1096
+ background: none;
1097
+ display: flex;
1098
+ align-items: baseline;
1099
+ gap: 0.4rem;
1100
+ }
1101
+
1102
+ .stat-item::before {
1103
+ display: none;
1104
+ }
1105
+
1106
+ .stat-number {
1107
+ font-size: 1.25rem;
1108
+ }
1109
+
1110
+ .stat-label {
1111
+ font-size: 0.6rem;
1112
+ }
1113
+
1114
+ .filters-section {
1115
+ padding: 1rem 1.5rem;
1116
+ }
1117
+
1118
+ .articles-section {
1119
+ padding: 2rem 1.5rem;
1120
+ }
1121
+
1122
+ .section-header {
1123
+ margin-bottom: 2rem;
1124
+ }
1125
+
1126
+ .articles-grid {
1127
+ gap: 1.5rem;
1128
+ }
1129
+
1130
+ .article-card {
1131
+ border-radius: 12px;
1132
+ max-width: none;
1133
+ }
1134
+
1135
+ .article-card:not(.has-image) {
1136
+ padding: 1.75rem;
1137
+ }
1138
+
1139
+ .article-card.has-image .article-content {
1140
+ padding: 1.25rem 1.5rem 1.5rem;
1141
+ }
1142
+
1143
+ .article-image {
1144
+ height: 160px;
1145
+ }
1146
+
1147
+ .article-headline {
1148
+ font-size: 1.25rem;
1149
+ }
1150
+
1151
+ .article-tldr {
1152
+ font-size: 0.95rem;
1153
+ }
1154
+
1155
+ .article-key-points {
1156
+ padding: 1rem;
1157
+ }
1158
+
1159
+ .key-point-item {
1160
+ font-size: 0.85rem;
1161
+ }
1162
+
1163
+ .article-source-info {
1164
+ flex-wrap: wrap;
1165
+ }
1166
+
1167
+ footer {
1168
+ flex-direction: column;
1169
+ gap: 1rem;
1170
+ padding: 2rem 1.5rem;
1171
+ }
1172
+ }
1173
+
1174
+ .pagination {
1175
+ display: flex;
1176
+ justify-content: center;
1177
+ align-items: center;
1178
+ gap: 0.5rem;
1179
+ margin-top: 3rem;
1180
+ padding-top: 2rem;
1181
+ border-top: 1px solid var(--border);
1182
+ }
1183
+
1184
+ .pagination-btn {
1185
+ font-family: var(--font-mono);
1186
+ font-size: 0.8rem;
1187
+ padding: 0.6rem 1rem;
1188
+ background: var(--bg-surface);
1189
+ border: 1px solid var(--border);
1190
+ border-radius: 6px;
1191
+ color: var(--text-secondary);
1192
+ cursor: pointer;
1193
+ transition: all 0.2s;
1194
+ }
1195
+
1196
+ .pagination-btn:hover:not(:disabled) {
1197
+ background: var(--bg-elevated);
1198
+ border-color: var(--accent);
1199
+ color: var(--accent);
1200
+ }
1201
+
1202
+ .pagination-btn:disabled {
1203
+ opacity: 0.4;
1204
+ cursor: not-allowed;
1205
+ }
1206
+
1207
+ .pagination-info {
1208
+ font-family: var(--font-mono);
1209
+ font-size: 0.75rem;
1210
+ color: var(--text-muted);
1211
+ padding: 0 1rem;
1212
+ }
1213
+
1214
+ /* Empty state */
1215
+ .empty-state {
1216
+ grid-column: 1 / -1;
1217
+ text-align: center;
1218
+ padding: 6rem 2rem;
1219
+ color: var(--text-muted);
1220
+ }
1221
+
1222
+ .empty-state-icon {
1223
+ font-size: 3rem;
1224
+ margin-bottom: 1rem;
1225
+ opacity: 0.3;
1226
+ }
1227
+
1228
+ .empty-state-text {
1229
+ font-family: var(--font-mono);
1230
+ font-size: 0.85rem;
1231
+ }
1232
+ </style>
1233
+ </head>
1234
+ <body>
1235
+ <header class="masthead">
1236
+ <div class="brand">
1237
+ <span class="brand-name">LinkPress</span>
1238
+ <h1 class="issue-title">Tech Briefing</h1>
1239
+ </div>
1240
+ <div class="masthead-right">
1241
+ <div class="masthead-meta">
1242
+ <div>${dateStr}</div>
1243
+ <div>${articles.length} articles curated</div>
1244
+ </div>
1245
+ <button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
1246
+ <svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1247
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
1248
+ </svg>
1249
+ <svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1250
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
1251
+ </svg>
1252
+ </button>
1253
+ </div>
1254
+ </header>
1255
+
1256
+ ${featuredHtml}
1257
+
1258
+ <section class="filters-section">
1259
+ <div class="filters-row">
1260
+ <div class="tab-buttons">
1261
+ <button class="tab-btn active" data-tab="unread">Unread <span class="tab-count" id="unread-count">${unreadArticles.length}</span></button>
1262
+ <button class="tab-btn" data-tab="read">Read <span class="tab-count" id="read-count">${readArticles.length}</span></button>
1263
+ </div>
1264
+ <div class="tag-filters">
1265
+ <span class="filter-label">Filter by</span>
1266
+ <button class="tag-btn active" data-tag="all">All</button>
1267
+ ${tagFilters}
1268
+ </div>
1269
+ </div>
1270
+ </section>
1271
+
1272
+ <main class="articles-section">
1273
+ <div class="section-header">
1274
+ <h2 class="section-title">Latest Articles</h2>
1275
+ <span class="section-count" id="section-count">${remainingUnread.length} stories</span>
1276
+ </div>
1277
+ <div class="articles-grid" id="unread-grid">
1278
+ ${unreadCards}
1279
+ </div>
1280
+ <div class="articles-grid hidden" id="read-grid">
1281
+ ${readCards}
1282
+ </div>
1283
+ <div class="empty-state hidden" id="empty-unread">
1284
+ <div class="empty-state-icon">◯</div>
1285
+ <p class="empty-state-text">No unread articles. All caught up!</p>
1286
+ </div>
1287
+ <div class="empty-state hidden" id="empty-read">
1288
+ <div class="empty-state-icon">✓</div>
1289
+ <p class="empty-state-text">No read articles yet.</p>
1290
+ </div>
1291
+ <div class="pagination" id="pagination">
1292
+ <button class="pagination-btn" id="prev-btn" disabled>← Prev</button>
1293
+ <span class="pagination-info" id="page-info">Page 1 of 1</span>
1294
+ <button class="pagination-btn" id="next-btn">Next →</button>
1295
+ </div>
1296
+ </main>
1297
+
1298
+ <footer>
1299
+ <div class="footer-brand">
1300
+ Curated by <a href="https://github.com/mindori/linkpress">LinkPress</a>
1301
+ </div>
1302
+ <div class="footer-info">
1303
+ <span id="footer-reading-time">${unreadReadingTime}</span> min total reading time
1304
+ </div>
1305
+ </footer>
1306
+
1307
+ <script>
1308
+ (function() {
1309
+ const html = document.documentElement;
1310
+ const themeToggle = document.getElementById('theme-toggle');
1311
+ const THEME_KEY = 'linkpress-theme';
1312
+
1313
+ function getPreferredTheme() {
1314
+ const stored = localStorage.getItem(THEME_KEY);
1315
+ if (stored) return stored;
1316
+ return 'light';
1317
+ }
1318
+
1319
+ function setTheme(theme) {
1320
+ html.setAttribute('data-theme', theme);
1321
+ localStorage.setItem(THEME_KEY, theme);
1322
+ }
1323
+
1324
+ setTheme(getPreferredTheme());
1325
+
1326
+ themeToggle.addEventListener('click', () => {
1327
+ const current = html.getAttribute('data-theme') || 'light';
1328
+ setTheme(current === 'dark' ? 'light' : 'dark');
1329
+ });
1330
+
1331
+ let currentTab = 'unread';
1332
+ let currentPage = 1;
1333
+ const ITEMS_PER_PAGE = 20;
1334
+
1335
+ const unreadGrid = document.getElementById('unread-grid');
1336
+ const readGrid = document.getElementById('read-grid');
1337
+ const emptyUnread = document.getElementById('empty-unread');
1338
+ const emptyRead = document.getElementById('empty-read');
1339
+ const unreadCountEl = document.getElementById('unread-count');
1340
+ const readCountEl = document.getElementById('read-count');
1341
+ const sectionCount = document.getElementById('section-count');
1342
+ const footerReadingTime = document.getElementById('footer-reading-time');
1343
+ const statsArticles = document.querySelector('.stat-number');
1344
+ const statsMinutes = document.querySelectorAll('.stat-number')[1];
1345
+ const prevBtn = document.getElementById('prev-btn');
1346
+ const nextBtn = document.getElementById('next-btn');
1347
+ const pageInfo = document.getElementById('page-info');
1348
+
1349
+ function updateEmptyStates() {
1350
+ const unreadCards = unreadGrid.querySelectorAll('.article-card');
1351
+ const readCards = readGrid.querySelectorAll('.article-card');
1352
+
1353
+ if (currentTab === 'unread') {
1354
+ emptyUnread.classList.toggle('hidden', unreadCards.length > 0);
1355
+ emptyRead.classList.add('hidden');
1356
+ } else {
1357
+ emptyRead.classList.toggle('hidden', readCards.length > 0);
1358
+ emptyUnread.classList.add('hidden');
1359
+ }
1360
+ }
1361
+
1362
+ function updatePagination() {
1363
+ const grid = currentTab === 'unread' ? unreadGrid : readGrid;
1364
+ const allCards = Array.from(grid.querySelectorAll('.article-card'));
1365
+ const totalPages = Math.ceil(allCards.length / ITEMS_PER_PAGE) || 1;
1366
+
1367
+ if (currentPage > totalPages) currentPage = totalPages;
1368
+ if (currentPage < 1) currentPage = 1;
1369
+
1370
+ allCards.forEach((card, idx) => {
1371
+ const pageOfCard = Math.floor(idx / ITEMS_PER_PAGE) + 1;
1372
+ card.style.display = pageOfCard === currentPage ? 'flex' : 'none';
1373
+ });
1374
+
1375
+ prevBtn.disabled = currentPage <= 1;
1376
+ nextBtn.disabled = currentPage >= totalPages;
1377
+ pageInfo.textContent = 'Page ' + currentPage + ' of ' + totalPages;
1378
+
1379
+ const start = (currentPage - 1) * ITEMS_PER_PAGE + 1;
1380
+ const end = Math.min(currentPage * ITEMS_PER_PAGE, allCards.length);
1381
+ sectionCount.textContent = start + '-' + end + ' of ' + allCards.length + ' stories';
1382
+ }
1383
+
1384
+ prevBtn.addEventListener('click', () => {
1385
+ if (currentPage > 1) {
1386
+ currentPage--;
1387
+ updatePagination();
1388
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1389
+ }
1390
+ });
1391
+
1392
+ nextBtn.addEventListener('click', () => {
1393
+ const grid = currentTab === 'unread' ? unreadGrid : readGrid;
1394
+ const totalPages = Math.ceil(grid.querySelectorAll('.article-card').length / ITEMS_PER_PAGE);
1395
+ if (currentPage < totalPages) {
1396
+ currentPage++;
1397
+ updatePagination();
1398
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1399
+ }
1400
+ });
1401
+
1402
+ function updateStats() {
1403
+ const unreadCards = unreadGrid.querySelectorAll('.article-card');
1404
+ const readCards = readGrid.querySelectorAll('.article-card');
1405
+
1406
+ let unreadTime = 0;
1407
+ unreadCards.forEach(card => {
1408
+ unreadTime += parseInt(card.dataset.readingTime) || 0;
1409
+ });
1410
+
1411
+ const featuredTime = parseInt(document.querySelector('.hero')?.dataset?.readingTime) || 0;
1412
+ const totalUnread = unreadCards.length + (document.querySelector('.hero[data-read="false"]') ? 1 : 0);
1413
+ const totalUnreadTime = unreadTime + featuredTime;
1414
+
1415
+ const featuredIsUnread = document.querySelector('.hero[data-read="false"]') ? 1 : 0;
1416
+ unreadCountEl.textContent = unreadCards.length + featuredIsUnread;
1417
+ readCountEl.textContent = readCards.length;
1418
+
1419
+ if (currentTab === 'unread') {
1420
+ sectionCount.textContent = unreadCards.length + ' stories';
1421
+ } else {
1422
+ sectionCount.textContent = readCards.length + ' stories';
1423
+ }
1424
+
1425
+ footerReadingTime.textContent = totalUnreadTime;
1426
+ if (statsArticles) statsArticles.textContent = totalUnread;
1427
+ if (statsMinutes) statsMinutes.textContent = totalUnreadTime;
1428
+
1429
+ updateEmptyStates();
1430
+ updatePagination();
1431
+ }
1432
+
1433
+ function switchTab(tab) {
1434
+ currentTab = tab;
1435
+ currentPage = 1;
1436
+ document.querySelectorAll('.tab-btn').forEach(btn => {
1437
+ btn.classList.toggle('active', btn.dataset.tab === tab);
1438
+ });
1439
+
1440
+ if (tab === 'unread') {
1441
+ unreadGrid.classList.remove('hidden');
1442
+ readGrid.classList.add('hidden');
1443
+ } else {
1444
+ unreadGrid.classList.add('hidden');
1445
+ readGrid.classList.remove('hidden');
1446
+ }
1447
+
1448
+ updatePagination();
1449
+
1450
+ updateStats();
1451
+ }
1452
+
1453
+ document.querySelectorAll('.tab-btn').forEach(btn => {
1454
+ btn.addEventListener('click', () => switchTab(btn.dataset.tab));
1455
+ });
1456
+
1457
+ document.querySelectorAll('.tag-btn').forEach(btn => {
1458
+ btn.addEventListener('click', () => {
1459
+ document.querySelectorAll('.tag-btn').forEach(b => b.classList.remove('active'));
1460
+ btn.classList.add('active');
1461
+
1462
+ const tag = btn.dataset.tag;
1463
+ const grid = currentTab === 'unread' ? unreadGrid : readGrid;
1464
+ grid.querySelectorAll('.article-card').forEach(card => {
1465
+ if (tag === 'all' || card.dataset.tags.includes(tag)) {
1466
+ card.style.display = 'flex';
1467
+ } else {
1468
+ card.style.display = 'none';
1469
+ }
1470
+ });
1471
+ });
1472
+ });
1473
+
1474
+ async function toggleRead(card) {
1475
+ const id = card.dataset.id;
1476
+ const isRead = card.dataset.read === 'true';
1477
+ const method = isRead ? 'DELETE' : 'POST';
1478
+
1479
+ try {
1480
+ const res = await fetch('/api/articles/' + id + '/read', { method });
1481
+ if (!res.ok) throw new Error('API error');
1482
+
1483
+ card.dataset.read = isRead ? 'false' : 'true';
1484
+
1485
+ if (isRead) {
1486
+ readGrid.removeChild(card);
1487
+ unreadGrid.appendChild(card);
1488
+ } else {
1489
+ unreadGrid.removeChild(card);
1490
+ readGrid.appendChild(card);
1491
+ }
1492
+
1493
+ updateStats();
1494
+ } catch (err) {
1495
+ console.error('Failed to toggle read status:', err);
1496
+ }
1497
+ }
1498
+
1499
+ document.querySelectorAll('.read-toggle-btn').forEach(btn => {
1500
+ btn.addEventListener('click', (e) => {
1501
+ e.preventDefault();
1502
+ e.stopPropagation();
1503
+ const card = btn.closest('.article-card');
1504
+ toggleRead(card);
1505
+ });
1506
+ });
1507
+
1508
+ if ('Notification' in window && Notification.permission === 'default') {
1509
+ Notification.requestPermission();
1510
+ }
1511
+
1512
+ updatePagination();
1513
+
1514
+ const evtSource = new EventSource('/api/events');
1515
+ evtSource.onmessage = (event) => {
1516
+ try {
1517
+ const data = JSON.parse(event.data);
1518
+ if (data.type === 'new-article') {
1519
+ const article = data.article;
1520
+ addNewArticleCard(article);
1521
+ showNotification(article);
1522
+ }
1523
+ } catch (e) {
1524
+ console.error('SSE parse error:', e);
1525
+ }
1526
+ };
1527
+
1528
+ function showNotification(article) {
1529
+ if ('Notification' in window && Notification.permission === 'granted') {
1530
+ new Notification('New Article', {
1531
+ body: article.title || article.url,
1532
+ icon: article.image || undefined,
1533
+ tag: article.id,
1534
+ });
1535
+ }
1536
+ }
1537
+
1538
+ function addNewArticleCard(article) {
1539
+ const card = createArticleCard(article);
1540
+ const firstCard = unreadGrid.querySelector('.article-card');
1541
+ if (firstCard) {
1542
+ unreadGrid.insertBefore(card, firstCard);
1543
+ } else {
1544
+ unreadGrid.appendChild(card);
1545
+ }
1546
+ updateStats();
1547
+
1548
+ card.style.animation = 'none';
1549
+ card.offsetHeight;
1550
+ card.style.animation = 'fadeInUp 0.6s ease forwards';
1551
+ }
1552
+
1553
+ function createArticleCard(article) {
1554
+ const div = document.createElement('article');
1555
+ const isRead = !!article.readAt;
1556
+ const readingTime = article.readingTimeMinutes || 0;
1557
+ const tags = article.tags || [];
1558
+ const difficulty = article.difficulty || 'intermediate';
1559
+ const difficultyLabels = { beginner: '입문', intermediate: '중급', advanced: '심화' };
1560
+
1561
+ let hostname = '';
1562
+ try { hostname = new URL(article.url).hostname.replace('www.', ''); } catch {}
1563
+
1564
+ div.className = 'article-card' + (article.image ? ' has-image' : '');
1565
+ div.dataset.tags = tags.join(',');
1566
+ div.dataset.id = article.id;
1567
+ div.dataset.read = String(isRead);
1568
+ div.dataset.readingTime = String(readingTime);
1569
+ div.dataset.index = '01';
1570
+
1571
+ const imageHtml = article.image ?
1572
+ '<div class="article-image"><img src="' + article.image + '" alt="" loading="lazy" onerror="this.onerror=null; this.parentElement.style.display=\\'none\\'; this.closest(\\'.article-card\\').classList.remove(\\'has-image\\');"></div>' : '';
1573
+
1574
+ div.innerHTML = \`
1575
+ <button class="read-toggle-btn" aria-label="Mark as \${isRead ? 'unread' : 'read'}">
1576
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
1577
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
1578
+ </svg>
1579
+ </button>
1580
+ \${imageHtml}
1581
+ <div class="article-content">
1582
+ <div class="article-meta-row">
1583
+ <span class="article-difficulty difficulty-\${difficulty}">\${difficultyLabels[difficulty]}</span>
1584
+ <span class="article-reading-time">\${readingTime || '?'} min read</span>
1585
+ </div>
1586
+ <h3 class="article-headline">
1587
+ <a href="\${article.url}" target="_blank" rel="noopener">\${article.title || article.url}</a>
1588
+ </h3>
1589
+ <div class="article-footer">
1590
+ <div class="article-source-info">
1591
+ <span class="article-source-label">\${article.sourceLabel || 'Article'}</span>
1592
+ <span class="article-source-divider">·</span>
1593
+ <span class="article-source">\${hostname}</span>
1594
+ </div>
1595
+ </div>
1596
+ <div class="article-tags">
1597
+ \${tags.slice(0, 5).map(tag => '<span class="article-tag">' + tag + '</span>').join('')}
1598
+ </div>
1599
+ </div>
1600
+ \`;
1601
+
1602
+ div.querySelector('.read-toggle-btn').addEventListener('click', (e) => {
1603
+ e.preventDefault();
1604
+ e.stopPropagation();
1605
+ toggleRead(div);
1606
+ });
1607
+
1608
+ return div;
1609
+ }
1610
+ })();
1611
+ </script>
1612
+ </body>
1613
+ </html>`;
1614
+ }
1615
+ function renderFeaturedCard(article, stats) {
1616
+ const summary = parseSummary(article.summary);
1617
+ const hostname = (() => {
1618
+ try {
1619
+ return new URL(article.url).hostname.replace('www.', '');
1620
+ }
1621
+ catch {
1622
+ return '';
1623
+ }
1624
+ })();
1625
+ const headline = summary?.headline || article.title;
1626
+ const tldr = summary?.tldr || article.description || '';
1627
+ const tags = article.tags.slice(0, 3);
1628
+ const isRead = !!article.readAt;
1629
+ const readingTime = article.readingTimeMinutes || 0;
1630
+ return `
1631
+ <section class="hero" data-read="${isRead}" data-reading-time="${readingTime}">
1632
+ <div class="hero-main">
1633
+ <div class="hero-content">
1634
+ <h2 class="hero-headline">
1635
+ <a href="${article.url}" target="_blank" rel="noopener">${escapeHtml(headline)}</a>
1636
+ </h2>
1637
+ ${tldr ? `<p class="hero-excerpt">${escapeHtml(tldr)}</p>` : ''}
1638
+ </div>
1639
+ <div class="hero-footer">
1640
+ <div class="hero-meta">
1641
+ <span>${escapeHtml(hostname)}</span> · ${readingTime || '?'} min read
1642
+ </div>
1643
+ <div class="hero-tags">
1644
+ ${tags.map(tag => `<span class="hero-tag">${escapeHtml(tag)}</span>`).join('')}
1645
+ </div>
1646
+ </div>
1647
+ </div>
1648
+ <aside class="hero-sidebar">
1649
+ <div class="sidebar-header">Issue Stats</div>
1650
+ <div class="stats-grid">
1651
+ <div class="stat-item">
1652
+ <div class="stat-number">${stats.totalArticles}</div>
1653
+ <div class="stat-label">Articles</div>
1654
+ </div>
1655
+ <div class="stat-item">
1656
+ <div class="stat-number">${stats.totalReadingTime}</div>
1657
+ <div class="stat-label">Minutes</div>
1658
+ </div>
1659
+ </div>
1660
+ </aside>
1661
+ </section>
1662
+ `;
1663
+ }
1664
+ function renderArticleCard(article, index) {
1665
+ const summary = parseSummary(article.summary);
1666
+ const hostname = (() => {
1667
+ try {
1668
+ return new URL(article.url).hostname.replace('www.', '');
1669
+ }
1670
+ catch {
1671
+ return '';
1672
+ }
1673
+ })();
1674
+ const headline = summary?.headline || article.title;
1675
+ const tldr = summary?.tldr || article.description || '';
1676
+ const keyPoints = summary?.keyPoints?.slice(0, 3) || [];
1677
+ const whyItMatters = summary?.whyItMatters || '';
1678
+ const keyQuote = summary?.keyQuote || '';
1679
+ const difficulty = article.difficulty || 'intermediate';
1680
+ const sourceLabel = article.sourceLabel || 'Article';
1681
+ const difficultyLabel = { beginner: '입문', intermediate: '중급', advanced: '심화' }[difficulty];
1682
+ const difficultyClass = `difficulty-${difficulty}`;
1683
+ const formattedIndex = String(index + 2).padStart(2, '0');
1684
+ const imageHtml = article.image ? `
1685
+ <div class="article-image">
1686
+ <img src="${escapeHtml(article.image)}" alt="" loading="lazy" onerror="this.onerror=null; this.parentElement.style.display='none'; this.closest('.article-card').classList.remove('has-image');" />
1687
+ </div>
1688
+ ` : '';
1689
+ const keyPointsHtml = keyPoints.length > 0 ? `
1690
+ <div class="article-key-points">
1691
+ ${keyPoints.map(point => `<div class="key-point-item">${escapeHtml(point)}</div>`).join('')}
1692
+ </div>
1693
+ ` : '';
1694
+ const whyMattersHtml = whyItMatters ? `
1695
+ <div class="article-why-matters">
1696
+ <div class="why-label">Why it matters</div>
1697
+ <div class="why-text">${escapeHtml(whyItMatters)}</div>
1698
+ </div>
1699
+ ` : '';
1700
+ const keyQuoteHtml = keyQuote ? `
1701
+ <div class="article-quote">"${escapeHtml(keyQuote)}"</div>
1702
+ ` : '';
1703
+ const isRead = !!article.readAt;
1704
+ const readingTime = article.readingTimeMinutes || 0;
1705
+ return `
1706
+ <article class="article-card ${article.image ? 'has-image' : ''}" data-tags="${article.tags.join(',')}" data-index="${formattedIndex}" data-id="${article.id}" data-read="${isRead}" data-reading-time="${readingTime}">
1707
+ <button class="read-toggle-btn" aria-label="Mark as ${isRead ? 'unread' : 'read'}">
1708
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
1709
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
1710
+ </svg>
1711
+ </button>
1712
+ ${imageHtml}
1713
+ <div class="article-content">
1714
+ <div class="article-meta-row">
1715
+ <span class="article-difficulty ${difficultyClass}">${difficultyLabel}</span>
1716
+ <span class="article-reading-time">${readingTime || '?'} min read</span>
1717
+ </div>
1718
+
1719
+ <h3 class="article-headline">
1720
+ <a href="${article.url}" target="_blank" rel="noopener">${escapeHtml(headline)}</a>
1721
+ </h3>
1722
+
1723
+ ${tldr ? `<p class="article-tldr">${escapeHtml(tldr)}</p>` : ''}
1724
+ ${keyPointsHtml}
1725
+ ${whyMattersHtml}
1726
+ ${keyQuoteHtml}
1727
+
1728
+ <div class="article-footer">
1729
+ <div class="article-source-info">
1730
+ <span class="article-source-label">${escapeHtml(sourceLabel)}</span>
1731
+ <span class="article-source-divider">·</span>
1732
+ <span class="article-source">${escapeHtml(hostname)}</span>
1733
+ </div>
1734
+ </div>
1735
+
1736
+ <div class="article-tags">
1737
+ ${article.tags.slice(0, 5).map(tag => `<span class="article-tag">${escapeHtml(tag)}</span>`).join('')}
1738
+ </div>
1739
+ </div>
1740
+ </article>
1741
+ `;
1742
+ }
1743
+ function escapeHtml(str) {
1744
+ return str
1745
+ .replace(/&/g, '&amp;')
1746
+ .replace(/</g, '&lt;')
1747
+ .replace(/>/g, '&gt;')
1748
+ .replace(/"/g, '&quot;')
1749
+ .replace(/'/g, '&#039;');
1750
+ }
1751
+ //# sourceMappingURL=magazine.js.map