@jant/core 0.2.16 → 0.2.18

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 (120) hide show
  1. package/dist/app.d.ts +5 -1
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +332 -119
  4. package/dist/i18n/context.d.ts +2 -2
  5. package/dist/i18n/context.js +1 -1
  6. package/dist/i18n/i18n.d.ts +1 -1
  7. package/dist/i18n/i18n.js +1 -1
  8. package/dist/i18n/index.d.ts +1 -1
  9. package/dist/i18n/index.js +1 -1
  10. package/dist/i18n/locales/en.d.ts.map +1 -1
  11. package/dist/i18n/locales/en.js +1 -1
  12. package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
  13. package/dist/i18n/locales/zh-Hans.js +1 -1
  14. package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
  15. package/dist/i18n/locales/zh-Hant.js +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/lib/config.d.ts +83 -0
  19. package/dist/lib/config.d.ts.map +1 -0
  20. package/dist/lib/config.js +104 -0
  21. package/dist/lib/constants.d.ts +2 -1
  22. package/dist/lib/constants.d.ts.map +1 -1
  23. package/dist/lib/constants.js +5 -2
  24. package/dist/lib/sse.d.ts +15 -0
  25. package/dist/lib/sse.d.ts.map +1 -1
  26. package/dist/lib/sse.js +13 -0
  27. package/dist/lib/theme.d.ts +44 -0
  28. package/dist/lib/theme.d.ts.map +1 -0
  29. package/dist/lib/theme.js +65 -0
  30. package/dist/routes/dash/appearance.d.ts +13 -0
  31. package/dist/routes/dash/appearance.d.ts.map +1 -0
  32. package/dist/routes/dash/appearance.js +164 -0
  33. package/dist/routes/dash/collections.d.ts.map +1 -1
  34. package/dist/routes/dash/collections.js +5 -4
  35. package/dist/routes/dash/index.d.ts.map +1 -1
  36. package/dist/routes/dash/index.js +2 -1
  37. package/dist/routes/dash/media.d.ts.map +1 -1
  38. package/dist/routes/dash/media.js +3 -2
  39. package/dist/routes/dash/pages.d.ts.map +1 -1
  40. package/dist/routes/dash/pages.js +5 -4
  41. package/dist/routes/dash/posts.d.ts.map +1 -1
  42. package/dist/routes/dash/posts.js +5 -4
  43. package/dist/routes/dash/redirects.d.ts.map +1 -1
  44. package/dist/routes/dash/redirects.js +3 -2
  45. package/dist/routes/dash/settings.d.ts.map +1 -1
  46. package/dist/routes/dash/settings.js +39 -38
  47. package/dist/routes/pages/archive.d.ts.map +1 -1
  48. package/dist/routes/pages/archive.js +2 -1
  49. package/dist/routes/pages/collection.d.ts.map +1 -1
  50. package/dist/routes/pages/collection.js +2 -1
  51. package/dist/routes/pages/home.d.ts.map +1 -1
  52. package/dist/routes/pages/home.js +2 -1
  53. package/dist/routes/pages/page.d.ts.map +1 -1
  54. package/dist/routes/pages/page.js +2 -1
  55. package/dist/routes/pages/post.d.ts.map +1 -1
  56. package/dist/routes/pages/post.js +2 -1
  57. package/dist/routes/pages/search.d.ts.map +1 -1
  58. package/dist/routes/pages/search.js +2 -1
  59. package/dist/services/settings.d.ts +1 -0
  60. package/dist/services/settings.d.ts.map +1 -1
  61. package/dist/services/settings.js +3 -0
  62. package/dist/theme/color-themes.d.ts +30 -0
  63. package/dist/theme/color-themes.d.ts.map +1 -0
  64. package/dist/theme/color-themes.js +268 -0
  65. package/dist/theme/layouts/BaseLayout.d.ts +5 -0
  66. package/dist/theme/layouts/BaseLayout.d.ts.map +1 -1
  67. package/dist/theme/layouts/BaseLayout.js +70 -3
  68. package/dist/theme/layouts/DashLayout.d.ts +2 -0
  69. package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
  70. package/dist/theme/layouts/DashLayout.js +10 -1
  71. package/dist/theme/layouts/index.d.ts +1 -1
  72. package/dist/theme/layouts/index.d.ts.map +1 -1
  73. package/dist/types.d.ts +64 -32
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +52 -0
  76. package/package.json +1 -1
  77. package/src/app.tsx +286 -59
  78. package/src/db/migrations/{0000_solid_moon_knight.sql → 0000_square_wallflower.sql} +3 -3
  79. package/src/db/migrations/meta/0000_snapshot.json +9 -9
  80. package/src/db/migrations/meta/_journal.json +2 -30
  81. package/src/i18n/context.tsx +2 -2
  82. package/src/i18n/i18n.ts +1 -1
  83. package/src/i18n/index.ts +1 -1
  84. package/src/i18n/locales/en.po +328 -252
  85. package/src/i18n/locales/en.ts +1 -1
  86. package/src/i18n/locales/zh-Hans.po +315 -278
  87. package/src/i18n/locales/zh-Hans.ts +1 -1
  88. package/src/i18n/locales/zh-Hant.po +315 -278
  89. package/src/i18n/locales/zh-Hant.ts +1 -1
  90. package/src/index.ts +0 -2
  91. package/src/lib/config.ts +120 -0
  92. package/src/lib/constants.ts +3 -0
  93. package/src/lib/sse.ts +38 -0
  94. package/src/lib/theme.ts +86 -0
  95. package/src/preset.css +9 -0
  96. package/src/routes/dash/appearance.tsx +180 -0
  97. package/src/routes/dash/collections.tsx +5 -4
  98. package/src/routes/dash/index.tsx +2 -1
  99. package/src/routes/dash/media.tsx +3 -2
  100. package/src/routes/dash/pages.tsx +5 -4
  101. package/src/routes/dash/posts.tsx +5 -4
  102. package/src/routes/dash/redirects.tsx +3 -2
  103. package/src/routes/dash/settings.tsx +51 -49
  104. package/src/routes/pages/archive.tsx +2 -1
  105. package/src/routes/pages/collection.tsx +2 -1
  106. package/src/routes/pages/home.tsx +2 -1
  107. package/src/routes/pages/page.tsx +2 -1
  108. package/src/routes/pages/post.tsx +2 -1
  109. package/src/routes/pages/search.tsx +2 -1
  110. package/src/services/settings.ts +5 -0
  111. package/src/styles/components.css +93 -0
  112. package/src/theme/color-themes.ts +321 -0
  113. package/src/theme/layouts/BaseLayout.tsx +61 -1
  114. package/src/theme/layouts/DashLayout.tsx +13 -2
  115. package/src/theme/layouts/index.ts +5 -1
  116. package/src/types.ts +74 -34
  117. package/src/db/migrations/0001_add_search_fts.sql +0 -40
  118. package/src/db/migrations/0002_collection_path.sql +0 -2
  119. package/src/db/migrations/0003_collection_path_nullable.sql +0 -21
  120. package/src/db/migrations/0004_media_uuid.sql +0 -35
