@jant/core 0.2.18 → 0.2.19

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.
@@ -14,7 +14,7 @@ import { DashLayout } from "../../theme/layouts/index.js";
14
14
  import { EmptyState, DangerZone } from "../../theme/components/index.js";
15
15
  import * as time from "../../lib/time.js";
16
16
  import { getMediaUrl, getImageUrl } from "../../lib/image.js";
17
- import { sse } from "../../lib/sse.js";
17
+ import { dsRedirect } from "../../lib/sse.js";
18
18
 
19
19
  type Env = { Bindings: Bindings; Variables: AppVariables };
20
20
 
@@ -90,8 +90,7 @@ function MediaCard({
90
90
  /**
91
91
  * Media list page content
92
92
  *
93
- * Uses plain JavaScript for upload state management (more reliable than Datastar signals
94
- * for complex async flows like file uploads with SSE responses).
93
+ * Upload is handled by media-upload.ts (client module) + Datastar @post for SSE.
95
94
  */
96
95
  function MediaListContent({
97
96
  mediaList,
@@ -121,163 +120,17 @@ function MediaListContent({
121
120
  comment: "@context: Upload error message",
122
121
  });
123
122
 
124
- // Plain JavaScript upload handler - shows progress in the list
125
- const uploadScript = `
126
- async function handleMediaUpload(input) {
127
- if (!input.files || !input.files[0]) return;
128
-
129
- const file = input.files[0];
130
- const errorBox = document.getElementById('upload-error');
131
- errorBox.classList.add('hidden');
132
-
133
- // Ensure grid exists (remove empty state if needed)
134
- let grid = document.getElementById('media-grid');
135
- if (!grid) {
136
- document.getElementById('empty-state')?.remove();
137
- grid = document.createElement('div');
138
- grid.id = 'media-grid';
139
- grid.className = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4';
140
- document.getElementById('media-content').appendChild(grid);
141
- }
142
-
143
- // Create placeholder card showing progress
144
- const placeholder = document.createElement('div');
145
- placeholder.id = 'upload-placeholder';
146
- placeholder.className = 'group relative';
147
- placeholder.innerHTML = \`
148
- <div class="aspect-square bg-muted rounded-lg overflow-hidden border flex items-center justify-center">
149
- <div class="text-center px-2">
150
- <svg class="animate-spin h-6 w-6 text-muted-foreground mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
151
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
152
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
153
- </svg>
154
- <span id="upload-status" class="text-xs text-muted-foreground">${processingText}</span>
155
- </div>
156
- </div>
157
- <div class="mt-2 text-xs truncate" title="\${file.name}">\${file.name}</div>
158
- <div class="text-xs text-muted-foreground">\${formatFileSize(file.size)}</div>
159
- \`;
160
- grid.prepend(placeholder);
161
-
162
- try {
163
- if (typeof ImageProcessor === 'undefined') {
164
- throw new Error('ImageProcessor not loaded');
165
- }
166
-
167
- // Process image client-side
168
- const processed = await ImageProcessor.processToFile(file);
169
- document.getElementById('upload-status').textContent = '${uploadingText}';
170
-
171
- // Upload with SSE response
172
- const fd = new FormData();
173
- fd.append('file', processed);
174
-
175
- const response = await fetch('/api/upload', {
176
- method: 'POST',
177
- body: fd,
178
- headers: { 'Accept': 'text/event-stream' }
179
- });
180
-
181
- if (!response.ok) throw new Error('Upload failed: ' + response.status);
182
-
183
- // Parse SSE stream - will replace placeholder with real card
184
- const reader = response.body.getReader();
185
- const decoder = new TextDecoder();
186
- let buffer = '';
187
-
188
- while (true) {
189
- const { done, value } = await reader.read();
190
- if (done) break;
191
-
192
- buffer += decoder.decode(value, { stream: true });
193
- const events = buffer.split('\\n\\n');
194
- buffer = events.pop() || '';
195
-
196
- for (const event of events) {
197
- if (!event.trim()) continue;
198
- processSSEEvent(event);
199
- }
200
- }
201
-
202
- } catch (err) {
203
- console.error('Upload error:', err);
204
- // Show error in placeholder
205
- placeholder.innerHTML = \`
206
- <div class="aspect-square bg-destructive/10 rounded-lg overflow-hidden border border-destructive flex items-center justify-center">
207
- <div class="text-center px-2">
208
- <span class="text-xs text-destructive">\${err.message || '${errorText}'}</span>
209
- </div>
210
- </div>
211
- <div class="mt-2 text-xs truncate text-destructive">\${file.name}</div>
212
- <button type="button" class="text-xs text-muted-foreground hover:underline" onclick="this.closest('.group').remove()">Remove</button>
213
- \`;
214
- }
215
-
216
- input.value = '';
217
- }
218
-
219
- function formatFileSize(bytes) {
220
- if (bytes < 1024) return bytes + ' B';
221
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
222
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
223
- }
224
-
225
- function processSSEEvent(event) {
226
- const lines = event.split('\\n');
227
- let eventType = '';
228
- const data = {};
229
- let elementsLines = [];
230
- let inElements = false;
231
-
232
- for (const line of lines) {
233
- if (line.startsWith('event: ')) {
234
- eventType = line.slice(7);
235
- } else if (line.startsWith('data: ')) {
236
- const content = line.slice(6);
237
- if (content.startsWith('mode ')) {
238
- data.mode = content.slice(5);
239
- inElements = false;
240
- } else if (content.startsWith('selector ')) {
241
- data.selector = content.slice(9);
242
- inElements = false;
243
- } else if (content.startsWith('elements ')) {
244
- elementsLines = [content.slice(9)];
245
- inElements = true;
246
- } else if (inElements) {
247
- // Continuation of elements content
248
- elementsLines.push(content);
249
- }
250
- }
251
- }
252
-
253
- if (elementsLines.length > 0) {
254
- data.elements = elementsLines.join('\\n');
255
- }
256
-
257
- if (eventType === 'datastar-patch-elements') {
258
- if (data.mode === 'remove' && data.selector) {
259
- document.querySelector(data.selector)?.remove();
260
- } else if (data.mode === 'outer' && data.selector && data.elements) {
261
- // Replace element entirely (used for placeholder -> real card)
262
- const target = document.querySelector(data.selector);
263
- if (target) {
264
- const temp = document.createElement('div');
265
- temp.innerHTML = data.elements;
266
- const newElement = temp.firstElementChild;
267
- if (newElement) {
268
- target.replaceWith(newElement);
269
- if (window.Datastar) Datastar.apply(newElement);
270
- }
271
- }
272
- }
273
- }
274
- }
275
- `.trim();
276
-
277
123
  return (
278
124
  <>
279
- {/* Upload script */}
280
- <script dangerouslySetInnerHTML={{ __html: uploadScript }}></script>
125
+ {/* Hidden form for Datastar-driven upload */}
126
+ <form
127
+ id="upload-form"
128
+ class="hidden"
129
+ enctype="multipart/form-data"
130
+ data-on:submit__prevent="@post('/api/upload', {contentType: 'form'})"
131
+ >
132
+ <input id="upload-file-input" type="file" name="file" />
133
+ </form>
281
134
 
282
135
  {/* Header */}
283
136
  <div class="flex items-center justify-between mb-6">
@@ -290,14 +143,14 @@ function processSSEEvent(event) {
290
143
  type="file"
291
144
  class="hidden"
292
145
  accept="image/*"
293
- onchange="handleMediaUpload(this)"
146
+ data-media-upload
147
+ data-text-processing={processingText}
148
+ data-text-uploading={uploadingText}
149
+ data-text-error={errorText}
294
150
  />
295
151
  </label>
296
152
  </div>
297
153
 
298
- {/* Hidden error container for global errors */}
299
- <div id="upload-error" class="hidden"></div>
300
-
301
154
  {/* Upload instructions */}
302
155
  <div class="card mb-6">
303
156
  <section class="text-sm text-muted-foreground">
@@ -610,7 +463,5 @@ mediaRoutes.post("/:id/delete", async (c) => {
610
463
  // Delete from database
611
464
  await c.var.services.media.delete(id);
612
465
 
613
- return sse(c, async (stream) => {
614
- await stream.redirect("/dash/media");
615
- });
466
+ return dsRedirect("/dash/media");
616
467
  });
@@ -21,7 +21,7 @@ import {
21
21
  } from "../../theme/components/index.js";
22
22
  import * as sqid from "../../lib/sqid.js";
23
23
  import * as time from "../../lib/time.js";
24
- import { sse } from "../../lib/sse.js";
24
+ import { dsRedirect } from "../../lib/sse.js";
25
25
 
26
26
  type Env = { Bindings: Bindings; Variables: AppVariables };
27
27
 
@@ -237,9 +237,7 @@ pagesRoutes.post("/", async (c) => {
237
237
  path: body.path.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
238
238
  });
239
239
 
240
- return sse(c, async (stream) => {
241
- await stream.redirect(`/dash/pages/${sqid.encode(page.id)}`);
242
- });
240
+ return dsRedirect(`/dash/pages/${sqid.encode(page.id)}`);
243
241
  });
244
242
 
245
243
  // View single page
@@ -306,9 +304,7 @@ pagesRoutes.post("/:id", async (c) => {
306
304
  path: body.path.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
307
305
  });
308
306
 
309
- return sse(c, async (stream) => {
310
- await stream.redirect(`/dash/pages/${sqid.encode(id)}`);
311
- });
307
+ return dsRedirect(`/dash/pages/${sqid.encode(id)}`);
312
308
  });
313
309
 
314
310
  // Delete page
@@ -318,7 +314,5 @@ pagesRoutes.post("/:id/delete", async (c) => {
318
314
 
319
315
  await c.var.services.posts.delete(id);
320
316
 
321
- return sse(c, async (stream) => {
322
- await stream.redirect("/dash/pages");
323
- });
317
+ return dsRedirect("/dash/pages");
324
318
  });
@@ -15,7 +15,7 @@ import {
15
15
  ActionButtons,
16
16
  } from "../../theme/components/index.js";
17
17
  import * as sqid from "../../lib/sqid.js";
18
- import { sse } from "../../lib/sse.js";
18
+ import { dsRedirect } from "../../lib/sse.js";
19
19
 
20
20
  type Env = { Bindings: Bindings; Variables: AppVariables };
21
21
 
@@ -105,9 +105,7 @@ postsRoutes.post("/", async (c) => {
105
105
  path: body.path || undefined,
106
106
  });
107
107
 
108
- return sse(c, async (stream) => {
109
- await stream.redirect(`/dash/posts/${sqid.encode(post.id)}`);
110
- });
108
+ return dsRedirect(`/dash/posts/${sqid.encode(post.id)}`);
111
109
  });
