@jant/core 0.2.18 → 0.2.20

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/dist/app.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.tsx"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAG5B,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAc,KAAK,IAAI,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAwCvD,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,UAAU,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,GAAG,GAAG,IAAI,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,YAAY,CAAA;CAAE,CAAC,CAAC;AAExE;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,SAAS,CAAC,MAAM,GAAE,UAAe,GAAG,GAAG,CA+qBtD"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.tsx"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAG5B,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAc,KAAK,IAAI,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAwCvD,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,UAAU,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,GAAG,GAAG,IAAI,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,YAAY,CAAA;CAAE,CAAC,CAAC;AAExE;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,SAAS,CAAC,MAAM,GAAE,UAAe,GAAG,GAAG,CAyoBtD"}
package/dist/app.js CHANGED
@@ -36,7 +36,7 @@ import { sitemapRoutes } from "./routes/feed/sitemap.js";
36
36
  import { requireAuth } from "./middleware/auth.js";
37
37
  // Layouts for auth pages
38
38
  import { BaseLayout } from "./theme/layouts/index.js";
39
- import { sse } from "./lib/sse.js";
39
+ import { dsRedirect, dsToast } from "./lib/sse.js";
40
40
  import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
41
41
  /**
42
42
  * Create a Jant application
@@ -247,19 +247,13 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
247
247
  const body = await c.req.json();
248
248
  const { name, email, password } = body;
249
249
  if (!name || !email || !password) {
250
- return sse(c, async (stream)=>{
251
- await stream.toast("All fields are required", "error");
252
- });
250
+ return dsToast("All fields are required", "error");
253
251
  }
254
252
  if (password.length < 8) {
255
- return sse(c, async (stream)=>{
256
- await stream.toast("Password must be at least 8 characters", "error");
257
- });
253
+ return dsToast("Password must be at least 8 characters", "error");
258
254
  }
259
255
  if (!c.var.auth) {
260
- return sse(c, async (stream)=>{
261
- await stream.toast("AUTH_SECRET not configured", "error");
262
- });
256
+ return dsToast("AUTH_SECRET not configured", "error");
263
257
  }
264
258
  try {
265
259
  const signUpResponse = await c.var.auth.api.signUpEmail({
@@ -270,20 +264,14 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
270
264
  }
271
265
  });
272
266
  if (!signUpResponse || "error" in signUpResponse) {
273
- return sse(c, async (stream)=>{
274
- await stream.toast("Failed to create account", "error");
275
- });
267
+ return dsToast("Failed to create account", "error");
276
268
  }
277
269
  await c.var.services.settings.completeOnboarding();
278
- return sse(c, async (stream)=>{
279
- await stream.redirect("/signin?setup");
280
- });
270
+ return dsRedirect("/signin?setup");
281
271
  } catch (err) {
282
272
  // eslint-disable-next-line no-console -- Error logging is intentional
283
273
  console.error("Setup error:", err);
284
- return sse(c, async (stream)=>{
285
- await stream.toast("Failed to create account", "error");
286
- });
274
+ return dsToast("Failed to create account", "error");
287
275
  }
288
276
  });
289
277
  // Signin page component
@@ -398,9 +386,7 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
398
386
  });
399
387
  app.post("/signin", async (c)=>{
400
388
  if (!c.var.auth) {
401
- return sse(c, async (stream)=>{
402
- await stream.toast("Auth not configured", "error");
403
- });
389
+ return dsToast("Auth not configured", "error");
404
390
  }
405
391
  const body = await c.req.json();
406
392
  const { email, password } = body;
@@ -417,9 +403,7 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
417
403
  });
418
404
  const response = await c.var.auth.handler(signInRequest);
419
405
  if (!response.ok) {
420
- return sse(c, async (stream)=>{
421
- await stream.toast("Invalid email or password", "error");
422
- });
406
+ return dsToast("Invalid email or password", "error");
423
407
  }
424
408
  // Forward Set-Cookie headers from auth response
425
409
  const cookieHeaders = {};
@@ -427,17 +411,13 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
427
411
  if (setCookie) {
428
412
  cookieHeaders["Set-Cookie"] = setCookie;
429
413
  }
430
- return sse(c, async (stream)=>{
431
- await stream.redirect("/dash");
432
- }, {
414
+ return dsRedirect("/dash", {
433
415
  headers: cookieHeaders
434
416
  });
435
417
  } catch (err) {
436
418
  // eslint-disable-next-line no-console -- Error logging is intentional
437
419
  console.error("Signin error:", err);
438
- return sse(c, async (stream)=>{
439
- await stream.toast("Invalid email or password", "error");
440
- });
420
+ return dsToast("Invalid email or password", "error");
441
421
  }
442
422
  });
443
423
  app.get("/signout", async (c)=>{
@@ -612,29 +592,21 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
612
592
  // Validate token
613
593
  const stored = await c.var.services.settings.get(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
614
594
  if (!stored) {
615
- return sse(c, async (stream)=>{
616
- await stream.toast("Invalid or expired reset link.", "error");
617
- });
595
+ return dsToast("Invalid or expired reset link.", "error");
618
596
  }
619
597
  const separatorIndex = stored.lastIndexOf(":");
620
598
  const storedToken = stored.substring(0, separatorIndex);
621
599
  const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
622
600
  const now = Math.floor(Date.now() / 1000);
623
601
  if (token !== storedToken || now > expiry) {
624
- return sse(c, async (stream)=>{
625
- await stream.toast("Invalid or expired reset link.", "error");
626
- });
602
+ return dsToast("Invalid or expired reset link.", "error");
627
603
  }
628
604
  // Validate passwords
629
605
  if (!password || password.length < 8) {
630
- return sse(c, async (stream)=>{
631
- await stream.toast("Password must be at least 8 characters.", "error");
632
- });
606
+ return dsToast("Password must be at least 8 characters.", "error");
633
607
  }
634
608
  if (password !== confirmPassword) {
635
- return sse(c, async (stream)=>{
636
- await stream.toast("Passwords do not match.", "error");
637
- });
609
+ return dsToast("Passwords do not match.", "error");
638
610
  }
639
611
  try {
640
612
  const hashedPassword = await hashPassword(password);
@@ -642,9 +614,7 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
642
614
  // Get admin user
643
615
  const userResult = await db.prepare("SELECT id FROM user LIMIT 1").first();
644
616
  if (!userResult) {
645
- return sse(c, async (stream)=>{
646
- await stream.toast("No user account found.", "error");
647
- });
617
+ return dsToast("No user account found.", "error");
648
618
  }
649
619
  // Update password
650
620
  await db.prepare("UPDATE account SET password = ? WHERE user_id = ? AND provider_id = 'credential'").bind(hashedPassword, userResult.id).run();
@@ -652,15 +622,11 @@ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
652
622
  await db.prepare("DELETE FROM session WHERE user_id = ?").bind(userResult.id).run();
653
623
  // Delete the reset token
654
624
  await c.var.services.settings.remove(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
655
- return sse(c, async (stream)=>{
656
- await stream.redirect("/signin?reset");
657
- });
625
+ return dsRedirect("/signin?reset");
658
626
  } catch (err) {
659
627
  // eslint-disable-next-line no-console -- Error logging is intentional
660
628
  console.error("Password reset error:", err);
661
- return sse(c, async (stream)=>{
662
- await stream.toast("Failed to reset password.", "error");
663
- });
629
+ return dsToast("Failed to reset password.", "error");
664
630
  }
665
631
  });
666
632
  // Dashboard routes (protected)
package/dist/client.js CHANGED
@@ -8,3 +8,4 @@
8
8
  */ import "./vendor/datastar.js";
