@jant/core 0.6.7 → 0.6.9

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/bin/commands/uploads/cleanup.js +2 -0
  2. package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
  3. package/dist/app-DqHzOwL5.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-CGf2m3qp.css +2 -0
  6. package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
  7. package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
  13. package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/note-expand.test.ts +130 -0
  22. package/src/client/archive-nav.js +2 -1
  23. package/src/client/audio-player.ts +7 -3
  24. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
  29. package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
  30. package/src/client/components/compose-format-convert.ts +255 -0
  31. package/src/client/components/compose-types.ts +2 -0
  32. package/src/client/components/jant-compose-dialog.ts +110 -44
  33. package/src/client/components/jant-compose-editor.ts +64 -11
  34. package/src/client/components/jant-settings-general.ts +56 -18
  35. package/src/client/components/settings-types.ts +11 -0
  36. package/src/client/compose-bridge.ts +17 -0
  37. package/src/client/feed-video-player.ts +1 -1
  38. package/src/client/hydrate-partial.ts +25 -0
  39. package/src/client/note-expand.ts +63 -0
  40. package/src/client/settings-bridge.ts +3 -0
  41. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  42. package/src/client/tiptap/bubble-menu.ts +37 -4
  43. package/src/client.ts +1 -0
  44. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  45. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  46. package/src/db/migrations/meta/_journal.json +7 -0
  47. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  48. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  49. package/src/db/migrations/pg/meta/_journal.json +7 -0
  50. package/src/db/pg/schema.ts +36 -0
  51. package/src/db/schema.ts +36 -0
  52. package/src/i18n/__tests__/middleware.test.ts +46 -0
  53. package/src/i18n/locales/public/en.po +41 -0
  54. package/src/i18n/locales/public/en.ts +1 -1
  55. package/src/i18n/locales/public/zh-Hans.po +41 -0
  56. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  57. package/src/i18n/locales/public/zh-Hant.po +41 -0
  58. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  59. package/src/i18n/locales/settings/en.po +37 -22
  60. package/src/i18n/locales/settings/en.ts +1 -1
  61. package/src/i18n/locales/settings/zh-Hans.po +37 -22
  62. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hant.po +37 -22
  64. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  65. package/src/i18n/middleware.ts +17 -8
  66. package/src/i18n/supported-locales.ts +5 -4
  67. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  68. package/src/lib/__tests__/markdown.test.ts +1 -1
  69. package/src/lib/__tests__/summary.test.ts +87 -0
  70. package/src/lib/__tests__/timeline.test.ts +48 -1
  71. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  72. package/src/lib/__tests__/url.test.ts +44 -0
  73. package/src/lib/__tests__/view.test.ts +168 -1
  74. package/src/lib/ids.ts +1 -0
  75. package/src/lib/navigation.ts +1 -0
  76. package/src/lib/resolve-config.ts +3 -2
  77. package/src/lib/summary.ts +42 -3
  78. package/src/lib/tiptap-render.ts +6 -2
  79. package/src/lib/upload.ts +16 -2
  80. package/src/lib/url.ts +41 -0
  81. package/src/lib/view.ts +102 -40
  82. package/src/preset.css +7 -1
  83. package/src/routes/api/__tests__/settings.test.ts +1 -4
  84. package/src/routes/api/__tests__/upload.test.ts +2 -0
  85. package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
  86. package/src/routes/api/internal/sites.ts +44 -1
  87. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  88. package/src/routes/api/public/archive.ts +22 -6
  89. package/src/routes/api/settings.ts +2 -1
  90. package/src/routes/api/telegram.ts +2 -1
  91. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  92. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  93. package/src/routes/dash/custom-urls.tsx +1 -1
  94. package/src/routes/dash/settings.tsx +23 -7
  95. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  96. package/src/routes/pages/archive.tsx +116 -20
  97. package/src/routes/pages/collections.tsx +1 -0
  98. package/src/services/__tests__/media.test.ts +274 -30
  99. package/src/services/__tests__/post.test.ts +81 -0
  100. package/src/services/__tests__/settings.test.ts +55 -0
  101. package/src/services/bootstrap.ts +7 -0
  102. package/src/services/export-theme/assets/client-site.js +1 -1
  103. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  104. package/src/services/export-theme/styles/main.css +49 -15
  105. package/src/services/media.ts +199 -42
  106. package/src/services/post.ts +22 -2
  107. package/src/services/search.ts +4 -4
  108. package/src/services/settings.ts +49 -15
  109. package/src/services/upload-session.ts +28 -0
  110. package/src/styles/tokens.css +7 -5
  111. package/src/styles/ui.css +163 -34
  112. package/src/types/bindings.ts +1 -0
  113. package/src/types/config.ts +14 -1
  114. package/src/types/props.ts +3 -0
  115. package/src/ui/compose/ComposeDialog.tsx +13 -0
  116. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  117. package/src/ui/dash/settings/GeneralContent.tsx +38 -4
  118. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  119. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  120. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  121. package/src/ui/feed/NoteCard.tsx +54 -5
  122. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  123. package/src/ui/layouts/BaseLayout.tsx +1 -0
  124. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
  125. package/src/ui/pages/ArchivePage.tsx +89 -6
  126. package/src/ui/pages/CollectionsPage.tsx +7 -1
  127. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  128. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  129. package/src/ui/shared/CollectionsManager.tsx +3 -0
  130. package/dist/app-C1QgMNRY.js +0 -6
  131. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