112
110
 
113
111
  function ViewPostContent({ post }: { post: Post }) {
@@ -227,9 +225,7 @@ postsRoutes.post("/:id", async (c) => {
227
225
  path: body.path || null,
228
226
  });
229
227
 
230
- return sse(c, async (stream) => {
231
- await stream.redirect(`/dash/posts/${sqid.encode(id)}`);
232
- });
228
+ return dsRedirect(`/dash/posts/${sqid.encode(id)}`);
233
229
  });
234
230
 
235
231
  // Delete post
@@ -239,7 +235,5 @@ postsRoutes.post("/:id/delete", async (c) => {
239
235
 
240
236
  await c.var.services.posts.delete(id);
241
237
 
242
- return sse(c, async (stream) => {
243
- await stream.redirect("/dash/posts");
244
- });
238
+ return dsRedirect("/dash/posts");
245
239
  });
@@ -14,7 +14,7 @@ import {
14
14
  ActionButtons,
15
15
  CrudPageHeader,
16
16
  } from "../../theme/components/index.js";
17
- import { sse } from "../../lib/sse.js";
17
+ import { dsRedirect } from "../../lib/sse.js";
18
18
 
19
19
  type Env = { Bindings: Bindings; Variables: AppVariables };
20
20
 
@@ -219,9 +219,7 @@ redirectsRoutes.post("/", async (c) => {
219
219
  const type = parseInt(body.type, 10) as 301 | 302;
220
220
  await c.var.services.redirects.create(body.fromPath, body.toPath, type);
221
221
 
222
- return sse(c, async (stream) => {
223
- await stream.redirect("/dash/redirects");
224
- });
222
+ return dsRedirect("/dash/redirects");
225
223
  });
