@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
@@ -7,7 +7,7 @@ import { useLingui as $_useLingui } from "@jant/core/i18n";
7
7
  import { DashLayout } from "../../theme/layouts/index.js";
8
8
  import { EmptyState, ListItemRow, ActionButtons, CrudPageHeader, DangerZone } from "../../theme/components/index.js";
9
9
  import * as sqid from "../../lib/sqid.js";
10
- import { sse } from "../../lib/sse.js";
10
+ import { dsRedirect } from "../../lib/sse.js";
11
11
  export const collectionsRoutes = new Hono();
12
12
  function CollectionsListContent({ collections }) {
13
13
  const { i18n: $__i18n, _: $__ } = $_useLingui();
@@ -428,9 +428,7 @@ collectionsRoutes.post("/", async (c)=>{
428
428
  path: body.path,
429
429
  description: body.description || undefined
430
430
  });
431
- return sse(c, async (stream)=>{
432
- await stream.redirect(`/dash/collections/${collection.id}`);
433
- });
431
+ return dsRedirect(`/dash/collections/${collection.id}`);
434
432
  });
435
433
  // View single collection
436
434
  collectionsRoutes.get("/:id", async (c)=>{
@@ -478,18 +476,14 @@ collectionsRoutes.post("/:id", async (c)=>{
478
476
  path: body.path,
479
477
  description: body.description || undefined
480
478
  });
481
- return sse(c, async (stream)=>{
482
- await stream.redirect(`/dash/collections/${id}`);
483
- });
479
+ return dsRedirect(`/dash/collections/${id}`);
484
480
  });
485
481
  // Delete collection
486
482
  collectionsRoutes.post("/:id/delete", async (c)=>{
487
483
  const id = parseInt(c.req.param("id"), 10);
488
484
  if (isNaN(id)) return c.notFound();
489
485
  await c.var.services.collections.delete(id);
490
- return sse(c, async (stream)=>{
491
- await stream.redirect("/dash/collections");
492
- });
486
+ return dsRedirect("/dash/collections");
493
487
  });
494
488
  // Remove post from collection
