@jant/core 0.3.32 → 0.3.34
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.
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +1442 -989
- package/dist/index.js +1431 -1057
- package/package.json +1 -1
- package/src/__tests__/helpers/app.ts +6 -3
- package/src/__tests__/helpers/db.ts +3 -0
- package/src/app.tsx +1 -1
- package/src/client.ts +2 -1
- package/src/db/migrations/0011_add_path_registry.sql +23 -0
- package/src/db/schema.ts +12 -1
- package/src/i18n/locales/en.po +225 -91
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +201 -152
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +201 -152
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/excerpt.test.ts +25 -0
- package/src/lib/__tests__/resolve-config.test.ts +26 -2
- package/src/lib/__tests__/timeline.test.ts +2 -1
- package/src/lib/compose-bridge.ts +30 -1
- package/src/lib/excerpt.ts +16 -7
- package/src/lib/nav-manager-bridge.ts +54 -0
- package/src/lib/navigation.ts +7 -4
- package/src/lib/render.tsx +5 -2
- package/src/lib/resolve-config.ts +7 -0
- package/src/lib/view.ts +42 -10
- package/src/middleware/error-handler.ts +16 -0
- package/src/routes/api/__tests__/posts.test.ts +80 -0
- package/src/routes/api/__tests__/settings.test.ts +1 -1
- package/src/routes/api/posts.ts +6 -29
- package/src/routes/api/upload.ts +2 -14
- package/src/routes/auth/__tests__/setup.test.ts +3 -2
- package/src/routes/auth/setup.tsx +1 -1
- package/src/routes/compose.tsx +13 -5
- package/src/routes/dash/__tests__/pages.test.ts +2 -1
- package/src/routes/dash/__tests__/settings-avatar.test.ts +151 -33
- package/src/routes/dash/appearance.tsx +71 -4
- package/src/routes/dash/collections.tsx +15 -21
- package/src/routes/dash/media.tsx +1 -13
- package/src/routes/dash/pages.tsx +5 -150
- package/src/routes/dash/posts.tsx +25 -32
- package/src/routes/dash/redirects.tsx +9 -11
- package/src/routes/dash/settings.tsx +29 -111
- package/src/routes/feed/__tests__/rss.test.ts +5 -1
- package/src/routes/pages/__tests__/collections.test.ts +2 -1
- package/src/routes/pages/__tests__/featured.test.ts +2 -1
- package/src/routes/pages/page.tsx +20 -25
- package/src/services/__tests__/collection.test.ts +2 -1
- package/src/services/__tests__/media.test.ts +78 -1
- package/src/services/__tests__/navigation.test.ts +2 -1
- package/src/services/__tests__/page.test.ts +78 -1
- package/src/services/__tests__/path-registry.test.ts +165 -0
- package/src/services/__tests__/post-timeline.test.ts +2 -1
- package/src/services/__tests__/post.test.ts +103 -1
- package/src/services/__tests__/redirect.test.ts +53 -4
- package/src/services/__tests__/search.test.ts +2 -1
- package/src/services/__tests__/settings.test.ts +153 -0
- package/src/services/index.ts +12 -4
- package/src/services/media.ts +72 -4
- package/src/services/page.ts +64 -17
- package/src/services/path-registry.ts +160 -0
- package/src/services/post.ts +119 -24
- package/src/services/redirect.ts +23 -3
- package/src/services/settings.ts +181 -0
- package/src/styles/components.css +135 -0
- package/src/styles/tokens.css +6 -1
- package/src/styles/ui.css +70 -26
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +7 -2
- package/src/types/constants.ts +9 -1
- package/src/types/sortablejs.d.ts +8 -2
- package/src/types/views.ts +1 -1
- package/src/ui/color-themes.ts +31 -31
- package/src/ui/components/__tests__/jant-settings-avatar.test.ts +0 -3
- package/src/ui/components/__tests__/jant-settings-general.test.ts +2 -6
- package/src/ui/components/jant-compose-dialog.ts +3 -2
- package/src/ui/components/jant-compose-editor.ts +17 -2
- package/src/ui/components/jant-nav-manager.ts +1067 -0
- package/src/ui/components/jant-settings-general.ts +2 -35
- package/src/ui/components/nav-manager-types.ts +72 -0
- package/src/ui/components/settings-types.ts +0 -3
- package/src/ui/compose/ComposePrompt.tsx +3 -11
- package/src/ui/dash/appearance/AdvancedContent.tsx +0 -3
- package/src/ui/dash/appearance/AppearanceNav.tsx +12 -8
- package/src/ui/dash/appearance/ColorThemeContent.tsx +1 -4
- package/src/ui/dash/appearance/FontThemeContent.tsx +0 -3
- package/src/ui/dash/appearance/NavigationContent.tsx +302 -0
- package/src/ui/dash/pages/PagesContent.tsx +74 -0
- package/src/ui/dash/settings/AccountContent.tsx +0 -3
- package/src/ui/dash/settings/GeneralContent.tsx +1 -19
- package/src/ui/dash/settings/SettingsNav.tsx +2 -6
- package/src/ui/feed/NoteCard.tsx +2 -2
- package/src/ui/layouts/DashLayout.tsx +83 -86
- package/src/ui/layouts/SiteLayout.tsx +82 -21
- package/src/lib/nav-reorder.ts +0 -26
- package/src/ui/dash/pages/LinkFormContent.tsx +0 -119
- 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
|
-
"
|
|
26
|
+
"Thoughts, links, and quotes — one post at a time",
|
|
27
27
|
);
|
|
28
28
|
expect(body.settings.SITE_LANGUAGE).toBe("en");
|
|
29
29
|
});
|
package/src/routes/api/posts.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 });
|
package/src/routes/api/upload.ts
CHANGED
|
@@ -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
|
-
|
|
284
|
+
assertFound(await c.var.services.media.getById(id), "Media");
|
|
285
285
|
|
|
286
|
-
|
|
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";
|
|
@@ -22,7 +23,7 @@ async function runSetupSeed(services: {
|
|
|
22
23
|
await services.navItems.create({
|
|
23
24
|
type: "link",
|
|
24
25
|
label: "Collections",
|
|
25
|
-
url: "/
|
|
26
|
+
url: "/c",
|
|
26
27
|
});
|
|
27
28
|
await services.navItems.create({
|
|
28
29
|
type: "link",
|
|
@@ -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
|
};
|
|
@@ -198,7 +198,7 @@ setupRoutes.post("/setup", async (c) => {
|
|
|
198
198
|
await c.var.services.navItems.create({
|
|
199
199
|
type: "link",
|
|
200
200
|
label: "Collections",
|
|
201
|
-
url: "/
|
|
201
|
+
url: "/c",
|
|
202
202
|
});
|
|
203
203
|
// Seed default navigation items
|
|
204
204
|
await c.var.services.navItems.create({
|
package/src/routes/compose.tsx
CHANGED
|
@@ -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
|
|
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
|
|
109
|
+
// Validate media IDs
|
|
109
110
|
if (data.mediaIds) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
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("
|
|
53
|
+
describe("uploadAvatar", () => {
|
|
32
54
|
it("stores avatar media and sets SITE_AVATAR to storageKey", async () => {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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).
|
|
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
|
-
|
|
53
|
-
await settingsService.
|
|
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
|
|
62
|
-
const
|
|
63
|
-
|
|
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(
|
|
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
|
|
71
|
-
|
|
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).
|
|
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("
|
|
79
|
-
it("removes all favicon-related settings
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
});
|