@jant/core 0.2.17 → 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.
Files changed (99) hide show
  1. package/dist/app.d.ts +1 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +307 -137
  4. package/dist/client.js +1 -0
  5. package/dist/i18n/context.d.ts +2 -2
  6. package/dist/i18n/context.js +1 -1
  7. package/dist/i18n/i18n.d.ts +1 -1
  8. package/dist/i18n/i18n.js +1 -1
  9. package/dist/i18n/index.d.ts +1 -1
  10. package/dist/i18n/index.js +1 -1
  11. package/dist/i18n/locales/en.d.ts.map +1 -1
  12. package/dist/i18n/locales/en.js +1 -1
  13. package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
  14. package/dist/i18n/locales/zh-Hans.js +1 -1
  15. package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
  16. package/dist/i18n/locales/zh-Hant.js +1 -1
  17. package/dist/lib/config.d.ts +44 -10
  18. package/dist/lib/config.d.ts.map +1 -1
  19. package/dist/lib/config.js +69 -44
  20. package/dist/lib/constants.d.ts +2 -1
  21. package/dist/lib/constants.d.ts.map +1 -1
  22. package/dist/lib/constants.js +5 -2
  23. package/dist/lib/image-processor.js +0 -4
  24. package/dist/lib/media-upload.js +104 -0
  25. package/dist/lib/sse.d.ts +82 -13
  26. package/dist/lib/sse.d.ts.map +1 -1
  27. package/dist/lib/sse.js +115 -17
  28. package/dist/lib/theme.d.ts +44 -0
  29. package/dist/lib/theme.d.ts.map +1 -0
  30. package/dist/lib/theme.js +65 -0
  31. package/dist/routes/api/upload.js +16 -18
  32. package/dist/routes/dash/appearance.d.ts +13 -0
  33. package/dist/routes/dash/appearance.d.ts.map +1 -0
  34. package/dist/routes/dash/appearance.js +160 -0
  35. package/dist/routes/dash/collections.js +5 -13
  36. package/dist/routes/dash/media.js +17 -167
  37. package/dist/routes/dash/pages.js +4 -10
  38. package/dist/routes/dash/posts.js +4 -10
  39. package/dist/routes/dash/redirects.js +3 -7
  40. package/dist/routes/dash/settings.d.ts.map +1 -1
  41. package/dist/routes/dash/settings.js +52 -42
  42. package/dist/services/settings.d.ts +1 -0
  43. package/dist/services/settings.d.ts.map +1 -1
  44. package/dist/services/settings.js +3 -0
  45. package/dist/theme/color-themes.d.ts +30 -0
  46. package/dist/theme/color-themes.d.ts.map +1 -0
  47. package/dist/theme/color-themes.js +268 -0
  48. package/dist/theme/layouts/BaseLayout.d.ts +5 -0
  49. package/dist/theme/layouts/BaseLayout.d.ts.map +1 -1
  50. package/dist/theme/layouts/BaseLayout.js +70 -3
  51. package/dist/theme/layouts/DashLayout.d.ts +2 -0
  52. package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
  53. package/dist/theme/layouts/DashLayout.js +11 -1
  54. package/dist/theme/layouts/index.d.ts +1 -1
  55. package/dist/theme/layouts/index.d.ts.map +1 -1
  56. package/dist/types.d.ts +53 -1
  57. package/dist/types.d.ts.map +1 -1
  58. package/dist/types.js +52 -0
  59. package/package.json +1 -1
  60. package/src/app.tsx +260 -81
  61. package/src/client.ts +1 -0
  62. package/src/db/migrations/{0000_solid_moon_knight.sql → 0000_square_wallflower.sql} +3 -3
  63. package/src/db/migrations/meta/0000_snapshot.json +9 -9
  64. package/src/db/migrations/meta/_journal.json +2 -30
  65. package/src/i18n/context.tsx +2 -2
  66. package/src/i18n/i18n.ts +1 -1
  67. package/src/i18n/index.ts +1 -1
  68. package/src/i18n/locales/en.po +328 -252
  69. package/src/i18n/locales/en.ts +1 -1
  70. package/src/i18n/locales/zh-Hans.po +315 -278
  71. package/src/i18n/locales/zh-Hans.ts +1 -1
  72. package/src/i18n/locales/zh-Hant.po +315 -278
  73. package/src/i18n/locales/zh-Hant.ts +1 -1
  74. package/src/lib/config.ts +73 -47
  75. package/src/lib/constants.ts +3 -0
  76. package/src/lib/image-processor.ts +0 -7
  77. package/src/lib/media-upload.ts +148 -0
  78. package/src/lib/sse.ts +156 -16
  79. package/src/lib/theme.ts +86 -0
  80. package/src/preset.css +9 -0
  81. package/src/routes/api/upload.ts +12 -18
  82. package/src/routes/dash/appearance.tsx +176 -0
  83. package/src/routes/dash/collections.tsx +5 -13
  84. package/src/routes/dash/media.tsx +16 -165
  85. package/src/routes/dash/pages.tsx +4 -10
  86. package/src/routes/dash/posts.tsx +4 -10
  87. package/src/routes/dash/redirects.tsx +3 -7
  88. package/src/routes/dash/settings.tsx +71 -55
  89. package/src/services/settings.ts +5 -0
  90. package/src/styles/components.css +93 -0
  91. package/src/theme/color-themes.ts +321 -0
  92. package/src/theme/layouts/BaseLayout.tsx +61 -1
  93. package/src/theme/layouts/DashLayout.tsx +14 -3
  94. package/src/theme/layouts/index.ts +5 -1
  95. package/src/types.ts +62 -1
  96. package/src/db/migrations/0001_add_search_fts.sql +0 -40
  97. package/src/db/migrations/0002_collection_path.sql +0 -2
  98. package/src/db/migrations/0003_collection_path_nullable.sql +0 -21
  99. package/src/db/migrations/0004_media_uuid.sql +0 -35
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Theme Resolution Helpers
3
+ *
4
+ * Resolves the active color theme and builds CSS for injection into `<head>`.
5
+ */
6
+
7
+ import type { ColorTheme } from "../theme/color-themes.js";
8
+ import { BUILTIN_COLOR_THEMES } from "../theme/color-themes.js";
9
+ import type { JantConfig } from "../types.js";
10
+
11
+ /**
12
+ * Get the list of available color themes.
13
+ *
14
+ * Returns `config.theme.colorThemes` if provided, otherwise the built-in list.
15
+ *
16
+ * @param config - The Jant configuration
17
+ * @returns Array of available color themes
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * const themes = getAvailableThemes(c.var.config);
22
+ * ```
23
+ */
24
+ export function getAvailableThemes(config: JantConfig): ColorTheme[] {
25
+ return config.theme?.colorThemes ?? BUILTIN_COLOR_THEMES;
26
+ }
27
+
28
+ /**
29
+ * Build a `<style>` CSS string from a color theme and optional cssVariables overlay.
30
+ *
31
+ * Priority (lowest → highest):
32
+ * BaseCoat defaults → selected theme → cssVariables
33
+ *
34
+ * @param theme - The active color theme (undefined = no theme overrides)
35
+ * @param cssVariables - Extra CSS variable overrides from `createApp({ theme: { cssVariables } })`
36
+ * @returns CSS string to inject in `<head>`, or empty string if nothing to inject
37
+ *
38
+ * Uses `:root:root` and `:root.dark` selectors for higher specificity than
39
+ * BaseCoat defaults (`:root` and `.dark`). This ensures theme overrides win
40
+ * regardless of source order — important because Vite dev mode injects CSS
41
+ * as `<style>` tags after the theme `<style>`.
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * const css = buildThemeStyle(blueTheme, { "--radius": "0.5rem" });
46
+ * // => ":root:root { --primary: oklch(...); ... }\n:root.dark { ... }"
47
+ * ```
48
+ */
49
+ export function buildThemeStyle(
50
+ theme: ColorTheme | undefined,
51
+ cssVariables?: Record<string, string>,
52
+ ): string {
53
+ const lightVars: Record<string, string> = {
54
+ ...(theme?.light ?? {}),
55
+ ...(cssVariables ?? {}),
56
+ };
57
+ const darkVars: Record<string, string> = {
58
+ ...(theme?.dark ?? {}),
59
+ ...(cssVariables ?? {}),
60
+ };
61
+
62
+ const hasLight = Object.keys(lightVars).length > 0;
63
+ const hasDark = Object.keys(darkVars).length > 0;
64
+
65
+ if (!hasLight && !hasDark) return "";
66
+
67
+ const parts: string[] = [];
68
+
69
+ if (hasLight) {
70
+ const declarations = Object.entries(lightVars)
71
+ .map(([k, v]) => ` ${k}: ${v};`)
72
+ .join("\n");
73
+ // :root:root has specificity (0,0,2) > BaseCoat's :root (0,0,1)
74
+ parts.push(`:root:root {\n${declarations}\n}`);
75
+ }
76
+
77
+ if (hasDark) {
78
+ const declarations = Object.entries(darkVars)
79
+ .map(([k, v]) => ` ${k}: ${v};`)
80
+ .join("\n");
81
+ // :root.dark has specificity (0,1,1) > BaseCoat's .dark (0,1,0)
82
+ parts.push(`:root.dark {\n${declarations}\n}`);
83
+ }
84
+
85
+ return parts.join("\n");
86
+ }
package/src/preset.css CHANGED
@@ -12,4 +12,13 @@
12
12
 
