@jant/core 0.3.25 → 0.3.26

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 (131) hide show
  1. package/dist/app.js +67 -562
  2. package/dist/client.js +1 -0
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/lib/avatar-upload.js +134 -0
  7. package/dist/lib/config.js +39 -0
  8. package/dist/lib/constants.js +10 -10
  9. package/dist/lib/favicon.js +102 -0
  10. package/dist/lib/image.js +13 -17
  11. package/dist/lib/media-helpers.js +2 -2
  12. package/dist/lib/navigation.js +23 -3
  13. package/dist/lib/render.js +10 -1
  14. package/dist/lib/schemas.js +31 -0
  15. package/dist/lib/timezones.js +388 -0
  16. package/dist/lib/view.js +1 -1
  17. package/dist/routes/api/posts.js +1 -1
  18. package/dist/routes/api/upload.js +3 -3
  19. package/dist/routes/auth/reset.js +221 -0
  20. package/dist/routes/auth/setup.js +194 -0
  21. package/dist/routes/auth/signin.js +176 -0
  22. package/dist/routes/dash/collections.js +23 -415
  23. package/dist/routes/dash/media.js +12 -392
  24. package/dist/routes/dash/pages.js +7 -330
  25. package/dist/routes/dash/redirects.js +18 -12
  26. package/dist/routes/dash/settings.js +198 -577
  27. package/dist/routes/feed/rss.js +2 -1
  28. package/dist/routes/feed/sitemap.js +4 -2
  29. package/dist/routes/pages/featured.js +5 -1
  30. package/dist/routes/pages/home.js +26 -1
  31. package/dist/routes/pages/latest.js +45 -0
  32. package/dist/services/post.js +30 -50
  33. package/dist/types/bindings.js +3 -0
  34. package/dist/types/config.js +147 -0
  35. package/dist/types/constants.js +27 -0
  36. package/dist/types/entities.js +3 -0
  37. package/dist/types/operations.js +3 -0
  38. package/dist/types/props.js +3 -0
  39. package/dist/types/views.js +5 -0
  40. package/dist/types.js +8 -111
  41. package/dist/ui/color-themes.js +33 -33
  42. package/dist/ui/compose/ComposeDialog.js +36 -21
  43. package/dist/ui/dash/PageForm.js +21 -15
  44. package/dist/ui/dash/PostForm.js +22 -16
  45. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  46. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  47. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  48. package/dist/ui/dash/media/MediaListContent.js +166 -0
  49. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  50. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  51. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  52. package/dist/ui/dash/settings/AccountContent.js +209 -0
  53. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  54. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  55. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  56. package/dist/ui/font-themes.js +36 -0
  57. package/dist/ui/layouts/BaseLayout.js +24 -2
  58. package/dist/ui/layouts/SiteLayout.js +47 -19
  59. package/package.json +1 -1
  60. package/src/app.tsx +93 -553
  61. package/src/client.ts +1 -0
  62. package/src/i18n/locales/en.po +240 -175
  63. package/src/i18n/locales/en.ts +1 -1
  64. package/src/i18n/locales/zh-Hans.po +240 -175
  65. package/src/i18n/locales/zh-Hans.ts +1 -1
  66. package/src/i18n/locales/zh-Hant.po +240 -175
  67. package/src/i18n/locales/zh-Hant.ts +1 -1
  68. package/src/lib/__tests__/config.test.ts +192 -0
  69. package/src/lib/__tests__/favicon.test.ts +151 -0
  70. package/src/lib/__tests__/image.test.ts +2 -6
  71. package/src/lib/__tests__/timezones.test.ts +61 -0
  72. package/src/lib/__tests__/view.test.ts +2 -2
  73. package/src/lib/avatar-upload.ts +165 -0
  74. package/src/lib/config.ts +47 -0
  75. package/src/lib/constants.ts +19 -11
  76. package/src/lib/favicon.ts +115 -0
  77. package/src/lib/image.ts +13 -21
  78. package/src/lib/media-helpers.ts +2 -2
  79. package/src/lib/navigation.ts +33 -2
  80. package/src/lib/render.tsx +15 -1
  81. package/src/lib/schemas.ts +39 -0
  82. package/src/lib/timezones.ts +325 -0
  83. package/src/lib/view.ts +1 -1
  84. package/src/routes/api/posts.ts +1 -1
  85. package/src/routes/api/upload.ts +2 -3
  86. package/src/routes/auth/reset.tsx +239 -0
  87. package/src/routes/auth/setup.tsx +189 -0
  88. package/src/routes/auth/signin.tsx +163 -0
  89. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  90. package/src/routes/dash/collections.tsx +17 -366
  91. package/src/routes/dash/media.tsx +12 -414
  92. package/src/routes/dash/pages.tsx +8 -348
  93. package/src/routes/dash/redirects.tsx +20 -14
  94. package/src/routes/dash/settings.tsx +243 -534
  95. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  96. package/src/routes/feed/rss.ts +3 -1
  97. package/src/routes/feed/sitemap.ts +4 -2
  98. package/src/routes/pages/featured.tsx +7 -1
  99. package/src/routes/pages/home.tsx +25 -2
  100. package/src/routes/pages/latest.tsx +59 -0
  101. package/src/services/post.ts +34 -66
  102. package/src/styles/components.css +0 -65
  103. package/src/styles/tokens.css +1 -1
  104. package/src/styles/ui.css +24 -40
  105. package/src/types/bindings.ts +30 -0
  106. package/src/types/config.ts +183 -0
  107. package/src/types/constants.ts +26 -0
  108. package/src/types/entities.ts +109 -0
  109. package/src/types/operations.ts +88 -0
  110. package/src/types/props.ts +115 -0
  111. package/src/types/views.ts +172 -0
  112. package/src/types.ts +8 -644
  113. package/src/ui/__tests__/font-themes.test.ts +34 -0
  114. package/src/ui/color-themes.ts +34 -34
  115. package/src/ui/compose/ComposeDialog.tsx +40 -21
  116. package/src/ui/dash/PageForm.tsx +25 -19
  117. package/src/ui/dash/PostForm.tsx +26 -20
  118. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  119. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  120. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  121. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  122. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  123. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  124. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  125. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  126. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  127. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  128. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  129. package/src/ui/font-themes.ts +54 -0
  130. package/src/ui/layouts/BaseLayout.tsx +17 -0
  131. package/src/ui/layouts/SiteLayout.tsx +45 -31
