@jant/core 0.3.25 → 0.3.27

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 (133) hide show
  1. package/dist/app.js +70 -563
  2. package/dist/auth.js +3 -0
  3. package/dist/client.js +1 -0
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/lib/avatar-upload.js +134 -0
  8. package/dist/lib/config.js +39 -0
  9. package/dist/lib/constants.js +10 -10
  10. package/dist/lib/favicon.js +102 -0
  11. package/dist/lib/image.js +13 -17
  12. package/dist/lib/media-helpers.js +2 -2
  13. package/dist/lib/navigation.js +23 -3
  14. package/dist/lib/render.js +10 -1
  15. package/dist/lib/schemas.js +31 -0
  16. package/dist/lib/timezones.js +388 -0
  17. package/dist/lib/view.js +1 -1
  18. package/dist/routes/api/posts.js +1 -1
  19. package/dist/routes/api/upload.js +3 -3
  20. package/dist/routes/auth/reset.js +221 -0
  21. package/dist/routes/auth/setup.js +194 -0
  22. package/dist/routes/auth/signin.js +176 -0
  23. package/dist/routes/dash/collections.js +23 -415
  24. package/dist/routes/dash/media.js +12 -392
  25. package/dist/routes/dash/pages.js +7 -330
  26. package/dist/routes/dash/redirects.js +18 -12
  27. package/dist/routes/dash/settings.js +198 -577
  28. package/dist/routes/feed/rss.js +2 -1
  29. package/dist/routes/feed/sitemap.js +4 -2
  30. package/dist/routes/pages/featured.js +5 -1
  31. package/dist/routes/pages/home.js +26 -1
  32. package/dist/routes/pages/latest.js +45 -0
  33. package/dist/services/post.js +30 -50
  34. package/dist/types/bindings.js +3 -0
  35. package/dist/types/config.js +147 -0
  36. package/dist/types/constants.js +27 -0
  37. package/dist/types/entities.js +3 -0
  38. package/dist/types/operations.js +3 -0
  39. package/dist/types/props.js +3 -0
  40. package/dist/types/views.js +5 -0
  41. package/dist/types.js +8 -111
  42. package/dist/ui/color-themes.js +33 -33
  43. package/dist/ui/compose/ComposeDialog.js +36 -21
  44. package/dist/ui/dash/PageForm.js +21 -15
  45. package/dist/ui/dash/PostForm.js +22 -16
  46. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  47. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  48. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  49. package/dist/ui/dash/media/MediaListContent.js +166 -0
  50. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  51. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  52. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  53. package/dist/ui/dash/settings/AccountContent.js +209 -0
  54. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  55. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  56. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  57. package/dist/ui/font-themes.js +36 -0
  58. package/dist/ui/layouts/BaseLayout.js +24 -2
  59. package/dist/ui/layouts/SiteLayout.js +47 -19
  60. package/package.json +1 -1
  61. package/src/app.tsx +95 -553
  62. package/src/auth.ts +4 -1
  63. package/src/client.ts +1 -0
  64. package/src/i18n/locales/en.po +240 -175
  65. package/src/i18n/locales/en.ts +1 -1
  66. package/src/i18n/locales/zh-Hans.po +240 -175
  67. package/src/i18n/locales/zh-Hans.ts +1 -1
  68. package/src/i18n/locales/zh-Hant.po +240 -175
  69. package/src/i18n/locales/zh-Hant.ts +1 -1
  70. package/src/lib/__tests__/config.test.ts +192 -0
  71. package/src/lib/__tests__/favicon.test.ts +151 -0
  72. package/src/lib/__tests__/image.test.ts +2 -6
  73. package/src/lib/__tests__/timezones.test.ts +61 -0
  74. package/src/lib/__tests__/view.test.ts +2 -2
  75. package/src/lib/avatar-upload.ts +165 -0
  76. package/src/lib/config.ts +47 -0
  77. package/src/lib/constants.ts +19 -11
  78. package/src/lib/favicon.ts +115 -0
  79. package/src/lib/image.ts +13 -21
  80. package/src/lib/media-helpers.ts +2 -2
  81. package/src/lib/navigation.ts +33 -2
  82. package/src/lib/render.tsx +15 -1
  83. package/src/lib/schemas.ts +39 -0
  84. package/src/lib/timezones.ts +325 -0
  85. package/src/lib/view.ts +1 -1
  86. package/src/routes/api/posts.ts +1 -1
  87. package/src/routes/api/upload.ts +2 -3
  88. package/src/routes/auth/reset.tsx +239 -0
  89. package/src/routes/auth/setup.tsx +189 -0
  90. package/src/routes/auth/signin.tsx +163 -0
  91. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  92. package/src/routes/dash/collections.tsx +17 -366
  93. package/src/routes/dash/media.tsx +12 -414
  94. package/src/routes/dash/pages.tsx +8 -348
  95. package/src/routes/dash/redirects.tsx +20 -14
  96. package/src/routes/dash/settings.tsx +243 -534
  97. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  98. package/src/routes/feed/rss.ts +3 -1
  99. package/src/routes/feed/sitemap.ts +4 -2
  100. package/src/routes/pages/featured.tsx +7 -1
  101. package/src/routes/pages/home.tsx +25 -2
  102. package/src/routes/pages/latest.tsx +59 -0
  103. package/src/services/post.ts +34 -66
  104. package/src/styles/components.css +0 -65
  105. package/src/styles/tokens.css +1 -1
  106. package/src/styles/ui.css +24 -40
  107. package/src/types/bindings.ts +30 -0
  108. package/src/types/config.ts +183 -0
  109. package/src/types/constants.ts +26 -0
  110. package/src/types/entities.ts +109 -0
  111. package/src/types/operations.ts +88 -0
  112. package/src/types/props.ts +115 -0
  113. package/src/types/views.ts +172 -0
  114. package/src/types.ts +8 -644
  115. package/src/ui/__tests__/font-themes.test.ts +34 -0
  116. package/src/ui/color-themes.ts +34 -34
  117. package/src/ui/compose/ComposeDialog.tsx +40 -21
  118. package/src/ui/dash/PageForm.tsx +25 -19
  119. package/src/ui/dash/PostForm.tsx +26 -20
  120. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  121. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  122. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  123. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  124. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  125. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  126. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  127. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  128. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  129. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  130. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  131. package/src/ui/font-themes.ts +54 -0
  132. package/src/ui/layouts/BaseLayout.tsx +17 -0
  133. package/src/ui/layouts/SiteLayout.tsx +45 -31
