@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
@@ -15,7 +15,7 @@ import {
15
15
  ActionButtons,
16
16
  } from "../../theme/components/index.js";
17
17
  import * as sqid from "../../lib/sqid.js";
18
- import { sse } from "../../lib/sse.js";
18
+ import { dsRedirect } from "../../lib/sse.js";
19
19
 
20
20
  type Env = { Bindings: Bindings; Variables: AppVariables };
21
21
 
@@ -105,9 +105,7 @@ postsRoutes.post("/", async (c) => {
105
105
  path: body.path || undefined,
106
106
  });
107
107
 
108
- return sse(c, async (stream) => {
109
- await stream.redirect(`/dash/posts/${sqid.encode(post.id)}`);
110
- });
108
+ return dsRedirect(`/dash/posts/${sqid.encode(post.id)}`);
111
109
  });
112
110
 
113
111
  function ViewPostContent({ post }: { post: Post }) {
@@ -227,9 +225,7 @@ postsRoutes.post("/:id", async (c) => {
227
225
  path: body.path || null,
228
226
  });
229
227
 
230
- return sse(c, async (stream) => {
231
- await stream.redirect(`/dash/posts/${sqid.encode(id)}`);
232
- });
228
+ return dsRedirect(`/dash/posts/${sqid.encode(id)}`);
233
229
  });
234
230
 
235
231
  // Delete post
@@ -239,7 +235,5 @@ postsRoutes.post("/:id/delete", async (c) => {
239
235
 
240
236
  await c.var.services.posts.delete(id);
241
237
 
242
- return sse(c, async (stream) => {
243
- await stream.redirect("/dash/posts");
244
- });
238
+ return dsRedirect("/dash/posts");
245
239
  });
@@ -14,7 +14,7 @@ import {
14
14
  ActionButtons,
15
15
  CrudPageHeader,
16
16
  } from "../../theme/components/index.js";
17
- import { sse } from "../../lib/sse.js";
17
+ import { dsRedirect } from "../../lib/sse.js";
18
18
 
19
19
  type Env = { Bindings: Bindings; Variables: AppVariables };
20
20
 
@@ -219,9 +219,7 @@ redirectsRoutes.post("/", async (c) => {
219
219
  const type = parseInt(body.type, 10) as 301 | 302;
220
220
  await c.var.services.redirects.create(body.fromPath, body.toPath, type);
221
221
 
222
- return sse(c, async (stream) => {
223
- await stream.redirect("/dash/redirects");
224
- });
222
+ return dsRedirect("/dash/redirects");
225
223
  });
226
224
 
227
225
  // Delete redirect
@@ -231,7 +229,5 @@ redirectsRoutes.post("/:id/delete", async (c) => {
231
229
  await c.var.services.redirects.delete(id);
232
230
  }
233
231
 
234
- return sse(c, async (stream) => {
235
- await stream.redirect("/dash/redirects");
236
- });
232
+ return dsRedirect("/dash/redirects");
237
233
  });
@@ -7,12 +7,17 @@ import { useLingui } from "@lingui/react/macro";
7
7
  import type { Bindings } from "../../types.js";
8
8
  import type { AppVariables } from "../../app.js";
9
9
  import { DashLayout } from "../../theme/layouts/index.js";
10
- import { sse } from "../../lib/sse.js";
11
- import {
12
- getSiteName,
13
- getSiteDescription,
14
- getSiteLanguage,
15
- } from "../../lib/config.js";
10
+ import { sse, dsToast } from "../../lib/sse.js";
11
+ import { getSiteLanguage, getConfigFallback } from "../../lib/config.js";
12
+
13
+ /** Escape HTML special characters for safe insertion into HTML strings */
14
+ function escapeHtml(str: string): string {
15
+ return str
16
+ .replace(/&/g, "&")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;");
20
+ }
16
21
 
17
22
  type Env = { Bindings: Bindings; Variables: AppVariables };
18
23
 
