@jant/core 0.3.31 → 0.3.33

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 (95) hide show
  1. package/dist/client/client.css +1 -1
  2. package/dist/client/client.js +1442 -989
  3. package/dist/index.js +1429 -1055
  4. package/package.json +2 -2
  5. package/src/__tests__/helpers/app.ts +6 -3
  6. package/src/__tests__/helpers/db.ts +3 -0
  7. package/src/client.ts +2 -1
  8. package/src/db/migrations/0011_add_path_registry.sql +23 -0
  9. package/src/db/schema.ts +12 -1
  10. package/src/i18n/locales/en.po +225 -91
  11. package/src/i18n/locales/en.ts +1 -1
  12. package/src/i18n/locales/zh-Hans.po +201 -152
  13. package/src/i18n/locales/zh-Hans.ts +1 -1
  14. package/src/i18n/locales/zh-Hant.po +201 -152
  15. package/src/i18n/locales/zh-Hant.ts +1 -1
  16. package/src/lib/__tests__/excerpt.test.ts +25 -0
  17. package/src/lib/__tests__/resolve-config.test.ts +26 -2
  18. package/src/lib/__tests__/timeline.test.ts +2 -1
  19. package/src/lib/compose-bridge.ts +30 -1
  20. package/src/lib/excerpt.ts +16 -7
  21. package/src/lib/nav-manager-bridge.ts +54 -0
  22. package/src/lib/navigation.ts +7 -4
  23. package/src/lib/render.tsx +5 -2
  24. package/src/lib/resolve-config.ts +7 -0
  25. package/src/lib/view.ts +42 -10
  26. package/src/middleware/error-handler.ts +16 -0
  27. package/src/routes/api/__tests__/posts.test.ts +80 -0
  28. package/src/routes/api/__tests__/settings.test.ts +1 -1
  29. package/src/routes/api/posts.ts +6 -29
  30. package/src/routes/api/upload.ts +2 -14
  31. package/src/routes/auth/__tests__/setup.test.ts +2 -1
  32. package/src/routes/compose.tsx +13 -5
  33. package/src/routes/dash/__tests__/pages.test.ts +2 -1
  34. package/src/routes/dash/__tests__/settings-avatar.test.ts +151 -33
  35. package/src/routes/dash/appearance.tsx +71 -4
  36. package/src/routes/dash/collections.tsx +15 -21
  37. package/src/routes/dash/media.tsx +1 -13
  38. package/src/routes/dash/pages.tsx +5 -150
  39. package/src/routes/dash/posts.tsx +25 -32
  40. package/src/routes/dash/redirects.tsx +9 -11
  41. package/src/routes/dash/settings.tsx +29 -111
  42. package/src/routes/feed/__tests__/rss.test.ts +5 -1
  43. package/src/routes/pages/__tests__/collections.test.ts +2 -1
  44. package/src/routes/pages/__tests__/featured.test.ts +2 -1
  45. package/src/routes/pages/page.tsx +20 -25
  46. package/src/services/__tests__/collection.test.ts +2 -1
  47. package/src/services/__tests__/media.test.ts +78 -1
  48. package/src/services/__tests__/navigation.test.ts +2 -1
  49. package/src/services/__tests__/page.test.ts +78 -1
  50. package/src/services/__tests__/path-registry.test.ts +165 -0
  51. package/src/services/__tests__/post-timeline.test.ts +2 -1
  52. package/src/services/__tests__/post.test.ts +103 -1
  53. package/src/services/__tests__/redirect.test.ts +53 -4
  54. package/src/services/__tests__/search.test.ts +2 -1
  55. package/src/services/__tests__/settings.test.ts +153 -0
  56. package/src/services/index.ts +12 -4
  57. package/src/services/media.ts +72 -4
  58. package/src/services/page.ts +64 -17
  59. package/src/services/path-registry.ts +160 -0
  60. package/src/services/post.ts +119 -24
  61. package/src/services/redirect.ts +23 -3
  62. package/src/services/settings.ts +181 -0
  63. package/src/styles/components.css +135 -0
  64. package/src/styles/tokens.css +6 -1
  65. package/src/styles/ui.css +70 -26
  66. package/src/types/bindings.ts +1 -0
  67. package/src/types/config.ts +7 -2
  68. package/src/types/constants.ts +9 -1
  69. package/src/types/sortablejs.d.ts +8 -2
  70. package/src/types/views.ts +1 -1
  71. package/src/ui/color-themes.ts +31 -31
  72. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +0 -3
  73. package/src/ui/components/__tests__/jant-settings-general.test.ts +2 -6
  74. package/src/ui/components/jant-compose-dialog.ts +3 -2
  75. package/src/ui/components/jant-compose-editor.ts +17 -2
  76. package/src/ui/components/jant-nav-manager.ts +1067 -0
  77. package/src/ui/components/jant-settings-general.ts +2 -35
  78. package/src/ui/components/nav-manager-types.ts +72 -0
  79. package/src/ui/components/settings-types.ts +0 -3
  80. package/src/ui/compose/ComposePrompt.tsx +3 -11
  81. package/src/ui/dash/appearance/AdvancedContent.tsx +0 -3
  82. package/src/ui/dash/appearance/AppearanceNav.tsx +12 -8
  83. package/src/ui/dash/appearance/ColorThemeContent.tsx +1 -4
  84. package/src/ui/dash/appearance/FontThemeContent.tsx +0 -3
  85. package/src/ui/dash/appearance/NavigationContent.tsx +302 -0
  86. package/src/ui/dash/pages/PagesContent.tsx +74 -0
  87. package/src/ui/dash/settings/AccountContent.tsx +0 -3
  88. package/src/ui/dash/settings/GeneralContent.tsx +1 -19
  89. package/src/ui/dash/settings/SettingsNav.tsx +2 -6
  90. package/src/ui/feed/NoteCard.tsx +2 -2
  91. package/src/ui/layouts/DashLayout.tsx +83 -86
  92. package/src/ui/layouts/SiteLayout.tsx +82 -21
  93. package/src/lib/nav-reorder.ts +0 -26
  94. package/src/ui/dash/pages/LinkFormContent.tsx +0 -119
  95. package/src/ui/dash/pages/UnifiedPagesContent.tsx +0 -203