@@ -5,19 +5,28 @@
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
- import { useLingui } from "@lingui/react/macro";
9
8
  import type { Bindings } from "../../types.js";
10
9
  import type { AppVariables } from "../../app.js";
11
10
  import { DashLayout } from "../../ui/layouts/DashLayout.js";
12
11
  import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
12
+ import { arrayBufferToBase64 } from "../../lib/favicon.js";
13
13
  import {
14
14
  getSiteLanguage,
15
15
  getSiteName,
16
+ getHomeDefaultView,
17
+ getTimeZone,
18
+ getSiteFooter,
19
+ isNoIndex,
16
20
  getConfigFallback,
17
21
  } from "../../lib/config.js";
18
22
  import { SETTINGS_KEYS } from "../../lib/constants.js";
19
23
  import { getAvailableThemes } from "../../lib/theme.js";
20
- import type { ColorTheme } from "../../ui/color-themes.js";
24
+ import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
25
+ import { TIMEZONES } from "../../lib/timezones.js";
26
+ import { BUILTIN_FONT_THEMES } from "../../ui/font-themes.js";
27
+ import { GeneralContent } from "../../ui/dash/settings/GeneralContent.js";
28
+ import { AppearanceContent } from "../../ui/dash/settings/AppearanceContent.js";
29
+ import { AccountContent } from "../../ui/dash/settings/AccountContent.js";
21
30
 
22
31
  /** Escape HTML special characters for safe insertion into HTML strings */
