@rmdes/indiekit-endpoint-podroll 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ /* Podroll endpoint styles */
2
+
3
+ /* Stats grid */
4
+ .podroll-stats {
5
+ display: grid;
6
+ gap: var(--space-s);
7
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
8
+ }
9
+
10
+ .podroll-stat {
11
+ background: var(--color-offset);
12
+ border-radius: var(--radius-m);
13
+ padding: var(--space-s);
14
+ text-align: center;
15
+ }
16
+
17
+ .podroll-stat dt {
18
+ color: var(--color-text-secondary);
19
+ font-size: var(--step--1);
20
+ margin-block-end: var(--space-3xs);
21
+ }
22
+
23
+ .podroll-stat dd {
24
+ font-size: var(--step-1);
25
+ font-weight: var(--font-weight-semibold);
26
+ margin: 0;
27
+ }
28
+
29
+ /* Settings form */
30
+ .podroll-form {
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: var(--space-m);
34
+ }
35
+
36
+ .podroll-field {
37
+ display: flex;
38
+ flex-direction: column;
39
+ gap: var(--space-3xs);
40
+ }
41
+
42
+ .podroll-field-static {
43
+ align-items: baseline;
44
+ border-block-start: 1px solid var(--color-border);
45
+ display: flex;
46
+ justify-content: space-between;
47
+ padding: var(--space-2xs) 0;
48
+ }
49
+
50
+ .podroll-field-static dt {
51
+ color: var(--color-text-secondary);
52
+ font-size: var(--step--1);
53
+ }
54
+
55
+ .podroll-field-static dd {
56
+ font-size: var(--step--1);
57
+ margin: 0;
58
+ }
59
+
60
+ /* API list */
61
+ .podroll-api-list {
62
+ display: flex;
63
+ flex-direction: column;
64
+ gap: var(--space-xs);
65
+ list-style: none;
66
+ margin: 0;
67
+ padding: 0;
68
+ }
69
+
70
+ .podroll-api-list li {
71
+ background: var(--color-offset);
72
+ border-radius: var(--radius-s);
73
+ font-size: var(--step--1);
74
+ padding: var(--space-xs) var(--space-s);
75
+ }
76
+
77
+ .podroll-api-list code {
78
+ color: var(--color-accent);
79
+ font-weight: var(--font-weight-semibold);
80
+ }
@@ -23,6 +23,22 @@ async function getEffectiveUrls(db, podrollConfig) {
23
23
  return { episodesUrl, opmlUrl };
24
24
  }
25
25
 
26
+ /**
27
+ * Extract and clear flash messages from session
28
+ * Returns { success, error } for Indiekit's native notificationBanner
29
+ */
30
+ function consumeFlashMessage(request) {
31
+ const result = {};
32
+ if (request.session?.messages?.length) {
33
+ const msg = request.session.messages[0];
34
+ if (msg.type === "success") result.success = msg.content;
35
+ else if (msg.type === "error" || msg.type === "warning")
36
+ result.error = msg.content;
37
+ request.session.messages = null;
38
+ }
39
+ return result;
40
+ }
41
+
26
42
  /**
27
43
  * Dashboard controller for admin UI
28
44
  */
@@ -44,12 +60,17 @@ export const dashboardController = {
44
60
  };
45
61
 
