@rmdes/indiekit-endpoint-rss 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.
package/views/rss.njk ADDED
@@ -0,0 +1,498 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <style>
5
+ .rss-section { margin-bottom: 2rem; }
6
+ .rss-section h2 { margin-bottom: 1rem; }
7
+
8
+ /* Stats grid */
9
+ .rss-stats-grid {
10
+ display: grid;
11
+ grid-template-columns: repeat(3, 1fr);
12
+ gap: 1rem;
13
+ margin-bottom: 1.5rem;
14
+ }
15
+ .rss-stat {
16
+ display: flex;
17
+ flex-direction: column;
18
+ align-items: center;
19
+ padding: 1rem;
20
+ background: var(--color-offset, #f5f5f5);
21
+ border-radius: 0.5rem;
22
+ text-align: center;
23
+ }
24
+ .rss-stat-value {
25
+ font-size: 1.5rem;
26
+ font-weight: 700;
27
+ color: var(--color-accent, #ff6600);
28
+ }
29
+ .rss-stat-label {
30
+ font-size: 0.75rem;
31
+ color: var(--color-text-secondary, #666);
32
+ text-transform: uppercase;
33
+ letter-spacing: 0.05em;
34
+ }
35
+
36
+ /* Sync status */
37
+ .rss-sync-status {
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: space-between;
41
+ padding: 1rem;
42
+ background: var(--color-offset, #f5f5f5);
43
+ border-radius: 0.5rem;
44
+ margin-bottom: 1.5rem;
45
+ }
46
+ .rss-sync-info {
47
+ display: flex;
48
+ flex-direction: column;
49
+ gap: 0.25rem;
50
+ }
51
+ .rss-sync-label {
52
+ font-size: 0.75rem;
53
+ color: var(--color-text-secondary, #666);
54
+ text-transform: uppercase;
55
+ }
56
+ .rss-sync-time {
57
+ font-weight: 500;
58
+ }
59
+ .rss-syncing {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: 0.5rem;
63
+ color: var(--color-accent, #ff6600);
64
+ }
65
+ .rss-spinner {
66
+ width: 16px;
67
+ height: 16px;
68
+ border: 2px solid currentColor;
69
+ border-top-color: transparent;
70
+ border-radius: 50%;
71
+ animation: rss-spin 0.8s linear infinite;
72
+ }
73
+ @keyframes rss-spin {
74
+ to { transform: rotate(360deg); }
75
+ }
76
+
77
+ /* Add feed form */
78
+ .rss-add-form {
79
+ display: flex;
80
+ gap: 0.5rem;
81
+ margin-bottom: 1.5rem;
82
+ }
83
+ .rss-add-form input {
84
+ flex: 1;
85
+ padding: 0.5rem 0.75rem;
86
+ border: 1px solid var(--color-border, #ddd);
87
+ border-radius: 0.25rem;
88
+ font-size: 0.875rem;
89
+ }
90
+ .rss-add-form input:focus {
91
+ outline: none;
92
+ border-color: var(--color-accent, #ff6600);
93
+ }
94
+
95
+ /* Feed list */
96
+ .rss-feed-list {
97
+ list-style: none;
98
+ padding: 0;
99
+ margin: 0;
100
+ }
101
+ .rss-feed-item {
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 0.75rem;
105
+ padding: 0.75rem 0;
106
+ border-bottom: 1px solid var(--color-border, #eee);
107
+ }
108
+ .rss-feed-item:last-child { border-bottom: none; }
109
+ .rss-feed-icon {
110
+ width: 32px;
111
+ height: 32px;
112
+ object-fit: cover;
113
+ border-radius: 0.25rem;
114
+ flex-shrink: 0;
115
+ }
116
+ .rss-feed-icon-placeholder {
117
+ width: 32px;
118
+ height: 32px;
119
+ background: var(--color-border, #ddd);
120
+ border-radius: 0.25rem;
121
+ flex-shrink: 0;
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ color: var(--color-text-secondary, #666);
126
+ font-size: 1rem;
127
+ }
128
+ .rss-feed-info {
129
+ flex: 1;
130
+ min-width: 0;
131
+ }
132
+ .rss-feed-title {
133
+ font-weight: 500;
134
+ white-space: nowrap;
135
+ overflow: hidden;
136
+ text-overflow: ellipsis;
137
+ }
138
+ .rss-feed-meta {
139
+ display: flex;
140
+ gap: 0.5rem;
141
+ font-size: 0.75rem;
142
+ color: var(--color-text-secondary, #666);
143
+ }
144
+ .rss-feed-actions {
145
+ display: flex;
146
+ gap: 0.5rem;
147
+ align-items: center;
148
+ }
149
+ .rss-feed-error {
150
+ color: #d32f2f;
151
+ font-size: 0.75rem;
152
+ }
153
+
154
+ /* Item list */
155
+ .rss-item-list {
156
+ list-style: none;
157
+ padding: 0;
158
+ margin: 0;
159
+ }
160
+ .rss-item {
161
+ display: flex;
162
+ gap: 0.75rem;
163
+ padding: 0.75rem 0;
164
+ border-bottom: 1px solid var(--color-border, #eee);
165
+ }
166
+ .rss-item:last-child { border-bottom: none; }
167
+ .rss-item-image {
168
+ width: 60px;
169
+ height: 60px;
170
+ object-fit: cover;
171
+ border-radius: 0.25rem;
172
+ flex-shrink: 0;
173
+ }
174
+ .rss-item-placeholder {
175
+ width: 60px;
176
+ height: 60px;
177
+ background: var(--color-border, #ddd);
178
+ border-radius: 0.25rem;
179
+ flex-shrink: 0;
180
+ }
181
+ .rss-item-info {
182
+ flex: 1;
183
+ min-width: 0;
184
+ }
185
+ .rss-item-title {
186
+ font-weight: 500;
187
+ text-decoration: none;
188
+ color: inherit;
189
+ display: block;
190
+ white-space: nowrap;
191
+ overflow: hidden;
192
+ text-overflow: ellipsis;
193
+ }
194
+ .rss-item-title:hover { text-decoration: underline; }
195
+ .rss-item-description {
196
+ font-size: 0.875rem;
197
+ color: var(--color-text-secondary, #666);
198
+ margin: 0.25rem 0;
199
+ display: -webkit-box;
200
+ -webkit-line-clamp: 2;
201
+ -webkit-box-orient: vertical;
202
+ overflow: hidden;
203
+ }
204
+ .rss-item-meta {
205
+ font-size: 0.75rem;
206
+ color: var(--color-text-secondary, #666);
207
+ }
208
+
209
+ /* Toggle switch */
210
+ .rss-toggle {
211
+ position: relative;
212
+ width: 40px;
213
+ height: 22px;
214
+ cursor: pointer;
215
+ }
216
+ .rss-toggle input {
217
+ opacity: 0;
218
+ width: 0;
219
+ height: 0;
220
+ }
221
+ .rss-toggle-slider {
222
+ position: absolute;
223
+ top: 0;
224
+ left: 0;
225
+ right: 0;
226
+ bottom: 0;
227
+ background-color: #ccc;
228
+ border-radius: 22px;
229
+ transition: 0.3s;
230
+ }
231
+ .rss-toggle-slider:before {
232
+ position: absolute;
233
+ content: "";
234
+ height: 16px;
235
+ width: 16px;
236
+ left: 3px;
237
+ bottom: 3px;
238
+ background-color: white;
239
+ border-radius: 50%;
240
+ transition: 0.3s;
241
+ }
242
+ .rss-toggle input:checked + .rss-toggle-slider {
243
+ background-color: var(--color-accent, #ff6600);
244
+ }
245
+ .rss-toggle input:checked + .rss-toggle-slider:before {
246
+ transform: translateX(18px);
247
+ }
248
+
249
+ /* Delete button */
250
+ .rss-delete-btn {
251
+ background: none;
252
+ border: none;
253
+ color: #d32f2f;
254
+ cursor: pointer;
255
+ padding: 0.25rem;
256
+ font-size: 1rem;
257
+ opacity: 0.6;
258
+ }
259
+ .rss-delete-btn:hover {
260
+ opacity: 1;
261
+ }
262
+ </style>
263
+
264
+ {% if error %}
265
+ {{ prose({ text: error.message }) }}
266
+ {% else %}
267
+ {# Sync Status #}
268
+ <div class="rss-sync-status">
269
+ <div class="rss-sync-info">
270
+ <span class="rss-sync-label">{{ __("rss.lastSynced") }}</span>
271
+ <span class="rss-sync-time">
272
+ {% if syncState.syncing %}
273
+ <span class="rss-syncing">
274
+ <span class="rss-spinner"></span>
275
+ {{ __("rss.syncing") }}
276
+ </span>
277
+ {% elif syncState.lastSync %}
278
+ {{ syncState.lastSync | date("PPpp") }}
279
+ {% else %}
280
+ {{ __("rss.never") }}
281
+ {% endif %}
282
+ </span>
283
+ {% if syncState.lastError %}
284
+ <span class="rss-feed-error">{{ syncState.lastError }}</span>
285
+ {% endif %}
286
+ </div>
287
+ <form action="{{ publicUrl }}/sync" method="post">
288
+ {{ button({
289
+ type: "submit",
290
+ text: __("rss.syncNow"),
291
+ disabled: syncState.syncing
292
+ }) }}
293
+ </form>
294
+ </div>
295
+
296
+ {# Stats #}
297
+ <section class="rss-section">
298
+ <div class="rss-stats-grid">
299
+ <div class="rss-stat">
300
+ <span class="rss-stat-value">{{ totalFeeds }}</span>
301
+ <span class="rss-stat-label">{{ __("rss.feedsCount") }}</span>
302
+ </div>
303
+ <div class="rss-stat">
304
+ <span class="rss-stat-value">{{ totalItems }}</span>
305
+ <span class="rss-stat-label">{{ __("rss.itemsCount") }}</span>
306
+ </div>
307
+ <div class="rss-stat">
308
+ <span class="rss-stat-value">{{ feeds | selectattr("enabled") | list | length }}</span>
309
+ <span class="rss-stat-label">{{ __("rss.enabled") }}</span>
310
+ </div>
311
+ </div>
312
+ </section>
313
+
314
+ {# Add Feed Form #}
315
+ <section class="rss-section">
316
+ <h2>{{ __("rss.addFeed") }}</h2>
317
+ <form class="rss-add-form" action="{{ publicUrl }}/api/feeds" method="post" id="add-feed-form">
318
+ <input
319
+ type="url"
320
+ name="url"
321
+ placeholder="{{ __("rss.feedUrlPlaceholder") }}"
322
+ required
323
+ >
324
+ {{ button({
325
+ type: "submit",
326
+ text: __("rss.addFeed")
327
+ }) }}
328
+ </form>
329
+ </section>
330
+
331
+ {# Feeds List #}
332
+ <section class="rss-section">
333
+ <h2>{{ __("rss.feeds") }}</h2>
334
+ {% if feeds and feeds.length > 0 %}
335
+ <ul class="rss-feed-list">
336
+ {% for feed in feeds %}
337
+ <li class="rss-feed-item" data-feed-id="{{ feed.id }}">
338
+ {% if feed.imageUrl %}
339
+ <img src="{{ feed.imageUrl }}" alt="" class="rss-feed-icon" loading="lazy">
340
+ {% else %}
341
+ <div class="rss-feed-icon-placeholder">&#128240;</div>
342
+ {% endif %}
343
+ <div class="rss-feed-info">
344
+ <div class="rss-feed-title">{{ feed.title }}</div>
345
+ <div class="rss-feed-meta">
346
+ <span>{{ feed.itemCount }} {{ __("rss.itemCount") }}</span>
347
+ {% if feed.lastFetchedAt %}
348
+ <span>&bull; {{ feed.lastFetchedAt | date("PP") }}</span>
349
+ {% endif %}
350
+ </div>
351
+ {% if feed.lastError %}
352
+ <div class="rss-feed-error">{{ feed.lastError }}</div>
353
+ {% endif %}
354
+ </div>
355
+ <div class="rss-feed-actions">
356
+ <label class="rss-toggle">
357
+ <input
358
+ type="checkbox"
359
+ {{ "checked" if feed.enabled }}
360
+ data-toggle-feed="{{ feed.id }}"
361
+ >
362
+ <span class="rss-toggle-slider"></span>
363
+ </label>
364
+ <button
365
+ type="button"
366
+ class="rss-delete-btn"
367
+ data-delete-feed="{{ feed.id }}"
368
+ title="{{ __("rss.removeFeed") }}"
369
+ >&#128465;</button>
370
+ </div>
371
+ </li>
372
+ {% endfor %}
373
+ </ul>
374
+ {% else %}
375
+ {{ prose({ text: __("rss.noFeeds") }) }}
376
+ {% endif %}
377
+ </section>
378
+
379
+ {# Recent Items #}
380
+ <section class="rss-section">
381
+ <h2>{{ __("rss.recentItems") }}</h2>
382
+ {% if recentItems and recentItems.length > 0 %}
383
+ <ul class="rss-item-list">
384
+ {% for item in recentItems %}
385
+ <li class="rss-item">
386
+ {% if item.imageUrl %}
387
+ <img src="{{ item.imageUrl }}" alt="" class="rss-item-image" loading="lazy">
388
+ {% else %}
389
+ <div class="rss-item-placeholder"></div>
390
+ {% endif %}
391
+ <div class="rss-item-info">
392
+ <a href="{{ item.link }}" class="rss-item-title" target="_blank" rel="noopener">
393
+ {{ item.title }}
394
+ </a>
395
+ {% if item.description %}
396
+ <p class="rss-item-description">{{ item.description }}</p>
397
+ {% endif %}
398
+ <div class="rss-item-meta">
399
+ <span>{{ item.feedTitle }}</span>
400
+ {% if item.pubDate %}
401
+ <span>&bull; {{ item.pubDate | date("PP") }}</span>
402
+ {% endif %}
403
+ </div>
404
+ </div>
405
+ </li>
406
+ {% endfor %}
407
+ </ul>
408
+ {% else %}
409
+ {{ prose({ text: __("rss.noItems") }) }}
410
+ {% endif %}
411
+ </section>
412
+
413
+ {# Link to public page #}
414
+ <div class="rss-sync-status">
415
+ <p>{{ __("rss.widget.description") }}</p>
416
+ {{ button({
417
+ href: "/news/",
418
+ text: __("rss.widget.view"),
419
+ target: "_blank"
420
+ }) }}
421
+ </div>
422
+ {% endif %}
423
+
424
+ <script>
425
+ // Handle add feed form
426
+ document.getElementById('add-feed-form')?.addEventListener('submit', async (e) => {
427
+ e.preventDefault();
428
+ const form = e.target;
429
+ const url = form.url.value;
430
+
431
+ try {
432
+ const response = await fetch(form.action, {
433
+ method: 'POST',
434
+ headers: { 'Content-Type': 'application/json' },
435
+ body: JSON.stringify({ url })
436
+ });
437
+
438
+ const data = await response.json();
439
+ if (response.ok) {
440
+ location.reload();
441
+ } else {
442
+ alert(data.error || 'Failed to add feed');
443
+ }
444
+ } catch (err) {
445
+ alert('Failed to add feed: ' + err.message);
446
+ }
447
+ });
448
+
449
+ // Handle toggle feed
450
+ document.querySelectorAll('[data-toggle-feed]').forEach(input => {
451
+ input.addEventListener('change', async (e) => {
452
+ const feedId = e.target.dataset.toggleFeed;
453
+ const enabled = e.target.checked;
454
+
455
+ try {
456
+ const response = await fetch(`{{ publicUrl }}/api/feeds/${feedId}`, {
457
+ method: 'PATCH',
458
+ headers: { 'Content-Type': 'application/json' },
459
+ body: JSON.stringify({ enabled })
460
+ });
461
+
462
+ if (!response.ok) {
463
+ e.target.checked = !enabled;
464
+ const data = await response.json();
465
+ alert(data.error || 'Failed to update feed');
466
+ }
467
+ } catch (err) {
468
+ e.target.checked = !enabled;
469
+ alert('Failed to update feed: ' + err.message);
470
+ }
471
+ });
472
+ });
473
+
474
+ // Handle delete feed
475
+ document.querySelectorAll('[data-delete-feed]').forEach(btn => {
476
+ btn.addEventListener('click', async (e) => {
477
+ const feedId = e.target.dataset.deleteFeed;
478
+
479
+ if (!confirm('Are you sure you want to remove this feed?')) return;
480
+
481
+ try {
482
+ const response = await fetch(`{{ publicUrl }}/api/feeds/${feedId}`, {
483
+ method: 'DELETE'
484
+ });
485
+
486
+ if (response.ok) {
487
+ location.reload();
488
+ } else {
489
+ const data = await response.json();
490
+ alert(data.error || 'Failed to remove feed');
491
+ }
492
+ } catch (err) {
493
+ alert('Failed to remove feed: ' + err.message);
494
+ }
495
+ });
496
+ });
497
+ </script>
498
+ {% endblock %}