@@ -23,7 +23,7 @@ describe("Settings API Routes", () => {
23
23
  expect(body.settings).toBeDefined();
24
24
  expect(body.settings.SITE_NAME).toBe("Jant");
25
25
  expect(body.settings.SITE_DESCRIPTION).toBe(
26
- "A microblog powered by Jant",
26
+ "Thoughts, links, and quotes — one post at a time",
27
27
  );
28
28
  expect(body.settings.SITE_LANGUAGE).toBe("en");
29
29
  });
@@ -9,7 +9,6 @@ import * as sqid from "../../lib/sqid.js";
9
9
  import {
10
10
  CreatePostSchema,
11
11
  UpdatePostSchema,
12
- validateMediaCount,
13
12
  parseValidated,
14
13
  } from "../../lib/schemas.js";
15
14
  import { requireAuthApi } from "../../middleware/auth.js";
@@ -64,24 +63,6 @@ function toMediaAttachment(
64
63
  };
65
64
  }
66
65
 
67
- /**
68
- * Validates media IDs: checks count limit and verifies all IDs exist.
69
- */
70
- async function validateMediaIds(
71
- mediaIds: string[],
72
- getByIds: (ids: string[]) => Promise<Media[]>,
73
- ): Promise<void> {
74
- const countError = validateMediaCount(mediaIds);
75
- if (countError) throw new ValidationError(countError);
76
-
77
- if (mediaIds.length > 0) {
78
- const existing = await getByIds(mediaIds);
79
- if (existing.length !== mediaIds.length) {
80
- throw new ValidationError("One or more media IDs are invalid");
81
- }
82
- }
83
- }
84
-
85
66
  // List posts