46
62
  if (db) {
47
- const [episodeCount, sourceCount, episodesMeta, sourcesMeta] = await Promise.all([
48
- db.collection("podrollEpisodes").countDocuments(),
49
- db.collection("podrollSources").countDocuments(),
50
- db.collection("podrollMeta").findOne({ key: "lastEpisodesSync" }),
51
- db.collection("podrollMeta").findOne({ key: "lastSourcesSync" }),
52
- ]);
63
+ const [episodeCount, sourceCount, episodesMeta, sourcesMeta] =
64
+ await Promise.all([
65
+ db.collection("podrollEpisodes").countDocuments(),
66
+ db.collection("podrollSources").countDocuments(),
67
+ db
68
+ .collection("podrollMeta")
69
+ .findOne({ key: "lastEpisodesSync" }),
70
+ db
71
+ .collection("podrollMeta")
72
+ .findOne({ key: "lastSourcesSync" }),
73
+ ]);
53
74
 
54
75
  // Convert Date objects to ISO strings for Nunjucks date filter
55
76
  const toISO = (d) => (d instanceof Date ? d.toISOString() : d);
@@ -64,6 +85,9 @@ export const dashboardController = {
64
85
 
65
86
  const urls = await getEffectiveUrls(db, application.podrollConfig);
66
87
 
88
+ // Extract flash messages for native Indiekit notification banner
89
+ const flash = consumeFlashMessage(request);
90
+
67
91
  response.render("dashboard", {
68
92
  title: response.__("podroll.title"),
69
93
  stats,
@@ -72,6 +96,8 @@ export const dashboardController = {
72
96
  opmlUrl: urls.opmlUrl,
73
97
  syncInterval: application.podrollConfig?.syncInterval || 900000,
74
98
  },
99
+ mountPath: request.baseUrl,
100
+ ...flash,
75
101
  });
76
102
  } catch (error) {
77
103
  console.error("[Podroll] Dashboard error:", error);
@@ -111,14 +137,16 @@ export const dashboardController = {
111
137
  );
112
138
 
113
139
  console.log("[Podroll] Settings saved");
114
- response.redirect(application.podrollEndpoint + "?saved=true");
140
+ request.session.messages = [
141
+ { type: "success", content: request.__("podroll.settingsSaved") },
142
+ ];
143
+ response.redirect(request.baseUrl);
115
144
  } catch (error) {
116
145
  console.error("[Podroll] Settings save error:", error);
117
- response.redirect(
118
- application.podrollEndpoint +
119
- "?error=" +
120
- encodeURIComponent(error.message),
121
- );
146
+ request.session.messages = [
147
+ { type: "error", content: error.message },
148
+ ];
149
+ response.redirect(request.baseUrl);
122
150
  }
123
151
  },
124
152
 
@@ -143,13 +171,18 @@ export const dashboardController = {
143
171
  opmlUrl: urls.opmlUrl,
144
172
  };
145
173
 
146
- const result = await runSync(db, syncOptions);
174
+ await runSync(db, syncOptions);
147
175
 
148
- // Redirect back to dashboard with success message
149
- response.redirect(application.podrollEndpoint + "?synced=true");
176
+ request.session.messages = [
177
+ { type: "success", content: request.__("podroll.syncSuccess") },
178
+ ];
179
+ response.redirect(request.baseUrl);
150
180
  } catch (error) {
151
181
  console.error("[Podroll] Sync error:", error);
152
- response.redirect(application.podrollEndpoint + "?error=" + encodeURIComponent(error.message));
182
+ request.session.messages = [
183
+ { type: "error", content: error.message },
184
+ ];
185
+ response.redirect(request.baseUrl);
153
186
  }
154
187
  },
155
188
 
@@ -170,7 +203,9 @@ export const dashboardController = {
170
203
  await Promise.all([
171
204
  db.collection("podrollEpisodes").deleteMany({}),
172
205
  db.collection("podrollSources").deleteMany({}),
173
- db.collection("podrollMeta").deleteMany({ key: { $ne: "settings" } }),
206
+ db
207
+ .collection("podrollMeta")
208
+ .deleteMany({ key: { $ne: "settings" } }),
174
209
  ]);
175
210
 
176
211
  console.log("[Podroll] Cleared all data, starting fresh sync...");
@@ -183,12 +218,18 @@ export const dashboardController = {
183
218
  opmlUrl: urls.opmlUrl,
184
219
  };
185
220
 
186
- const result = await runSync(db, syncOptions);
221
+ await runSync(db, syncOptions);
187
222
 
188
- response.redirect(application.podrollEndpoint + "?cleared=true");
223
+ request.session.messages = [
224
+ { type: "success", content: request.__("podroll.clearSuccess") },
225
+ ];
226
+ response.redirect(request.baseUrl);
189
227
  } catch (error) {
190
228
  console.error("[Podroll] Clear/resync error:", error);
191
- response.redirect(application.podrollEndpoint + "?error=" + encodeURIComponent(error.message));
229
+ request.session.messages = [
230
+ { type: "error", content: error.message },
231
+ ];
232
+ response.redirect(request.baseUrl);
192
233
  }