9
9
  import "basecoat-css/all";
10
10
  import "./lib/image-processor.js";
11
+ import "./lib/media-upload.js";
@@ -185,7 +185,3 @@ export const ImageProcessor = {
185
185
  process,
186
186
  processToFile
187
187
  };
188
- // Expose globally for inline scripts
189
- if (typeof window !== "undefined") {
190
- window.ImageProcessor = ImageProcessor;
191
- }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Client-side Media Upload Handler
3
+ *
4
+ * Handles file upload flow:
5
+ * 1. User selects file via [data-media-upload] input
6
+ * 2. Creates placeholder in grid with spinner
7
+ * 3. Processes image via ImageProcessor (resize/convert to WebP)
8
+ * 4. Sets processed file on hidden Datastar form via DataTransfer API
9
+ * 5. Triggers form.requestSubmit() — Datastar handles upload + SSE response
10
+ */ import { ImageProcessor } from "./image-processor.js";
11
+ /**
12
+ * Format file size for display
13
+ */ function formatFileSize(bytes) {
14
+ if (bytes < 1024) return `${bytes} B`;
15
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
16
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
17
+ }
18
+ /**
19
+ * Ensure the media grid exists, removing empty state if needed
20
+ */ function ensureGridExists() {
21
+ let grid = document.getElementById("media-grid");
22
+ if (grid) return grid;
23
+ document.getElementById("empty-state")?.remove();
24
+ grid = document.createElement("div");
25
+ grid.id = "media-grid";
26
+ grid.className = "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4";
27
+ document.getElementById("media-content")?.appendChild(grid);
28
+ return grid;
29
+ }
30
+ /**
31
+ * Create a placeholder card with spinner in the media grid
32
+ */ function createPlaceholder(fileName, fileSize, statusText) {
33
+ const placeholder = document.createElement("div");
34
+ placeholder.id = "upload-placeholder";
35
+ placeholder.className = "group relative";
36
+ placeholder.innerHTML = `
37
+ <div class="aspect-square bg-muted rounded-lg overflow-hidden border flex items-center justify-center">
38
+ <div class="text-center px-2">
39
+ <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">
40
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
41
+ <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>
42
+ </svg>
43
+ <span id="upload-status" class="text-xs text-muted-foreground">${statusText}</span>
44
+ </div>
45
+ </div>
46
+ <div class="mt-2 text-xs truncate" title="${fileName}">${fileName}</div>
47
+ <div class="text-xs text-muted-foreground">${formatFileSize(fileSize)}</div>
48
+ `;
49
+ return placeholder;
50
+ }
51
+ /**
52
+ * Replace placeholder content with an error message
53
+ */ function showPlaceholderError(placeholder, fileName, errorMessage) {
54
+ placeholder.innerHTML = `
55
+ <div class="aspect-square bg-destructive/10 rounded-lg overflow-hidden border border-destructive flex items-center justify-center">
56
+ <div class="text-center px-2">
57
+ <span class="text-xs text-destructive">${errorMessage}</span>
58
+ </div>
59
+ </div>
60
+ <div class="mt-2 text-xs truncate text-destructive">${fileName}</div>
61
+ <button type="button" class="text-xs text-muted-foreground hover:underline" onclick="this.closest('.group').remove()">Remove</button>
62
+ `;
63
+ }
64
+ /**
65
+ * Handle the upload flow for a selected file
66
+ */ async function handleUpload(input, file) {
67
+ const processingText = input.dataset.textProcessing || "Processing...";
68
+ const uploadingText = input.dataset.textUploading || "Uploading...";
69
+ const errorText = input.dataset.textError || "Upload failed. Please try again.";
70
+ const grid = ensureGridExists();
71
+ const placeholder = createPlaceholder(file.name, file.size, processingText);
72
+ grid.prepend(placeholder);
73
+ try {
74
+ // Process image client-side (resize, convert to WebP)
75
+ const processed = await ImageProcessor.processToFile(file);
76
+ // Update status
77
+ const statusEl = document.getElementById("upload-status");
78
+ if (statusEl) statusEl.textContent = uploadingText;
79
+ // Set processed file on hidden form input via DataTransfer API
80
+ const formInput = document.getElementById("upload-file-input");
81
+ const form = document.getElementById("upload-form");
82
+ if (!formInput || !form) throw new Error("Upload form not found");
83
+ const dt = new DataTransfer();
84
+ dt.items.add(processed);
85
+ formInput.files = dt.files;
86
+ // Trigger Datastar-intercepted form submission
87
+ form.requestSubmit();
88
+ } catch (err) {
89
+ const message = err instanceof Error ? err.message : errorText;
90
+ showPlaceholderError(placeholder, file.name, message);
91
+ }
92
+ // Reset file input so the same file can be re-selected
93
+ input.value = "";
94
+ }
95
+ /**
96
+ * Initialize media upload via event delegation
97
+ */ function initMediaUpload() {
98
+ document.addEventListener("change", (e)=>{
99
+ const input = e.target.closest("[data-media-upload]");
100
+ if (!input?.files?.[0]) return;
101
+ handleUpload(input, input.files[0]);
102
+ });
103
+ }
104
+ initMediaUpload();
package/dist/lib/sse.d.ts CHANGED
@@ -1,20 +1,21 @@
1
1
  /**
2
- * Server-Sent Events (SSE) utilities for Datastar v1.0.0-RC.7
2
+ * Datastar response utilities for v1.0.0-RC.7
3
3
  *
4
- * Generates SSE events compatible with the Datastar client's expected format.
4
+ * Provides both SSE (multi-event) and plain HTTP (single-event) response helpers.
5
5
  *
6
- * @see https://data-star.dev/
6
+ * **Non-SSE helpers** (preferred for single operations):
7
+ * - `dsRedirect(url)` — redirect via text/html
8
+ * - `dsToast(message, type)` — toast notification via text/html
9
+ * - `dsSignals(signals)` — signal patch via application/json
7
10
  *
8
- * @example
9
- * ```ts
10
- * app.post("/api/example", (c) => {
11
- * return sse(c, async (stream) => {
12
- * await stream.patchSignals({ loading: false });
13
- * await stream.patchElements('<div id="result">Done!</div>');
14
- * await stream.redirect("/success");
15
- * });
16
- * });
17
- * ```
11
+ * **SSE** (for multiple operations in one response):
12
+ * - `sse(c, handler)` — streaming SSE with full stream API
13
+ *
14
+ * Datastar auto-detects response type by Content-Type:
15
+ * - `text/html` dispatches as `datastar-patch-elements`
16
+ * - `application/json` → dispatches as `datastar-patch-signals`
17
+ *
18
+ * @see https://data-star.dev/
18
19
  */
