@rmdes/indiekit-endpoint-rss 1.0.4 → 1.0.7

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/index.js CHANGED
@@ -19,6 +19,7 @@ const defaults = {
19
19
  maxItemsPerFeed: 50,
20
20
  fetchTimeout: 10_000,
21
21
  maxConcurrentFetches: 3,
22
+ retentionDays: 30,
22
23
  };
23
24
 
24
25
  export default class RssEndpoint {
@@ -12,16 +12,16 @@ export const dashboardController = {
12
12
 
13
13
  if (!rssConfig) {
14
14
  return response.status(500).render("rss", {
15
- title: response.locals.__("rss.title"),
16
- error: { message: response.locals.__("rss.error.noConfig") },
15
+ title: response.__("rss.title"),
16
+ configError: response.__("rss.error.noConfig"),
17
17
  });
18
18
  }
19
19
 
20
20
  const db = request.app.locals.application.getRssDb?.();
21
21
  if (!db) {
22
22
  return response.render("rss", {
23
- title: response.locals.__("rss.title"),
24
- error: { message: response.locals.__("rss.error.noDatabase") },
23
+ title: response.__("rss.title"),
24
+ configError: response.__("rss.error.noDatabase"),
25
25
  });
26
26
  }
27
27
 
@@ -42,7 +42,7 @@ export const dashboardController = {
42
42
  const syncState = getSyncState();
43
43
 
44
44
  response.render("rss", {
45
- title: response.locals.__("rss.title"),
45
+ title: response.__("rss.title"),
46
46
  feeds: feeds.map(formatFeed),
47
47
  recentItems: recentItems.map((item) => formatItem(item)),
48
48
  totalFeeds: feeds.length,
@@ -52,7 +52,14 @@ export const dashboardController = {
52
52
  lastSync: syncState.lastSync?.toISOString(),
53
53
  lastError: syncState.lastError,
54
54
  },
55
+ mountPath: request.baseUrl,
55
56
  publicUrl: rssEndpoint,
57
+ synced: request.query.synced,
58
+ syncedFeeds: request.query.feeds,
59
+ syncedItems: request.query.items,
60
+ syncedPruned: request.query.pruned,
61
+ syncInProgress: request.query.syncing,
62
+ queryError: request.query.error,
56
63
  });
57
64
  } catch (error) {
58
65
  next(error);
@@ -121,45 +128,45 @@ export const dashboardController = {
121
128
  const { rssConfig, getRssDb } = request.app.locals.application;
122
129
 
123
130
  if (!rssConfig) {
124
- return response.status(500).json({
125
- error: response.locals.__("rss.error.noConfig"),
126
- });
131
+ return response.redirect(
132
+ request.baseUrl + "?error=" + encodeURIComponent("Not configured"),
133
+ );
127
134
  }
128
135
 
129
136
  const db = getRssDb?.();
130
137
  if (!db) {
131
- return response.status(500).json({
132
- error: response.locals.__("rss.error.noDatabase"),
133
- });
138
+ return response.redirect(
139
+ request.baseUrl +
140
+ "?error=" +
141
+ encodeURIComponent("Database not available"),
142
+ );
134
143
  }
135
144
 
136
145
  const syncState = getSyncState();
137
146
  if (syncState.syncing) {
138
- return response.status(409).json({
139
- error: "Sync already in progress",
140
- syncing: true,
141
- });
147
+ return response.redirect(request.baseUrl + "?syncing=true");
142
148
  }
143
149
 
144
- // Start sync and wait for result - pass db directly
145
150
  const result = await runSync(db, rssConfig);
146
151
 
147
152
  if (result.error) {
148
- return response.status(500).json({
149
- success: false,
150
- error: result.error,
151
- });
153
+ return response.redirect(
154
+ request.baseUrl + "?error=" + encodeURIComponent(result.error),
155
+ );
152
156
  }
153
157
 
154
- response.json({
155
- success: true,
156
- message: response.locals.__("rss.success.syncComplete"),
157
- feedsProcessed: result.feedsProcessed,
158
- itemsAdded: result.itemsAdded,
158
+ const params = new URLSearchParams({
159
+ synced: "true",
160
+ feeds: result.feedsProcessed || 0,
161
+ items: result.itemsAdded || 0,
162
+ pruned: result.itemsPruned || 0,
159
163
  });
164
+ response.redirect(request.baseUrl + "?" + params.toString());
160
165
  } catch (error) {
161
166
  console.error("[RSS] Sync error:", error.message);
162
- response.status(500).json({ error: error.message });
167
+ response.redirect(
168
+ request.baseUrl + "?error=" + encodeURIComponent(error.message),
169
+ );
163
170
  }
164
171
  },
165
172
  };
package/lib/rss-client.js CHANGED
@@ -326,14 +326,23 @@ export class RssClient {
326
326
  * @returns {string|null}
327
327
  */
328
328
  extractFeedImage(parsed) {
329
+ // Helper to get first string from value (handles arrays)
330
+ const getString = (val) => {
331
+ if (!val) return null;
332
+ if (Array.isArray(val)) return val[0] || null;
333
+ if (typeof val === "object" && val.url) return val.url;
334
+ return typeof val === "string" ? val : null;
335
+ };
336
+
329
337
  // RSS 2.0 image
330
- if (parsed.image?.url) return parsed.image.url;
338
+ if (parsed.image?.url) return getString(parsed.image.url);
339
+ if (parsed.image) return getString(parsed.image);
331
340
  // Atom icon
332
- if (parsed.icon) return parsed.icon;
341
+ if (parsed.icon) return getString(parsed.icon);
333
342
  // Atom logo
334
- if (parsed.logo) return parsed.logo;
343
+ if (parsed.logo) return getString(parsed.logo);
335
344
  // itunes:image
336
- if (parsed.itunes?.image) return parsed.itunes.image;
345
+ if (parsed.itunes?.image) return getString(parsed.itunes.image);
337
346
  return null;
338
347
  }
339
348
 
package/lib/sync.js CHANGED
@@ -116,16 +116,25 @@ export async function runSync(dbOrIndiekit, options) {
116
116
  syncState.feedsProcessed++;
117
117
  }
118
118
 
119
+ // Prune old items
120
+ const retentionDays = options.retentionDays || 30;
121
+ const itemsPruned = await pruneOldItems(
122
+ itemsCollection,
123
+ feedsCollection,
124
+ retentionDays,
125
+ );
126
+
119
127
  syncState.lastSync = new Date();
120
128
  syncState.syncing = false;
121
129
 
122
130
  console.log(
123
- `[RSS] Sync complete: ${syncState.feedsProcessed} feeds, ${syncState.itemsAdded} new items`
131
+ `[RSS] Sync complete: ${syncState.feedsProcessed} feeds, ${syncState.itemsAdded} new items, ${itemsPruned} pruned`
124
132
  );
125
133
 
126
134
  return {
127
135
  feedsProcessed: syncState.feedsProcessed,
128
136
  itemsAdded: syncState.itemsAdded,
137
+ itemsPruned,
129
138
  };
130
139
  } catch (error) {
131
140
  syncState.lastError = error.message;
@@ -248,6 +257,47 @@ async function createIndexes(feedsCollection, itemsCollection) {
248
257
  await itemsCollection.createIndex({ fetchedAt: -1 });
249
258
  }
250
259
 
260
+ /**
261
+ * Prune items older than retention period and update feed item counts
262
+ * @param {Collection} itemsCollection - Items collection
263
+ * @param {Collection} feedsCollection - Feeds collection
264
+ * @param {number} retentionDays - Days to keep items
265
+ * @returns {Promise<number>} Number of items pruned
266
+ */
267
+ async function pruneOldItems(itemsCollection, feedsCollection, retentionDays) {
268
+ const cutoff = new Date();
269
+ cutoff.setDate(cutoff.getDate() - retentionDays);
270
+
271
+ try {
272
+ const result = await itemsCollection.deleteMany({
273
+ pubDate: { $lt: cutoff },
274
+ });
275
+
276
+ if (result.deletedCount > 0) {
277
+ console.log(
278
+ `[RSS] Pruned ${result.deletedCount} items older than ${retentionDays} days`,
279
+ );
280
+
281
+ // Update item counts for all feeds
282
+ const feeds = await feedsCollection.find({}).toArray();
283
+ for (const feed of feeds) {
284
+ const count = await itemsCollection.countDocuments({
285
+ feedId: feed._id,
286
+ });
287
+ await feedsCollection.updateOne(
288
+ { _id: feed._id },
289
+ { $set: { itemCount: count } },
290
+ );
291
+ }
292
+ }
293
+
294
+ return result.deletedCount;
295
+ } catch (err) {
296
+ console.error("[RSS] Prune error:", err.message);
297
+ return 0;
298
+ }
299
+ }
300
+
251
301
  /**
252
302
  * Process feeds with concurrency limit
253
303
  * @param {Array} feeds - Array of feeds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-rss",
3
- "version": "1.0.4",
3
+ "version": "1.0.7",
4
4
  "description": "RSS feed reader endpoint for Indiekit. Aggregates multiple feeds, caches in MongoDB, displays on frontend.",
5
5
  "keywords": [
6
6
  "indiekit",
package/views/rss.njk CHANGED
@@ -1,309 +1,383 @@
1
1
  {% extends "document.njk" %}
2
2
 
3
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
- }
4
+ <style>
5
+ .rss-dashboard {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--space-xl, 2rem);
9
+ }
35
10
 
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
- }
11
+ .rss-section {
12
+ background: var(--color-offset, #f5f5f5);
13
+ border-radius: var(--border-radius-small, 0.5rem);
14
+ padding: var(--space-m, 1.5rem);
15
+ }
76
16
 
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
- }
17
+ .rss-section h2 {
18
+ font: var(--font-heading, bold 1.25rem/1.4 sans-serif);
19
+ margin-block-end: var(--space-s, 0.75rem);
20
+ padding-block-end: var(--space-xs, 0.5rem);
21
+ border-block-end: 1px solid var(--color-outline-variant, #ddd);
22
+ }
94
23
 
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
- }
24
+ /* Notifications */
25
+ .rss-notification {
26
+ border-radius: var(--border-radius-small, 0.25rem);
27
+ padding: var(--space-s, 0.75rem) var(--space-m, 1rem);
28
+ margin-block-end: var(--space-s, 0.75rem);
29
+ }
30
+ .rss-notification--success {
31
+ background: var(--color-success, #d4edda);
32
+ color: var(--color-on-success, #155724);
33
+ }
34
+ .rss-notification--warning {
35
+ background: #fff3cd;
36
+ color: #856404;
37
+ }
38
+ .rss-notification--error {
39
+ background: var(--color-error, #f8d7da);
40
+ color: var(--color-on-error, #721c24);
41
+ }
153
42
 
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
- }
43
+ /* Stats grid */
44
+ .rss-stats-grid {
45
+ display: grid;
46
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
47
+ gap: var(--space-s, 0.75rem);
48
+ }
49
+ .rss-stat {
50
+ display: flex;
51
+ flex-direction: column;
52
+ align-items: center;
53
+ padding: var(--space-s, 0.75rem);
54
+ background: var(--color-background, #fff);
55
+ border-radius: var(--border-radius-small, 0.5rem);
56
+ text-align: center;
57
+ }
58
+ .rss-stat-value {
59
+ font-size: 1.5rem;
60
+ font-weight: 700;
61
+ color: var(--color-accent, #ff6600);
62
+ }
63
+ .rss-stat-label {
64
+ font-size: 0.75rem;
65
+ color: var(--color-on-offset, #666);
66
+ text-transform: uppercase;
67
+ letter-spacing: 0.05em;
68
+ }
208
69
 
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
- }
70
+ /* Sync status */
71
+ .rss-sync-bar {
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: space-between;
75
+ gap: var(--space-m, 1rem);
76
+ flex-wrap: wrap;
77
+ }
78
+ .rss-sync-info {
79
+ display: flex;
80
+ flex-direction: column;
81
+ gap: 0.25rem;
82
+ }
83
+ .rss-sync-label {
84
+ font-size: 0.75rem;
85
+ color: var(--color-on-offset, #666);
86
+ text-transform: uppercase;
87
+ }
88
+ .rss-sync-time {
89
+ font-weight: 500;
90
+ }
91
+ .rss-syncing {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ gap: 0.5rem;
95
+ color: var(--color-accent, #ff6600);
96
+ }
97
+ .rss-spinner {
98
+ width: 16px;
99
+ height: 16px;
100
+ border: 2px solid currentColor;
101
+ border-top-color: transparent;
102
+ border-radius: 50%;
103
+ animation: rss-spin 0.8s linear infinite;
104
+ }
105
+ @keyframes rss-spin {
106
+ to { transform: rotate(360deg); }
107
+ }
248
108
 
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>
109
+ /* Add feed form */
110
+ .rss-add-form {
111
+ display: flex;
112
+ gap: 0.5rem;
113
+ }
114
+ .rss-add-form input {
115
+ flex: 1;
116
+ appearance: none;
117
+ background-color: var(--color-background, #fff);
118
+ border: 1px solid var(--color-outline-variant, #ccc);
119
+ border-radius: var(--border-radius-small, 0.25rem);
120
+ font: var(--font-body, 0.875rem/1.4 sans-serif);
121
+ padding: calc(var(--space-s, 0.75rem) / 2) var(--space-s, 0.75rem);
122
+ }
123
+ .rss-add-form input:focus {
124
+ border-color: var(--color-primary, #0066cc);
125
+ outline: 2px solid var(--color-primary, #0066cc);
126
+ outline-offset: 1px;
127
+ }
128
+
129
+ /* Feed list */
130
+ .rss-feed-list {
131
+ list-style: none;
132
+ padding: 0;
133
+ margin: 0;
134
+ }
135
+ .rss-feed-item {
136
+ display: flex;
137
+ align-items: center;
138
+ gap: 0.75rem;
139
+ padding: 0.75rem 0;
140
+ border-bottom: 1px solid var(--color-outline-variant, #eee);
141
+ }
142
+ .rss-feed-item:last-child { border-bottom: none; }
143
+ .rss-feed-icon {
144
+ width: 32px;
145
+ height: 32px;
146
+ object-fit: cover;
147
+ border-radius: 0.25rem;
148
+ flex-shrink: 0;
149
+ }
150
+ .rss-feed-icon-placeholder {
151
+ width: 32px;
152
+ height: 32px;
153
+ background: var(--color-outline-variant, #ddd);
154
+ border-radius: 0.25rem;
155
+ flex-shrink: 0;
156
+ display: flex;
157
+ align-items: center;
158
+ justify-content: center;
159
+ color: var(--color-on-offset, #666);
160
+ font-size: 1rem;
161
+ }
162
+ .rss-feed-info {
163
+ flex: 1;
164
+ min-width: 0;
165
+ }
166
+ .rss-feed-title {
167
+ font-weight: 500;
168
+ white-space: nowrap;
169
+ overflow: hidden;
170
+ text-overflow: ellipsis;
171
+ }
172
+ .rss-feed-meta {
173
+ display: flex;
174
+ gap: 0.5rem;
175
+ font-size: 0.75rem;
176
+ color: var(--color-on-offset, #666);
177
+ }
178
+ .rss-feed-actions {
179
+ display: flex;
180
+ gap: 0.5rem;
181
+ align-items: center;
182
+ }
183
+ .rss-feed-error {
184
+ color: #d32f2f;
185
+ font-size: 0.75rem;
186
+ }
187
+
188
+ /* Item list */
189
+ .rss-item-list {
190
+ list-style: none;
191
+ padding: 0;
192
+ margin: 0;
193
+ }
194
+ .rss-item {
195
+ display: flex;
196
+ gap: 0.75rem;
197
+ padding: 0.75rem 0;
198
+ border-bottom: 1px solid var(--color-outline-variant, #eee);
199
+ }
200
+ .rss-item:last-child { border-bottom: none; }
201
+ .rss-item-image {
202
+ width: 60px;
203
+ height: 60px;
204
+ object-fit: cover;
205
+ border-radius: 0.25rem;
206
+ flex-shrink: 0;
207
+ }
208
+ .rss-item-placeholder {
209
+ width: 60px;
210
+ height: 60px;
211
+ background: var(--color-outline-variant, #ddd);
212
+ border-radius: 0.25rem;
213
+ flex-shrink: 0;
214
+ }
215
+ .rss-item-info {
216
+ flex: 1;
217
+ min-width: 0;
218
+ }
219
+ .rss-item-title {
220
+ font-weight: 500;
221
+ text-decoration: none;
222
+ color: inherit;
223
+ display: block;
224
+ white-space: nowrap;
225
+ overflow: hidden;
226
+ text-overflow: ellipsis;
227
+ }
228
+ .rss-item-title:hover { text-decoration: underline; }
229
+ .rss-item-description {
230
+ font-size: 0.875rem;
231
+ color: var(--color-on-offset, #666);
232
+ margin: 0.25rem 0;
233
+ display: -webkit-box;
234
+ -webkit-line-clamp: 2;
235
+ -webkit-box-orient: vertical;
236
+ overflow: hidden;
237
+ }
238
+ .rss-item-meta {
239
+ font-size: 0.75rem;
240
+ color: var(--color-on-offset, #666);
241
+ }
242
+
243
+ /* Toggle switch */
244
+ .rss-toggle {
245
+ position: relative;
246
+ width: 40px;
247
+ height: 22px;
248
+ cursor: pointer;
249
+ }
250
+ .rss-toggle input {
251
+ opacity: 0;
252
+ width: 0;
253
+ height: 0;
254
+ }
255
+ .rss-toggle-slider {
256
+ position: absolute;
257
+ top: 0; left: 0; right: 0; bottom: 0;
258
+ background-color: #ccc;
259
+ border-radius: 22px;
260
+ transition: 0.3s;
261
+ }
262
+ .rss-toggle-slider:before {
263
+ position: absolute;
264
+ content: "";
265
+ height: 16px;
266
+ width: 16px;
267
+ left: 3px;
268
+ bottom: 3px;
269
+ background-color: white;
270
+ border-radius: 50%;
271
+ transition: 0.3s;
272
+ }
273
+ .rss-toggle input:checked + .rss-toggle-slider {
274
+ background-color: var(--color-accent, #ff6600);
275
+ }
276
+ .rss-toggle input:checked + .rss-toggle-slider:before {
277
+ transform: translateX(18px);
278
+ }
263
279
 
264
- {% if error %}
265
- {{ prose({ text: error.message }) }}
280
+ /* Delete button */
281
+ .rss-delete-btn {
282
+ background: none;
283
+ border: none;
284
+ color: #d32f2f;
285
+ cursor: pointer;
286
+ padding: 0.25rem;
287
+ font-size: 1rem;
288
+ opacity: 0.6;
289
+ }
290
+ .rss-delete-btn:hover { opacity: 1; }
291
+
292
+ /* Public link */
293
+ .rss-public-link {
294
+ display: flex;
295
+ align-items: center;
296
+ justify-content: space-between;
297
+ gap: var(--space-m, 1rem);
298
+ flex-wrap: wrap;
299
+ }
300
+ .rss-public-link p {
301
+ margin: 0;
302
+ color: var(--color-on-offset, #666);
303
+ font: var(--font-caption, 0.875rem/1.4 sans-serif);
304
+ }
305
+ </style>
306
+
307
+ {# Notifications #}
308
+ {% if synced %}
309
+ <div class="rss-notification rss-notification--success">
310
+ {% if syncedItems and syncedItems != "0" %}
311
+ Synced {{ syncedItems }} new items from {{ syncedFeeds }} feeds
266
312
  {% 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") }}
313
+ Feeds are up to date, nothing new to sync
314
+ {% endif %}
315
+ {% if syncedPruned and syncedPruned != "0" %}
316
+ ({{ syncedPruned }} old items pruned)
317
+ {% endif %}
318
+ </div>
319
+ {% endif %}
320
+
321
+ {% if syncInProgress %}
322
+ <div class="rss-notification rss-notification--warning">
323
+ A sync is already in progress, please wait
324
+ </div>
325
+ {% endif %}
326
+
327
+ {% if queryError %}
328
+ <div class="rss-notification rss-notification--error">
329
+ {{ queryError }}
330
+ </div>
331
+ {% endif %}
332
+
333
+ {% if configError %}
334
+ <div class="rss-notification rss-notification--error">
335
+ {{ configError }}
336
+ </div>
337
+ {% endif %}
338
+
339
+ <div class="rss-dashboard">
340
+ {% if not configError %}
341
+ {# Sync Status & Actions #}
342
+ <section class="rss-section">
343
+ <h2>{{ __("rss.syncStatus") }}</h2>
344
+ <div class="rss-sync-bar">
345
+ <div class="rss-sync-info">
346
+ <span class="rss-sync-label">{{ __("rss.lastSynced") }}</span>
347
+ <span class="rss-sync-time">
348
+ {% if syncState.syncing %}
349
+ <span class="rss-syncing">
350
+ <span class="rss-spinner"></span>
351
+ {{ __("rss.syncing") }}
352
+ </span>
353
+ {% elif syncState.lastSync %}
354
+ {{ syncState.lastSync | date("PPpp") }}
355
+ {% else %}
356
+ {{ __("rss.never") }}
357
+ {% endif %}
358
+ </span>
359
+ {% if syncState.lastError %}
360
+ <span class="rss-feed-error">{{ syncState.lastError }}</span>
281
361
  {% endif %}
282
- </span>
283
- {% if syncState.lastError %}
284
- <span class="rss-feed-error">{{ syncState.lastError }}</span>
285
- {% endif %}
286
- </div>
287
- <div style="display: flex; gap: 0.5rem;">
288
- <form action="{{ publicUrl }}/sync" method="post" style="margin: 0;">
289
- {{ button({
290
- type: "submit",
291
- text: __("rss.syncNow"),
292
- disabled: syncState.syncing
293
- }) }}
294
- </form>
295
- <form action="{{ publicUrl }}/clear-resync" method="post" style="margin: 0;" id="clear-resync-form">
296
- {{ button({
297
- type: "submit",
298
- text: __("rss.clearResync"),
299
- disabled: syncState.syncing
300
- }) }}
301
- </form>
362
+ </div>
363
+ <div class="button-group" style="display: flex; gap: 0.5rem;">
364
+ <form action="{{ mountPath }}/sync" method="post" style="display: inline;">
365
+ <button type="submit" class="button button--primary" {% if syncState.syncing %}disabled{% endif %}>
366
+ {{ __("rss.syncNow") }}
367
+ </button>
368
+ </form>
369
+ <form action="{{ mountPath }}/clear-resync" method="post" style="display: inline;" id="clear-resync-form">
370
+ <button type="submit" class="button" {% if syncState.syncing %}disabled{% endif %}>
371
+ {{ __("rss.clearResync") }}
372
+ </button>
373
+ </form>
374
+ </div>
302
375
  </div>
303
- </div>
376
+ </section>
304
377
 
305
378
  {# Stats #}
306
379
  <section class="rss-section">
380
+ <h2>{{ __("rss.status") }}</h2>
307
381
  <div class="rss-stats-grid">
308
382
  <div class="rss-stat">
309
383
  <span class="rss-stat-value">{{ totalFeeds }}</span>
@@ -323,24 +397,23 @@
323
397
  {# Add Feed Form #}
324
398
  <section class="rss-section">
325
399
  <h2>{{ __("rss.addFeed") }}</h2>
326
- <form class="rss-add-form" action="{{ publicUrl }}/api/feeds" method="post" id="add-feed-form">
400
+ <form class="rss-add-form" action="{{ mountPath }}/api/feeds" method="post" id="add-feed-form">
327
401
  <input
328
402
  type="url"
329
403
  name="url"
330
404
  placeholder="{{ __("rss.feedUrlPlaceholder") }}"
331
405
  required
332
406
  >
333
- {{ button({
334
- type: "submit",
335
- text: __("rss.addFeed")
336
- }) }}
407
+ <button type="submit" class="button button--primary">
408
+ {{ __("rss.addFeed") }}
409
+ </button>
337
410
  </form>
338
411
  </section>
339
412
 
340
413
  {# Feeds List #}
414
+ {% if feeds and feeds.length > 0 %}
341
415
  <section class="rss-section">
342
416
  <h2>{{ __("rss.feeds") }}</h2>
343
- {% if feeds and feeds.length > 0 %}
344
417
  <ul class="rss-feed-list">
345
418
  {% for feed in feeds %}
346
419
  <li class="rss-feed-item" data-feed-id="{{ feed.id }}">
@@ -380,15 +453,13 @@
380
453
  </li>
381
454
  {% endfor %}
382
455
  </ul>
383
- {% else %}
384
- {{ prose({ text: __("rss.noFeeds") }) }}
385
- {% endif %}
386
456
  </section>
457
+ {% endif %}
387
458
 
388
459
  {# Recent Items #}
460
+ {% if recentItems and recentItems.length > 0 %}
389
461
  <section class="rss-section">
390
462
  <h2>{{ __("rss.recentItems") }}</h2>
391
- {% if recentItems and recentItems.length > 0 %}
392
463
  <ul class="rss-item-list">
393
464
  {% for item in recentItems %}
394
465
  <li class="rss-item">
@@ -414,133 +485,132 @@
414
485
  </li>
415
486
  {% endfor %}
416
487
  </ul>
417
- {% else %}
418
- {{ prose({ text: __("rss.noItems") }) }}
419
- {% endif %}
420
488
  </section>
489
+ {% endif %}
421
490
 
422
- {# Link to public page #}
423
- <div class="rss-sync-status">
424
- <p>{{ __("rss.widget.description") }}</p>
425
- {{ button({
426
- href: "/news/",
427
- text: __("rss.widget.view"),
428
- target: "_blank"
429
- }) }}
430
- </div>
491
+ {# Public Page Link #}
492
+ <section class="rss-section">
493
+ <div class="rss-public-link">
494
+ <p>{{ __("rss.widget.description") }}</p>
495
+ <a href="/news/" class="button button--secondary" target="_blank">
496
+ {{ __("rss.widget.view") }}
497
+ </a>
498
+ </div>
499
+ </section>
431
500
  {% endif %}
501
+ </div>
502
+
503
+ <script>
504
+ // Handle add feed form
505
+ document.getElementById('add-feed-form')?.addEventListener('submit', async (e) => {
506
+ e.preventDefault();
507
+ const form = e.target;
508
+ const url = form.url.value;
509
+
510
+ try {
511
+ const response = await fetch(form.action, {
512
+ method: 'POST',
513
+ headers: { 'Content-Type': 'application/json' },
514
+ body: JSON.stringify({ url })
515
+ });
432
516
 
433
- <script>
434
- // Handle add feed form
435
- document.getElementById('add-feed-form')?.addEventListener('submit', async (e) => {
436
- e.preventDefault();
437
- const form = e.target;
438
- const url = form.url.value;
517
+ const data = await response.json();
518
+ if (response.ok) {
519
+ location.reload();
520
+ } else {
521
+ alert(data.error || 'Failed to add feed');
522
+ }
523
+ } catch (err) {
524
+ alert('Failed to add feed: ' + err.message);
525
+ }
526
+ });
527
+
528
+ // Handle toggle feed
529
+ document.querySelectorAll('[data-toggle-feed]').forEach(input => {
530
+ input.addEventListener('change', async (e) => {
531
+ const feedId = e.target.dataset.toggleFeed;
532
+ const enabled = e.target.checked;
439
533
 
440
534
  try {
441
- const response = await fetch(form.action, {
442
- method: 'POST',
535
+ const response = await fetch(`{{ mountPath }}/api/feeds/${feedId}`, {
536
+ method: 'PATCH',
443
537
  headers: { 'Content-Type': 'application/json' },
444
- body: JSON.stringify({ url })
538
+ body: JSON.stringify({ enabled })
445
539
  });
446
540
 
447
- const data = await response.json();
448
- if (response.ok) {
449
- location.reload();
450
- } else {
451
- alert(data.error || 'Failed to add feed');
541
+ if (!response.ok) {
542
+ e.target.checked = !enabled;
543
+ const data = await response.json();
544
+ alert(data.error || 'Failed to update feed');
452
545
  }
453
546
  } catch (err) {
454
- alert('Failed to add feed: ' + err.message);
547
+ e.target.checked = !enabled;
548
+ alert('Failed to update feed: ' + err.message);
455
549
  }
456
550
  });
551
+ });
457
552
 
458
- // Handle toggle feed
459
- document.querySelectorAll('[data-toggle-feed]').forEach(input => {
460
- input.addEventListener('change', async (e) => {
461
- const feedId = e.target.dataset.toggleFeed;
462
- const enabled = e.target.checked;
463
-
464
- try {
465
- const response = await fetch(`{{ publicUrl }}/api/feeds/${feedId}`, {
466
- method: 'PATCH',
467
- headers: { 'Content-Type': 'application/json' },
468
- body: JSON.stringify({ enabled })
469
- });
470
-
471
- if (!response.ok) {
472
- e.target.checked = !enabled;
473
- const data = await response.json();
474
- alert(data.error || 'Failed to update feed');
475
- }
476
- } catch (err) {
477
- e.target.checked = !enabled;
478
- alert('Failed to update feed: ' + err.message);
479
- }
480
- });
481
- });
553
+ // Handle clear & re-sync
554
+ document.getElementById('clear-resync-form')?.addEventListener('submit', async (e) => {
555
+ e.preventDefault();
556
+ if (!confirm('This will delete all cached items and re-fetch them from feeds. Continue?')) return;
557
+
558
+ const form = e.target;
559
+ const submitBtn = form.querySelector('button[type="submit"]');
560
+ const originalText = submitBtn?.textContent;
561
+ if (submitBtn) {
562
+ submitBtn.disabled = true;
563
+ submitBtn.textContent = 'Clearing...';
564
+ }
482
565
 
483
- // Handle clear & re-sync
484
- document.getElementById('clear-resync-form')?.addEventListener('submit', async (e) => {
485
- e.preventDefault();
486
- if (!confirm('This will delete all cached items and re-fetch them from feeds. Continue?')) return;
566
+ try {
567
+ const response = await fetch(form.action, {
568
+ method: 'POST',
569
+ headers: { 'Content-Type': 'application/json' }
570
+ });
487
571
 
488
- const form = e.target;
489
- const submitBtn = form.querySelector('button[type="submit"]');
490
- const originalText = submitBtn?.textContent;
572
+ const data = await response.json();
573
+ if (response.ok) {
574
+ alert(`Cleared ${data.itemsCleared} items, re-fetched ${data.itemsAdded} items from ${data.feedsProcessed} feeds.`);
575
+ location.reload();
576
+ } else {
577
+ alert(data.error || 'Failed to clear & re-sync');
578
+ if (submitBtn) {
579
+ submitBtn.disabled = false;
580
+ submitBtn.textContent = originalText;
581
+ }
582
+ }
583
+ } catch (err) {
584
+ alert('Failed to clear & re-sync: ' + err.message);
491
585
  if (submitBtn) {
492
- submitBtn.disabled = true;
493
- submitBtn.textContent = 'Clearing...';
586
+ submitBtn.disabled = false;
587
+ submitBtn.textContent = originalText;
494
588
  }
589
+ }
590
+ });
591
+
592
+ // Handle delete feed
593
+ document.querySelectorAll('[data-delete-feed]').forEach(btn => {
594
+ btn.addEventListener('click', async (e) => {
595
+ const feedId = e.target.dataset.deleteFeed;
596
+
597
+ if (!confirm('Are you sure you want to remove this feed?')) return;
495
598
 
496
599
  try {
497
- const response = await fetch(form.action, {
498
- method: 'POST',
499
- headers: { 'Content-Type': 'application/json' }
600
+ const response = await fetch(`{{ mountPath }}/api/feeds/${feedId}`, {
601
+ method: 'DELETE'
500
602
  });
501
603
 
502
- const data = await response.json();
503
604
  if (response.ok) {
504
- alert(`Cleared ${data.itemsCleared} items, re-fetched ${data.itemsAdded} items from ${data.feedsProcessed} feeds.`);
505
605
  location.reload();
506
606
  } else {
507
- alert(data.error || 'Failed to clear & re-sync');
508
- if (submitBtn) {
509
- submitBtn.disabled = false;
510
- submitBtn.textContent = originalText;
511
- }
607
+ const data = await response.json();
608
+ alert(data.error || 'Failed to remove feed');
512
609
  }
513
610
  } catch (err) {
514
- alert('Failed to clear & re-sync: ' + err.message);
515
- if (submitBtn) {
516
- submitBtn.disabled = false;
517
- submitBtn.textContent = originalText;
518
- }
611
+ alert('Failed to remove feed: ' + err.message);
519
612
  }
520
613
  });
521
-
522
- // Handle delete feed
523
- document.querySelectorAll('[data-delete-feed]').forEach(btn => {
524
- btn.addEventListener('click', async (e) => {
525
- const feedId = e.target.dataset.deleteFeed;
526
-
527
- if (!confirm('Are you sure you want to remove this feed?')) return;
528
-
529
- try {
530
- const response = await fetch(`{{ publicUrl }}/api/feeds/${feedId}`, {
531
- method: 'DELETE'
532
- });
533
-
534
- if (response.ok) {
535
- location.reload();
536
- } else {
537
- const data = await response.json();
538
- alert(data.error || 'Failed to remove feed');
539
- }
540
- } catch (err) {
541
- alert('Failed to remove feed: ' + err.message);
542
- }
543
- });
544
- });
545
- </script>
614
+ });
615
+ </script>
546
616
  {% endblock %}