193
234
  },
194
235
 
@@ -208,12 +249,17 @@ export const dashboardController = {
208
249
  });
209
250
  }
210
251
 
211
- const [episodeCount, sourceCount, episodesMeta, sourcesMeta] = await Promise.all([
212
- db.collection("podrollEpisodes").countDocuments(),
213
- db.collection("podrollSources").countDocuments(),
214
- db.collection("podrollMeta").findOne({ key: "lastEpisodesSync" }),
215
- db.collection("podrollMeta").findOne({ key: "lastSourcesSync" }),
216
- ]);
252
+ const [episodeCount, sourceCount, episodesMeta, sourcesMeta] =
253
+ await Promise.all([
254
+ db.collection("podrollEpisodes").countDocuments(),
255
+ db.collection("podrollSources").countDocuments(),
256
+ db
257
+ .collection("podrollMeta")
258
+ .findOne({ key: "lastEpisodesSync" }),
259
+ db
260
+ .collection("podrollMeta")
261
+ .findOne({ key: "lastSourcesSync" }),
262
+ ]);
217
263
 
218
264
  response.json({
219
265
  status: "ok",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-podroll",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Podcast roll endpoint for Indiekit. Aggregates podcast episodes from FreshRSS, displays on frontend with OPML sidebar.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -33,6 +33,7 @@
33
33
  ".": "./index.js"
34
34
  },
35
35
  "files": [
36
+ "assets",
36
37
  "lib",
37
38
  "locales",
38
39
  "views",
@@ -1,250 +1,80 @@
1
- {% extends "document.njk" %}
1
+ {% extends "layouts/podroll.njk" %}
2
2
 
3
- {% block content %}
4
- <style>
5
- .pr-dashboard {
6
- display: flex;
7
- flex-direction: column;
8
- gap: var(--space-xl, 2rem);
9
- }
10
-
11
- .pr-section {
12
- background: var(--color-offset, #f5f5f5);
13
- border-radius: var(--border-radius-small, 0.5rem);
14
- padding: var(--space-m, 1.5rem);
15
- }
16
-
17
- .pr-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
- }
23
-
24
- .pr-section p.pr-hint {
25
- color: var(--color-on-offset, #666);
26
- font: var(--font-caption, 0.875rem/1.4 sans-serif);
27
- margin-block-end: var(--space-m, 1rem);
28
- }
29
-
30
- .pr-stats-grid {
31
- display: grid;
32
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
33
- gap: var(--space-s, 0.75rem);
34
- }
35
-
36
- .pr-stat {
37
- background: var(--color-background, #fff);
38
- border-radius: var(--border-radius-small, 0.5rem);
39
- padding: var(--space-s, 0.75rem);
40
- text-align: center;
41
- }
42
-
43
- .pr-stat dt {
44
- color: var(--color-on-offset, #666);
45
- font: var(--font-caption, 0.875rem/1.4 sans-serif);
46
- margin-block-end: var(--space-2xs, 0.25rem);
47
- }
48
-
49
- .pr-stat dd {
50
- font: var(--font-subhead, bold 1.125rem/1.4 sans-serif);
51
- margin: 0;
52
- }
53
-
54
- .pr-form {
55
- display: flex;
56
- flex-direction: column;
57
- gap: var(--space-m, 1rem);
58
- }
59
-
60
- .pr-field {
61
- display: flex;
62
- flex-direction: column;
63
- gap: var(--space-2xs, 0.25rem);
64
- }
65
-
66
- .pr-field label {
67
- font: var(--font-label, bold 0.875rem/1.4 sans-serif);
68
- }
69
-
70
- .pr-field .pr-field-hint {
71
- color: var(--color-on-offset, #666);
72
- font: var(--font-caption, 0.875rem/1.4 sans-serif);
73
- }
74
-
75
- .pr-field input {
76
- appearance: none;
77
- background-color: var(--color-background, #fff);
78
- border: 1px solid var(--color-outline-variant, #ccc);
79
- border-radius: var(--border-radius-small, 0.25rem);
80
- font: var(--font-body, 0.875rem/1.4 sans-serif);
81
- padding: calc(var(--space-s, 0.75rem) / 2) var(--space-s, 0.75rem);
82
- width: 100%;
83
- }
84
-
85
- .pr-field input:focus {
86
- border-color: var(--color-primary, #0066cc);
87
- outline: 2px solid var(--color-primary, #0066cc);
88
- outline-offset: 1px;
89
- }
90
-
91
- .pr-field-static {
92
- display: flex;
93
- justify-content: space-between;
94
- align-items: baseline;
95
- padding: calc(var(--space-s, 0.75rem) / 2) 0;
96
- border-block-start: 1px solid var(--color-outline-variant, #eee);
97
- }
98
-
99
- .pr-field-static dt {
100
- color: var(--color-on-offset, #666);
101
- font: var(--font-caption, 0.875rem/1.4 sans-serif);
102
- }
103
-
104
- .pr-field-static dd {
105
- margin: 0;
106
- font: var(--font-body, 0.875rem/1.4 sans-serif);
107
- }
108
-
109
- .pr-api-list {
110
- list-style: none;
111
- padding: 0;
112
- margin: 0;
113
- display: flex;
114
- flex-direction: column;
115
- gap: var(--space-xs, 0.5rem);
116
- }
117
-
118
- .pr-api-list li {
119
- background: var(--color-background, #fff);
120
- border-radius: var(--border-radius-small, 0.25rem);
121
- padding: var(--space-xs, 0.5rem) var(--space-s, 0.75rem);
122
- font: var(--font-caption, 0.875rem/1.4 sans-serif);
123
- }
124
-
125
- .pr-api-list code {
126
- font-weight: 600;
127
- color: var(--color-primary, #0066cc);
128
- }
129
-
130
- .pr-notification {
131
- border-radius: var(--border-radius-small, 0.25rem);
132
- padding: var(--space-s, 0.75rem) var(--space-m, 1rem);
133
- margin-block-end: var(--space-s, 0.75rem);
134
- }
135
-
136
- .pr-notification--success {
137
- background: var(--color-success, #d4edda);
138
- color: var(--color-on-success, #155724);
139
- }
140
-
141
- .pr-notification--error {
142
- background: var(--color-error, #f8d7da);
143
- color: var(--color-on-error, #721c24);
144
- }
145
- </style>
146
-
147
- <header class="page-header">
148
- <h1 class="page-header__title">{{ __("podroll.title") }}</h1>
149
- <p class="page-header__description">{{ __("podroll.description") }}</p>
150
- </header>
151
-
152
- {% if request.query.synced %}
153
- <div class="pr-notification pr-notification--success">
154
- {{ __("podroll.syncSuccess") }}
155
- </div>
156
- {% endif %}
157
-
158
- {% if request.query.cleared %}
159
- <div class="pr-notification pr-notification--success">
160
- {{ __("podroll.clearSuccess") }}
161
- </div>
162
- {% endif %}
163
-
164
- {% if request.query.saved %}
165
- <div class="pr-notification pr-notification--success">
166
- {{ __("podroll.settingsSaved") }}
167
- </div>
168
- {% endif %}
169
-
170
- {% if request.query.error %}
171
- <div class="pr-notification pr-notification--error">
172
- {{ __("podroll.syncError") }}: {{ request.query.error }}
173
- </div>
174
- {% endif %}
175
-
176
- <div class="pr-dashboard">
177
- <section class="pr-section">
178
- <h2>{{ __("podroll.stats") }}</h2>
179
- <dl class="pr-stats-grid">
180
- <div class="pr-stat">
3
+ {% block podroll %}
4
+ {# Stats #}
5
+ {% call section({ title: __("podroll.stats") }) %}
6
+ <dl class="podroll-stats">
7
+ <div class="podroll-stat">
181
8
  <dt>{{ __("podroll.episodeCount") }}</dt>
182
9
  <dd>{{ stats.episodeCount }}</dd>
183
10
  </div>
184
- <div class="pr-stat">
11
+ <div class="podroll-stat">
185
12
  <dt>{{ __("podroll.sourceCount") }}</dt>
186
13
  <dd>{{ stats.sourceCount }}</dd>
187
14
  </div>
188
- <div class="pr-stat">
15
+ <div class="podroll-stat">
189
16
  <dt>{{ __("podroll.lastEpisodesSync") }}</dt>
190
- <dd>{{ stats.lastEpisodesSync | date("PPpp") if stats.lastEpisodesSync else __("podroll.never") }}</dd>
17
+ <dd>{% if stats.lastEpisodesSync %}{{ stats.lastEpisodesSync | date("PPpp") }}{% else %}{{ __("podroll.never") }}{% endif %}</dd>
191
18
  </div>
192
- <div class="pr-stat">
19
+ <div class="podroll-stat">
193
20
  <dt>{{ __("podroll.lastSourcesSync") }}</dt>
194
- <dd>{{ stats.lastSourcesSync | date("PPpp") if stats.lastSourcesSync else __("podroll.never") }}</dd>
21
+ <dd>{% if stats.lastSourcesSync %}{{ stats.lastSourcesSync | date("PPpp") }}{% else %}{{ __("podroll.never") }}{% endif %}</dd>
195
22
  </div>
196
23
  </dl>
197
- </section>
198
-
199
- <section class="pr-section">
200
- <h2>{{ __("podroll.configuration") }}</h2>
201
- <p class="pr-hint">{{ __("podroll.configurationHelp") }}</p>
202
- <form method="post" action="{{ application.podrollEndpoint }}/settings" class="pr-form">
203
- <div class="pr-field">
204
- <label for="episodesUrl">{{ __("podroll.episodesUrl") }}</label>
205
- <span class="pr-field-hint" id="episodesUrl-hint">{{ __("podroll.episodesUrlHelp") }}</span>
206
- <input type="url" id="episodesUrl" name="episodesUrl" value="{{ config.episodesUrl }}" aria-describedby="episodesUrl-hint" placeholder="https://...">
24
+ {% endcall %}
25
+
26
+ {# Configuration #}
27
+ {% call section({ title: __("podroll.configuration") }) %}
28
+ <p class="hint">{{ __("podroll.configurationHelp") }}</p>
29
+ <form method="post" action="{{ mountPath }}/settings" class="podroll-form">
30
+ <div class="podroll-field">
31
+ <label class="label" for="episodesUrl">{{ __("podroll.episodesUrl") }}</label>
32
+ <span class="hint" id="episodesUrl-hint">{{ __("podroll.episodesUrlHelp") }}</span>
33
+ <input class="input" type="url" id="episodesUrl" name="episodesUrl" value="{{ config.episodesUrl }}" aria-describedby="episodesUrl-hint" placeholder="https://...">
207
34
  </div>
208
- <div class="pr-field">
209
- <label for="opmlUrl">{{ __("podroll.opmlUrl") }}</label>
210
- <span class="pr-field-hint" id="opmlUrl-hint">{{ __("podroll.opmlUrlHelp") }}</span>
211
- <input type="url" id="opmlUrl" name="opmlUrl" value="{{ config.opmlUrl }}" aria-describedby="opmlUrl-hint" placeholder="https://...">
35
+ <div class="podroll-field">
36
+ <label class="label" for="opmlUrl">{{ __("podroll.opmlUrl") }}</label>
37
+ <span class="hint" id="opmlUrl-hint">{{ __("podroll.opmlUrlHelp") }}</span>
38
+ <input class="input" type="url" id="opmlUrl" name="opmlUrl" value="{{ config.opmlUrl }}" aria-describedby="opmlUrl-hint" placeholder="https://...">
212
39
  </div>
213
- <dl class="pr-field-static">
40
+ <dl class="podroll-field-static">
214
41
  <dt>{{ __("podroll.syncInterval") }}</dt>
215
42
  <dd>{{ (config.syncInterval / 60000) | round }} {{ __("podroll.minutes") }}</dd>
216
43
  </dl>
217
44
  <div>
218
- <button type="submit" class="button button--primary">
219
- {{ __("podroll.saveSettings") }}
220
- </button>
45
+ {{ button({
46
+ type: "submit",
47
+ text: __("podroll.saveSettings")
48
+ }) }}
221
49
  </div>
222
50
  </form>
223
- </section>
51
+ {% endcall %}
224
52
 
225
- <section class="pr-section">
226
- <h2>{{ __("podroll.actions") }}</h2>
53
+ {# Actions #}
54
+ {% call section({ title: __("podroll.actions") }) %}
227
55
  <div class="button-group">
228
- <form method="post" action="{{ application.podrollEndpoint }}/sync" style="display: inline;">
229
- <button type="submit" class="button button--primary">
230
- {{ __("podroll.syncNow") }}
231
- </button>
56
+ <form method="post" action="{{ mountPath }}/sync" style="display: inline;">
57
+ {{ button({
58
+ type: "submit",
59
+ text: __("podroll.syncNow")
60
+ }) }}
232
61
  </form>
233
- <form method="post" action="{{ application.podrollEndpoint }}/clear-resync" style="display: inline;" onsubmit="return confirm('{{ __("podroll.clearConfirm") }}');">
234
- <button type="submit" class="button button--secondary">
235
- {{ __("podroll.clearResync") }}
236
- </button>
62
+ <form method="post" action="{{ mountPath }}/clear-resync" style="display: inline;" onsubmit="return confirm('{{ __("podroll.clearConfirm") }}');">
63
+ {{ button({
64
+ classes: "button--secondary",
65
+ type: "submit",
66
+ text: __("podroll.clearResync")
67
+ }) }}
237
68
  </form>
238
69
  </div>
239
- </section>
240
-
241
- <section class="pr-section">
242
- <h2>{{ __("podroll.apiEndpoints") }}</h2>
243
- <ul class="pr-api-list">
244
- <li><code>GET {{ application.podrollEndpoint }}/api/episodes</code> - {{ __("podroll.apiEpisodes") }}</li>
245
- <li><code>GET {{ application.podrollEndpoint }}/api/sources</code> - {{ __("podroll.apiSources") }}</li>
246
- <li><code>GET {{ application.podrollEndpoint }}/api/status</code> - {{ __("podroll.apiStatus") }}</li>
70
+ {% endcall %}
71
+
72
+ {# API Endpoints #}
73
+ {% call section({ title: __("podroll.apiEndpoints") }) %}
74
+ <ul class="podroll-api-list">
75
+ <li><code>GET {{ mountPath }}/api/episodes</code> - {{ __("podroll.apiEpisodes") }}</li>
76
+ <li><code>GET {{ mountPath }}/api/sources</code> - {{ __("podroll.apiSources") }}</li>
77
+ <li><code>GET {{ mountPath }}/api/status</code> - {{ __("podroll.apiStatus") }}</li>
247
78
  </ul>
248
- </section>
249
- </div>
79
+ {% endcall %}
250
80
  {% endblock %}
@@ -0,0 +1,6 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-podroll/styles.css">
5
+ {% block podroll %}{% endblock %}
6
+ {% endblock %}