23
32
  function escapeHtml(str: string): string {
@@ -32,544 +41,46 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
32
41
 
33
42
  export const settingsRoutes = new Hono<Env>();
34
43
 
35
- // ---------------------------------------------------------------------------
36
- // Shared sub-navigation
37
- // ---------------------------------------------------------------------------
38
-
39
- type SettingsTab = "general" | "appearance" | "account";
40
-
41
- function SettingsNav({ currentTab }: { currentTab: SettingsTab }) {
42
- const { t } = useLingui();
43
-
44
- const tabs: { id: SettingsTab; label: string; href: string }[] = [
45
- {
46
- id: "general",
47
- label: t({
48
- message: "General",
49
- comment: "@context: Settings sub-navigation tab",
50
- }),
51
- href: "/dash/settings",
52
- },
53
- {
54
- id: "appearance",
55
- label: t({
56
- message: "Appearance",
57
- comment: "@context: Settings sub-navigation tab",
58
- }),
59
- href: "/dash/settings/appearance",
60
- },
61
- {
62
- id: "account",
63
- label: t({
64
- message: "Account",
65
- comment: "@context: Settings sub-navigation tab",
66
- }),
67
- href: "/dash/settings/account",
68
- },
69
- ];
70
-
71
- return (
72
- <nav class="flex gap-1 mb-6">
73
- {tabs.map((tab) => (
74
- <a
75
- key={tab.id}
76
- href={tab.href}
77
- class={`px-3 py-2 text-sm rounded-md ${
78
- tab.id === currentTab
79
- ? "bg-accent text-accent-foreground font-medium"
80
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
81
- }`}
82
- >
83
- {tab.label}
84
- </a>
85
- ))}
86
- </nav>
87
- );
88
- }
89
-
90
- // ---------------------------------------------------------------------------
91
- // General tab
92
- // ---------------------------------------------------------------------------
93
-
94
- function GeneralContent({
95
- siteName,
96
- siteDescription,
97
- siteLanguage,
98
- siteNameFallback,
99
- siteDescriptionFallback,
100
- }: {
101
- siteName: string;
102
- siteDescription: string;
103
- siteLanguage: string;
104
- siteNameFallback: string;
105
- siteDescriptionFallback: string;
106
- }) {
107
- const { t } = useLingui();
108
-
109
- const generalSignals = JSON.stringify({
110
- siteName,
111
- siteDescription,
112
- siteLanguage,
113
- }).replace(/</g, "\\u003c");
114
-
115
- return (
116
- <>
117
- <h1 class="text-2xl font-semibold mb-2">
118
- {t({ message: "Settings", comment: "@context: Dashboard heading" })}
119
- </h1>
120
- <SettingsNav currentTab="general" />
121
-
122
- <div class="flex flex-col gap-6 max-w-lg">
123
- <form
124
- data-signals={generalSignals}
125
- data-on:submit__prevent="@post('/dash/settings')"
126
- data-indicator="_loading"
127
- >
128
- <div class="card">
129
- <header>
130
- <h2>
131
- {t({
132
- message: "General",
133
- comment: "@context: Settings section heading",
134
- })}
135
- </h2>
136
- </header>
137
- <section class="flex flex-col gap-4">
138
- <div class="field">
139
- <label class="label">
140
- {t({
141
- message: "Site Name",
142
- comment: "@context: Settings form field",
143
- })}
144
- </label>
145
- <input
146
- type="text"
147
- data-bind="siteName"
148
- class="input"
149
- placeholder={siteNameFallback}
150
- />
151
- </div>
152
-
153
- <div class="field">
154
- <label class="label">
155
- {t({
156
- message: "Site Description",
157
- comment: "@context: Settings form field",
158
- })}
159
- </label>
160
- <textarea
161
- data-bind="siteDescription"
162
- class="textarea"
163
- rows={3}
164
- placeholder={siteDescriptionFallback}
165
- >
166
- {siteDescription}
167
- </textarea>
168
- </div>
169
-
170
- <div class="field">
171
- <label class="label">
172
- {t({
173
- message: "Language",
174
- comment: "@context: Settings form field",
175
- })}
176
- </label>
177
- <select data-bind="siteLanguage" class="select">
178
- <option value="en" selected={siteLanguage === "en"}>
179
- English
180
- </option>
181
- <option value="zh-Hans" selected={siteLanguage === "zh-Hans"}>
182
- 简体中文
183
- </option>
184
- <option value="zh-Hant" selected={siteLanguage === "zh-Hant"}>
185
- 繁體中文
186
- </option>
187
- </select>
188
- </div>
189
- </section>
190
- </div>
191
-
192
- <button type="submit" class="btn mt-4" data-attr-disabled="$_loading">
193
- <span data-show="!$_loading">
194
- {t({
195
- message: "Save Settings",
196
- comment: "@context: Button to save settings",
197
- })}
198
- </span>
199
- <span data-show="$_loading">
200
- {t({
201
- message: "Processing...",
202
- comment:
203
- "@context: Loading text shown on submit button while request is in progress",
204
- })}
205
- </span>
206
- </button>
207
- </form>
208
- </div>
209
- </>
210
- );
211
- }
212
-
213
- // ---------------------------------------------------------------------------
214
- // Appearance tab
215
- // ---------------------------------------------------------------------------
216
-
217
- function ThemeCard({
218
- theme,
219
- selected,
220
- }: {
221
- theme: ColorTheme;
222
- selected: boolean;
223
- }) {
224
- const expr = `$theme === '${theme.id}'`;
225
- const { preview } = theme;
226
-
227
- return (
228
- <label
229
- class={`block cursor-pointer rounded-lg border overflow-hidden transition-colors ${selected ? "border-primary" : "border-border"}`}
230
- data-class:border-primary={expr}
231
- data-class:border-border={`$theme !== '${theme.id}'`}
232
- >
233
- <div class="grid grid-cols-2">
234
- <div
235
- class="p-5"
236
- style={`background-color:${preview.lightBg};color:${preview.lightText}`}
237
- >
238
- <input
239
- type="radio"
240
- name="theme"
241
- value={theme.id}
242
- data-bind="theme"
243
- checked={selected || undefined}
244
- class="mb-1"
245
- />
246
- <h3 class="font-bold text-lg">{theme.name}</h3>
247
- <p class="text-sm mt-2 leading-relaxed">
248
- This is the {theme.name} theme in light mode. Links{" "}
249
- <a
250
- tabIndex={-1}
251
- class="underline"
252
- style={`color:${preview.lightLink}`}
253
- >
254
- look like this
255
- </a>
256
- . We'll show the correct light or dark mode based on your visitor's
257
- settings.
258
- </p>
259
- </div>
260
- <div
261
- class="p-5"
262
- style={`background-color:${preview.darkBg};color:${preview.darkText}`}
263
- >
264
- <h3 class="font-bold text-lg">{theme.name}</h3>
265
- <p class="text-sm mt-2 leading-relaxed">
266
- This is the {theme.name} theme in dark mode. Links{" "}
267
- <a
268
- tabIndex={-1}
269
- class="underline"
270
- style={`color:${preview.darkLink}`}
271
- >
272
- look like this
273
- </a>
274
- . We'll show the correct light or dark mode based on your visitor's
275
- settings.
276
- </p>
277
- </div>
278
- </div>
279
- </label>
280
- );
281
- }
282
-
283
- function AppearanceContent({
284
- themes,
285
- currentThemeId,
286
- customCSS,
287
- }: {
288
- themes: ColorTheme[];
289
- currentThemeId: string;
290
- customCSS: string;
291
- }) {
292
- const { t } = useLingui();
293
-
294
- const themeSignals = JSON.stringify({ theme: currentThemeId }).replace(
295
- /</g,
296
- "\\u003c",
297
- );
298
-
299
- const cssSignals = JSON.stringify({ customCSS }).replace(/</g, "\\u003c");
300
-
301
- return (
302
- <>
303
- <h1 class="text-2xl font-semibold mb-2">
304
- {t({ message: "Settings", comment: "@context: Dashboard heading" })}
305
- </h1>
306
- <SettingsNav currentTab="appearance" />
307
-
308
- <div
309
- data-signals={themeSignals}
310
- data-on:change="@post('/dash/settings/appearance')"
311
- class="max-w-3xl"
312
- >
313
- <fieldset>
314
- <legend class="text-lg font-semibold">
315
- {t({
316
- message: "Color theme",
317
- comment: "@context: Appearance settings heading",
318
- })}
319
- </legend>
320
- <p class="text-sm text-muted-foreground mb-4">
321
- {t({
322
- message:
323
- "This will theme both your site and your dashboard. All color themes support dark mode.",
324
- comment: "@context: Appearance settings description",
325
- })}
326
- </p>
327
-
328
- <div class="flex flex-col gap-4">
329
- {themes.map((theme) => (
330
- <ThemeCard
331
- key={theme.id}
332
- theme={theme}
333
- selected={theme.id === currentThemeId}
334
- />
335
- ))}
336
- </div>
337
- </fieldset>
338
- </div>
339
-
340
- <form
341
- data-signals={cssSignals}
342
- data-on:submit__prevent="@post('/dash/settings/custom-css')"
343
- data-indicator="_cssLoading"
344
- class="max-w-3xl mt-8"
345
- >
346
- <fieldset>
347
- <legend class="text-lg font-semibold">
348
- {t({
349
- message: "Custom CSS",
350
- comment: "@context: Appearance settings heading for custom CSS",
351
- })}
352
- </legend>
353
- <p class="text-sm text-muted-foreground mb-4">
354
- {t({
355
- message:
356
- "Add custom CSS to override any styles. Use data attributes like [data-page], [data-post], [data-format] to target specific elements.",
357
- comment: "@context: Custom CSS settings description",
358
- })}
359
- </p>
360
- <textarea
361
- data-bind="customCSS"
362
- class="textarea font-mono text-sm min-h-32"
363
- rows={8}
364
- placeholder={t({
365
- message: "/* Your custom CSS here */",
366
- comment: "@context: Custom CSS textarea placeholder",
367
- })}
368
- >
369
- {customCSS}
370
- </textarea>
371
- </fieldset>
372
- <button
373
- type="submit"
374
- class="btn mt-4"
375
- data-attr-disabled="$_cssLoading"
376
- >
377
- <span data-show="!$_cssLoading">
378
- {t({
379
- message: "Save CSS",
380
- comment: "@context: Button to save custom CSS",
381
- })}
382
- </span>
383
- <span data-show="$_cssLoading">
384
- {t({
385
- message: "Processing...",
386
- comment:
387
- "@context: Loading text shown on submit button while request is in progress",
388
- })}
389
- </span>
390
- </button>
391
- </form>
392
- </>
393
- );
394
- }
44
+ // ===========================================================================
45
+ // General settings
46
+ // ===========================================================================
395
47
 
396
- // ---------------------------------------------------------------------------
397
- // Account tab
398
- // ---------------------------------------------------------------------------
399
-
400
- function AccountContent({ userName }: { userName: string }) {
401
- const { t } = useLingui();
402
-
403
- const profileSignals = JSON.stringify({ userName }).replace(/</g, "\\u003c");
404
-
405
- return (
406
- <>
407
- <h1 class="text-2xl font-semibold mb-2">
408
- {t({ message: "Settings", comment: "@context: Dashboard heading" })}
409
- </h1>
410
- <SettingsNav currentTab="account" />
411
-
412
- <div class="flex flex-col gap-6 max-w-lg">
413
- <form
414
- data-signals={profileSignals}
415
- data-on:submit__prevent="@post('/dash/settings/account')"
416
- data-indicator="_profileLoading"
417
- >
418
- <div class="card">
419
- <header>
420
- <h2>
421
- {t({
422
- message: "Profile",
423
- comment: "@context: Account settings section heading",
424
- })}
425
- </h2>
426
- </header>
427
- <section class="flex flex-col gap-4">
428
- <div class="field">
429
- <label class="label">
430
- {t({
431
- message: "Name",
432
- comment: "@context: Account settings form field",
433
- })}
434
- </label>
435
- <input
436
- type="text"
437
- data-bind="userName"
438
- class="input"
439
- required
440
- />
441
- </div>
442
- </section>
443
- </div>
444
-
445
- <button
446
- type="submit"
447
- class="btn mt-4"
448
- data-attr-disabled="$_profileLoading"
449
- >
450
- <span data-show="!$_profileLoading">
451
- {t({
452
- message: "Save Profile",
453
- comment: "@context: Button to save profile",
454
- })}
455
- </span>
456
- <span data-show="$_profileLoading">
457
- {t({
458
- message: "Processing...",
459
- comment:
460
- "@context: Loading text shown on submit button while request is in progress",
461
- })}
462
- </span>
463
- </button>
464
- </form>
465
-
466
- <form
467
- data-signals="{currentPassword: '', newPassword: '', confirmPassword: ''}"
468
- data-on:submit__prevent="@post('/dash/settings/password')"
469
- data-indicator="_passwordLoading"
470
- >
471
- <div class="card">
472
- <header>
473
- <h2>
474
- {t({
475
- message: "Change Password",
476
- comment: "@context: Settings section heading",
477
- })}
478
- </h2>
479
- </header>
480
- <section class="flex flex-col gap-4">
481
- <div class="field">
482
- <label class="label">
483
- {t({
484
- message: "Current Password",
485
- comment: "@context: Password form field",
486
- })}
487
- </label>
488
- <input
489
- type="password"
490
- data-bind="currentPassword"
491
- class="input"
492
- required
493
- autocomplete="current-password"
494
- />
495
- </div>
496
-
497
- <div class="field">
498
- <label class="label">
499
- {t({
500
- message: "New Password",
501
- comment: "@context: Password form field",
502
- })}
503
- </label>
504
- <input
505
- type="password"
506
- data-bind="newPassword"
507
- class="input"
508
- required
509
- minlength={8}
510
- autocomplete="new-password"
511
- />
512
- </div>
513
-
514
- <div class="field">
515
- <label class="label">
516
- {t({
517
- message: "Confirm New Password",
518
- comment: "@context: Password form field",
519
- })}
520
- </label>
521
- <input
522
- type="password"
523
- data-bind="confirmPassword"
524
- class="input"
525
- required
526
- minlength={8}
527
- autocomplete="new-password"
528
- />
529
- </div>
530
- </section>
531
- </div>
532
-
533
- <button
534
- type="submit"
535
- class="btn mt-4"
536
- data-attr-disabled="$_passwordLoading"
537
- >
538
- <span data-show="!$_passwordLoading">
539
- {t({
540
- message: "Change Password",
541
- comment: "@context: Button to change password",
542
- })}
543
- </span>
544
- <span data-show="$_passwordLoading">
545
- {t({
546
- message: "Processing...",
547
- comment:
548
- "@context: Loading text shown on submit button while request is in progress",
549
- })}
550
- </span>
551
- </button>
552
- </form>
553
- </div>
554
- </>
48
+ /** Resolve the avatar storage key to a URL */
49
+ async function resolveAvatarUrl(c: {
50
+ var: { services: AppVariables["services"] };
51
+ env: Bindings;
52
+ }): Promise<string> {
53
+ const avatarKey = await c.var.services.settings.get("SITE_AVATAR");
54
+ if (!avatarKey) return "";
55
+ const publicUrl = getPublicUrlForProvider(
56
+ c.env.STORAGE_DRIVER || "r2",
57
+ c.env.R2_PUBLIC_URL,
58
+ c.env.S3_PUBLIC_URL,
555
59
  );
60
+ return getMediaUrl(avatarKey, publicUrl);
556
61
  }
557
62
 
558
- // ===========================================================================
559
- // Route handlers
560
- // ===========================================================================
561
-
562
- // General settings page
563
63
  settingsRoutes.get("/", async (c) => {
564
64
  const { settings } = c.var.services;
565
65
 
566
66
  const dbSiteName = await settings.get("SITE_NAME");
567
67
  const dbSiteDescription = await settings.get("SITE_DESCRIPTION");
568
- const siteLanguage = await getSiteLanguage(c);
68
+ const [siteLanguage, homeDefaultView, timeZone, siteFooter, noindex] =
69
+ await Promise.all([
70
+ getSiteLanguage(c),
71
+ getHomeDefaultView(c),
72
+ getTimeZone(c),
73
+ getSiteFooter(c),
74
+ isNoIndex(c),
75
+ ]);
569
76
 
570
77
  const siteNameFallback = getConfigFallback(c, "SITE_NAME");
571
78
  const siteDescriptionFallback = getConfigFallback(c, "SITE_DESCRIPTION");
572
79
 
80
+ const siteAvatarUrl = await resolveAvatarUrl(c);
81
+ const showHeaderAvatar =
82
+ (await settings.get("SHOW_HEADER_AVATAR")) === "true";
83
+
573
84
  const saved = c.req.query("saved") !== undefined;
574
85
 
575
86
  return c.html(
@@ -584,19 +95,27 @@ settingsRoutes.get("/", async (c) => {
584
95
  siteName={dbSiteName || ""}
585
96
  siteDescription={dbSiteDescription || ""}
586
97
  siteLanguage={siteLanguage}
98
+ homeDefaultView={homeDefaultView}
587
99
  siteNameFallback={siteNameFallback}
588
100
  siteDescriptionFallback={siteDescriptionFallback}
101
+ siteAvatarUrl={siteAvatarUrl}
102
+ showHeaderAvatar={showHeaderAvatar}
103
+ timeZone={timeZone}
104
+ siteFooter={siteFooter}
105
+ noindex={noindex}
106
+ timezones={TIMEZONES}
589
107
  />
590
108
  </DashLayout>,
591
109
  );
592
110
  });
593
111
 
594
- // Save general settings
595
112
  settingsRoutes.post("/", async (c) => {
596
113
  const body = await c.req.json<{
597
114
  siteName: string;
598
115
  siteDescription: string;
599
116
  siteLanguage: string;
117
+ homeDefaultView: string;
118
+ timeZone: string;
600
119
  }>();
601
120
 
602
121
  const { settings } = c.var.services;
@@ -617,6 +136,20 @@ settingsRoutes.post("/", async (c) => {
617
136
 
618
137
  await settings.set("SITE_LANGUAGE", body.siteLanguage);
619
138
 
139
+ // Save homepage default view (only store if non-default)
140
+ if (body.homeDefaultView === "featured") {
141
+ await settings.set("HOME_DEFAULT_VIEW", body.homeDefaultView);
142
+ } else {
143
+ await settings.remove("HOME_DEFAULT_VIEW");
144
+ }
145
+
146
+ // Timezone
147
+ if (body.timeZone && body.timeZone !== "UTC") {
148
+ await settings.set("TIME_ZONE", body.timeZone);
149
+ } else {
150
+ await settings.remove("TIME_ZONE");
151
+ }
152
+
620
153
  const languageChanged = oldLanguage !== body.siteLanguage;
621
154
  const displayName = body.siteName.trim() || getConfigFallback(c, "SITE_NAME");
622
155
 
@@ -633,15 +166,172 @@ settingsRoutes.post("/", async (c) => {
633
166
  selector: "title",
634
167
  });
635
168
  await stream.toast("Settings saved successfully.");
169
+ await stream.patchSignals({
170
+ _orig_siteName: body.siteName,
171
+ _orig_siteDescription: body.siteDescription,
172
+ _orig_siteLanguage: body.siteLanguage,
173
+ _orig_homeDefaultView: body.homeDefaultView,
174
+ _orig_timeZone: body.timeZone,
175
+ _generalDirty: false,
176
+ });
177
+ }
178
+ });
179
+ });
180
+
181
+ settingsRoutes.post("/footer", async (c) => {
182
+ const body = await c.req.json<{ siteFooter: string }>();
183
+ const { settings } = c.var.services;
184
+
185
+ if (body.siteFooter?.trim()) {
186
+ await settings.set("SITE_FOOTER", body.siteFooter.trim());
187
+ } else {
188
+ await settings.remove("SITE_FOOTER");
189
+ }
190
+
191
+ return sse(c, async (stream) => {
192
+ await stream.toast("Footer saved successfully.");
193
+ await stream.patchSignals({
194
+ _orig_siteFooter: body.siteFooter,
195
+ _footerDirty: false,
196
+ });
197
+ });
198
+ });
199
+
200
+ settingsRoutes.post("/seo", async (c) => {
201
+ const body = await c.req.json<{ noindex: string }>();
202
+ const { settings } = c.var.services;
203
+
204
+ // Checkbox "noindex" is the allow-indexing signal:
205
+ // checked (value "true") = indexing allowed -> remove NOINDEX
206
+ // unchecked (value "") = indexing blocked -> set NOINDEX=true
207
+ if (body.noindex === "true") {
208
+ await settings.remove("NOINDEX");
209
+ } else {
210
+ await settings.set("NOINDEX", "true");
211
+ }
212
+
213
+ return sse(c, async (stream) => {
214
+ await stream.toast("SEO settings saved successfully.");
215
+ await stream.patchSignals({
216
+ _orig_noindex: body.noindex,
217
+ _seoDirty: false,
218
+ });
219
+ });
220
+ });
221
+
222
+ // ===========================================================================
223
+ // Avatar upload & removal
224
+ // ===========================================================================
225
+
226
+ settingsRoutes.post("/avatar", async (c) => {
227
+ const storage = c.var.storage;
228
+ if (!storage) {
229
+ return dsToast("Storage not configured.", "error");
230
+ }
231
+
232
+ const formData = await c.req.formData();
233
+ const file = formData.get("file") as File | null;
234
+ if (!file) {
235
+ return dsToast("No file provided.", "error");
236
+ }
237
+
238
+ const allowedTypes = [
239
+ "image/jpeg",
240
+ "image/png",
241
+ "image/gif",
242
+ "image/webp",
243
+ "image/svg+xml",
244
+ ];
245
+ if (!allowedTypes.includes(file.type)) {
246
+ return dsToast("File type not allowed.", "error");
247
+ }
248
+
249
+ const maxSize = 10 * 1024 * 1024;
250
+ if (file.size > maxSize) {
251
+ return dsToast("File too large (max 10MB).", "error");
252
+ }
253
+
254
+ const { uuidv7 } = await import("uuidv7");
255
+ const ext = file.name.split(".").pop() || "bin";
256
+ const id = uuidv7();
257
+ const date = new Date();
258
+ const year = date.getUTCFullYear();
259
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
260
+ const filename = `${id}.${ext}`;
261
+ const storageKey = `media/${year}/${month}/${filename}`;
262
+
263
+ try {
264
+ await storage.put(storageKey, file.stream(), {
265
+ contentType: file.type,
266
+ });
267
+
268
+ await c.var.services.media.create({
269
+ id,
270
+ filename,
271
+ originalName: file.name,
272
+ mimeType: file.type,
273
+ size: file.size,
274
+ storageKey,
275
+ provider: c.env.STORAGE_DRIVER || "r2",
276
+ });
277
+
278
+ await c.var.services.settings.set("SITE_AVATAR", storageKey);
279
+
280
+ // Store favicon variants as base64 in settings (small files, accessed every page load)
281
+ const faviconFile = formData.get("favicon") as File | null;
282
+ const appleTouchFile = formData.get("appleTouch") as File | null;
283
+
284
+ if (faviconFile) {
285
+ const b64 = arrayBufferToBase64(await faviconFile.arrayBuffer());
286
+ await c.var.services.settings.set("SITE_FAVICON_ICO", b64);
636
287
  }
288
+
289
+ if (appleTouchFile) {
290
+ const b64 = arrayBufferToBase64(await appleTouchFile.arrayBuffer());
291
+ await c.var.services.settings.set("SITE_FAVICON_APPLE_TOUCH", b64);
292
+ }
293
+
294
+ return dsRedirect("/dash/settings?saved");
295
+ } catch {
296
+ return dsToast("Upload failed. Please try again.", "error");
297
+ }
298
+ });
299
+
300
+ settingsRoutes.post("/avatar/remove", async (c) => {
301
+ await c.var.services.settings.remove("SITE_AVATAR");
302
+ await c.var.services.settings.remove("SITE_FAVICON_ICO");
303
+ await c.var.services.settings.remove("SITE_FAVICON_APPLE_TOUCH");
304
+ return dsRedirect("/dash/settings?saved");
305
+ });
306
+
307
+ settingsRoutes.post("/avatar/display", async (c) => {
308
+ const body = await c.req.json<{ showHeaderAvatar: string }>();
309
+ const { settings } = c.var.services;
310
+
311
+ if (body.showHeaderAvatar === "true") {
312
+ await settings.set("SHOW_HEADER_AVATAR", "true");
313
+ } else {
314
+ await settings.remove("SHOW_HEADER_AVATAR");
315
+ }
316
+
317
+ return sse(c, async (stream) => {
318
+ await stream.toast("Avatar display setting saved successfully.");
319
+ await stream.patchSignals({
320
+ _orig_showHeaderAvatar: body.showHeaderAvatar,
321
+ _avatarDisplayDirty: false,
322
+ });
637
323
  });
638
324
  });
639
325
 
640
- // Appearance page
326
+ // ===========================================================================
327
+ // Appearance
328
+ // ===========================================================================
329
+
641
330
  settingsRoutes.get("/appearance", async (c) => {
642
331
  const { settings } = c.var.services;
643
332
  const siteName = await getSiteName(c);
644
333
  const currentThemeId = (await settings.get(SETTINGS_KEYS.THEME)) ?? "default";
334
+ const currentFontThemeId = (await settings.get("FONT_THEME")) ?? "default";
645
335
  const customCSS = (await settings.get(SETTINGS_KEYS.CUSTOM_CSS)) ?? "";
646
336
  const themes = getAvailableThemes(c.var.config);
647
337
  const saved = c.req.query("saved") !== undefined;
@@ -657,13 +347,14 @@ settingsRoutes.get("/appearance", async (c) => {
657
347
  <AppearanceContent
658
348
  themes={themes}
659
349
  currentThemeId={currentThemeId}
350
+ fontThemes={BUILTIN_FONT_THEMES}
351
+ currentFontThemeId={currentFontThemeId}
660
352
  customCSS={customCSS}
661
353
  />
662
354
  </DashLayout>,
663
355
  );
664
356
  });
665
357
 
666
- // Save theme
667
358
  settingsRoutes.post("/appearance", async (c) => {
668
359
  const body = await c.req.json<{ theme: string }>();
669
360
  const { settings } = c.var.services;
@@ -683,7 +374,24 @@ settingsRoutes.post("/appearance", async (c) => {
683
374
  return dsRedirect("/dash/settings/appearance?saved");
684
375
  });
685
376
 
686
- // Save custom CSS
377
+ settingsRoutes.post("/font-theme", async (c) => {
378
+ const body = await c.req.json<{ fontTheme: string }>();
379
+ const { settings } = c.var.services;
380
+
381
+ const validFont = BUILTIN_FONT_THEMES.find((f) => f.id === body.fontTheme);
382
+ if (!validFont) {
383
+ return dsToast("Invalid font theme selected.", "error");
384
+ }
385
+
386
+ if (validFont.id === "default") {
387
+ await settings.remove("FONT_THEME");
388
+ } else {
389
+ await settings.set("FONT_THEME", validFont.id);
390
+ }
391
+
392
+ return dsRedirect("/dash/settings/appearance?saved");
393
+ });
394
+
687
395
  settingsRoutes.post("/custom-css", async (c) => {
688
396
  const body = await c.req.json<{ customCSS: string }>();
689
397
  const { settings } = c.var.services;
@@ -699,7 +407,10 @@ settingsRoutes.post("/custom-css", async (c) => {
699
407
  return dsToast("Custom CSS saved successfully.");
700
408
  });
701
409
 
702
- // Account page
410
+ // ===========================================================================
411
+ // Account
412
+ // ===========================================================================
413
+
703
414
  settingsRoutes.get("/account", async (c) => {
704
415
  const siteName = await getSiteName(c);
705
416
  const session = await c.var.auth.api.getSession({
@@ -721,7 +432,6 @@ settingsRoutes.get("/account", async (c) => {
721
432
  );
722
433
  });
723
434
 
724
- // Save account profile
725
435
  settingsRoutes.post("/account", async (c) => {
726
436
  const body = await c.req.json<{ userName: string }>();
727
437
  const name = body.userName?.trim();
@@ -742,7 +452,6 @@ settingsRoutes.post("/account", async (c) => {
742
452
  return dsToast("Profile saved successfully.");
743
453
  });
744
454
 
745
- // Change password
746
455
  settingsRoutes.post("/password", async (c) => {
747
456
  const body = await c.req.json<{
748
457
  currentPassword: string;