package/src/app.tsx CHANGED
@@ -3,15 +3,17 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import type { FC } from "hono/jsx";
7
6
  import { createDatabase } from "./db/index.js";
8
7
  import { createServices, type Services } from "./services/index.js";
9
8
  import { createAuth, type Auth } from "./auth.js";
10
9
  import { i18nMiddleware } from "./i18n/index.js";
11
- import { useLingui } from "@lingui/react/macro";
12
10
  import type { Bindings, JantConfig } from "./types.js";
13
11
  import { SETTINGS_KEYS } from "./lib/constants.js";
14
- import { hashPassword } from "better-auth/crypto";
12
+
13
+ // Routes - Auth
14
+ import { setupRoutes } from "./routes/auth/setup.js";
15
+ import { signinRoutes } from "./routes/auth/signin.js";
16
+ import { resetRoutes } from "./routes/auth/reset.js";
15
17
 
16
18
  // Routes - Pages
17
19
  import { homeRoutes } from "./routes/pages/home.js";
@@ -21,6 +23,7 @@ import { collectionRoutes } from "./routes/pages/collection.js";
21
23
  import { archiveRoutes } from "./routes/pages/archive.js";
22
24
  import { searchRoutes } from "./routes/pages/search.js";
23
25
  import { featuredRoutes } from "./routes/pages/featured.js";
26
+ import { latestRoutes } from "./routes/pages/latest.js";
24
27
  import { collectionsPageRoutes } from "./routes/pages/collections.js";
25
28
 
26
29
  // Routes - Dashboard
@@ -51,11 +54,11 @@ import { sitemapRoutes } from "./routes/feed/sitemap.js";
51
54
  import { requireAuth } from "./middleware/auth.js";