13
13
  @theme {
14
14
  --radius-default: 0.5rem;
15
+ --color-success: var(--success);
16
+ }
17
+
18
+ :root {
19
+ --success: oklch(0.518 0.16 145.071);
20
+ }
21
+
22
+ .dark {
23
+ --success: oklch(0.627 0.194 149.214);
15
24
  }
@@ -11,7 +11,7 @@ import type { Bindings } from "../../types.js";
11
11
  import type { AppVariables } from "../../app.js";
12
12
  import { requireAuthApi } from "../../middleware/auth.js";
13
13
  import { getMediaUrl, getImageUrl } from "../../lib/image.js";
14
- import { sse } from "../../lib/sse.js";
14
+ import { sse, dsSignals } from "../../lib/sse.js";
15
15
 
16
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
17
17
 
@@ -117,11 +117,7 @@ function wantsSSE(c: {
117
117
  uploadApiRoutes.post("/", async (c) => {
118
118
  if (!c.env.R2) {
119
119
  if (wantsSSE(c)) {
120
- return sse(c, async (stream) => {
121
- await stream.patchSignals({
122
- _uploadError: "R2 storage not configured",
123
- });
124
- });
120
+ return dsSignals({ _uploadError: "R2 storage not configured" });
125
121
  }
126
122
  return c.json({ error: "R2 storage not configured" }, 500);
127
123
  }
@@ -131,9 +127,7 @@ uploadApiRoutes.post("/", async (c) => {
131
127
 
132
128
  if (!file) {
133
129
  if (wantsSSE(c)) {
134
- return sse(c, async (stream) => {
135
- await stream.patchSignals({ _uploadError: "No file provided" });
136
- });
130
+ return dsSignals({ _uploadError: "No file provided" });
137
131
  }
138
132
  return c.json({ error: "No file provided" }, 400);
139
133
  }
@@ -148,9 +142,7 @@ uploadApiRoutes.post("/", async (c) => {
148
142
  ];
149
143
  if (!allowedTypes.includes(file.type)) {
150
144
  if (wantsSSE(c)) {
151
- return sse(c, async (stream) => {
152
- await stream.patchSignals({ _uploadError: "File type not allowed" });
153
- });
145
+ return dsSignals({ _uploadError: "File type not allowed" });
154
146
  }
155
147
  return c.json({ error: "File type not allowed" }, 400);
156
148
  }
@@ -159,11 +151,7 @@ uploadApiRoutes.post("/", async (c) => {
159
151
  const maxSize = 10 * 1024 * 1024;
160
152
  if (file.size > maxSize) {
161
153
  if (wantsSSE(c)) {
162
- return sse(c, async (stream) => {
163
- await stream.patchSignals({
164
- _uploadError: "File too large (max 10MB)",
165
- });
166
- });
154
+ return dsSignals({ _uploadError: "File too large (max 10MB)" });
167
155
  }
168
156
  return c.json({ error: "File too large (max 10MB)" }, 400);
169
157
  }
@@ -206,6 +194,7 @@ uploadApiRoutes.post("/", async (c) => {
206
194
  mode: "outer",
207
195
  selector: "#upload-placeholder",
208
196
  });
197
+ await stream.toast("Upload successful!");
209
198
  });
210
199
  }
211
200
 
@@ -222,7 +211,12 @@ uploadApiRoutes.post("/", async (c) => {
222
211
  // eslint-disable-next-line no-console -- Error logging is intentional
223
212
  console.error("Upload error:", err);
224
213
 
225
- // Return error - client will handle updating the placeholder
214
+ if (wantsSSE(c)) {
215
+ return sse(c, async (stream) => {
216
+ await stream.remove("#upload-placeholder");
217
+ await stream.toast("Upload failed. Please try again.", "error");
218
+ });
219
+ }
226
220
  return c.json({ error: "Upload failed" }, 500);
227
221
  }
228
222
  });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Dashboard Appearance Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { useLingui } from "@lingui/react/macro";
7
+ import type { Bindings } from "../../types.js";
8
+ import type { AppVariables } from "../../app.js";
9
+ import { DashLayout } from "../../theme/layouts/index.js";
10
+ import { dsRedirect, dsToast } from "../../lib/sse.js";
11
+ import { getSiteName } from "../../lib/config.js";
12
+ import { SETTINGS_KEYS } from "../../lib/constants.js";
13
+ import { getAvailableThemes } from "../../lib/theme.js";
14
+ import type { ColorTheme } from "../../theme/color-themes.js";
15
+
16
+ type Env = { Bindings: Bindings; Variables: AppVariables };
17
+
18
+ export const appearanceRoutes = new Hono<Env>();
19
+
20
+ function ThemeCard({
21
+ theme,
22
+ selected,
23
+ }: {
24
+ theme: ColorTheme;
25
+ selected: boolean;
26
+ }) {
27
+ const expr = `$theme === '${theme.id}'`;
28
+ const { preview } = theme;
29
+
30
+ return (
31
+ <label
32
+ class={`block cursor-pointer rounded-lg border overflow-hidden transition-colors ${selected ? "border-primary" : "border-border"}`}
33
+ data-class:border-primary={expr}
34
+ data-class:border-border={`$theme !== '${theme.id}'`}
35
+ >
36
+ <div class="grid grid-cols-2">
37
+ <div
38
+ class="p-5"
39
+ style={`background-color:${preview.lightBg};color:${preview.lightText}`}
40
+ >
41
+ <input
42
+ type="radio"
43
+ name="theme"
44
+ value={theme.id}
45
+ data-bind="theme"
46
+ checked={selected || undefined}
47
+ class="mb-1"
48
+ />
49
+ <h3 class="font-bold text-lg">{theme.name}</h3>
50
+ <p class="text-sm mt-2 leading-relaxed">
51
+ This is the {theme.name} theme in light mode. Links{" "}
52
+ <a
53
+ tabIndex={-1}
54
+ class="underline"
55
+ style={`color:${preview.lightLink}`}
56
+ >
57
+ look like this
58
+ </a>
59
+ . We'll show the correct light or dark mode based on your visitor's
60
+ settings.
61
+ </p>
62
+ </div>
63
+ <div
64
+ class="p-5"
65
+ style={`background-color:${preview.darkBg};color:${preview.darkText}`}
66
+ >
67
+ <h3 class="font-bold text-lg">{theme.name}</h3>
68
+ <p class="text-sm mt-2 leading-relaxed">
69
+ This is the {theme.name} theme in dark mode. Links{" "}
70
+ <a
71
+ tabIndex={-1}
72
+ class="underline"
73
+ style={`color:${preview.darkLink}`}
74
+ >
75
+ look like this
76
+ </a>
77
+ . We'll show the correct light or dark mode based on your visitor's
78
+ settings.
79
+ </p>
80
+ </div>
81
+ </div>
82
+ </label>
83
+ );
84
+ }
85
+
86
+ function AppearanceContent({
87
+ themes,
88
+ currentThemeId,
89
+ }: {
90
+ themes: ColorTheme[];
91
+ currentThemeId: string;
92
+ }) {
93
+ const { t } = useLingui();
94
+
95
+ const signals = JSON.stringify({ theme: currentThemeId }).replace(
96
+ /</g,
97
+ "\\u003c",
98
+ );
99
+
100
+ return (
101
+ <div
102
+ data-signals={signals}
103
+ data-on:change="@post('/dash/appearance')"
104
+ class="max-w-3xl"
105
+ >
106
+ <fieldset>
107
+ <legend class="text-lg font-semibold">
108
+ {t({
109
+ message: "Color theme",
110
+ comment: "@context: Appearance settings heading",
111
+ })}
112
+ </legend>
113
+ <p class="text-sm text-muted-foreground mb-4">
114
+ {t({
115
+ message:
116
+ "This will theme both your site and your dashboard. All color themes support dark mode.",
117
+ comment: "@context: Appearance settings description",
118
+ })}
119
+ </p>
120
+
121
+ <div class="flex flex-col gap-4">
122
+ {themes.map((theme) => (
123
+ <ThemeCard
124
+ key={theme.id}
125
+ theme={theme}
126
+ selected={theme.id === currentThemeId}
127
+ />
128
+ ))}
129
+ </div>
130
+ </fieldset>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ // Appearance page
136
+ appearanceRoutes.get("/", async (c) => {
137
+ const { settings } = c.var.services;
138
+ const siteName = await getSiteName(c);
139
+ const currentThemeId = (await settings.get(SETTINGS_KEYS.THEME)) ?? "default";
140
+ const themes = getAvailableThemes(c.var.config);
141
+ const saved = c.req.query("saved") !== undefined;
142
+
143
+ return c.html(
144
+ <DashLayout
145
+ c={c}
146
+ title="Appearance"
147
+ siteName={siteName}
148
+ currentPath="/dash/appearance"
149
+ toast={saved ? { message: "Theme saved successfully." } : undefined}
150
+ >
151
+ <AppearanceContent themes={themes} currentThemeId={currentThemeId} />
152
+ </DashLayout>,
153
+ );
154
+ });
155
+
156
+ // Save theme
157
+ appearanceRoutes.post("/", async (c) => {
158
+ const body = await c.req.json<{ theme: string }>();
159
+ const { settings } = c.var.services;
160
+ const themes = getAvailableThemes(c.var.config);
161
+
162
+ // Validate theme ID
163
+ const validTheme = themes.find((t) => t.id === body.theme);
164
+ if (!validTheme) {
165
+ return dsToast("Invalid theme selected.", "error");
166
+ }
167
+
168
+ if (validTheme.id === "default") {
169
+ await settings.remove(SETTINGS_KEYS.THEME);
170
+ } else {
171
+ await settings.set(SETTINGS_KEYS.THEME, validTheme.id);
172
+ }
173
+
174
+ // Full page reload to apply the new theme CSS
175
+ return dsRedirect("/dash/appearance?saved");
176
+ });
@@ -16,7 +16,7 @@ import {
16
16
  DangerZone,
17
17
  } from "../../theme/components/index.js";
18
18
  import * as sqid from "../../lib/sqid.js";
19
- import { sse } from "../../lib/sse.js";
19
+ import { dsRedirect } from "../../lib/sse.js";
20
20
 
21
21
  type Env = { Bindings: Bindings; Variables: AppVariables };
22
22
 
@@ -409,9 +409,7 @@ collectionsRoutes.post("/", async (c) => {
409
409
  description: body.description || undefined,
410
410
  });
411
411
 
412
- return sse(c, async (stream) => {
413
- await stream.redirect(`/dash/collections/${collection.id}`);
414
- });
412
+ return dsRedirect(`/dash/collections/${collection.id}`);
415
413
  });
416
414
 
417
415
  // View single collection
@@ -476,9 +474,7 @@ collectionsRoutes.post("/:id", async (c) => {
476
474
  description: body.description || undefined,
477
475
  });
478
476
 
479
- return sse(c, async (stream) => {
480
- await stream.redirect(`/dash/collections/${id}`);
481
- });
477
+ return dsRedirect(`/dash/collections/${id}`);
482
478
  });
483
479
 
484
480
  // Delete collection
@@ -488,9 +484,7 @@ collectionsRoutes.post("/:id/delete", async (c) => {
488
484
 
489
485
  await c.var.services.collections.delete(id);
490
486
 
491
- return sse(c, async (stream) => {
492
- await stream.redirect("/dash/collections");
493
- });
487
+ return dsRedirect("/dash/collections");
494
488
  });
495
489
 
496
490
  // Remove post from collection
@@ -504,7 +498,5 @@ collectionsRoutes.post("/:id/remove-post", async (c) => {
504
498
  await c.var.services.collections.removePost(id, body.postId);
505
499
  }
506
500
 
507
- return sse(c, async (stream) => {
508
- await stream.redirect(`/dash/collections/${id}`);
509
- });
501
+ return dsRedirect(`/dash/collections/${id}`);
510
502
  });
@@ -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
  });