495
489
  collectionsRoutes.post("/:id/remove-post", async (c)=>{
@@ -499,7 +493,5 @@ collectionsRoutes.post("/:id/remove-post", async (c)=>{
499
493
  if (body.postId) {
500
494
  await c.var.services.collections.removePost(id, body.postId);
501
495
  }
502
- return sse(c, async (stream)=>{
503
- await stream.redirect(`/dash/collections/${id}`);
504
- });
496
+ return dsRedirect(`/dash/collections/${id}`);
505
497
  });
@@ -11,7 +11,7 @@ import { DashLayout } from "../../theme/layouts/index.js";
11
11
  import { EmptyState, DangerZone } from "../../theme/components/index.js";
12
12
  import * as time from "../../lib/time.js";
13
13
  import { getMediaUrl, getImageUrl } from "../../lib/image.js";
14
- import { sse } from "../../lib/sse.js";
14
+ import { dsRedirect } from "../../lib/sse.js";
15
15
  export const mediaRoutes = new Hono();
16
16
  /**
17
17
  * Format file size for display
@@ -72,8 +72,7 @@ export const mediaRoutes = new Hono();
72
72
  /**
73
73
  * Media list page content
74
74
  *
75
- * Uses plain JavaScript for upload state management (more reliable than Datastar signals
76
- * for complex async flows like file uploads with SSE responses).
75
+ * Upload is handled by media-upload.ts (client module) + Datastar @post for SSE.
77
76
  */ function MediaListContent({ mediaList, r2PublicUrl, imageTransformUrl }) {
78
77
  const { i18n: $__i18n, _: $__ } = $_useLingui();
79
78
  const processingText = $__i18n._({
@@ -92,164 +91,18 @@ export const mediaRoutes = new Hono();
92
91
  id: "pZq3aX",
93
92
  message: "Upload failed. Please try again."
94
93
  });
95
- // Plain JavaScript upload handler - shows progress in the list
96
- const uploadScript = `
97
- async function handleMediaUpload(input) {
98
- if (!input.files || !input.files[0]) return;
99
-
100
- const file = input.files[0];
101
- const errorBox = document.getElementById('upload-error');
102
- errorBox.classList.add('hidden');
103
-
104
- // Ensure grid exists (remove empty state if needed)
105
- let grid = document.getElementById('media-grid');
106
- if (!grid) {
107
- document.getElementById('empty-state')?.remove();
108
- grid = document.createElement('div');
109
- grid.id = 'media-grid';
110
- grid.className = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4';
111
- document.getElementById('media-content').appendChild(grid);
112
- }
113
-
114
- // Create placeholder card showing progress
115
- const placeholder = document.createElement('div');
116
- placeholder.id = 'upload-placeholder';
117
- placeholder.className = 'group relative';
118
- placeholder.innerHTML = \`
119
- <div class="aspect-square bg-muted rounded-lg overflow-hidden border flex items-center justify-center">
120
- <div class="text-center px-2">
121
- <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">
122
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
123
- <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>
124
- </svg>
125
- <span id="upload-status" class="text-xs text-muted-foreground">${processingText}</span>
126
- </div>
127
- </div>
128
- <div class="mt-2 text-xs truncate" title="\${file.name}">\${file.name}</div>
129
- <div class="text-xs text-muted-foreground">\${formatFileSize(file.size)}</div>
130
- \`;
131
- grid.prepend(placeholder);
132
-
133
- try {
134
- if (typeof ImageProcessor === 'undefined') {
135
- throw new Error('ImageProcessor not loaded');
136
- }
137
-
138
- // Process image client-side
139
- const processed = await ImageProcessor.processToFile(file);
140
- document.getElementById('upload-status').textContent = '${uploadingText}';
141
-
142
- // Upload with SSE response
143
- const fd = new FormData();
144
- fd.append('file', processed);
145
-
146
- const response = await fetch('/api/upload', {
147
- method: 'POST',
148
- body: fd,
149
- headers: { 'Accept': 'text/event-stream' }
150
- });
151
-
152
- if (!response.ok) throw new Error('Upload failed: ' + response.status);
153
-
154
- // Parse SSE stream - will replace placeholder with real card
155
- const reader = response.body.getReader();
156
- const decoder = new TextDecoder();
157
- let buffer = '';
158
-
159
- while (true) {
160
- const { done, value } = await reader.read();
161
- if (done) break;
162
-
163
- buffer += decoder.decode(value, { stream: true });
164
- const events = buffer.split('\\n\\n');
165
- buffer = events.pop() || '';
166
-
167
- for (const event of events) {
168
- if (!event.trim()) continue;
169
- processSSEEvent(event);
170
- }
171
- }
172
-
173
- } catch (err) {
174
- console.error('Upload error:', err);
175
- // Show error in placeholder
176
- placeholder.innerHTML = \`
177
- <div class="aspect-square bg-destructive/10 rounded-lg overflow-hidden border border-destructive flex items-center justify-center">
178
- <div class="text-center px-2">
179
- <span class="text-xs text-destructive">\${err.message || '${errorText}'}</span>
180
- </div>
181
- </div>
182
- <div class="mt-2 text-xs truncate text-destructive">\${file.name}</div>
183
- <button type="button" class="text-xs text-muted-foreground hover:underline" onclick="this.closest('.group').remove()">Remove</button>
184
- \`;
185
- }
186
-
187
- input.value = '';
188
- }
189
-
190
- function formatFileSize(bytes) {
191
- if (bytes < 1024) return bytes + ' B';
192
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
193
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
194
- }
195
-
196
- function processSSEEvent(event) {
197
- const lines = event.split('\\n');
198
- let eventType = '';
199
- const data = {};
200
- let elementsLines = [];
201
- let inElements = false;
202
-
203
- for (const line of lines) {
204
- if (line.startsWith('event: ')) {
205
- eventType = line.slice(7);
206
- } else if (line.startsWith('data: ')) {
207
- const content = line.slice(6);
208
- if (content.startsWith('mode ')) {
209
- data.mode = content.slice(5);
210
- inElements = false;
211
- } else if (content.startsWith('selector ')) {
212
- data.selector = content.slice(9);
213
- inElements = false;
214
- } else if (content.startsWith('elements ')) {
215
- elementsLines = [content.slice(9)];
216
- inElements = true;
217
- } else if (inElements) {
218
- // Continuation of elements content
219
- elementsLines.push(content);
220
- }
221
- }
222
- }
223
-
224
- if (elementsLines.length > 0) {
225
- data.elements = elementsLines.join('\\n');
226
- }
227
-
228
- if (eventType === 'datastar-patch-elements') {
229
- if (data.mode === 'remove' && data.selector) {
230
- document.querySelector(data.selector)?.remove();
231
- } else if (data.mode === 'outer' && data.selector && data.elements) {
232
- // Replace element entirely (used for placeholder -> real card)
233
- const target = document.querySelector(data.selector);
234
- if (target) {
235
- const temp = document.createElement('div');
236
- temp.innerHTML = data.elements;
237
- const newElement = temp.firstElementChild;
238
- if (newElement) {
239
- target.replaceWith(newElement);
240
- if (window.Datastar) Datastar.apply(newElement);
241
- }
242
- }
243
- }
244
- }
245
- }
246
- `.trim();
247
94
  return /*#__PURE__*/ _jsxs(_Fragment, {
248
95
  children: [
249
- /*#__PURE__*/ _jsx("script", {
250
- dangerouslySetInnerHTML: {
251
- __html: uploadScript
252
- }
96
+ /*#__PURE__*/ _jsx("form", {
97
+ id: "upload-form",
98
+ class: "hidden",
99
+ enctype: "multipart/form-data",
100
+ "data-on:submit__prevent": "@post('/api/upload', {contentType: 'form'})",
101
+ children: /*#__PURE__*/ _jsx("input", {
102
+ id: "upload-file-input",
103
+ type: "file",
104
+ name: "file"
105
+ })
253
106
  }),
254
107
  /*#__PURE__*/ _jsxs("div", {
255
108
  class: "flex items-center justify-between mb-6",
@@ -271,16 +124,15 @@ function processSSEEvent(event) {
271
124
  type: "file",
272
125
  class: "hidden",
273
126
  accept: "image/*",
274
- onchange: "handleMediaUpload(this)"
127
+ "data-media-upload": true,
128
+ "data-text-processing": processingText,
129
+ "data-text-uploading": uploadingText,
130
+ "data-text-error": errorText
275
131
  })
276
132
  ]
277
133
  })
278
134
  ]
279
135
  }),
280
- /*#__PURE__*/ _jsx("div", {
281
- id: "upload-error",
282
- class: "hidden"
283
- }),
284
136
  /*#__PURE__*/ _jsx("div", {
285
137
  class: "card mb-6",
286
138
  children: /*#__PURE__*/ _jsx("section", {
@@ -582,7 +434,5 @@ mediaRoutes.post("/:id/delete", async (c)=>{
582
434
  }
583
435
  // Delete from database
584
436
  await c.var.services.media.delete(id);
585
- return sse(c, async (stream)=>{
586
- await stream.redirect("/dash/media");
587
- });
437
+ return dsRedirect("/dash/media");
588
438
  });
@@ -10,7 +10,7 @@ import { DashLayout } from "../../theme/layouts/index.js";
10
10
  import { PageForm, VisibilityBadge, EmptyState, ListItemRow, ActionButtons, CrudPageHeader, DangerZone } from "../../theme/components/index.js";
11
11
  import * as sqid from "../../lib/sqid.js";
12
12
  import * as time from "../../lib/time.js";
13
- import { sse } from "../../lib/sse.js";
13
+ import { dsRedirect } from "../../lib/sse.js";
14
14
  export const pagesRoutes = new Hono();
15
15
  function PagesListContent({ pages }) {
16
16
  const { i18n: $__i18n, _: $__ } = $_useLingui();
@@ -224,9 +224,7 @@ pagesRoutes.post("/", async (c)=>{
224
224
  visibility: body.visibility,
225
225
  path: body.path.toLowerCase().replace(/[^a-z0-9-]/g, "-")
226
226
  });
227
- return sse(c, async (stream)=>{
228
- await stream.redirect(`/dash/pages/${sqid.encode(page.id)}`);
229
- });
227
+ return dsRedirect(`/dash/pages/${sqid.encode(page.id)}`);
230
228
  });
231
229
  // View single page
232
230
  pagesRoutes.get("/:id", async (c)=>{
@@ -274,16 +272,12 @@ pagesRoutes.post("/:id", async (c)=>{
274
272
  visibility: body.visibility,
275
273
  path: body.path.toLowerCase().replace(/[^a-z0-9-]/g, "-")
276
274
  });
277
- return sse(c, async (stream)=>{
278
- await stream.redirect(`/dash/pages/${sqid.encode(id)}`);
279
- });
275
+ return dsRedirect(`/dash/pages/${sqid.encode(id)}`);
280
276
  });
281
277
  // Delete page
282
278
  pagesRoutes.post("/:id/delete", async (c)=>{
283
279
  const id = sqid.decode(c.req.param("id"));
284
280
  if (!id) return c.notFound();
285
281
  await c.var.services.posts.delete(id);
286
- return sse(c, async (stream)=>{
287
- await stream.redirect("/dash/pages");
288
- });
282
+ return dsRedirect("/dash/pages");
289
283
  });
@@ -7,7 +7,7 @@ import { useLingui as $_useLingui } from "@jant/core/i18n";
7
7
  import { DashLayout } from "../../theme/layouts/index.js";
8
8
  import { PostForm, PostList, CrudPageHeader, ActionButtons } from "../../theme/components/index.js";
9
9
  import * as sqid from "../../lib/sqid.js";
10
- import { sse } from "../../lib/sse.js";
10
+ import { dsRedirect } from "../../lib/sse.js";
11
11
  export const postsRoutes = new Hono();
12
12
  function PostsListContent({ posts }) {
13
13
  const { i18n: $__i18n, _: $__ } = $_useLingui();
@@ -90,9 +90,7 @@ postsRoutes.post("/", async (c)=>{
90
90
  sourceUrl: body.sourceUrl || undefined,
91
91
  path: body.path || undefined
92
92
  });
93
- return sse(c, async (stream)=>{
94
- await stream.redirect(`/dash/posts/${sqid.encode(post.id)}`);
95
- });
93
+ return dsRedirect(`/dash/posts/${sqid.encode(post.id)}`);
96
94
  });
97
95
  function ViewPostContent({ post }) {
98
96
  const { i18n: $__i18n, _: $__ } = $_useLingui();
@@ -203,16 +201,12 @@ postsRoutes.post("/:id", async (c)=>{
203
201
  sourceUrl: body.sourceUrl || null,
204
202
  path: body.path || null
205
203
  });
206
- return sse(c, async (stream)=>{
207
- await stream.redirect(`/dash/posts/${sqid.encode(id)}`);
208
- });
204
+ return dsRedirect(`/dash/posts/${sqid.encode(id)}`);
209
205
  });
210
206
  // Delete post
211
207
  postsRoutes.post("/:id/delete", async (c)=>{
212
208
  const id = sqid.decode(c.req.param("id"));
213
209
  if (!id) return c.notFound();
214
210
  await c.var.services.posts.delete(id);
215
- return sse(c, async (stream)=>{
216
- await stream.redirect("/dash/posts");
217
- });
211
+ return dsRedirect("/dash/posts");
218
212
  });
@@ -6,7 +6,7 @@ import { getSiteName } from "../../lib/config.js";
6
6
  import { useLingui as $_useLingui } from "@jant/core/i18n";
7
7
  import { DashLayout } from "../../theme/layouts/index.js";
8
8
  import { EmptyState, ListItemRow, ActionButtons, CrudPageHeader } from "../../theme/components/index.js";
9
- import { sse } from "../../lib/sse.js";
9
+ import { dsRedirect } from "../../lib/sse.js";
10
10
  export const redirectsRoutes = new Hono();
11
11
  function RedirectsListContent({ redirects }) {
12
12
  const { i18n: $__i18n, _: $__ } = $_useLingui();
@@ -225,9 +225,7 @@ redirectsRoutes.post("/", async (c)=>{
225
225
  const body = await c.req.json();
226
226
  const type = parseInt(body.type, 10);
227
227
  await c.var.services.redirects.create(body.fromPath, body.toPath, type);
228
- return sse(c, async (stream)=>{
229
- await stream.redirect("/dash/redirects");
230
- });
228
+ return dsRedirect("/dash/redirects");
231
229
  });
232
230
  // Delete redirect
233
231
  redirectsRoutes.post("/:id/delete", async (c)=>{
@@ -235,7 +233,5 @@ redirectsRoutes.post("/:id/delete", async (c)=>{
235
233
  if (!isNaN(id)) {
236
234
  await c.var.services.redirects.delete(id);
237
235
  }
238
- return sse(c, async (stream)=>{
239
- await stream.redirect("/dash/redirects");
240
- });
236
+ return dsRedirect("/dash/redirects");
241
237
  });
@@ -1 +1 @@
1
- {"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../../src/routes/dash/settings.tsx"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AASjD,KAAK,GAAG,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,YAAY,CAAA;CAAE,CAAC;AAE3D,eAAO,MAAM,cAAc,kDAAkB,CAAC"}
1
+ {"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../../src/routes/dash/settings.tsx"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAcjD,KAAK,GAAG,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,YAAY,CAAA;CAAE,CAAC;AAE3D,eAAO,MAAM,cAAc,kDAAkB,CAAC"}
@@ -4,10 +4,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "hono/jsx/jsx-
4
4
  */ import { Hono } from "hono";
5
5
  import { useLingui as $_useLingui } from "@jant/core/i18n";
6
6
  import { DashLayout } from "../../theme/layouts/index.js";
7
- import { sse } from "../../lib/sse.js";
8
- import { getSiteName, getSiteDescription, getSiteLanguage } from "../../lib/config.js";
7
+ import { sse, dsToast } from "../../lib/sse.js";
8
+ import { getSiteLanguage, getConfigFallback } from "../../lib/config.js";
9
+ /** Escape HTML special characters for safe insertion into HTML strings */ function escapeHtml(str) {
10
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
11
+ }
9
12
  export const settingsRoutes = new Hono();
10
- function SettingsContent({ siteName, siteDescription, siteLanguage, saved }) {
13
+ function SettingsContent({ siteName, siteDescription, siteLanguage, siteNameFallback, siteDescriptionFallback }) {
11
14
  const { i18n: $__i18n, _: $__ } = $_useLingui();
12
15
  const generalSignals = JSON.stringify({
13
16
  siteName,
@@ -23,17 +26,6 @@ function SettingsContent({ siteName, siteDescription, siteLanguage, saved }) {
23
26
  message: "Settings"
24
27
  })
25
28
  }),
26
- saved && /*#__PURE__*/ _jsx("div", {
27
- id: "settings-saved-toast",
28
- class: "alert mb-4 max-w-lg transition-opacity duration-300",
29
- "data-init": `console.log('[toast] init fired at', Date.now()); history.replaceState({}, '', '/dash/settings'); setTimeout(() => { console.log('[toast] hiding at', Date.now()); const el = document.getElementById('settings-saved-toast'); if (el) { el.style.opacity = '0'; setTimeout(() => el.remove(), 300) } }, 3000)`,
30
- children: /*#__PURE__*/ _jsx("h2", {
31
- children: $__i18n._({
32
- id: "T0bsor",
33
- message: "Settings saved successfully."
34
- })
35
- })
36
- }),
37
29
  /*#__PURE__*/ _jsxs("div", {
38
30
  class: "flex flex-col gap-6 max-w-lg",
39
31
  children: [
@@ -41,9 +33,6 @@ function SettingsContent({ siteName, siteDescription, siteLanguage, saved }) {
41
33
  "data-signals": generalSignals,
42
34
  "data-on:submit__prevent": "@post('/dash/settings')",
43
35
  children: [
44
- /*#__PURE__*/ _jsx("div", {
45
- id: "settings-message"
46
- }),
47
36
  /*#__PURE__*/ _jsxs("div", {
48
37
  class: "card",
49
38
  children: [
@@ -72,7 +61,7 @@ function SettingsContent({ siteName, siteDescription, siteLanguage, saved }) {
72
61
  type: "text",
73
62
  "data-bind": "siteName",
74
63
  class: "input",
75
- required: true
64
+ placeholder: siteNameFallback
76
65
  })
77
66
  ]
78
67
  }),
@@ -90,6 +79,7 @@ function SettingsContent({ siteName, siteDescription, siteLanguage, saved }) {
90
79
  "data-bind": "siteDescription",
91
80
  class: "textarea",
92
81
  rows: 3,
82
+ placeholder: siteDescriptionFallback,
93
83
  children: siteDescription
94
84
  })
95
85
  ]
@@ -145,9 +135,6 @@ function SettingsContent({ siteName, siteDescription, siteLanguage, saved }) {
145
135
  "data-signals": "{currentPassword: '', newPassword: '', confirmPassword: ''}",
146
136
  "data-on:submit__prevent": "@post('/dash/settings/password')",
147
137
  children: [
148
- /*#__PURE__*/ _jsx("div", {
149
- id: "password-message"
150
- }),
151
138
  /*#__PURE__*/ _jsxs("div", {
152
139
  class: "card",
153
140
  children: [
@@ -242,40 +229,67 @@ function SettingsContent({ siteName, siteDescription, siteLanguage, saved }) {
242
229
  }
243
230
  // Settings page
244
231
  settingsRoutes.get("/", async (c)=>{
245
- const siteName = await getSiteName(c);
246
- const siteDescription = await getSiteDescription(c);
232
+ const { settings } = c.var.services;
233
+ // Fetch raw DB values (null if not set)
234
+ const dbSiteName = await settings.get("SITE_NAME");
235
+ const dbSiteDescription = await settings.get("SITE_DESCRIPTION");
247
236
  const siteLanguage = await getSiteLanguage(c);
237
+ // Fallback values (ENV > Default) for placeholders
238
+ const siteNameFallback = getConfigFallback(c, "SITE_NAME");
239
+ const siteDescriptionFallback = getConfigFallback(c, "SITE_DESCRIPTION");
248
240
  const saved = c.req.query("saved") !== undefined;
249
241
  return c.html(/*#__PURE__*/ _jsx(DashLayout, {
250
242
  c: c,
251
243
  title: "Settings",
252
- siteName: siteName,
244
+ siteName: dbSiteName || siteNameFallback,
253
245
  currentPath: "/dash/settings",
246
+ toast: saved ? {
247
+ message: "Settings saved successfully."
248
+ } : undefined,
254
249
  children: /*#__PURE__*/ _jsx(SettingsContent, {
255
- siteName: siteName,
256
- siteDescription: siteDescription,
250
+ siteName: dbSiteName || "",
251
+ siteDescription: dbSiteDescription || "",
257
252
  siteLanguage: siteLanguage,
258
- saved: saved
253
+ siteNameFallback: siteNameFallback,
254
+ siteDescriptionFallback: siteDescriptionFallback
259
255
  })
260
256
  }));
261
257
  });
262
258
  // Update settings
263
259
  settingsRoutes.post("/", async (c)=>{
264
260
  const body = await c.req.json();
265
- const oldLanguage = await c.var.services.settings.get("SITE_LANGUAGE") ?? "en";
266
- await c.var.services.settings.setMany({
267
- SITE_NAME: body.siteName,
268
- SITE_DESCRIPTION: body.siteDescription,
269
- SITE_LANGUAGE: body.siteLanguage
270
- });
261
+ const { settings } = c.var.services;
262
+ const oldLanguage = await settings.get("SITE_LANGUAGE") ?? "en";
263
+ // For text fields: empty = remove from DB (fall back to ENV > Default)
264
+ if (body.siteName.trim()) {
265
+ await settings.set("SITE_NAME", body.siteName.trim());
266
+ } else {
267
+ await settings.remove("SITE_NAME");
268
+ }
269
+ if (body.siteDescription.trim()) {
270
+ await settings.set("SITE_DESCRIPTION", body.siteDescription.trim());
271
+ } else {
272
+ await settings.remove("SITE_DESCRIPTION");
273
+ }
274
+ // Language always has a value from the select
275
+ await settings.set("SITE_LANGUAGE", body.siteLanguage);
271
276
  const languageChanged = oldLanguage !== body.siteLanguage;
277
+ // Determine the effective display name after save
278
+ const displayName = body.siteName.trim() || getConfigFallback(c, "SITE_NAME");
272
279
  return sse(c, async (stream)=>{
273
280
  if (languageChanged) {
274
281
  // Language changed - full reload needed to update all UI text
275
282
  await stream.redirect("/dash/settings?saved");
276
283
  } else {
277
- // No language change - show inline success message
278
- await stream.patchElements('<div id="settings-message"><div class="alert mb-4 transition-opacity duration-300" data-init="setTimeout(() => { el.style.opacity = \'0\'; setTimeout(() => el.remove(), 300) }, 3000)"><h2>Settings saved successfully.</h2></div></div>');
284
+ const escaped = escapeHtml(displayName);
285
+ // Update header site name
286
+ await stream.patchElements(`<a id="site-name" href="/dash" class="font-semibold">${escaped}</a>`);
287
+ // Update page title
288
+ await stream.patchElements(`Settings - ${escaped}`, {
289
+ mode: "inner",
290
+ selector: "title"
291
+ });
292
+ await stream.toast("Settings saved successfully.");
279
293
  }
280
294
  });
281
295
  });
@@ -283,9 +297,7 @@ settingsRoutes.post("/", async (c)=>{
283
297
  settingsRoutes.post("/password", async (c)=>{
284
298
  const body = await c.req.json();
285
299
  if (body.newPassword !== body.confirmPassword) {
286
- return sse(c, async (stream)=>{
287
- await stream.patchElements('<div id="password-message"><div class="alert-destructive mb-4"><h2>Passwords do not match.</h2></div></div>');
288
- });
300
+ return dsToast("Passwords do not match.", "error");
289
301
  }
290
302
  try {
291
303
  await c.var.auth.api.changePassword({
@@ -297,12 +309,10 @@ settingsRoutes.post("/password", async (c)=>{
297
309
  headers: c.req.raw.headers
298
310
  });
299
311
  } catch {
300
- return sse(c, async (stream)=>{
301
- await stream.patchElements('<div id="password-message"><div class="alert-destructive mb-4"><h2>Current password is incorrect.</h2></div></div>');
302
- });
312
+ return dsToast("Current password is incorrect.", "error");
303
313
  }
304
314
  return sse(c, async (stream)=>{
305
- await stream.patchElements('<div id="password-message"><div class="alert mb-4"><h2>Password changed successfully.</h2></div></div>');
315
+ await stream.toast("Password changed successfully.");
306
316
  await stream.patchSignals({
307
317
  currentPassword: "",
308
318
  newPassword: "",
@@ -10,6 +10,7 @@ export interface SettingsService {
10
10
  getAll(): Promise<Record<string, string>>;
11
11
  set(key: SettingsKey, value: string): Promise<void>;
12
12
  setMany(entries: Partial<Record<SettingsKey, string>>): Promise<void>;
13
+ remove(key: SettingsKey): Promise<void>;
13
14
  isOnboardingComplete(): Promise<boolean>;
14
15
  completeOnboarding(): Promise<void>;
15
16
  }
@@ -1 +1 @@
1
- {"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../src/services/settings.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAG/C,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,qBAAqB,CAAC;AAE7B,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9C,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1C,GAAG,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtE,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,QAAQ,GAAG,eAAe,CA6DnE"}
1
+ {"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../src/services/settings.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAG/C,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,qBAAqB,CAAC;AAE7B,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9C,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1C,GAAG,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtE,MAAM,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,QAAQ,GAAG,eAAe,CAiEnE"}
@@ -34,6 +34,9 @@ export function createSettingsService(db) {
34
34
  }
35
35
  });
36
36
  },
37
+ async remove (key) {
38
+ await db.delete(settings).where(eq(settings.key, key));
39
+ },
37
40
  async setMany (entries) {
38
41
  const timestamp = now();
39
42
  const keys = Object.keys(entries);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Built-in Color Themes
3
+ *
4
+ * Each theme defines CSS variable overrides for light and dark modes,
5
+ * plus preview colors for the theme picker UI.
6
+ */
7
+ /**
8
+ * A color theme definition with light and dark mode CSS variable overrides.
9
+ */
10
+ export interface ColorTheme {
11
+ /** Stored in DB settings, e.g. "beach" */
12
+ id: string;
13
+ /** Display name, e.g. "Beach" */
14
+ name: string;
15
+ /** CSS variable overrides for :root (light mode) */
16
+ light: Record<string, string>;
17
+ /** CSS variable overrides for .dark (dark mode) */
18
+ dark: Record<string, string>;
19
+ /** Preview colors (hex) for theme picker cards */
20
+ preview: {
21
+ lightBg: string;
22
+ lightText: string;
23
+ lightLink: string;
24
+ darkBg: string;
25
+ darkText: string;
26
+ darkLink: string;
27
+ };
28
+ }
29
+ export declare const BUILTIN_COLOR_THEMES: ColorTheme[];
30
+ //# sourceMappingURL=color-themes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"color-themes.d.ts","sourceRoot":"","sources":["../../src/theme/color-themes.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,0CAA0C;IAC1C,EAAE,EAAE,MAAM,CAAC;IACX,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,mDAAmD;IACnD,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,kDAAkD;IAClD,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;QAClB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC;CACH;AA2FD,eAAO,MAAM,oBAAoB,EAAE,UAAU,EAyM5C,CAAC"}