52
55
  import { requireOnboarding } from "./middleware/onboarding.js";
53
56
 
54
- // Layouts for auth pages
55
- import { BaseLayout } from "./ui/layouts/BaseLayout.js";
56
- import { dsRedirect, dsToast } from "./lib/sse.js";
57
57
  import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
58
58
  import { createStorageDriver, type StorageDriver } from "./lib/storage.js";
59
+ import { BUILTIN_FONT_THEMES } from "./ui/font-themes.js";
60
+ import { getMediaUrl, getPublicUrlForProvider } from "./lib/image.js";
61
+ import { base64ToUint8Array } from "./lib/favicon.js";
59
62
 
60
63
  // Extend Hono's context variables
61
64
  export interface AppVariables {
@@ -66,6 +69,8 @@ export interface AppVariables {
66
69
  customCSS: string;
67
70
  isAuthenticated: boolean;
68
71
  storage: StorageDriver | null;
72
+ faviconUrl?: string;
73
+ noindex?: boolean;
69
74
  }
70
75
 
71
76
  export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
@@ -97,8 +102,6 @@ export function createApp(config: JantConfig = {}): App {
97
102
  // Initialize services, auth, and config middleware
98
103
  app.use("*", async (c, next) => {
99
104
  // Use withSession() to enable D1 Read Replication
100
- // Automatically routes read queries to the nearest replica for lower latency
101
- // See: https://developers.cloudflare.com/d1/best-practices/read-replication/
102
105
  const session = c.env.DB.withSession();
103
106
 
104
107
  // Note: Drizzle ORM doesn't officially support D1DatabaseSession yet (issue #2226)
@@ -109,11 +112,20 @@ export function createApp(config: JantConfig = {}): App {
109
112
  c.set("config", resolvedConfig);
110
113
  c.set("storage", createStorageDriver(c.env));
111
114
 
115
+ if (!c.env.AUTH_SECRET) {
116
+ // eslint-disable-next-line no-console -- Startup warning is intentional
117
+ console.warn(
118
+ "[Jant] AUTH_SECRET is not set. Authentication is disabled. Set AUTH_SECRET in .dev.vars or wrangler.toml to enable auth.",
119
+ );
120
+ }
121
+
112
122
  if (c.env.AUTH_SECRET) {
113
123
  const baseURL = c.env.SITE_URL || new URL(c.req.url).origin;
124
+ const requestUrl = new URL(c.req.url);
114
125
  const auth = createAuth(session as unknown as D1Database, {
115
126
  secret: c.env.AUTH_SECRET,
116
127
  baseURL,
128
+ useSecureCookies: requestUrl.protocol === "https:",
117
129
  });
118
130
  c.set("auth", auth);
119
131
  }
@@ -124,23 +136,50 @@ export function createApp(config: JantConfig = {}): App {
124
136
  // Onboarding gate — redirect to /setup if not yet initialized
125
137
  app.use("*", requireOnboarding());
126
138
 
127
- // Theme middleware - resolve active color theme, custom CSS, and auth state
139
+ // Theme middleware - resolve active color theme, font theme, custom CSS, and auth state
128
140
  app.use("*", async (c, next) => {
129
- const [themeId, customCSS] = await Promise.all([
130
- c.var.services.settings.get(SETTINGS_KEYS.THEME),
131
- c.var.services.settings.get(SETTINGS_KEYS.CUSTOM_CSS),
132
- ]);
141
+ const [themeId, fontThemeId, customCSS, noindexValue, avatarKey] =
142
+ await Promise.all([
143
+ c.var.services.settings.get(SETTINGS_KEYS.THEME),
144
+ c.var.services.settings.get("FONT_THEME"),
145
+ c.var.services.settings.get(SETTINGS_KEYS.CUSTOM_CSS),
146
+ c.var.services.settings.get("NOINDEX"),
147
+ c.var.services.settings.get("SITE_AVATAR"),
148
+ ]);
133
149
  const themes = getAvailableThemes(resolvedConfig);
134
150
  const activeTheme = themeId
135
151
  ? themes.find((t) => t.id === themeId)
136
152
  : undefined;
137
- const themeStyle = buildThemeStyle(
138
- activeTheme,
139
- resolvedConfig.cssVariables,
140
- );
153
+
154
+ // Build font override CSS variables
155
+ const fontTheme = fontThemeId
156
+ ? BUILTIN_FONT_THEMES.find((f) => f.id === fontThemeId)
157
+ : undefined;
158
+ const fontOverrides: Record<string, string> = {};
159
+ if (fontTheme) {
160
+ fontOverrides["--font-body"] = fontTheme.fontFamily;
161
+ }
162
+
163
+ const themeStyle = buildThemeStyle(activeTheme, {
164
+ ...resolvedConfig.cssVariables,
165
+ ...fontOverrides,
166
+ });
141
167
  c.set("themeStyle", themeStyle);
142
168
  c.set("customCSS", customCSS ?? "");
143
169
 
170
+ // Noindex
171
+ c.set("noindex", noindexValue === "true");
172
+
173
+ // Resolve favicon from avatar storage key
174
+ if (avatarKey) {
175
+ const publicUrl = getPublicUrlForProvider(
176
+ c.env.STORAGE_DRIVER || "r2",
177
+ c.env.R2_PUBLIC_URL,
178
+ c.env.S3_PUBLIC_URL,
179
+ );
180
+ c.set("faviconUrl", getMediaUrl(avatarKey, publicUrl));
181
+ }
182
+
144
183
  // Check auth state for data-authenticated attribute on <body>
145
184
  let isAuthenticated = false;
146
185
  if (c.var.auth) {
@@ -196,6 +235,31 @@ export function createApp(config: JantConfig = {}): App {
196
235
  }),
197
236
  );
198
237
 
238
+ // Favicon routes - serve from DB settings (small files, avoids R2 round-trip)
239
+ app.get("/favicon.ico", async (c) => {
240
+ const data = await c.var.services.settings.get("SITE_FAVICON_ICO");
241
+ if (!data) return c.notFound();
242
+
243
+ return new Response(base64ToUint8Array(data), {
244
+ headers: {
245
+ "Content-Type": "image/x-icon",
246
+ "Cache-Control": "public, max-age=86400",
247
+ },
248
+ });
249
+ });
250
+
251
+ app.get("/apple-touch-icon.png", async (c) => {
252
+ const data = await c.var.services.settings.get("SITE_FAVICON_APPLE_TOUCH");
253
+ if (!data) return c.notFound();
254
+
255
+ return new Response(base64ToUint8Array(data), {
256
+ headers: {
257
+ "Content-Type": "image/png",
258
+ "Cache-Control": "public, max-age=86400",
259
+ },
260
+ });
261
+ });
262
+
199
263
  // better-auth handler
200
264
  app.all("/api/auth/*", async (c) => {
201
265
  if (!c.var.auth) {
@@ -211,529 +275,10 @@ export function createApp(config: JantConfig = {}): App {
211
275
  app.route("/api/collections", collectionsApiRoutes);
212
276
  app.route("/api/settings", settingsApiRoutes);
213
277
 
214
- // Setup page component
215
- const SetupContent: FC = () => {
216
- const { t } = useLingui();
217
-
218
- return (
219
- <div class="min-h-screen flex items-center justify-center">
220
- <div class="card max-w-md w-full">
221
- <header>
222
- <h2>
223
- {t({
224
- message: "Welcome to Jant",
225
- comment: "@context: Setup page welcome heading",
226
- })}
227
- </h2>
228
- <p>
229
- {t({
230
- message: "Create your admin account.",
231
- comment: "@context: Setup page description",
232
- })}
233
- </p>
234
- </header>
235
- <section>
236
- <form
237
- data-signals="{name: '', email: '', password: ''}"
238
- data-on:submit__prevent="@post('/setup')"
239
- data-indicator="_loading"
240
- class="flex flex-col gap-4"
241
- >
242
- <div class="field">
243
- <label class="label">
244
- {t({
245
- message: "Your Name",
246
- comment: "@context: Setup form field - user name",
247
- })}
248
- </label>
249
- <input
250
- type="text"
251
- data-bind="name"
252
- class="input"
253
- required
254
- placeholder="John Doe"
255
- />
256
- </div>
257
- <div class="field">
258
- <label class="label">
259
- {t({
260
- message: "Email",
261
- comment: "@context: Setup/signin form field - email",
262
- })}
263
- </label>
264
- <input
265
- type="email"
266
- data-bind="email"
267
- class="input"
268
- required
269
- placeholder="you@example.com"
270
- />
271
- </div>
272
- <div class="field">
273
- <label class="label">
274
- {t({
275
- message: "Password",
276
- comment: "@context: Setup/signin form field - password",
277
- })}
278
- </label>
279
- <input
280
- type="password"
281
- data-bind="password"
282
- class="input"
283
- required
284
- minLength={8}
285
- />
286
- </div>
287
- <button type="submit" class="btn" data-attr-disabled="$_loading">
288
- <span data-show="!$_loading">
289
- {t({
290
- message: "Complete Setup",
291
- comment: "@context: Setup form submit button",
292
- })}
293
- </span>
294
- <span data-show="$_loading">
295
- {t({
296
- message: "Processing...",
297
- comment:
298
- "@context: Loading text shown on submit button while request is in progress",
299
- })}
300
- </span>
301
- </button>
302
- </form>
303
- </section>
304
- </div>
305
- </div>
306
- );
307
- };
308
-
309
- // Setup page
310
- app.get("/setup", async (c) => {
311
- const isComplete = await c.var.services.settings.isOnboardingComplete();
312
- if (isComplete) return c.redirect("/");
313
-
314
- return c.html(
315
- <BaseLayout title="Setup - Jant" c={c}>
316
- <SetupContent />
317
- </BaseLayout>,
318
- );
319
- });
320
-
321
- app.post("/setup", async (c) => {
322
- const isComplete = await c.var.services.settings.isOnboardingComplete();
323
- if (isComplete) return c.redirect("/");
324
-
325
- const body = await c.req.json<{
326
- name: string;
327
- email: string;
328
- password: string;
329
- }>();
330
- const { name, email, password } = body;
331
-
332
- if (!name || !email || !password) {
333
- return dsToast("All fields are required", "error");
334
- }
335
-
336
- if (password.length < 8) {
337
- return dsToast("Password must be at least 8 characters", "error");
338
- }
339
-
340
- if (!c.var.auth) {
341
- return dsToast("AUTH_SECRET not configured", "error");
342
- }
343
-
344
- try {
345
- const signUpResponse = await c.var.auth.api.signUpEmail({
346
- body: { name, email, password },
347
- });
348
-
349
- if (!signUpResponse || "error" in signUpResponse) {
350
- return dsToast("Failed to create account", "error");
351
- }
352
-
353
- await c.var.services.settings.completeOnboarding();
354
-
355
- // Seed default navigation items
356
- await c.var.services.navItems.create({
357
- type: "link",
358
- label: "Featured",
359
- url: "/featured",
360
- });
361
- await c.var.services.navItems.create({
362
- type: "link",
363
- label: "Collections",
364
- url: "/collections",
365
- });
366
-
367
- return dsRedirect("/signin?setup");
368
- } catch (err) {
369
- // eslint-disable-next-line no-console -- Error logging is intentional
370
- console.error("Setup error:", err);
371
- return dsToast("Failed to create account", "error");
372
- }
373
- });
374
-
375
- // Signin page component
376
- const SigninContent: FC<{
377
- demoEmail?: string;
378
- demoPassword?: string;
379
- }> = ({ demoEmail, demoPassword }) => {
380
- const { t } = useLingui();
381
- const signals = JSON.stringify({
382
- email: demoEmail || "",
383
- password: demoPassword || "",
384
- }).replace(/</g, "\\u003c");
385
-
386
- return (
387
- <div class="min-h-screen flex items-center justify-center">
388
- <div class="card max-w-md w-full">
389
- <header>
390
- <h2>
391
- {t({
392
- message: "Sign In",
393
- comment: "@context: Sign in page heading",
394
- })}
395
- </h2>
396
- </header>
397
- <section>
398
- {demoEmail && demoPassword && (
399
- <p class="text-muted-foreground text-sm mb-4">
400
- {t({
401
- message: "Demo account pre-filled. Just click Sign In.",
402
- comment:
403
- "@context: Hint shown on signin page when demo credentials are pre-filled",
404
- })}
405
- </p>
406
- )}
407
- <form
408
- data-signals={signals}
409
- data-on:submit__prevent="@post('/signin')"
410
- data-indicator="_loading"
411
- class="flex flex-col gap-4"
412
- >
413
- <div class="field">
414
- <label class="label">
415
- {t({
416
- message: "Email",
417
- comment: "@context: Setup/signin form field - email",
418
- })}
419
- </label>
420
- <input type="email" data-bind="email" class="input" required />
421
- </div>
422
- <div class="field">
423
- <label class="label">
424
- {t({
425
- message: "Password",
426
- comment: "@context: Setup/signin form field - password",
427
- })}
428
- </label>
429
- <input
430
- type="password"
431
- data-bind="password"
432
- class="input"
433
- required
434
- />
435
- </div>
436
- <button type="submit" class="btn" data-attr-disabled="$_loading">
437
- <span data-show="!$_loading">
438
- {t({
439
- message: "Sign In",
440
- comment: "@context: Sign in form submit button",
441
- })}
442
- </span>
443
- <span data-show="$_loading">
444
- {t({
445
- message: "Processing...",
446
- comment:
447
- "@context: Loading text shown on submit button while request is in progress",
448
- })}
449
- </span>
450
- </button>
451
- </form>
452
- </section>
453
- </div>
454
- </div>
455
- );
456
- };
457
-
458
- // Signin page
459
- app.get("/signin", async (c) => {
460
- const isSetup = c.req.query("setup") !== undefined;
461
- const isReset = c.req.query("reset") !== undefined;
462
- let toast: { message: string } | undefined;
463
- if (isSetup) {
464
- toast = { message: "Account created successfully. Please sign in." };
465
- } else if (isReset) {
466
- toast = { message: "Password reset successfully. Please sign in." };
467
- }
468
-
469
- return c.html(
470
- <BaseLayout title="Sign In - Jant" c={c} toast={toast}>
471
- <SigninContent
472
- demoEmail={c.env.DEMO_EMAIL}
473
- demoPassword={c.env.DEMO_PASSWORD}
474
- />
475
- </BaseLayout>,
476
- );
477
- });
478
-
479
- app.post("/signin", async (c) => {
480
- if (!c.var.auth) {
481
- return dsToast("Auth not configured", "error");
482
- }
483
-
484
- const body = await c.req.json<{ email: string; password: string }>();
485
- const { email, password } = body;
486
-
487
- try {
488
- const { headers } = await c.var.auth.api.signInEmail({
489
- returnHeaders: true,
490
- body: { email, password },
491
- headers: c.req.raw.headers,
492
- });
493
-
494
- return dsRedirect("/dash", { headers });
495
- } catch {
496
- return dsToast("Invalid email or password", "error");
497
- }
498
- });
499
-
500
- app.get("/signout", async (c) => {
501
- if (c.var.auth) {
502
- try {
503
- await c.var.auth.api.signOut({ headers: c.req.raw.headers });
504
- } catch {
505
- // Ignore signout errors
506
- }
507
- }
508
- return c.redirect("/");
509
- });
510
-
511
- // Password reset via one-time token
512
- const ResetContent: FC<{ token: string }> = ({ token }) => {
513
- const { t } = useLingui();
514
- const signals = JSON.stringify({
515
- password: "",
516
- confirmPassword: "",
517
- token,
518
- }).replace(/</g, "\\u003c");
519
-
520
- return (
521
- <div class="min-h-screen flex items-center justify-center">
522
- <div class="card max-w-md w-full">
523
- <header>
524
- <h2>
525
- {t({
526
- message: "Reset Password",
527
- comment: "@context: Password reset page heading",
528
- })}
529
- </h2>
530
- <p>
531
- {t({
532
- message: "Enter your new password.",
533
- comment: "@context: Password reset page description",
534
- })}
535
- </p>
536
- </header>
537
- <section>
538
- <form
539
- data-signals={signals}
540
- data-on:submit__prevent="@post('/reset')"
541
- data-indicator="_loading"
542
- class="flex flex-col gap-4"
543
- >
544
- <div class="field">
545
- <label class="label">
546
- {t({
547
- message: "New Password",
548
- comment: "@context: Password reset form field",
549
- })}
550
- </label>
551
- <input
552
- type="password"
553
- data-bind="password"
554
- class="input"
555
- required
556
- minLength={8}
557
- autocomplete="new-password"
558
- />
559
- </div>
560
- <div class="field">
561
- <label class="label">
562
- {t({
563
- message: "Confirm Password",
564
- comment: "@context: Password reset form field",
565
- })}
566
- </label>
567
- <input
568
- type="password"
569
- data-bind="confirmPassword"
570
- class="input"
571
- required
572
- minLength={8}
573
- autocomplete="new-password"
574
- />
575
- </div>
576
- <button type="submit" class="btn" data-attr-disabled="$_loading">
577
- <span data-show="!$_loading">
578
- {t({
579
- message: "Reset Password",
580
- comment: "@context: Password reset form submit button",
581
- })}
582
- </span>
583
- <span data-show="$_loading">
584
- {t({
585
- message: "Processing...",
586
- comment:
587
- "@context: Loading text shown on submit button while request is in progress",
588
- })}
589
- </span>
590
- </button>
591
- </form>
592
- </section>
593
- </div>
594
- </div>
595
- );
596
- };
597
-
598
- const ResetErrorContent: FC = () => {
599
- const { t } = useLingui();
600
-
601
- return (
602
- <div class="min-h-screen flex items-center justify-center">
603
- <div class="card max-w-md w-full">
604
- <header>
605
- <h2>
606
- {t({
607
- message: "Invalid or Expired Link",
608
- comment: "@context: Password reset error heading",
609
- })}
610
- </h2>
611
- </header>
612
- <section>
613
- <p class="text-muted-foreground">
614
- {t({
615
- message:
616
- "This password reset link is invalid or has expired. Please generate a new one.",
617
- comment: "@context: Password reset error description",
618
- })}
619
- </p>
620
- </section>
621
- </div>
622
- </div>
623
- );
624
- };
625
-
626
- app.get("/reset", async (c) => {
627
- const token = c.req.query("token");
628
- if (!token) {
629
- return c.html(
630
- <BaseLayout title="Reset Password - Jant" c={c}>
631
- <ResetErrorContent />
632
- </BaseLayout>,
633
- );
634
- }
635
-
636
- const stored = await c.var.services.settings.get(
637
- SETTINGS_KEYS.PASSWORD_RESET_TOKEN,
638
- );
639
- if (!stored) {
640
- return c.html(
641
- <BaseLayout title="Reset Password - Jant" c={c}>
642
- <ResetErrorContent />
643
- </BaseLayout>,
644
- );
645
- }
646
-
647
- const separatorIndex = stored.lastIndexOf(":");
648
- const storedToken = stored.substring(0, separatorIndex);
649
- const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
650
- const now = Math.floor(Date.now() / 1000);
651
-
652
- if (token !== storedToken || now > expiry) {
653
- return c.html(
654
- <BaseLayout title="Reset Password - Jant" c={c}>
655
- <ResetErrorContent />
656
- </BaseLayout>,
657
- );
658
- }
659
-
660
- return c.html(
661
- <BaseLayout title="Reset Password - Jant" c={c}>
662
- <ResetContent token={token} />
663
- </BaseLayout>,
664
- );
665
- });
666
-
667
- app.post("/reset", async (c) => {
668
- const body = await c.req.json<{
669
- password: string;
670
- confirmPassword: string;
671
- token: string;
672
- }>();
673
- const { password, confirmPassword, token } = body;
674
-
675
- // Validate token
676
- const stored = await c.var.services.settings.get(
677
- SETTINGS_KEYS.PASSWORD_RESET_TOKEN,
678
- );
679
- if (!stored) {
680
- return dsToast("Invalid or expired reset link.", "error");
681
- }
682
-
683
- const separatorIndex = stored.lastIndexOf(":");
684
- const storedToken = stored.substring(0, separatorIndex);
685
- const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
686
- const now = Math.floor(Date.now() / 1000);
687
-
688
- if (token !== storedToken || now > expiry) {
689
- return dsToast("Invalid or expired reset link.", "error");
690
- }
691
-
692
- // Validate passwords
693
- if (!password || password.length < 8) {
694
- return dsToast("Password must be at least 8 characters.", "error");
695
- }
696
-
697
- if (password !== confirmPassword) {
698
- return dsToast("Passwords do not match.", "error");
699
- }
700
-
701
- try {
702
- const hashedPassword = await hashPassword(password);
703
- const db = c.env.DB.withSession() as unknown as D1Database;
704
-
705
- // Get admin user
706
- const userResult = await db
707
- .prepare("SELECT id FROM user LIMIT 1")
708
- .first<{ id: string }>();
709
- if (!userResult) {
710
- return dsToast("No user account found.", "error");
711
- }
712
-
713
- // Update password
714
- await db
715
- .prepare(
716
- "UPDATE account SET password = ? WHERE user_id = ? AND provider_id = 'credential'",
717
- )
718
- .bind(hashedPassword, userResult.id)
719
- .run();
720
-
721
- // Delete all sessions
722
- await db
723
- .prepare("DELETE FROM session WHERE user_id = ?")
724
- .bind(userResult.id)
725
- .run();
726
-
727
- // Delete the reset token
728
- await c.var.services.settings.remove(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
729
-
730
- return dsRedirect("/signin?reset");
731
- } catch (err) {
732
- // eslint-disable-next-line no-console -- Error logging is intentional
733
- console.error("Password reset error:", err);
734
- return dsToast("Failed to reset password.", "error");
735
- }
736
- });
278
+ // Auth routes
279
+ app.route("/", setupRoutes);
280
+ app.route("/", signinRoutes);
281
+ app.route("/", resetRoutes);
737
282
 
738
283
  // Dashboard routes (protected)
739
284
  app.use("/dash/*", requireAuth());
@@ -748,29 +293,25 @@ export function createApp(config: JantConfig = {}): App {
748
293
  app.route("/api/upload", uploadApiRoutes);
749
294
  app.route("/api/search", searchApiRoutes);
750
295
 
751
- // Media files from storage (UUIDv7-based URLs with extension)
752
- app.get("/media/:idWithExt", async (c) => {
296
+ // Media files from storage (path matches storage key: media/YYYY/MM/uuid.ext)
297
+ app.get("/media/*", async (c) => {
753
298
  const storage = c.var.storage;
754
299
  if (!storage) {
755
300
  return c.notFound();
756
301
  }
757
302
 
758
- // Extract ID from "uuid.ext" format
759
- const idWithExt = c.req.param("idWithExt");
760
- const mediaId = idWithExt.replace(/\.[^.]+$/, "");
761
-
762
- const media = await c.var.services.media.getById(mediaId);
763
- if (!media) {
764
- return c.notFound();
765
- }
766
-
767
- const object = await storage.get(media.storageKey);
303
+ // The storage key is the full path without the leading "/"
304
+ const storageKey = c.req.path.slice(1);
305
+ const object = await storage.get(storageKey);
768
306
  if (!object) {
769
307
  return c.notFound();
770
308
  }
771
309
 
772
310
  const headers = new Headers();
773
- headers.set("Content-Type", object.contentType || media.mimeType);
311
+ headers.set(
312
+ "Content-Type",
313
+ object.contentType || "application/octet-stream",
314
+ );
774
315
  headers.set("Cache-Control", "public, max-age=31536000, immutable");
775
316
 
776
317
  return new Response(object.body, { headers });
@@ -787,6 +328,7 @@ export function createApp(config: JantConfig = {}): App {
787
328
  app.route("/search", searchRoutes);
788
329
  app.route("/archive", archiveRoutes);
789
330
  app.route("/featured", featuredRoutes);
331
+ app.route("/latest", latestRoutes);
790
332
  app.route("/collections", collectionsPageRoutes);
791
333
  app.route("/c", collectionRoutes);
792
334
  app.route("/p", postRoutes);