package/src/app.tsx CHANGED
@@ -10,6 +10,8 @@ import { createAuth, type Auth } from "./auth.js";
10
10
  import { i18nMiddleware } from "./i18n/index.js";
11
11
  import { useLingui } from "@lingui/react/macro";
12
12
  import type { Bindings, JantConfig } from "./types.js";
13
+ import { SETTINGS_KEYS } from "./lib/constants.js";
14
+ import { hashPassword } from "better-auth/crypto";
13
15
 
14
16
  // Routes - Pages
15
17
  import { homeRoutes } from "./routes/pages/home.js";
@@ -27,6 +29,7 @@ import { mediaRoutes as dashMediaRoutes } from "./routes/dash/media.js";
27
29
  import { settingsRoutes as dashSettingsRoutes } from "./routes/dash/settings.js";
28
30
  import { redirectsRoutes as dashRedirectsRoutes } from "./routes/dash/redirects.js";
29
31
  import { collectionsRoutes as dashCollectionsRoutes } from "./routes/dash/collections.js";
32
+ import { appearanceRoutes as dashAppearanceRoutes } from "./routes/dash/appearance.js";
30
33
 
31
34
  // Routes - API
32
35
  import { postsApiRoutes } from "./routes/api/posts.js";
@@ -43,12 +46,14 @@ import { requireAuth } from "./middleware/auth.js";
43
46
  // Layouts for auth pages