19
20
  import type { Context } from "hono";
20
21
  /**
@@ -135,4 +136,57 @@ export interface SSEStream {
135
136
  export declare function sse(c: Context, handler: (stream: SSEStream) => Promise<void>, options?: {
136
137
  headers?: Record<string, string>;
137
138
  }): Response;
139
+ /**
140
+ * Datastar redirect via text/html
141
+ *
142
+ * Returns a plain HTML response that Datastar dispatches as `datastar-patch-elements`.
143
+ * Use instead of `sse()` when the only action is a redirect.
144
+ *
145
+ * @param url - The URL to redirect to
146
+ * @param options - Optional extra headers (e.g. Set-Cookie for auth)
147
+ * @returns Response with text/html content-type
148
+ *
149
+ * @example
150
+ * ```ts
151
+ * return dsRedirect("/dash/posts");
152
+ *
153
+ * // With cookie forwarding (for auth)
154
+ * return dsRedirect("/dash", { headers: { "Set-Cookie": cookie } });
155
+ * ```
156
+ */
157
+ export declare function dsRedirect(url: string, options?: {
158
+ headers?: Record<string, string>;
159
+ }): Response;
160
+ /**
161
+ * Datastar toast notification via text/html
162
+ *
163
+ * Returns a plain HTML response that Datastar dispatches as `datastar-patch-elements`.
164
+ * Use instead of `sse()` when the only action is showing a toast.
165
+ *
166
+ * @param message - The message to display
167
+ * @param type - Toast type: "success" (default) or "error"
168
+ * @returns Response with text/html content-type
169
+ *
170
+ * @example
171
+ * ```ts
172
+ * return dsToast("Settings saved successfully.");
173
+ * return dsToast("Something went wrong.", "error");
174
+ * ```
175
+ */
176
+ export declare function dsToast(message: string, type?: "success" | "error"): Response;
177
+ /**
178
+ * Datastar signal patch via application/json
179
+ *
180
+ * Returns a JSON response that Datastar dispatches as `datastar-patch-signals`.
181
+ * Use instead of `sse()` when the only action is updating signals.
182
+ *
183
+ * @param signals - Object containing signal values to update
184
+ * @returns Response with application/json content-type
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * return dsSignals({ _uploadError: "File too large" });
189
+ * ```
190
+ */
191
+ export declare function dsSignals(signals: Record<string, unknown>): Response;
138
192
  //# sourceMappingURL=sse.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../../src/lib/sse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC;;;;GAIG;AACH,MAAM,MAAM,SAAS,GACjB,OAAO,GACP,OAAO,GACP,SAAS,GACT,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,QAAQ,CAAC;AAEb;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB;;;;;;;;;;OAUG;IACH,YAAY,CACV,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,OAAO,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GACpC,IAAI,CAAC;IAER;;;;;;;;;;;;;;;;;OAiBG;IACH,aAAa,CACX,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QACR,IAAI,CAAC,EAAE,SAAS,CAAC;QACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,iBAAiB,CAAC,EAAE,OAAO,CAAC;KAC7B,GACA,IAAI,CAAC;IAER;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAE5B;;;;;;;;;OASG;IACH,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAE/B;;;;;;;;;;;;;OAaG;IACH,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,SAAS,GAAG,OAAO,GAAG,IAAI,CAAC;CAC1D;AAkBD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,GAAG,CACjB,CAAC,EAAE,OAAO,EACV,OAAO,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,EAC7C,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAC7C,QAAQ,CAiGV"}