86
67
  postsApiRoutes.get("/", async (c) => {
87
68
  const format = c.req.query("format") as Format | undefined;
@@ -142,9 +123,7 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
142
123
 
143
124
  // Validate media IDs
144
125
  if (body.mediaIds) {
145
- await validateMediaIds(body.mediaIds, (ids) =>
146
- c.var.services.media.getByIds(ids),
147
- );
126
+ await c.var.services.media.validateIds(body.mediaIds);
148
127
  }
149
128
 
150
129
  const post = await c.var.services.posts.create({
@@ -194,9 +173,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
194
173
 
195
174
  // Validate media IDs if provided
196
175
  if (body.mediaIds !== undefined) {
197
- await validateMediaIds(body.mediaIds, (ids) =>
198
- c.var.services.media.getByIds(ids),
199
- );
176
+ await c.var.services.media.validateIds(body.mediaIds);
200
177
  }
201
178
 
202
179
  const post = assertFound(
@@ -241,10 +218,10 @@ postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
241
218
  const id = sqid.decode(c.req.param("id"));
242
219
  if (!id) throw new ValidationError("Invalid ID");
243
220
 
244
- // Detach media before deleting
245
- await c.var.services.media.detachFromPost(id);
246
-
247
- const success = await c.var.services.posts.delete(id);
221
+ const success = await c.var.services.posts.delete(id, {
222
+ media: c.var.services.media,
223
+ storage: c.var.storage,
224
+ });
248
225
  if (!success) throw new NotFoundError("Post");
249
226
 
250
227
  return c.json({ success: true });
@@ -281,21 +281,9 @@ uploadApiRoutes.get("/", async (c) => {
281
281
  // Delete a file
282
282
  uploadApiRoutes.delete("/:id", async (c) => {
283
283
  const id = c.req.param("id");
284
- const media = assertFound(await c.var.services.media.getById(id), "Media");
284
+ assertFound(await c.var.services.media.getById(id), "Media");
285
285
 
286
- // Delete from storage
287
- const storage = c.var.storage;
288
- if (storage) {
289
- try {
290
- await storage.delete(media.storageKey);
291
- } catch (err) {
292
- // eslint-disable-next-line no-console -- Error logging is intentional
293
- console.error("Storage delete error:", err);
294
- }
295
- }
296
-
297
- // Delete from database
298
- await c.var.services.media.delete(id);
286
+ await c.var.services.media.delete(id, c.var.storage);
299
287
 
300
288
  return c.json({ success: true });
301
289
  });
@@ -3,6 +3,7 @@ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
3
3
  import { createPageService } from "../../../services/page.js";
4
4
  import { createSettingsService } from "../../../services/settings.js";
5
5
  import { createNavItemService } from "../../../services/navigation.js";
6
+ import { createPathRegistryService } from "../../../services/path-registry.js";
6
7
  import type { Database } from "../../../db/index.js";
7
8
  import type { PageService } from "../../../services/page.js";
8
9
  import type { SettingsService } from "../../../services/settings.js";
@@ -62,7 +63,7 @@ describe("Setup seed logic", () => {
62
63
  const testDb = createTestDatabase();
63
64
  const db = testDb.db as unknown as Database;
64
65
  services = {
65
- pages: createPageService(db),
66
+ pages: createPageService(db, createPathRegistryService(db)),
66
67
  settings: createSettingsService(db),
67
68
  navItems: createNavItemService(db),
68
69
  };
@@ -11,7 +11,8 @@ import { msg } from "@lingui/core/macro";
11
11
  import type { Bindings, Post } from "../types.js";
12
12
  import type { AppVariables } from "../types/app-context.js";
13
13
  import { requireAuth } from "../middleware/auth.js";
14
- import { CreatePostSchema, validateMediaCount } from "../lib/schemas.js";
14
+ import { CreatePostSchema } from "../lib/schemas.js";
15
+ import { ValidationError } from "../lib/errors.js";
15
16
  import { sse, dsToast } from "../lib/sse.js";
16
17
  import { getI18n } from "../i18n/index.js";
17
18
  import {
@@ -105,11 +106,18 @@ composeRoutes.post("/", async (c) => {
105
106
 
106
107
  const data = result.data;
107
108
 
108
- // Validate media count
109
+ // Validate media IDs
109
110
  if (data.mediaIds) {
110
- const mediaError = validateMediaCount(data.mediaIds);
111
- if (mediaError) {
112
- return dsToast(mediaError, "error");
111
+ try {
112
+ await c.var.services.media.validateIds(data.mediaIds);
113
+ } catch (e) {
114
+ if (e instanceof ValidationError) {
115
+ if (wantsJson) {
116
+ return c.json({ status: "error" as const, error: e.message }, 422);
117
+ }
118
+ return dsToast(e.message, "error");
119
+ }
120
+ throw e;
113
121
  }
114
122
  }
115
123
 
@@ -10,6 +10,7 @@ import { describe, it, expect, beforeEach } from "vitest";
10
10
  import { createTestDatabase } from "../../../__tests__/helpers/db.js";
11
11
  import { createPageService } from "../../../services/page.js";
12
12
  import { createNavItemService } from "../../../services/navigation.js";
13
+ import { createPathRegistryService } from "../../../services/path-registry.js";
13
14
  import type { Database } from "../../../db/index.js";
14
15
 
15
16
  describe("Dashboard Pages - Nav Management Logic", () => {
@@ -20,7 +21,7 @@ describe("Dashboard Pages - Nav Management Logic", () => {
20
21
  beforeEach(() => {
21
22
  const testDb = createTestDatabase();
22
23
  db = testDb.db as unknown as Database;
23
- pageService = createPageService(db);
24
+ pageService = createPageService(db, createPathRegistryService(db));
24
25
  navItemService = createNavItemService(db);
25
26
  });
26
27
 
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Tests for avatar upload with favicon variant storage.
2
+ * Tests for avatar upload/removal service methods.
3
3
  *
4
4
  * Note: Route handlers that import JSX components with @lingui/react/macro
5
5
  * cannot run in vitest (requires SWC plugin). These tests verify the
6
- * service-layer and storage operations that the routes orchestrate.
6
+ * service-layer operations that the routes delegate to.
7
7
  */
8
8
 
9
- import { describe, it, expect, beforeEach } from "vitest";
9
+ import { describe, it, expect, beforeEach, vi } from "vitest";
10
10
  import { createTestDatabase } from "../../../__tests__/helpers/db.js";
11
11
  import { createSettingsService } from "../../../services/settings.js";
12
12
  import { createMediaService } from "../../../services/media.js";
@@ -15,6 +15,28 @@ import {
15
15
  base64ToUint8Array,
16
16
  } from "../../../lib/favicon.js";
17
17
  import type { Database } from "../../../db/index.js";
18
+ import type { StorageDriver } from "../../../lib/storage.js";
19
+
20
+ function createMockStorage(): StorageDriver {
21
+ return {
22
+ put: vi.fn().mockResolvedValue(undefined),
23
+ get: vi.fn().mockResolvedValue(null),
24
+ delete: vi.fn().mockResolvedValue(undefined),
25
+ };
26
+ }
27
+
28
+ function createMockFile(
29
+ name: string,
30
+ type: string,
31
+ size: number,
32
+ ): { stream(): ReadableStream; name: string; type: string; size: number } {
33
+ return {
34
+ name,
35
+ type,
36
+ size,
37
+ stream: () => new ReadableStream(),
38
+ };
39
+ }
18
40
 
19
41
  describe("Dashboard Settings - Avatar Upload Logic", () => {
20
42
  let db: Database;
@@ -28,29 +50,47 @@ describe("Dashboard Settings - Avatar Upload Logic", () => {
28
50
  mediaService = createMediaService(db);
29
51
  });
30
52
 
31
- describe("avatar upload with favicon variants", () => {
53
+ describe("uploadAvatar", () => {
32
54
  it("stores avatar media and sets SITE_AVATAR to storageKey", async () => {
33
- const storageKey = "media/2026/02/test-avatar-id.png";
34
- await mediaService.create({
35
- id: "test-avatar-id",
36
- filename: "test-avatar-id.png",
37
- originalName: "logo.png",
38
- mimeType: "image/png",
39
- size: 5000,
40
- storageKey,
41
- provider: "r2",
42
- });
43
-
44
- await settingsService.set("SITE_AVATAR", storageKey);
55
+ const storage = createMockStorage();
56
+ const file = createMockFile("logo.png", "image/png", 5000);
57
+
58
+ await settingsService.uploadAvatar(
59
+ { file },
60
+ { media: mediaService, storage, storageProvider: "r2" },
61
+ );
45
62
 
46
63
  const avatarKey = await settingsService.get("SITE_AVATAR");
47
- expect(avatarKey).toBe(storageKey);
64
+ expect(avatarKey).not.toBeNull();
65
+ expect(avatarKey).toContain("media/");
66
+ expect(storage.put).toHaveBeenCalled();
67
+ });
68
+
69
+ it("creates media record for the avatar", async () => {
70
+ const storage = createMockStorage();
71
+ const file = createMockFile("logo.png", "image/png", 5000);
72
+
73
+ await settingsService.uploadAvatar(
74
+ { file },
75
+ { media: mediaService, storage, storageProvider: "r2" },
76
+ );
77
+
78
+ const mediaList = await mediaService.list();
79
+ expect(mediaList).toHaveLength(1);
80
+ expect(mediaList[0].originalName).toBe("logo.png");
81
+ expect(mediaList[0].mimeType).toBe("image/png");
82
+ expect(mediaList[0].provider).toBe("r2");
48
83
  });
49
84
 
50
85
  it("stores favicon ICO as base64 in settings", async () => {
86
+ const storage = createMockStorage();
87
+ const file = createMockFile("logo.png", "image/png", 5000);
51
88
  const fakeIcoData = new Uint8Array([0, 0, 1, 0, 1, 0, 32, 32]);
52
- const b64 = arrayBufferToBase64(fakeIcoData.buffer);
53
- await settingsService.set("SITE_FAVICON_ICO", b64);
89
+
90
+ await settingsService.uploadAvatar(
91
+ { file, faviconIco: fakeIcoData.buffer },
92
+ { media: mediaService, storage, storageProvider: "r2" },
93
+ );
54
94
 
55
95
  const stored = await settingsService.get("SITE_FAVICON_ICO");
56
96
  expect(stored).not.toBeNull();
@@ -58,25 +98,63 @@ describe("Dashboard Settings - Avatar Upload Logic", () => {
58
98
  expect(Array.from(decoded)).toEqual(Array.from(fakeIcoData));
59
99
  });
60
100
 
61
- it("stores apple-touch-icon as R2 storage key in settings", async () => {
62
- const appleTouchKey = "favicon/apple-touch-icon.png";
63
- await settingsService.set("SITE_FAVICON_APPLE_TOUCH", appleTouchKey);
101
+ it("stores apple-touch-icon in storage and sets key in settings", async () => {
102
+ const storage = createMockStorage();
103
+ const file = createMockFile("logo.png", "image/png", 5000);
104
+ const appleTouchData = new Uint8Array([137, 80, 78, 71]).buffer;
105
+
106
+ await settingsService.uploadAvatar(
107
+ { file, appleTouchIcon: appleTouchData },
108
+ { media: mediaService, storage, storageProvider: "r2" },
109
+ );
64
110
 
65
111
  const stored = await settingsService.get("SITE_FAVICON_APPLE_TOUCH");
66
- expect(stored).toBe(appleTouchKey);
112
+ expect(stored).toBe("favicon/apple-touch-icon.png");
113
+ // storage.put should be called twice: avatar file + apple-touch-icon
114
+ expect(storage.put).toHaveBeenCalledTimes(2);
67
115
  });
68
116
 
69
117
  it("sets SITE_FAVICON_VERSION on upload", async () => {
70
- const version = "202602191430";
71
- await settingsService.set("SITE_FAVICON_VERSION", version);
118
+ const storage = createMockStorage();
119
+ const file = createMockFile("logo.png", "image/png", 5000);
120
+
121
+ await settingsService.uploadAvatar(
122
+ { file },
123
+ { media: mediaService, storage, storageProvider: "r2" },
124
+ );
72
125
 
73
126
  const stored = await settingsService.get("SITE_FAVICON_VERSION");
74
- expect(stored).toBe(version);
127
+ expect(stored).not.toBeNull();
128
+ expect(stored).toMatch(/^\d{12}$/);
129
+ });
130
+
131
+ it("throws ValidationError for disallowed file type", async () => {
132
+ const storage = createMockStorage();
133
+ const file = createMockFile("doc.pdf", "application/pdf", 5000);
134
+
135
+ await expect(
136
+ settingsService.uploadAvatar(
137
+ { file },
138
+ { media: mediaService, storage, storageProvider: "r2" },
139
+ ),
140
+ ).rejects.toThrow("File type not allowed");
141
+ });
142
+
143
+ it("throws ValidationError for oversized file", async () => {
144
+ const storage = createMockStorage();
145
+ const file = createMockFile("big.png", "image/png", 20 * 1024 * 1024);
146
+
147
+ await expect(
148
+ settingsService.uploadAvatar(
149
+ { file },
150
+ { media: mediaService, storage, storageProvider: "r2" },
151
+ ),
152
+ ).rejects.toThrow("File too large");
75
153
  });
76
154
  });
77
155
 
78
- describe("avatar removal cleans up favicon settings", () => {
79
- it("removes all favicon-related settings including version", async () => {
156
+ describe("removeAvatar", () => {
157
+ it("removes all favicon-related settings", async () => {
80
158
  await settingsService.set("SITE_AVATAR", "media/2026/02/some-id.png");
81
159
  await settingsService.set("SITE_FAVICON_ICO", "base64data");
82
160
  await settingsService.set(
@@ -85,16 +163,56 @@ describe("Dashboard Settings - Avatar Upload Logic", () => {
85
163
  );
86
164
  await settingsService.set("SITE_FAVICON_VERSION", "202602191430");
87
165
 
88
- // Simulate avatar removal
89
- await settingsService.remove("SITE_AVATAR");
90
- await settingsService.remove("SITE_FAVICON_ICO");
91
- await settingsService.remove("SITE_FAVICON_APPLE_TOUCH");
92
- await settingsService.remove("SITE_FAVICON_VERSION");
166
+ await settingsService.removeAvatar();
93
167
 
94
168
  expect(await settingsService.get("SITE_AVATAR")).toBeNull();
95
169
  expect(await settingsService.get("SITE_FAVICON_ICO")).toBeNull();
96
170
  expect(await settingsService.get("SITE_FAVICON_APPLE_TOUCH")).toBeNull();
97
171
  expect(await settingsService.get("SITE_FAVICON_VERSION")).toBeNull();
98
172
  });
173
+
174
+ it("deletes apple-touch-icon from storage when storage is provided", async () => {
175
+ const storage = createMockStorage();
176
+ await settingsService.set(
177
+ "SITE_FAVICON_APPLE_TOUCH",
178
+ "favicon/apple-touch-icon.png",
179
+ );
180
+
181
+ await settingsService.removeAvatar(storage);
182
+
183
+ expect(storage.delete).toHaveBeenCalledWith(
184
+ "favicon/apple-touch-icon.png",
185
+ );
186
+ });
187
+
188
+ it("skips storage delete when no apple-touch-icon key exists", async () => {
189
+ const storage = createMockStorage();
190
+
191
+ await settingsService.removeAvatar(storage);
192
+
193
+ expect(storage.delete).not.toHaveBeenCalled();
194
+ });
195
+
196
+ it("handles null storage gracefully", async () => {
197
+ await settingsService.set("SITE_AVATAR", "media/2026/02/some-id.png");
198
+ await settingsService.set(
199
+ "SITE_FAVICON_APPLE_TOUCH",
200
+ "favicon/apple-touch-icon.png",
201
+ );
202
+
203
+ await settingsService.removeAvatar(null);
204
+
205
+ expect(await settingsService.get("SITE_AVATAR")).toBeNull();
206
+ expect(await settingsService.get("SITE_FAVICON_APPLE_TOUCH")).toBeNull();
207
+ });
208
+ });
209
+
210
+ describe("arrayBufferToBase64 / base64ToUint8Array roundtrip", () => {
211
+ it("encodes and decodes correctly", () => {
212
+ const original = new Uint8Array([0, 0, 1, 0, 1, 0, 32, 32]);
213
+ const b64 = arrayBufferToBase64(original.buffer);
214
+ const decoded = base64ToUint8Array(b64);
215
+ expect(Array.from(decoded)).toEqual(Array.from(original));
216
+ });
99
217
  });
100
218
  });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Dashboard Appearance Routes
3
3
  *
4
- * Sub-pages: Color Theme, Font Theme, Advanced (Custom CSS)
4
+ * Sub-pages: Navigation (default), Color Theme, Font Theme, Advanced (Custom CSS)
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
@@ -16,6 +16,7 @@ import { getAvailableThemes } from "../../lib/theme.js";
16
16
  import { BUILTIN_FONT_THEMES } from "../../ui/font-themes.js";
17
17
  import { ColorThemeContent } from "../../ui/dash/appearance/ColorThemeContent.js";
18
18
  import { FontThemeContent } from "../../ui/dash/appearance/FontThemeContent.js";
19
+ import { NavigationContent } from "../../ui/dash/appearance/NavigationContent.js";
19
20
  import { AdvancedContent } from "../../ui/dash/appearance/AdvancedContent.js";
20
21
 
21
22
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -23,10 +24,76 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
23
24
  export const appearanceRoutes = new Hono<Env>();
24
25
 
25
26
  // ===========================================================================
26
- // Color Theme
27
+ // Navigation (default tab)
27
28
  // ===========================================================================
28
29
 
29
30
  appearanceRoutes.get("/", async (c) => {
31
+ const [navItems, availablePages] = await Promise.all([
32
+ c.var.services.navItems.list(),
33
+ c.var.services.pages.listNotInNav(),
34
+ ]);
35
+ const siteName = c.var.appConfig.siteName;
36
+ const headerNavMaxVisible = c.var.appConfig.headerNavMaxVisible;
37
+ const homeDefaultView = c.var.appConfig.homeDefaultView;
38
+
39
+ return c.html(
40
+ <DashLayout
41
+ c={c}
42
+ title="Appearance"
43
+ siteName={siteName}
44
+ currentPath="/dash/appearance"
45
+ >
46
+ <NavigationContent
47
+ navItems={navItems}
48
+ availablePages={availablePages}
49
+ headerNavMaxVisible={headerNavMaxVisible}
50
+ homeDefaultView={homeDefaultView}
51
+ siteName={siteName}
52
+ />
53
+ </DashLayout>,
54
+ );
55
+ });
56
+
57
+ // ===========================================================================
58
+ // Nav max visible links
59
+ // ===========================================================================
60
+
61
+ appearanceRoutes.post("/nav-max-visible", async (c) => {
62
+ const body = await c.req.json<{ value: number }>();
63
+ const { settings } = c.var.services;
64
+
65
+ const navMax = Math.max(0, Math.min(5, body.value ?? 3));
66
+ if (navMax !== 3) {
67
+ await settings.set("HEADER_NAV_MAX_VISIBLE", String(navMax));
68
+ } else {
69
+ await settings.remove("HEADER_NAV_MAX_VISIBLE");
70
+ }
71
+
72
+ return c.json({ ok: true });
73
+ });
74
+
75
+ // ===========================================================================
76
+ // Home default view
77
+ // ===========================================================================
78
+
79
+ appearanceRoutes.post("/home-default-view", async (c) => {
80
+ const body = await c.req.json<{ value: string }>();
81
+ const { settings } = c.var.services;
82
+
83
+ if (body.value === "featured") {
84
+ await settings.set("HOME_DEFAULT_VIEW", "featured");
85
+ } else {
86
+ await settings.remove("HOME_DEFAULT_VIEW");
87
+ }
88
+
89
+ return c.json({ ok: true });
90
+ });
91
+
92
+ // ===========================================================================
93
+ // Color Theme
94
+ // ===========================================================================
95
+
96
+ appearanceRoutes.get("/color", async (c) => {
30
97
  const siteName = c.var.appConfig.siteName;
31
98
  const defaultThemeId = c.var.appConfig.fallbacks.defaultTheme;
32
99
  const currentThemeId =
@@ -47,7 +114,7 @@ appearanceRoutes.get("/", async (c) => {
47
114
  );
48
115
  });
49
116
 
50
- appearanceRoutes.post("/", async (c) => {
117
+ appearanceRoutes.post("/color", async (c) => {
51
118
  const i18n = getI18n(c);
52
119
  const body = await c.req.json<{ theme: string }>();
53
120
  const { settings } = c.var.services;
@@ -73,7 +140,7 @@ appearanceRoutes.post("/", async (c) => {
73
140
  await settings.set(SETTINGS_KEYS.THEME, validTheme.id);
74
141
  }
75
142
 
76
- return dsRedirect("/dash/appearance?saved");
143
+ return dsRedirect("/dash/appearance/color?saved");
77
144
  });
78
145
 
79
146
  // ===========================================================================
@@ -3,11 +3,16 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import type { Bindings, SortOrder } from "../../types.js";
6
+ import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../types/app-context.js";
8
8
  import { DashLayout } from "../../ui/layouts/DashLayout.js";
9
9
  import { DangerZone } from "../../ui/dash/index.js";
10
10
  import { dsRedirect } from "../../lib/sse.js";
11
+ import {
12
+ CreateCollectionSchema,
13
+ UpdateCollectionSchema,
14
+ parseValidated,
15
+ } from "../../lib/schemas.js";
11
16
  import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
12
17
  import { slugify } from "../../lib/url.js";
13
18
  import { CollectionsListContent } from "../../ui/dash/collections/CollectionsListContent.js";
@@ -63,23 +68,18 @@ collectionsRoutes.get("/new", async (c) => {
63
68
  // Create collection
64
69
  collectionsRoutes.post("/", async (c) => {
65
70
  const wantsJson = c.req.header("Accept")?.includes("application/json");
66
- const body = await c.req.json<{
67
- title: string;
68
- slug: string;
69
- description?: string;
70
- icon?: string;
71
- sortOrder?: string;
72
- }>();
73
-
74
- // Auto-generate slug from title if empty
75
- const slug = body.slug || slugify(body.title);
71
+ const raw = await c.req.json();
72
+ const body = parseValidated(CreateCollectionSchema, {
73
+ ...raw,
74
+ slug: raw.slug || slugify(raw.title ?? ""),
75
+ });
76
76
 
77
77
  const collection = await c.var.services.collections.create({
78
78
  title: body.title,
79
- slug,
79
+ slug: body.slug,
80
80
  description: body.description || undefined,
81
81
  icon: body.icon || undefined,
82
- sortOrder: (body.sortOrder as SortOrder) || undefined,
82
+ sortOrder: body.sortOrder || undefined,
83
83
  });
84
84
 
85
85
  const redirectUrl = `/dash/collections/${collection.id}`;
@@ -182,20 +182,14 @@ collectionsRoutes.post("/:id", async (c) => {
182
182
  if (isNaN(id)) return c.notFound();
183
183
 
184
184
  const wantsJson = c.req.header("Accept")?.includes("application/json");
185
- const body = await c.req.json<{
186
- title: string;
187
- slug: string;
188
- description?: string;
189
- icon?: string;
190
- sortOrder?: string;
191
- }>();
185
+ const body = parseValidated(UpdateCollectionSchema, await c.req.json());
192
186
 
193
187
  await c.var.services.collections.update(id, {
194
188
  title: body.title,
195
189
  slug: body.slug,
196
190
  description: body.description || null,
197
191
  icon: body.icon || null,
198
- sortOrder: (body.sortOrder as SortOrder) || undefined,
192
+ sortOrder: body.sortOrder || undefined,
199
193
  });
200
194
 
201
195
  const redirectUrl = `/dash/collections/${id}`;
@@ -126,19 +126,7 @@ mediaRoutes.post("/:id/delete", async (c) => {
126
126
  const media = await c.var.services.media.getById(id);
127
127
  if (!media) return c.notFound();
128
128
 
129
- // Delete from storage
130
- const storage = c.var.storage;
131
- if (storage) {
132
- try {
133
- await storage.delete(media.storageKey);
134
- } catch (err) {
135
- // eslint-disable-next-line no-console -- Error logging is intentional
136
- console.error("Storage delete error:", err);
137
- }
138
- }
139
-
140
- // Delete from database
141
- await c.var.services.media.delete(id);
129
+ await c.var.services.media.delete(id, c.var.storage);
142
130
 
143
131
  return dsRedirect("/dash/media");
144
132
  });