@@ -22,12 +27,14 @@ function SettingsContent({
22
27
  siteName,
23
28
  siteDescription,
24
29
  siteLanguage,
25
- saved,
30
+ siteNameFallback,
31
+ siteDescriptionFallback,
26
32
  }: {
27
33
  siteName: string;
28
34
  siteDescription: string;
29
35
  siteLanguage: string;
30
- saved: boolean;
36
+ siteNameFallback: string;
37
+ siteDescriptionFallback: string;
31
38
  }) {
32
39
  const { t } = useLingui();
33
40
 
@@ -43,27 +50,11 @@ function SettingsContent({
43
50
  {t({ message: "Settings", comment: "@context: Dashboard heading" })}
44
51
  </h1>
45
52
 
46
- {saved && (
47
- <div
48
- id="settings-saved-toast"
49
- class="alert mb-4 max-w-lg transition-opacity duration-300"
50
- 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)`}
51
- >
52
- <h2>
53
- {t({
54
- message: "Settings saved successfully.",
55
- comment: "@context: Toast message after saving settings",
56
- })}
57
- </h2>
58
- </div>
59
- )}
60
-
61
53
  <div class="flex flex-col gap-6 max-w-lg">
62
54
  <form
63
55
  data-signals={generalSignals}
64
56
  data-on:submit__prevent="@post('/dash/settings')"
65
57
  >
66
- <div id="settings-message"></div>
67
58
  <div class="card">
68
59
  <header>
69
60
  <h2>
@@ -85,7 +76,7 @@ function SettingsContent({
85
76
  type="text"
86
77
  data-bind="siteName"
87
78
  class="input"
88
- required
79
+ placeholder={siteNameFallback}
89
80
  />
90
81
  </div>
91
82
 
@@ -96,7 +87,12 @@ function SettingsContent({
96
87
  comment: "@context: Settings form field",
97
88
  })}
98
89
  </label>
99
- <textarea data-bind="siteDescription" class="textarea" rows={3}>
90
+ <textarea
91
+ data-bind="siteDescription"
92
+ class="textarea"
93
+ rows={3}
94
+ placeholder={siteDescriptionFallback}
95
+ >
100
96
  {siteDescription}
101
97
  </textarea>
102
98
  </div>
@@ -135,7 +131,6 @@ function SettingsContent({
135
131
  data-signals="{currentPassword: '', newPassword: '', confirmPassword: ''}"
136
132
  data-on:submit__prevent="@post('/dash/settings/password')"
137
133
  >
138
- <div id="password-message"></div>
139
134
  <div class="card">
140
135
  <header>
141
136
  <h2>
@@ -212,23 +207,33 @@ function SettingsContent({
212
207
 
213
208
  // Settings page
214
209
  settingsRoutes.get("/", async (c) => {
215
- const siteName = await getSiteName(c);
216
- const siteDescription = await getSiteDescription(c);
210
+ const { settings } = c.var.services;
211
+
212
+ // Fetch raw DB values (null if not set)
213
+ const dbSiteName = await settings.get("SITE_NAME");
214
+ const dbSiteDescription = await settings.get("SITE_DESCRIPTION");
217
215
  const siteLanguage = await getSiteLanguage(c);
216
+
217
+ // Fallback values (ENV > Default) for placeholders
218
+ const siteNameFallback = getConfigFallback(c, "SITE_NAME");
219
+ const siteDescriptionFallback = getConfigFallback(c, "SITE_DESCRIPTION");
220
+
218
221
  const saved = c.req.query("saved") !== undefined;
219
222
 
220
223
  return c.html(
221
224
  <DashLayout
222
225
  c={c}
223
226
  title="Settings"
224
- siteName={siteName}
227
+ siteName={dbSiteName || siteNameFallback}
225
228
  currentPath="/dash/settings"
229
+ toast={saved ? { message: "Settings saved successfully." } : undefined}
226
230
  >
227
231
  <SettingsContent
228
- siteName={siteName}
229
- siteDescription={siteDescription}
232
+ siteName={dbSiteName || ""}
233
+ siteDescription={dbSiteDescription || ""}
230
234
  siteLanguage={siteLanguage}
231
- saved={saved}
235
+ siteNameFallback={siteNameFallback}
236
+ siteDescriptionFallback={siteDescriptionFallback}
232
237
  />
233
238
  </DashLayout>,
234
239
  );
@@ -242,26 +247,47 @@ settingsRoutes.post("/", async (c) => {
242
247
  siteLanguage: string;
243
248
  }>();
244
249
 
245
- const oldLanguage =
246
- (await c.var.services.settings.get("SITE_LANGUAGE")) ?? "en";
250
+ const { settings } = c.var.services;
247
251
 
248
- await c.var.services.settings.setMany({
249
- SITE_NAME: body.siteName,
250
- SITE_DESCRIPTION: body.siteDescription,
251
- SITE_LANGUAGE: body.siteLanguage,
252
- });
252
+ const oldLanguage = (await settings.get("SITE_LANGUAGE")) ?? "en";
253
+
254
+ // For text fields: empty = remove from DB (fall back to ENV > Default)
255
+ if (body.siteName.trim()) {
256
+ await settings.set("SITE_NAME", body.siteName.trim());
257
+ } else {
258
+ await settings.remove("SITE_NAME");
259
+ }
260
+
261
+ if (body.siteDescription.trim()) {
262
+ await settings.set("SITE_DESCRIPTION", body.siteDescription.trim());
263
+ } else {
264
+ await settings.remove("SITE_DESCRIPTION");
265
+ }
266
+
267
+ // Language always has a value from the select
268
+ await settings.set("SITE_LANGUAGE", body.siteLanguage);
253
269
 
254
270
  const languageChanged = oldLanguage !== body.siteLanguage;
255
271
 
272
+ // Determine the effective display name after save
273
+ const displayName = body.siteName.trim() || getConfigFallback(c, "SITE_NAME");
274
+
256
275
  return sse(c, async (stream) => {
257
276
  if (languageChanged) {
258
277
  // Language changed - full reload needed to update all UI text
259
278
  await stream.redirect("/dash/settings?saved");
260
279
  } else {
261
- // No language change - show inline success message
280
+ const escaped = escapeHtml(displayName);
281
+ // Update header site name
262
282
  await stream.patchElements(
263
- '<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>',
283
+ `<a id="site-name" href="/dash" class="font-semibold">${escaped}</a>`,
264
284
  );
285
+ // Update page title
286
+ await stream.patchElements(`Settings - ${escaped}`, {
287
+ mode: "inner",
288
+ selector: "title",
289
+ });
290
+ await stream.toast("Settings saved successfully.");
265
291
  }
266
292
  });
267
293
  });
@@ -275,11 +301,7 @@ settingsRoutes.post("/password", async (c) => {
275
301
  }>();
276
302
 
277
303
  if (body.newPassword !== body.confirmPassword) {
278
- return sse(c, async (stream) => {
279
- await stream.patchElements(
280
- '<div id="password-message"><div class="alert-destructive mb-4"><h2>Passwords do not match.</h2></div></div>',
281
- );
282
- });
304
+ return dsToast("Passwords do not match.", "error");
283
305
  }
284
306
 
285
307
  try {
@@ -292,17 +314,11 @@ settingsRoutes.post("/password", async (c) => {
292
314
  headers: c.req.raw.headers,
293
315
  });
294
316
  } catch {
295
- return sse(c, async (stream) => {
296
- await stream.patchElements(
297
- '<div id="password-message"><div class="alert-destructive mb-4"><h2>Current password is incorrect.</h2></div></div>',
298
- );
299
- });
317
+ return dsToast("Current password is incorrect.", "error");
300
318
  }
301
319
 
302
320
  return sse(c, async (stream) => {
303
- await stream.patchElements(
304
- '<div id="password-message"><div class="alert mb-4"><h2>Password changed successfully.</h2></div></div>',
305
- );
321
+ await stream.toast("Password changed successfully.");
306
322
  await stream.patchSignals({
307
323
  currentPassword: "",
308
324
  newPassword: "",
@@ -19,6 +19,7 @@ export interface SettingsService {
19
19
  getAll(): Promise<Record<string, string>>;
20
20
  set(key: SettingsKey, value: string): Promise<void>;
21
21
  setMany(entries: Partial<Record<SettingsKey, string>>): Promise<void>;
22
+ remove(key: SettingsKey): Promise<void>;
22
23
  isOnboardingComplete(): Promise<boolean>;
23
24
  completeOnboarding(): Promise<void>;
24
25
  }
@@ -54,6 +55,10 @@ export function createSettingsService(db: Database): SettingsService {
54
55
  });
55
56
  },
56
57
 
58
+ async remove(key) {
59
+ await db.delete(settings).where(eq(settings.key, key));
60
+ },
61
+
57
62
  async setMany(entries) {
58
63
  const timestamp = now();
59
64
  const keys = Object.keys(entries) as SettingsKey[];
@@ -12,6 +12,32 @@
12
12
  }
13
13
  }
14
14
 
15
+ /* Alert variants */
16
+ @layer components {
17
+ .alert-success {
18
+ @apply relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current;
19
+ @apply text-success bg-card [&>svg]:text-current;
20
+
21
+ > h2,
22
+ > h3,
23
+ > h4,
24
+ > h5,
25
+ > h6,
26
+ > strong,
27
+ > [data-title] {
28
+ @apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight;
29
+ }
30
+
31
+ > section {
32
+ @apply text-success col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed;
33
+
34
+ ul {
35
+ @apply list-inside list-disc text-sm;
36
+ }
37
+ }
38
+ }
39
+ }
40
+
15
41
  /* Badge components */
16
42
  @layer components {
17
43
  .badge {
@@ -45,3 +71,70 @@
45
71
  color: white;
46
72
  }
47
73
  }
74
+
75
+ /* Toast notifications */
76
+ @layer components {
77
+ .toast-container {
78
+ @apply fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none;
79
+ }
80
+
81
+ .toast {
82
+ @apply pointer-events-auto rounded-lg border px-4 py-3 text-sm shadow-lg;
83
+ @apply flex items-start gap-2.5 min-w-64 max-w-sm;
84
+ background-color: var(--color-card);
85
+ animation: toast-in 0.3s ease-out;
86
+
87
+ > svg:first-child {
88
+ @apply size-4 shrink-0 translate-y-0.5;
89
+ }
90
+
91
+ > span {
92
+ @apply flex-1;
93
+ }
94
+ }
95
+
96
+ .toast-success {
97
+ color: var(--color-success);
98
+ }
99
+
100
+ .toast-error {
101
+ color: var(--color-destructive);
102
+ }
103
+
104
+ .toast-close {
105
+ @apply shrink-0 translate-y-0.5 cursor-pointer rounded-sm p-0 border-0 bg-transparent;
106
+ color: var(--color-muted-foreground);
107
+
108
+ &:hover {
109
+ color: var(--color-foreground);
110
+ }
111
+
112
+ > svg {
113
+ @apply size-3.5;
114
+ }
115
+ }
116
+
117
+ .toast-out {
118
+ animation: toast-out 0.3s ease-in forwards;
119
+ }
120
+ }
121
+
122
+ @keyframes toast-in {
123
+ from {
124
+ opacity: 0;
125
+ transform: translateY(-0.5rem);
126
+ }
127
+ to {
128
+ opacity: 1;
129
+ transform: translateY(0);
130
+ }
131
+ }
132
+
133
+ @keyframes toast-out {
134
+ from {
135
+ opacity: 1;
136
+ }
137
+ to {
138
+ opacity: 0;
139
+ }
140
+ }