226
224
 
227
225
  // Delete redirect
@@ -231,7 +229,5 @@ redirectsRoutes.post("/:id/delete", async (c) => {
231
229
  await c.var.services.redirects.delete(id);
232
230
  }
233
231
 
234
- return sse(c, async (stream) => {
235
- await stream.redirect("/dash/redirects");
236
- });
232
+ return dsRedirect("/dash/redirects");
237
233
  });
@@ -7,9 +7,18 @@ import { useLingui } from "@lingui/react/macro";
7
7
  import type { Bindings } from "../../types.js";
8
8
  import type { AppVariables } from "../../app.js";
9
9
  import { DashLayout } from "../../theme/layouts/index.js";
10
- import { sse } from "../../lib/sse.js";
10
+ import { sse, dsToast } from "../../lib/sse.js";
11
11
  import { getSiteLanguage, getConfigFallback } from "../../lib/config.js";
12
12
 
13
+ /** Escape HTML special characters for safe insertion into HTML strings */
14
+ function escapeHtml(str: string): string {
15
+ return str
16
+ .replace(/&/g, "&amp;")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;");
20
+ }
21
+
13
22
  type Env = { Bindings: Bindings; Variables: AppVariables };
14
23
 
15
24
  export const settingsRoutes = new Hono<Env>();