1
+ {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../../src/lib/sse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC;;;;GAIG;AACH,MAAM,MAAM,SAAS,GACjB,OAAO,GACP,OAAO,GACP,SAAS,GACT,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,QAAQ,CAAC;AAEb;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB;;;;;;;;;;OAUG;IACH,YAAY,CACV,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,OAAO,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GACpC,IAAI,CAAC;IAER;;;;;;;;;;;;;;;;;OAiBG;IACH,aAAa,CACX,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QACR,IAAI,CAAC,EAAE,SAAS,CAAC;QACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,iBAAiB,CAAC,EAAE,OAAO,CAAC;KAC7B,GACA,IAAI,CAAC;IAER;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAE5B;;;;;;;;;OASG;IACH,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAE/B;;;;;;;;;;;;;OAaG;IACH,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,SAAS,GAAG,OAAO,GAAG,IAAI,CAAC;CAC1D;AA+CD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,GAAG,CACjB,CAAC,EAAE,OAAO,EACV,OAAO,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,EAC7C,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAC7C,QAAQ,CAoFV;AAMD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,CACxB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAC7C,QAAQ,CASV;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,OAAO,CACrB,OAAO,EAAE,MAAM,EACf,IAAI,GAAE,SAAS,GAAG,OAAmB,GACpC,QAAQ,CAQV;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,QAAQ,CAIpE"}
package/dist/lib/sse.js CHANGED
@@ -1,21 +1,39 @@
1
1
  /**
2
- * Server-Sent Events (SSE) utilities for Datastar v1.0.0-RC.7
2
+ * Datastar response utilities for v1.0.0-RC.7
3
3
  *
4
- * Generates SSE events compatible with the Datastar client's expected format.
4
+ * Provides both SSE (multi-event) and plain HTTP (single-event) response helpers.
5
5
  *
6
- * @see https://data-star.dev/
6
+ * **Non-SSE helpers** (preferred for single operations):
7
+ * - `dsRedirect(url)` — redirect via text/html
8
+ * - `dsToast(message, type)` — toast notification via text/html
9
+ * - `dsSignals(signals)` — signal patch via application/json
7
10
  *
8
- * @example
9
- * ```ts
10
- * app.post("/api/example", (c) => {
11
- * return sse(c, async (stream) => {
12
- * await stream.patchSignals({ loading: false });
13
- * await stream.patchElements('<div id="result">Done!</div>');
14
- * await stream.redirect("/success");
15
- * });
16
- * });
17
- * ```
18
- */ /**
11
+ * **SSE** (for multiple operations in one response):
12
+ * - `sse(c, handler)` — streaming SSE with full stream API
13
+ *
14
+ * Datastar auto-detects response type by Content-Type:
15
+ * - `text/html` dispatches as `datastar-patch-elements`
16
+ * - `application/json` → dispatches as `datastar-patch-signals`
17
+ *
18
+ * @see https://data-star.dev/
19
+ */ // ---------------------------------------------------------------------------
20
+ // Shared internal helpers (used by both SSE and non-SSE response builders)
21
+ // ---------------------------------------------------------------------------
22
+ /** Build the redirect script tag for Datastar patch-elements */ function buildRedirectScript(url) {
23
+ const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
24
+ return `<script data-effect="el.remove()">window.location.href='${escapedUrl}'</script>`;
25
+ }
26
+ /** Build a toast notification HTML element */ function buildToastHtml(message, type) {
27
+ const cls = type === "error" ? "toast-error" : "toast-success";
28
+ const icon = type === "error" ? '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg>' : '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>';
29
+ const closeBtn = `<button class="toast-close" data-on:click="el.closest('.toast').classList.add('toast-out'); el.closest('.toast').addEventListener('animationend', () => el.closest('.toast').remove())"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path d="M18 6 6 18M6 6l12 12"/></svg></button>`;
30
+ const escapedMessage = message.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
31
+ return `<div class="toast ${cls}" data-init="setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)">${icon}<span>${escapedMessage}</span>${closeBtn}</div>`;
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // SSE helpers
35
+ // ---------------------------------------------------------------------------
36
+ /**
19
37
  * Format a single SSE event string
20
38
  *
21
39
  * @param eventType - The Datastar event type (e.g. "datastar-patch-elements")
@@ -88,10 +106,8 @@
88
106
  controller.enqueue(encoder.encode(formatEvent("datastar-patch-elements", dataLines)));
89
107
  },
90
108
  redirect (url) {
91
- const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
92
- const script = `<script data-effect="el.remove()">window.location.href='${escapedUrl}'</script>`;
93
109
  const dataLines = [
94
- `elements ${script}`,
110
+ `elements ${buildRedirectScript(url)}`,
95
111
  "mode append",
96
112
  "selector body"
97
113
  ];
@@ -105,13 +121,8 @@
105
121
  ])));
106
122
  },
107
123
  toast (message, type = "success") {
108
- const cls = type === "error" ? "toast-error" : "toast-success";
109
- const icon = type === "error" ? '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg>' : '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>';
110
- const closeBtn = `<button class="toast-close" data-on:click="el.closest('.toast').classList.add('toast-out'); el.closest('.toast').addEventListener('animationend', () => el.closest('.toast').remove())"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path d="M18 6 6 18M6 6l12 12"/></svg></button>`;
111
- const escapedMessage = message.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
112
- const html = `<div class="toast ${cls}" data-init="setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)">${icon}<span>${escapedMessage}</span>${closeBtn}</div>`;
113
124
  const dataLines = [
114
- `elements ${html}`,
125
+ `elements ${buildToastHtml(message, type)}`,
115
126
  "mode append",
116
127
  "selector #toast-container"
117
128
  ];
@@ -132,3 +143,77 @@
132
143
  headers
133
144
  });
134
145
  }
146
+ // ---------------------------------------------------------------------------
147
+ // Non-SSE Datastar helpers (for single-operation responses)
148
+ // ---------------------------------------------------------------------------
149
+ /**
150
+ * Datastar redirect via text/html
151
+ *
152
+ * Returns a plain HTML response that Datastar dispatches as `datastar-patch-elements`.
153
+ * Use instead of `sse()` when the only action is a redirect.
154
+ *
155
+ * @param url - The URL to redirect to
156
+ * @param options - Optional extra headers (e.g. Set-Cookie for auth)
157
+ * @returns Response with text/html content-type
158
+ *
159
+ * @example
160
+ * ```ts
161
+ * return dsRedirect("/dash/posts");
162
+ *
163
+ * // With cookie forwarding (for auth)
164
+ * return dsRedirect("/dash", { headers: { "Set-Cookie": cookie } });
165
+ * ```
166
+ */ export function dsRedirect(url, options) {
167
+ return new Response(buildRedirectScript(url), {
168
+ headers: {
169
+ "Content-Type": "text/html",
170
+ "Datastar-Mode": "append",
171
+ "Datastar-Selector": "body",
172
+ ...options?.headers
173
+ }
174
+ });
175
+ }
176
+ /**
177
+ * Datastar toast notification via text/html
178
+ *
179
+ * Returns a plain HTML response that Datastar dispatches as `datastar-patch-elements`.
180
+ * Use instead of `sse()` when the only action is showing a toast.
181
+ *
182
+ * @param message - The message to display
183
+ * @param type - Toast type: "success" (default) or "error"
184
+ * @returns Response with text/html content-type
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * return dsToast("Settings saved successfully.");
189
+ * return dsToast("Something went wrong.", "error");
190
+ * ```
191
+ */ export function dsToast(message, type = "success") {
192
+ return new Response(buildToastHtml(message, type), {
193
+ headers: {
194
+ "Content-Type": "text/html",
195
+ "Datastar-Mode": "append",
196
+ "Datastar-Selector": "#toast-container"
197
+ }
198
+ });
199
+ }
200
+ /**
201
+ * Datastar signal patch via application/json
202
+ *
203
+ * Returns a JSON response that Datastar dispatches as `datastar-patch-signals`.
204
+ * Use instead of `sse()` when the only action is updating signals.
205
+ *
206
+ * @param signals - Object containing signal values to update
207
+ * @returns Response with application/json content-type
208
+ *
209
+ * @example
210
+ * ```ts
211
+ * return dsSignals({ _uploadError: "File too large" });
212
+ * ```
213
+ */ export function dsSignals(signals) {
214
+ return new Response(JSON.stringify(signals), {
215
+ headers: {
216
+ "Content-Type": "application/json"
217
+ }
218
+ });
219
+ }
@@ -7,7 +7,7 @@
7
7
  import { html } from "hono/html";
8
8
  import { requireAuthApi } from "../../middleware/auth.js";
9
9
  import { getMediaUrl, getImageUrl } from "../../lib/image.js";
10
- import { sse } from "../../lib/sse.js";
10
+ import { sse, dsSignals } from "../../lib/sse.js";
11
11
  export const uploadApiRoutes = new Hono();
12
12
  // Require auth for all upload routes
13
13
  uploadApiRoutes.use("*", requireAuthApi());
@@ -88,10 +88,8 @@ function formatSize(bytes) {
88
88
  uploadApiRoutes.post("/", async (c)=>{
89
89
  if (!c.env.R2) {
90
90
  if (wantsSSE(c)) {
91
- return sse(c, async (stream)=>{
92
- await stream.patchSignals({
93
- _uploadError: "R2 storage not configured"
94
- });
91
+ return dsSignals({
92
+ _uploadError: "R2 storage not configured"
95
93
  });
96
94
  }
97
95
  return c.json({
@@ -102,10 +100,8 @@ uploadApiRoutes.post("/", async (c)=>{
102
100
  const file = formData.get("file");
103
101
  if (!file) {
104
102
  if (wantsSSE(c)) {
105
- return sse(c, async (stream)=>{
106
- await stream.patchSignals({
107
- _uploadError: "No file provided"
108
- });
103
+ return dsSignals({
104
+ _uploadError: "No file provided"
109
105
  });
110
106
  }
111
107
  return c.json({
@@ -122,10 +118,8 @@ uploadApiRoutes.post("/", async (c)=>{
122
118
  ];
123
119
  if (!allowedTypes.includes(file.type)) {
124
120
  if (wantsSSE(c)) {
125
- return sse(c, async (stream)=>{
126
- await stream.patchSignals({
127
- _uploadError: "File type not allowed"
128
- });
121
+ return dsSignals({
122
+ _uploadError: "File type not allowed"
129
123
  });
130
124
  }
131
125
  return c.json({
@@ -136,10 +130,8 @@ uploadApiRoutes.post("/", async (c)=>{
136
130
  const maxSize = 10 * 1024 * 1024;
137
131
  if (file.size > maxSize) {
138
132
  if (wantsSSE(c)) {
139
- return sse(c, async (stream)=>{
140
- await stream.patchSignals({
141
- _uploadError: "File too large (max 10MB)"
142
- });
133
+ return dsSignals({
134
+ _uploadError: "File too large (max 10MB)"
143
135
  });
144
136
  }
145
137
  return c.json({
@@ -176,6 +168,7 @@ uploadApiRoutes.post("/", async (c)=>{
176
168
  mode: "outer",
177
169
  selector: "#upload-placeholder"
178
170
  });
171
+ await stream.toast("Upload successful!");
179
172
  });
180
173
  }
181
174
  // JSON response for API clients
@@ -190,7 +183,12 @@ uploadApiRoutes.post("/", async (c)=>{
190
183
  } catch (err) {
191
184
  // eslint-disable-next-line no-console -- Error logging is intentional
192
185
  console.error("Upload error:", err);
193
- // Return error - client will handle updating the placeholder
186
+ if (wantsSSE(c)) {
187
+ return sse(c, async (stream)=>{
188
+ await stream.remove("#upload-placeholder");
189
+ await stream.toast("Upload failed. Please try again.", "error");
190
+ });
191
+ }
194
192
  return c.json({
195
193
  error: "Upload failed"
196
194
  }, 500);