44
47
  import { BaseLayout } from "./theme/layouts/index.js";
45
48
  import { sse } from "./lib/sse.js";
49
+ import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
46
50
 
47
51
  // Extend Hono's context variables
48
52
  export interface AppVariables {
49
53
  services: Services;
50
54
  auth: Auth;
51
55
  config: JantConfig;
56
+ themeStyle: string;
52
57
  }
53
58
 
54
59
  export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
@@ -59,12 +64,15 @@ export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
59
64
  * @param config - Optional configuration
60
65
  * @returns Hono app instance
61
66
  *
67
+ * Site settings (name, description, language) should be configured via
68
+ * environment variables (SITE_NAME, SITE_DESCRIPTION, SITE_LANGUAGE).
69
+ * They can also be set in the dashboard, which stores them in the database.
70
+ *
62
71
  * @example
63
72
  * ```typescript
64
73
  * import { createApp } from "@jant/core";
65
74
  *
66
75
  * export default createApp({
67
- * site: { name: "My Blog" },
68
76
  * theme: { components: { PostCard: MyPostCard } },
69
77
  * });
70
78
  * ```
@@ -74,13 +82,20 @@ export function createApp(config: JantConfig = {}): App {
74
82
 
75
83
  // Initialize services, auth, and config middleware
76
84
  app.use("*", async (c, next) => {
77
- const db = createDatabase(c.env.DB);
78
- const services = createServices(db, c.env.DB);
85
+ // Use withSession() to enable D1 Read Replication
86
+ // Automatically routes read queries to the nearest replica for lower latency
87
+ // See: https://developers.cloudflare.com/d1/best-practices/read-replication/
88
+ const session = c.env.DB.withSession();
89
+
90
+ // Note: Drizzle ORM doesn't officially support D1DatabaseSession yet (issue #2226)
91
+ // but it works at runtime. We use type assertion as a temporary workaround.
92
+ const db = createDatabase(session as unknown as D1Database);
93
+ const services = createServices(db, session as unknown as D1Database);
79
94
  c.set("services", services);
80
95
  c.set("config", config);
81
96
 
82
97
  if (c.env.AUTH_SECRET) {
83
- const auth = createAuth(c.env.DB, {
98
+ const auth = createAuth(session as unknown as D1Database, {
84
99
  secret: c.env.AUTH_SECRET,
85
100
  baseURL: c.env.SITE_URL,
86
101
  });
@@ -90,6 +105,18 @@ export function createApp(config: JantConfig = {}): App {
90
105
  await next();
91
106
  });
92
107
 
108
+ // Theme middleware - resolve active color theme and build CSS
109
+ app.use("*", async (c, next) => {
110
+ const themeId = await c.var.services.settings.get(SETTINGS_KEYS.THEME);
111
+ const themes = getAvailableThemes(config);
112
+ const activeTheme = themeId
113
+ ? themes.find((t) => t.id === themeId)
114
+ : undefined;
115
+ const themeStyle = buildThemeStyle(activeTheme, config.theme?.cssVariables);
116
+ c.set("themeStyle", themeStyle);
117
+ await next();
118
+ });
119
+
93
120
  // i18n middleware
94
121
  app.use("*", i18nMiddleware());
95
122
 
@@ -155,36 +182,17 @@ export function createApp(config: JantConfig = {}): App {
155
182
  </h2>
156
183
  <p>
157
184
  {t({
158
- message: "Let's set up your site.",
185
+ message: "Create your admin account.",
159
186
  comment: "@context: Setup page description",
160
187
  })}
161
188
  </p>
162
189
  </header>
163
190
  <section>
164
- <div id="setup-message"></div>
165
191
  <form
166
- data-signals="{siteName: '', name: '', email: '', password: ''}"
192
+ data-signals="{name: '', email: '', password: ''}"
167
193
  data-on:submit__prevent="@post('/setup')"
168
194
  class="flex flex-col gap-4"
169
195
  >
170
- <div class="field">
171
- <label class="label">
172
- {t({
173
- message: "Site Name",
174
- comment: "@context: Setup form field - site name",
175
- })}
176
- </label>
177
- <input
178
- type="text"
179
- data-bind="siteName"
180
- class="input"
181
- required
182
- placeholder={t({
183
- message: "My Blog",
184
- comment: "@context: Setup site name placeholder",
185
- })}
186
- />
187
- </div>
188
196
  <div class="field">
189
197
  <label class="label">
190
198
  {t({
@@ -260,34 +268,27 @@ export function createApp(config: JantConfig = {}): App {
260
268
  if (isComplete) return c.redirect("/");
261
269
 
262
270
  const body = await c.req.json<{
263
- siteName: string;
264
271
  name: string;
265
272
  email: string;
266
273
  password: string;
267
274
  }>();
268
- const { siteName, name, email, password } = body;
275
+ const { name, email, password } = body;
269
276
 
270
- if (!siteName || !name || !email || !password) {
277
+ if (!name || !email || !password) {
271
278
  return sse(c, async (stream) => {
272
- await stream.patchElements(
273
- '<div id="setup-message"><div class="alert-destructive mb-4"><h2>All fields are required</h2></div></div>',
274
- );
279
+ await stream.toast("All fields are required", "error");
275
280
  });
276
281
  }
277
282
 
278
283
  if (password.length < 8) {
279
284
  return sse(c, async (stream) => {
280
- await stream.patchElements(
281
- '<div id="setup-message"><div class="alert-destructive mb-4"><h2>Password must be at least 8 characters</h2></div></div>',
282
- );
285
+ await stream.toast("Password must be at least 8 characters", "error");
283
286
  });
284
287
  }
285
288
 
286
289
  if (!c.var.auth) {
287
290
  return sse(c, async (stream) => {
288
- await stream.patchElements(
289
- '<div id="setup-message"><div class="alert-destructive mb-4"><h2>AUTH_SECRET not configured</h2></div></div>',
290
- );
291
+ await stream.toast("AUTH_SECRET not configured", "error");
291
292
  });
292
293
  }
293
294
 
@@ -298,28 +299,20 @@ export function createApp(config: JantConfig = {}): App {
298
299
 
299
300
  if (!signUpResponse || "error" in signUpResponse) {
300
301
  return sse(c, async (stream) => {
301
- await stream.patchElements(
302
- '<div id="setup-message"><div class="alert-destructive mb-4"><h2>Failed to create account</h2></div></div>',
303
- );
302
+ await stream.toast("Failed to create account", "error");
304
303
  });
305
304
  }
306
305
 
307
- await c.var.services.settings.setMany({
308
- SITE_NAME: siteName,
309
- SITE_LANGUAGE: "en",
310
- });
311
306
  await c.var.services.settings.completeOnboarding();
312
307
 
313
308
  return sse(c, async (stream) => {
314
- await stream.redirect("/signin");
309
+ await stream.redirect("/signin?setup");
315
310
  });
316
311
  } catch (err) {
317
312
  // eslint-disable-next-line no-console -- Error logging is intentional
318
313
  console.error("Setup error:", err);
319
314
  return sse(c, async (stream) => {
320
- await stream.patchElements(
321
- '<div id="setup-message"><div class="alert-destructive mb-4"><h2>Failed to create account</h2></div></div>',
322
- );
315
+ await stream.toast("Failed to create account", "error");
323
316
  });
324
317
  }
325
318
  });
@@ -347,7 +340,6 @@ export function createApp(config: JantConfig = {}): App {
347
340
  </h2>
348
341
  </header>
349
342
  <section>
350
- <div id="signin-message"></div>
351
343
  {demoEmail && demoPassword && (
352
344
  <p class="text-muted-foreground text-sm mb-4">
353
345
  {t({
@@ -400,8 +392,17 @@ export function createApp(config: JantConfig = {}): App {
400
392
 
401
393
  // Signin page
402
394
  app.get("/signin", async (c) => {
395
+ const isSetup = c.req.query("setup") !== undefined;
396
+ const isReset = c.req.query("reset") !== undefined;
397
+ let toast: { message: string } | undefined;
398
+ if (isSetup) {
399
+ toast = { message: "Account created successfully. Please sign in." };
400
+ } else if (isReset) {
401
+ toast = { message: "Password reset successfully. Please sign in." };
402
+ }
403
+
403
404
  return c.html(
404
- <BaseLayout title="Sign In - Jant" c={c}>
405
+ <BaseLayout title="Sign In - Jant" c={c} toast={toast}>
405
406
  <SigninContent
406
407
  demoEmail={c.env.DEMO_EMAIL}
407
408
  demoPassword={c.env.DEMO_PASSWORD}
@@ -413,9 +414,7 @@ export function createApp(config: JantConfig = {}): App {
413
414
  app.post("/signin", async (c) => {
414
415
  if (!c.var.auth) {
415
416
  return sse(c, async (stream) => {
416
- await stream.patchElements(
417
- '<div id="signin-message"><div class="alert-destructive mb-4"><h2>Auth not configured</h2></div></div>',
418
- );
417
+ await stream.toast("Auth not configured", "error");
419
418
  });
420
419
  }
421
420
 
@@ -436,9 +435,7 @@ export function createApp(config: JantConfig = {}): App {
436
435
 
437
436
  if (!response.ok) {
438
437
  return sse(c, async (stream) => {
439
- await stream.patchElements(
440
- '<div id="signin-message"><div class="alert-destructive mb-4"><h2>Invalid email or password</h2></div></div>',
441
- );
438
+ await stream.toast("Invalid email or password", "error");
442
439
  });
443
440
  }
444
441
 
@@ -460,9 +457,7 @@ export function createApp(config: JantConfig = {}): App {
460
457
  // eslint-disable-next-line no-console -- Error logging is intentional
461
458
  console.error("Signin error:", err);
462
459
  return sse(c, async (stream) => {
463
- await stream.patchElements(
464
- '<div id="signin-message"><div class="alert-destructive mb-4"><h2>Invalid email or password</h2></div></div>',
465
- );
460
+ await stream.toast("Invalid email or password", "error");
466
461
  });
467
462
  }
468
463
  });
@@ -478,6 +473,237 @@ export function createApp(config: JantConfig = {}): App {
478
473
  return c.redirect("/");
479
474
  });
480
475
 
476
+ // Password reset via one-time token
477
+ const ResetContent: FC<{ token: string }> = ({ token }) => {
478
+ const { t } = useLingui();
479
+ const signals = JSON.stringify({
480
+ password: "",
481
+ confirmPassword: "",
482
+ token,
483
+ }).replace(/</g, "\\u003c");
484
+
485
+ return (
486
+ <div class="min-h-screen flex items-center justify-center">
487
+ <div class="card max-w-md w-full">
488
+ <header>
489
+ <h2>
490
+ {t({
491
+ message: "Reset Password",
492
+ comment: "@context: Password reset page heading",
493
+ })}
494
+ </h2>
495
+ <p>
496
+ {t({
497
+ message: "Enter your new password.",
498
+ comment: "@context: Password reset page description",
499
+ })}
500
+ </p>
501
+ </header>
502
+ <section>
503
+ <form
504
+ data-signals={signals}
505
+ data-on:submit__prevent="@post('/reset')"
506
+ class="flex flex-col gap-4"
507
+ >
508
+ <div class="field">
509
+ <label class="label">
510
+ {t({
511
+ message: "New Password",
512
+ comment: "@context: Password reset form field",
513
+ })}
514
+ </label>
515
+ <input
516
+ type="password"
517
+ data-bind="password"
518
+ class="input"
519
+ required
520
+ minLength={8}
521
+ autocomplete="new-password"
522
+ />
523
+ </div>
524
+ <div class="field">
525
+ <label class="label">
526
+ {t({
527
+ message: "Confirm Password",
528
+ comment: "@context: Password reset form field",
529
+ })}
530
+ </label>
531
+ <input
532
+ type="password"
533
+ data-bind="confirmPassword"
534
+ class="input"
535
+ required
536
+ minLength={8}
537
+ autocomplete="new-password"
538
+ />
539
+ </div>
540
+ <button type="submit" class="btn">
541
+ {t({
542
+ message: "Reset Password",
543
+ comment: "@context: Password reset form submit button",
544
+ })}
545
+ </button>
546
+ </form>
547
+ </section>
548
+ </div>
549
+ </div>
550
+ );
551
+ };
552
+
553
+ const ResetErrorContent: FC = () => {
554
+ const { t } = useLingui();
555
+
556
+ return (
557
+ <div class="min-h-screen flex items-center justify-center">
558
+ <div class="card max-w-md w-full">
559
+ <header>
560
+ <h2>
561
+ {t({
562
+ message: "Invalid or Expired Link",
563
+ comment: "@context: Password reset error heading",
564
+ })}
565
+ </h2>
566
+ </header>
567
+ <section>
568
+ <p class="text-muted-foreground">
569
+ {t({
570
+ message:
571
+ "This password reset link is invalid or has expired. Please generate a new one.",
572
+ comment: "@context: Password reset error description",
573
+ })}
574
+ </p>
575
+ </section>
576
+ </div>
577
+ </div>
578
+ );
579
+ };
580
+
581
+ app.get("/reset", async (c) => {
582
+ const token = c.req.query("token");
583
+ if (!token) {
584
+ return c.html(
585
+ <BaseLayout title="Reset Password - Jant" c={c}>
586
+ <ResetErrorContent />
587
+ </BaseLayout>,
588
+ );
589
+ }
590
+
591
+ const stored = await c.var.services.settings.get(
592
+ SETTINGS_KEYS.PASSWORD_RESET_TOKEN,
593
+ );
594
+ if (!stored) {
595
+ return c.html(
596
+ <BaseLayout title="Reset Password - Jant" c={c}>
597
+ <ResetErrorContent />
598
+ </BaseLayout>,
599
+ );
600
+ }
601
+
602
+ const separatorIndex = stored.lastIndexOf(":");
603
+ const storedToken = stored.substring(0, separatorIndex);
604
+ const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
605
+ const now = Math.floor(Date.now() / 1000);
606
+
607
+ if (token !== storedToken || now > expiry) {
608
+ return c.html(
609
+ <BaseLayout title="Reset Password - Jant" c={c}>
610
+ <ResetErrorContent />
611
+ </BaseLayout>,
612
+ );
613
+ }
614
+
615
+ return c.html(
616
+ <BaseLayout title="Reset Password - Jant" c={c}>
617
+ <ResetContent token={token} />
618
+ </BaseLayout>,
619
+ );
620
+ });
621
+
622
+ app.post("/reset", async (c) => {
623
+ const body = await c.req.json<{
624
+ password: string;
625
+ confirmPassword: string;
626
+ token: string;
627
+ }>();
628
+ const { password, confirmPassword, token } = body;
629
+
630
+ // Validate token
631
+ const stored = await c.var.services.settings.get(
632
+ SETTINGS_KEYS.PASSWORD_RESET_TOKEN,
633
+ );
634
+ if (!stored) {
635
+ return sse(c, async (stream) => {
636
+ await stream.toast("Invalid or expired reset link.", "error");
637
+ });
638
+ }
639
+
640
+ const separatorIndex = stored.lastIndexOf(":");
641
+ const storedToken = stored.substring(0, separatorIndex);
642
+ const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
643
+ const now = Math.floor(Date.now() / 1000);
644
+
645
+ if (token !== storedToken || now > expiry) {
646
+ return sse(c, async (stream) => {
647
+ await stream.toast("Invalid or expired reset link.", "error");
648
+ });
649
+ }
650
+
651
+ // Validate passwords
652
+ if (!password || password.length < 8) {
653
+ return sse(c, async (stream) => {
654
+ await stream.toast("Password must be at least 8 characters.", "error");
655
+ });
656
+ }
657
+
658
+ if (password !== confirmPassword) {
659
+ return sse(c, async (stream) => {
660
+ await stream.toast("Passwords do not match.", "error");
661
+ });
662
+ }
663
+
664
+ try {
665
+ const hashedPassword = await hashPassword(password);
666
+ const db = c.env.DB.withSession() as unknown as D1Database;
667
+
668
+ // Get admin user
669
+ const userResult = await db
670
+ .prepare("SELECT id FROM user LIMIT 1")
671
+ .first<{ id: string }>();
672
+ if (!userResult) {
673
+ return sse(c, async (stream) => {
674
+ await stream.toast("No user account found.", "error");
675
+ });
676
+ }
677
+
678
+ // Update password
679
+ await db
680
+ .prepare(
681
+ "UPDATE account SET password = ? WHERE user_id = ? AND provider_id = 'credential'",
682
+ )
683
+ .bind(hashedPassword, userResult.id)
684
+ .run();
685
+
686
+ // Delete all sessions
687
+ await db
688
+ .prepare("DELETE FROM session WHERE user_id = ?")
689
+ .bind(userResult.id)
690
+ .run();
691
+
692
+ // Delete the reset token
693
+ await c.var.services.settings.remove(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
694
+
695
+ return sse(c, async (stream) => {
696
+ await stream.redirect("/signin?reset");
697
+ });
698
+ } catch (err) {
699
+ // eslint-disable-next-line no-console -- Error logging is intentional
700
+ console.error("Password reset error:", err);
701
+ return sse(c, async (stream) => {
702
+ await stream.toast("Failed to reset password.", "error");
703
+ });
704
+ }
705
+ });
706
+
481
707
  // Dashboard routes (protected)
482
708
  app.use("/dash/*", requireAuth());
483
709
  app.route("/dash", dashIndexRoutes);
@@ -487,6 +713,7 @@ export function createApp(config: JantConfig = {}): App {
487
713
  app.route("/dash/settings", dashSettingsRoutes);
488
714
  app.route("/dash/redirects", dashRedirectsRoutes);
489
715
  app.route("/dash/collections", dashCollectionsRoutes);
716
+ app.route("/dash/appearance", dashAppearanceRoutes);
490
717
 
491
718
  // API routes
492
719
  app.route("/api/upload", uploadApiRoutes);
@@ -17,16 +17,16 @@ CREATE TABLE `account` (
17
17
  --> statement-breakpoint
18
18
  CREATE TABLE `collections` (
19
19
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
20
- `slug` text NOT NULL,
20
+ `path` text,
21
21
  `title` text NOT NULL,
22
22
  `description` text,
23
23
  `created_at` integer NOT NULL,
24
24
  `updated_at` integer NOT NULL
25
25
  );
26
26
  --> statement-breakpoint
27
- CREATE UNIQUE INDEX `collections_slug_unique` ON `collections` (`slug`);--> statement-breakpoint
27
+ CREATE UNIQUE INDEX `collections_path_unique` ON `collections` (`path`);--> statement-breakpoint
28
28
  CREATE TABLE `media` (
29
- `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
29
+ `id` text PRIMARY KEY NOT NULL,
30
30
  `post_id` integer,
31
31
  `filename` text NOT NULL,
32
32
  `original_name` text NOT NULL,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": "6",
3
3
  "dialect": "sqlite",
4
- "id": "d32a99d8-a262-45b7-99e4-bc81bbb6f305",
4
+ "id": "3d04f8ef-088d-43f4-9fe9-55ffa2d7a73f",
5
5
  "prevId": "00000000-0000-0000-0000-000000000000",
6
6
  "tables": {
7
7
  "account": {
@@ -125,11 +125,11 @@
125
125
  "notNull": true,
126
126
  "autoincrement": true
127
127
  },
128
- "slug": {
129
- "name": "slug",
128
+ "path": {
129
+ "name": "path",
130
130
  "type": "text",
131
131
  "primaryKey": false,
132
- "notNull": true,
132
+ "notNull": false,
133
133
  "autoincrement": false
134
134
  },
135
135
  "title": {
@@ -162,9 +162,9 @@
162
162
  }
163
163
  },
164
164
  "indexes": {
165
- "collections_slug_unique": {
166
- "name": "collections_slug_unique",
167
- "columns": ["slug"],
165
+ "collections_path_unique": {
166
+ "name": "collections_path_unique",
167
+ "columns": ["path"],
168
168
  "isUnique": true
169
169
  }
170
170
  },
@@ -178,10 +178,10 @@
178
178
  "columns": {
179
179
  "id": {
180
180
  "name": "id",
181
- "type": "integer",
181
+ "type": "text",
182
182
  "primaryKey": true,
183
183
  "notNull": true,
184
- "autoincrement": true
184
+ "autoincrement": false
185
185
  },
186
186
  "post_id": {
187
187
  "name": "post_id",
@@ -5,36 +5,8 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "6",
8
- "when": 1769858252020,
9
- "tag": "0000_solid_moon_knight",
10
- "breakpoints": true
11
- },
12
- {
13
- "idx": 1,
14
- "version": "6",
15
- "when": 1769859000000,
16
- "tag": "0001_add_search_fts",
17
- "breakpoints": true
18
- },
19
- {
20
- "idx": 2,
21
- "version": "6",
22
- "when": 1769860000000,
23
- "tag": "0002_collection_path",
24
- "breakpoints": true
25
- },
26
- {
27
- "idx": 3,
28
- "version": "6",
29
- "when": 1769861000000,
30
- "tag": "0003_collection_path_nullable",
31
- "breakpoints": true
32
- },
33
- {
34
- "idx": 4,
35
- "version": "6",
36
- "when": 1770024000000,
37
- "tag": "0004_media_uuid",
8
+ "when": 1770564499811,
9
+ "tag": "0000_square_wallflower",
38
10
  "breakpoints": true
39
11
  }
40
12
  ]
@@ -28,7 +28,7 @@ let currentI18n: I18n | null = null;
28
28
  *
29
29
  * @example
30
30
  * ```tsx
31
- * import { I18nProvider } from "@/i18n";
31
+ * import { I18nProvider } from "../i18n/index.js";
32
32
  *
33
33
  * return c.html(
34
34
  * <I18nProvider c={c}>
@@ -56,7 +56,7 @@ export const I18nProvider: FC<I18nProviderProps> = ({ c, children }) => {
56
56
  * @example
57
57
  * ```tsx
58
58
  * import { t } from "@lingui/core/macro";
59
- * import { useLingui } from "@/i18n";
59
+ * import { useLingui } from "../i18n/index.js";
60
60
  *
61
61
  * function MyComponent() {
62
62
  * const { t: _ } = useLingui(); // Use _ to avoid conflict with macro
package/src/i18n/i18n.ts CHANGED
@@ -48,7 +48,7 @@ export function createI18n(locale: Locale): I18n {
48
48
  *
49
49
  * @example
50
50
  * import { msg } from "@lingui/core/macro";
51
- * import { getI18n } from "@/i18n";
51
+ * import { getI18n } from "../i18n/index.js";
52
52
  *
53
53
  * const i18n = getI18n(c);
54
54
  * const title = i18n._(msg({ message: "Dashboard", comment: "@context: Page title" }));
package/src/i18n/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Usage:
11
11
  * ```tsx
12
- * import { useLingui, Trans, I18nProvider } from "@/i18n";
12
+ * import { useLingui, Trans, I18nProvider } from "../i18n/index.js";
13
13
  *
14
14
  * // Wrap your app in I18nProvider (automatically done by BaseLayout when c is provided)
15
15
  * c.html(