@@ -260,11 +269,24 @@ settingsRoutes.post("/", async (c) => {
260
269
 
261
270
  const languageChanged = oldLanguage !== body.siteLanguage;
262
271
 
272
+ // Determine the effective display name after save
273
+ const displayName = body.siteName.trim() || getConfigFallback(c, "SITE_NAME");
274
+
263
275
  return sse(c, async (stream) => {
264
276
  if (languageChanged) {
265
277
  // Language changed - full reload needed to update all UI text
266
278
  await stream.redirect("/dash/settings?saved");
267
279
  } else {
280
+ const escaped = escapeHtml(displayName);
281
+ // Update header site name
282
+ await stream.patchElements(
283
+ `<a id="site-name" href="/dash" class="font-semibold">${escaped}</a>`,
284
+ );
285
+ // Update page title
286
+ await stream.patchElements(`Settings - ${escaped}`, {
287
+ mode: "inner",
288
+ selector: "title",
289
+ });
268
290
  await stream.toast("Settings saved successfully.");
269
291
  }
270
292
  });
@@ -279,9 +301,7 @@ settingsRoutes.post("/password", async (c) => {
279
301
  }>();
280
302
 
281
303
  if (body.newPassword !== body.confirmPassword) {
282
- return sse(c, async (stream) => {
283
- await stream.toast("Passwords do not match.", "error");
284
- });
304
+ return dsToast("Passwords do not match.", "error");
285
305
  }
286
306
 
287
307
  try {
@@ -294,9 +314,7 @@ settingsRoutes.post("/password", async (c) => {
294
314
  headers: c.req.raw.headers,
295
315
  });
296
316
  } catch {
297
- return sse(c, async (stream) => {
298
- await stream.toast("Current password is incorrect.", "error");
299
- });
317
+ return dsToast("Current password is incorrect.", "error");
300
318
  }
301
319
 
302
320
  return sse(c, async (stream) => {
@@ -42,7 +42,7 @@ function DashLayoutContent({
42
42
  {/* Header */}
43
43
  <header class="border-b bg-card">
44
44
  <div class="container flex h-14 items-center justify-between">
45
- <a href="/dash" class="font-semibold">
45
+ <a id="site-name" href="/dash" class="font-semibold">
46
46
  {siteName}
47
47
  </a>
48
48
  <nav class="flex items-center gap-4">