package/src/preset.css CHANGED
@@ -315,9 +315,15 @@ html {
315
315
 
316
316
  /* Blockquotes — tinted background card with quote icon */
317
317
  blockquote {
318
+ --_blockquote-pad-x: 1rem;
318
319
  position: relative;
319
320
  margin: 1.4rem 0;
320
- padding: 1.4rem 1rem 0.75rem;
321
+ padding: 1.4rem var(--_blockquote-pad-x) 0.75rem;
322
+ /* A footnote referenced inside a quote floats within this padded box, so its
323
+ gutter anchor is inset by the right padding and the note drifts left. Expose
324
+ that inset (same value as the padding, so the two can't drift) — the sidenote
325
+ rule in ui.css subtracts it to pull the note back out to the gutter. */
326
+ --sidenote-anchor-inset: var(--_blockquote-pad-x);
321
327
  border: none;
322
328
  background: var(--site-blockquote-bg);
323
329
  border-radius: 6px;
@@ -623,7 +623,7 @@ describe("Settings API Routes", () => {
623
623
  expect(res.status).toBe(401);
624
624
  });
625
625
 
626
- it("removes avatar-related settings and deletes the apple-touch icon", async () => {
626
+ it("removes avatar-related settings", async () => {
627
627
  const storage = createMockStorage();
628
628
  const { app, services } = createTestApp({
629
629
  authenticated: true,
@@ -658,9 +658,6 @@ describe("Settings API Routes", () => {
658
658
  await services.settings.get("SITE_FAVICON_APPLE_TOUCH"),
659
659
  ).toBeNull();
660
660
  expect(await services.settings.get("SITE_FAVICON_VERSION")).toBeNull();
661
- expect(storage.delete).toHaveBeenCalledWith(
662
- "media/sit_test00000000000000000000000/assets/favicon/apple-touch-icon.png",
663
- );
664
661
  });
665
662
  });
666
663
  });
@@ -118,6 +118,8 @@ describe("Upload API Routes", () => {
118
118
  expect(res.status).toBe(200);
119
119
  await expect(res.json()).resolves.toEqual({ success: true });
120
120
  expect(await services.media.getById(media.id)).toBeNull();
121
+ // This mock exposes no server-side copy, so the object is retired by
122
+ // deleting the original key immediately (no recycle window).
121
123
  expect(storage.delete).toHaveBeenCalledWith("media/photo.webp");
122
124
  });
123
125
 
@@ -62,6 +62,11 @@ function createMockStorage(): StorageDriver & {
62
62
  async delete(key) {
63
63
  files.delete(key);
64
64
  },
65
+
66
+ async copy(sourceKey, destKey) {
67
+ const file = files.get(sourceKey);
68
+ if (file) files.set(destKey, { ...file });
69
+ },
65
70
  };
66
71
  }
67
72
 
@@ -120,6 +125,8 @@ describe("Internal upload admin routes", () => {
120
125
  await expect(res.json()).resolves.toEqual({
121
126
  abortedMultipartUploads: 0,
122
127
  deletedSessions: 1,
128
+ deletedOrphanMedia: 0,
129
+ purgedStorageObjects: 0,
123
130
  });
124
131
 
125
132
  const remaining = sqlite
@@ -128,4 +135,83 @@ describe("Internal upload admin routes", () => {
128
135
  expect(remaining).toBeUndefined();
129
136
  expect(storage.files.has(row.tempStorageKey)).toBe(false);
130
137
  });
138
+
139
+ it("deletes orphaned media past the grace window and keeps fresh orphans", async () => {
140
+ const storage = createMockStorage();
141
+ const { app, services, sqlite } = createTestApp({
142
+ authenticated: false,
143
+ internalAdminToken: "internal-secret",
144
+ storage,
145
+ });
146
+ app.route("/api/internal/uploads", internalUploadsRoutes);
147
+
148
+ const oldStorageKey = "media/old-orphan.jpg";
149
+ const freshStorageKey = "media/fresh-orphan.jpg";
150
+ await storage.put(oldStorageKey, createFakeWebpBytes(), {
151
+ contentType: "image/jpeg",
152
+ });
153
+ await storage.put(freshStorageKey, createFakeWebpBytes(), {
154
+ contentType: "image/jpeg",
155
+ });
156
+
157
+ const oldOrphan = await services.media.create({
158
+ filename: "old.jpg",
159
+ originalName: "old.jpg",
160
+ mimeType: "image/jpeg",
161
+ size: 1024,
162
+ storageKey: oldStorageKey,
163
+ });
164
+ const freshOrphan = await services.media.create({
165
+ filename: "fresh.jpg",
166
+ originalName: "fresh.jpg",
167
+ mimeType: "image/jpeg",
168
+ size: 1024,
169
+ storageKey: freshStorageKey,
170
+ });
171
+
172
+ // Backdate the first orphan beyond the 7-day grace window.
173
+ sqlite
174
+ .prepare("update media set created_at = 0 where id = ?")
175
+ .run(oldOrphan.id);
176
+
177
+ const res = await app.request("/api/internal/uploads/cleanup", {
178
+ method: "POST",
179
+ headers: {
180
+ Authorization: "Bearer internal-secret",
181
+ "Content-Type": "application/json",
182
+ },
183
+ body: JSON.stringify({ limit: 10 }),
184
+ });
185
+
186
+ expect(res.status).toBe(200);
187
+ await expect(res.json()).resolves.toEqual({
188
+ abortedMultipartUploads: 0,
189
+ deletedSessions: 0,
190
+ deletedOrphanMedia: 1,
191
+ // The reaped orphan's object is freshly enqueued (30 days out), so the
192
+ // same sweep purges nothing yet.
193
+ purgedStorageObjects: 0,
194
+ });
195
+
196
+ // Old orphan: DB row gone, original key freed immediately, bytes moved to
197
+ // a trash/ key recorded in storage_purge (recoverable within the window).
198
+ expect(
199
+ sqlite.prepare("select id from media where id = ?").get(oldOrphan.id),
200
+ ).toBeUndefined();
201
+ expect(storage.files.has(oldStorageKey)).toBe(false);
202
+ expect([...storage.files.keys()].some((k) => k.startsWith("trash/"))).toBe(
203
+ true,
204
+ );
205
+ expect(
206
+ sqlite
207
+ .prepare("select id from storage_purge where original_key = ?")
208
+ .get(oldStorageKey),
209
+ ).toBeDefined();
210
+
211
+ // Fresh orphan: untouched.
212
+ expect(
213
+ sqlite.prepare("select id from media where id = ?").get(freshOrphan.id),
214
+ ).toBeDefined();
215
+ expect(storage.files.has(freshStorageKey)).toBe(true);
216
+ });
131
217
  });
@@ -3,7 +3,10 @@ import { z } from "zod";
3
3
  import { requireInternalAdminApi } from "../../../middleware/auth.js";
4
4
  import { ConflictError } from "../../../lib/errors.js";
5
5
  import { parseValidated } from "../../../lib/schemas.js";
6
- import { getSiteResolutionMode } from "../../../lib/env.js";
6
+ import {
7
+ getConfiguredStorageDriver,
8
+ getSiteResolutionMode,
9
+ } from "../../../lib/env.js";
7
10
  import type { Bindings } from "../../../types.js";
8
11
  import type { AppVariables } from "../../../types/app-context.js";
9
12
 
@@ -46,6 +49,10 @@ const SitePostCountsSchema = z.object({
46
49
  siteIds: z.array(z.string().trim().min(1)).max(200),
47
50
  });
48
51
 
52
+ const CleanupSiteUploadsSchema = z.object({
53
+ limit: z.number().int().positive().max(500).optional(),
54
+ });
55
+
49
56
  const ManagedSiteDomainSchema = z.object({
50
57
  host: z
51
58
  .string()
@@ -156,6 +163,42 @@ internalSitesRoutes.get(
156
163
  },
157
164
  );
158
165
 
166
+ // Clean up a single managed site's expired upload sessions and orphaned media
167
+ // (uploaded during compose but never published). Invoked per-site by the
168
+ // hosted control plane's scheduled maintenance. Self-hosted operators use the
169
+ // host-scoped `POST /api/internal/uploads/cleanup` route / `jant uploads
170
+ // cleanup` CLI instead.
171
+ internalSitesRoutes.post(
172
+ "/:siteId/uploads/cleanup",
173
+ requireInternalAdminApi(),
174
+ async (c) => {
175
+ assertHostBasedMode(c.env);
176
+
177
+ const storage = c.var.storage;
178
+ if (!storage) {
179
+ return c.json(
180
+ { error: "File storage isn't set up. Check your server config." },
181
+ 500,
182
+ );
183
+ }
184
+
185
+ const contentType = c.req.header("Content-Type") || "";
186
+ const rawBody = contentType.includes("application/json")
187
+ ? await c.req.json().catch(() => ({}))
188
+ : {};
189
+ const body = parseValidated(CleanupSiteUploadsSchema, rawBody);
190
+
191
+ const services = c.var.servicesForSite(c.req.param("siteId"));
192
+ const result = await services.uploads.cleanupExpired({
193
+ storage,
194
+ storageDriver: getConfiguredStorageDriver(c.env),
195
+ limit: body.limit,
196
+ });
197
+
198
+ return c.json(result);
199
+ },
200
+ );
201
+
159
202
  internalSitesRoutes.get(
160
203
  "/:siteId/export",
161
204
  requireInternalAdminApi(),
@@ -143,6 +143,72 @@ describe("Public Archive API Routes", () => {
143
143
  expect(noMediaBody.posts).toHaveLength(2);
144
144
  });
145
145
 
146
+ it("filters by title and media presence words", async () => {
147
+ const { app, services } = createTestApp({ authenticated: false });
148
+ app.route("/api/public/archive", publicArchiveApiRoutes);
149
+
150
+ const titled = await services.posts.create({
151
+ format: "note",
152
+ title: "Has title",
153
+ bodyMarkdown: "body",
154
+ });
155
+ const untitled = await services.posts.create({
156
+ format: "note",
157
+ bodyMarkdown: "body without title",
158
+ });
159
+
160
+ const withTitle = await app.request("/api/public/archive?title=any");
161
+ const withTitleBody = await withTitle.json();
162
+ expect(withTitleBody.posts.map((p: { id: string }) => p.id)).toEqual([
163
+ titled.id,
164
+ ]);
165
+
166
+ const noTitle = await app.request("/api/public/archive?title=none");
167
+ const noTitleBody = await noTitle.json();
168
+ expect(noTitleBody.posts.map((p: { id: string }) => p.id)).toEqual([
169
+ untitled.id,
170
+ ]);
171
+
172
+ const noMedia = await app.request("/api/public/archive?media=none");
173
+ const noMediaBody = await noMedia.json();
174
+ expect(noMediaBody.posts).toHaveLength(2);
175
+
176
+ const anyMedia = await app.request("/api/public/archive?media=any");
177
+ const anyMediaBody = await anyMedia.json();
178
+ expect(anyMediaBody.posts).toHaveLength(0);
179
+ });
180
+
181
+ it("filters threads and single posts via replies", async () => {
182
+ const { app, services } = createTestApp({ authenticated: false });
183
+ app.route("/api/public/archive", publicArchiveApiRoutes);
184
+
185
+ const threadRoot = await services.posts.create({
186
+ format: "note",
187
+ bodyMarkdown: "thread root",
188
+ });
189
+ await services.posts.create({
190
+ format: "note",
191
+ bodyMarkdown: "reply",
192
+ replyToId: threadRoot.id,
193
+ });
194
+ const standalone = await services.posts.create({
195
+ format: "note",
196
+ bodyMarkdown: "standalone",
197
+ });
198
+
199
+ const threads = await app.request("/api/public/archive?replies=any");
200
+ const threadsBody = await threads.json();
201
+ expect(threadsBody.posts.map((p: { id: string }) => p.id)).toEqual([
202
+ threadRoot.id,
203
+ ]);
204
+
205
+ const singles = await app.request("/api/public/archive?replies=none");
206
+ const singlesBody = await singles.json();
207
+ expect(singlesBody.posts.map((p: { id: string }) => p.id)).toEqual([
208
+ standalone.id,
209
+ ]);
210
+ });
211
+
146
212
  it("filters by media kind", async () => {
147
213
  const { app, services } = createTestApp({ authenticated: false });
148
214
  app.route("/api/public/archive", publicArchiveApiRoutes);
@@ -12,20 +12,26 @@ export const publicArchiveApiRoutes = new Hono<Env>();
12
12
 
13
13
  const MediaKindSchema = z.enum(MEDIA_KINDS);
14
14
  const MEDIA_KIND_LIST = MEDIA_KINDS.join(", ");
15
- const INVALID_MEDIA_KIND_MESSAGE =
16
- "Invalid media kind. Allowed: " + MEDIA_KIND_LIST;
15
+ const INVALID_MEDIA_MESSAGE =
16
+ "Invalid media value. Allowed: any, none, or kinds: " + MEDIA_KIND_LIST;
17
17
 
18
18
  const BoolFlagSchema = z.enum(["0", "1"]).transform((value) => value === "1");
19
+ const PresenceSchema = z
20
+ .enum(["any", "none"])
21
+ .transform((value) => value === "any");
19
22
 
20
23
  const ListPublicArchiveQuerySchema = z.object({
21
24
  format: FormatSchema.optional(),
22
25
  collection: z.string().optional(),
23
26
  year: z.coerce.number().int().min(1971).optional(),
27
+ // Kinds list (image,video,...) or presence words: any = posts with any
28
+ // attachment, none = posts without attachments.
24
29
  media: z
25
30
  .string()
26
31
  .optional()
27
32
  .transform((value, ctx) => {
28
33
  if (!value) return undefined;
34
+ if (value === "any" || value === "none") return value;
29
35
  const parts = value
30
36
  .split(",")
31
37
  .map((part) => part.trim())
@@ -35,12 +41,15 @@ const ListPublicArchiveQuerySchema = z.object({
35
41
  if (!result.success) {
36
42
  ctx.addIssue({
37
43
  code: z.ZodIssueCode.custom,
38
- message: INVALID_MEDIA_KIND_MESSAGE,
44
+ message: INVALID_MEDIA_MESSAGE,
39
45
  });
40
46
  return z.NEVER;
41
47
  }
42
48
  return result.data;
43
49
  }),
50
+ title: PresenceSchema.optional(),
51
+ replies: PresenceSchema.optional(),
52
+ // Deprecated: use media=any|none and title=any|none instead.
44
53
  hasMedia: BoolFlagSchema.optional(),
45
54
  hasTitle: BoolFlagSchema.optional(),
46
55
  cursor: z.string().optional(),
@@ -54,6 +63,8 @@ publicArchiveApiRoutes.get("/", async (c) => {
54
63
  collection,
55
64
  year,
56
65
  media,
66
+ title,
67
+ replies,
57
68
  hasMedia,
58
69
  hasTitle,
59
70
  cursor,
@@ -61,6 +72,10 @@ publicArchiveApiRoutes.get("/", async (c) => {
61
72
  content,
62
73
  } = parseValidated(ListPublicArchiveQuerySchema, c.req.query());
63
74
 
75
+ const mediaKinds = Array.isArray(media) ? media : undefined;
76
+ const mediaPresence =
77
+ media === "any" ? true : media === "none" ? false : undefined;
78
+
64
79
  let collectionIds: string[] | undefined;
65
80
  if (collection) {
66
81
  // Accept both "tech,art" and "tech+art", matching the page URL convention.
@@ -89,9 +104,10 @@ publicArchiveApiRoutes.get("/", async (c) => {
89
104
  excludeReplies: true,
90
105
  publishedAfter,
91
106
  publishedBefore,
92
- mediaKinds: media,
93
- hasMedia,
94
- hasTitle,
107
+ mediaKinds,
108
+ hasMedia: mediaPresence ?? hasMedia,
109
+ hasTitle: title ?? hasTitle,
110
+ hasReplies: replies,
95
111
  });
96
112
 
97
113
  const postIds = posts.map((post) => post.id);
@@ -195,7 +195,8 @@ settingsApiRoutes.post("/avatar", requireAuthApi(), async (c) => {
195
195
 
196
196
  // Remove site avatar (requires auth)
197
197
  settingsApiRoutes.delete("/avatar", requireAuthApi(), async (c) => {
198
- await c.var.services.settings.removeAvatar(c.var.storage, {
198
+ await c.var.services.settings.removeAvatar({
199
+ storage: c.var.storage,
199
200
  media: c.var.services.media,
200
201
  storageProvider: c.var.appConfig.storageDriver,
201
202
  });
@@ -66,7 +66,8 @@ function uploadConfigFromEnv(env: Bindings): {
66
66
  maxFileSizeMB: number;
67
67
  } {
68
68
  const maxFileSizeMB =
69
- parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "500", 10) || 500;
69
+ parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "1024", 10) ||
70
+ 1024;
70
71
  return {
71
72
  storageDriver: getConfiguredStorageDriver(env),
72
73
  maxFileSizeMB,
@@ -79,6 +79,20 @@ describe("Setup bootstrap logic", () => {
79
79
  );
80
80
  });
81
81
 
82
+ it("pins the dashboard language to the detected catalog locale", async () => {
83
+ await runSetupBootstrap(services, { siteLanguage: "zh-TW" });
84
+
85
+ const rows = await services.db.select().from(settings);
86
+ // Content language stays verbatim; the dashboard locale is the resolved
87
+ // catalog (zh-Hant) so it is stable if content language later changes.
88
+ expect(rows.find((row) => row.key === "SITE_LANGUAGE")?.value).toBe(
89
+ "zh-TW",
90
+ );
91
+ expect(rows.find((row) => row.key === "DASHBOARD_LANGUAGE")?.value).toBe(
92
+ "zh-Hant",
93
+ );
94
+ });
95
+
82
96
  it("is idempotent when default navigation already exists", async () => {
83
97
  const timestamp = Math.floor(Date.now() / 1000);
84
98
  await services.db.run(sql`
@@ -215,29 +215,47 @@ describe("Settings - Avatar Upload Logic", () => {
215
215
  expect(await settingsService.get("SITE_FAVICON_VERSION")).toBeNull();
216
216
  });
217
217
 
218
- it("deletes apple-touch-icon from storage when storage is provided", async () => {
218
+ it("removes the apple-touch-icon media row and retires its object", async () => {
219
219
  const storage = createMockStorage();
220
- await settingsService.set(
221
- "SITE_FAVICON_APPLE_TOUCH",
222
- `media/${DEFAULT_TEST_SITE_ID}/assets/favicon/apple-touch-icon.png`,
223
- );
224
-
225
- await settingsService.removeAvatar(storage);
226
-
227
- expect(storage.delete).toHaveBeenCalledWith(
228
- `media/${DEFAULT_TEST_SITE_ID}/assets/favicon/apple-touch-icon.png`,
229
- );
220
+ const appleTouchKey = `media/${DEFAULT_TEST_SITE_ID}/assets/favicon/apple-touch-icon.png`;
221
+ const media = await mediaService.create({
222
+ filename: "apple-touch-icon.png",
223
+ originalName: "apple-touch-icon.png",
224
+ mimeType: "image/png",
225
+ size: 1234,
226
+ storageKey: appleTouchKey,
227
+ provider: "r2",
228
+ });
229
+ await settingsService.set("SITE_FAVICON_APPLE_TOUCH", appleTouchKey);
230
+
231
+ await settingsService.removeAvatar({
232
+ storage,
233
+ media: mediaService,
234
+ storageProvider: "r2",
235
+ });
236
+
237
+ // Row removed and setting cleared; the object is retired via storage
238
+ // (this mock has no server-side copy, so it's deleted outright).
239
+ expect(await mediaService.getById(media.id)).toBeNull();
240
+ expect(await settingsService.get("SITE_FAVICON_APPLE_TOUCH")).toBeNull();
241
+ expect(storage.delete).toHaveBeenCalledWith(appleTouchKey);
230
242
  });
231
243
 
232
- it("skips storage delete when no apple-touch-icon key exists", async () => {
233
- const storage = createMockStorage();
244
+ it("is a no-op for media when no apple-touch-icon key exists", async () => {
245
+ await settingsService.set(
246
+ "SITE_AVATAR",
247
+ `media/${DEFAULT_TEST_SITE_ID}/assets/avatar/some-id.png`,
248
+ );
234
249
 
235
- await settingsService.removeAvatar(storage);
250
+ await settingsService.removeAvatar({
251
+ media: mediaService,
252
+ storageProvider: "r2",
253
+ });
236
254
 
237
- expect(storage.delete).not.toHaveBeenCalled();
255
+ expect(await settingsService.get("SITE_AVATAR")).toBeNull();
238
256
  });
239
257
 
240
- it("handles null storage gracefully", async () => {
258
+ it("clears settings even without a media service", async () => {
241
259
  await settingsService.set(
242
260
  "SITE_AVATAR",
243
261
  `media/${DEFAULT_TEST_SITE_ID}/assets/avatar/some-id.png`,
@@ -247,7 +265,7 @@ describe("Settings - Avatar Upload Logic", () => {
247
265
  `media/${DEFAULT_TEST_SITE_ID}/assets/favicon/apple-touch-icon.png`,
248
266
  );
249
267
 
250
- await settingsService.removeAvatar(null);
268
+ await settingsService.removeAvatar();
251
269
 
252
270
  expect(await settingsService.get("SITE_AVATAR")).toBeNull();
253
271
  expect(await settingsService.get("SITE_FAVICON_APPLE_TOUCH")).toBeNull();
@@ -507,7 +507,7 @@ function NewCustomUrlContent({
507
507
  type="text"
508
508
  data-bind="archiveQuery"
509
509
  class="input"
510
- placeholder="format=note&hasTitle=0&visibility=public&view=list"
510
+ placeholder="format=note&title=none&visibility=public&view=list"
511
511
  />
512
512
  <p class="text-xs text-muted-foreground mt-1">
513
513
  {i18n._(
@@ -13,7 +13,7 @@ import { z } from "zod";
13
13
  import type { Bindings } from "../../types.js";
14
14
  import type { AppVariables } from "../../types/app-context.js";
15
15
  import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
16
- import { getI18n } from "../../i18n/index.js";
16
+ import { getI18n, isLocale, resolveCatalogLocale } from "../../i18n/index.js";
17
17
  import { renderPublicPage } from "../../lib/render.js";
18
18
  import { getNavigationData } from "../../lib/navigation.js";
19
19
  import { buildPageTitle } from "../../lib/page-title.js";
@@ -57,6 +57,7 @@ import {
57
57
  getHostedControlPlaneAccountUrl,
58
58
  getHostedControlPlaneProviderLabel,
59
59
  getHostedControlPlaneSiteDeleteUrl,
60
+ getHostedControlPlaneSiteSettingsUrl,
60
61
  } from "../../lib/hosted-signin.js";
61
62
  import { syncHostedControlPlaneSiteAvatar } from "../../lib/hosted-control-plane-sync.js";
62
63
  import {
@@ -93,6 +94,9 @@ export const settingsRoutes = new Hono<Env>();
93
94
 
94
95
  const UpdateLocaleSettingsSchema = z.object({
95
96
  siteLanguage: z.string(),
97
+ // Optional for back-compat: absent = leave the dashboard language untouched,
98
+ // "" = clear it (follow content language).
99
+ dashboardLanguage: z.string().optional(),
96
100
  cjkSerifFont: z.string(),
97
101
  timeZone: z.string(),
98
102
  });
@@ -265,6 +269,11 @@ function demoRestrictionResponse(c: Context<Env>, message: string): Response {
265
269
 
266
270
  settingsRoutes.get("/", async (c) => {
267
271
  const navData = await getNavigationData(c);
272
+ const hostedControlPlaneSiteSettingsUrl =
273
+ getHostedControlPlaneSiteSettingsUrl(c.env, c.var.currentSite.id);
274
+ const hostedControlPlaneProviderLabel = getHostedControlPlaneProviderLabel(
275
+ c.env,
276
+ );
268
277
 
269
278
  return renderPublicPage(c, {
270
279
  title: buildPageTitle("Settings", navData.siteName),
@@ -274,6 +283,8 @@ settingsRoutes.get("/", async (c) => {
274
283
  <SettingsRootContent
275
284
  sitePathPrefix={c.var.appConfig.sitePathPrefix}
276
285
  demoMode={c.var.appConfig.demoMode}
286
+ hostedControlPlaneSiteSettingsUrl={hostedControlPlaneSiteSettingsUrl}
287
+ hostedControlPlaneProviderLabel={hostedControlPlaneProviderLabel}
277
288
  />
278
289
  </div>
279
290
  ),
@@ -310,6 +321,11 @@ settingsRoutes.get("/general", async (c) => {
310
321
  siteName={dbSiteName || ""}
311
322
  siteDescription={dbSiteDescription || ""}
312
323
  siteLanguage={appConfig.siteLanguage}
324
+ dashboardLanguage={
325
+ isLocale(appConfig.dashboardLanguage)
326
+ ? appConfig.dashboardLanguage
327
+ : resolveCatalogLocale(appConfig.siteLanguage)
328
+ }
313
329
  cjkSerifFont={appConfig.cjkSerifFont}
314
330
  siteNameFallback={appConfig.fallbacks.siteName}
315
331
  siteDescriptionFallback={appConfig.fallbacks.siteDescription}
@@ -419,6 +435,7 @@ settingsRoutes.post("/general/language-time", async (c) => {
419
435
  await c.var.services.settings.updateLocaleSettings(body, {
420
436
  oldLanguage: c.var.appConfig.siteLanguage,
421
437
  oldCjkSerifFont: c.var.appConfig.cjkSerifFont,
438
+ oldDashboardLanguage: c.var.appConfig.dashboardLanguage,
422
439
  });
423
440
 
424
441
  const wantsJson = c.req.header("accept")?.includes("application/json");
@@ -643,7 +660,11 @@ settingsRoutes.post("/avatar", async (c) => {
643
660
  });
644
661
 
645
662
  settingsRoutes.post("/avatar/remove", async (c) => {
646
- await c.var.services.settings.removeAvatar(c.var.storage);
663
+ await c.var.services.settings.removeAvatar({
664
+ storage: c.var.storage,
665
+ media: c.var.services.media,
666
+ storageProvider: c.var.appConfig.storageDriver,
667
+ });
647
668
  try {
648
669
  await syncHostedControlPlaneSiteAvatar({
649
670
  appConfig: c.var.appConfig,
@@ -990,10 +1011,6 @@ settingsRoutes.get("/account", async (c) => {
990
1011
  const hostedControlPlaneProviderLabel = getHostedControlPlaneProviderLabel(
991
1012
  c.env,
992
1013
  );
993
- const hostedControlPlaneSiteDeleteUrl = getHostedControlPlaneSiteDeleteUrl(
994
- c.env,
995
- c.var.currentSite.id,
996
- );
997
1014
 
998
1015
  return renderPublicPage(c, {
999
1016
  title: buildPageTitle("Account", navData.siteName),
@@ -1010,7 +1027,6 @@ settingsRoutes.get("/account", async (c) => {
1010
1027
  demoMode={c.var.appConfig.demoMode}
1011
1028
  hostedControlPlaneAccountUrl={hostedControlPlaneAccountUrl}
1012
1029
  hostedControlPlaneProviderLabel={hostedControlPlaneProviderLabel}
1013
- hostedControlPlaneSiteDeleteUrl={hostedControlPlaneSiteDeleteUrl}
1014
1030
  />
1015
1031
  </>
1016
1032
  ),