@jant/core 0.3.35 → 0.3.37

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 (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Custom URLs Routes
3
+ *
4
+ * Mounted under /settings/custom-urls
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import { useLingui } from "@lingui/react/macro";
9
+ import type { Bindings, CustomUrl } from "../../types.js";
10
+ import type { AppVariables } from "../../types/app-context.js";
11
+ import { EmptyState, ListItemRow, ActionButtons } from "../../ui/dash/index.js";
12
+ import { dsRedirect } from "../../lib/sse.js";
13
+ import { parseIdParam } from "../../lib/errors.js";
14
+ import { CreateCustomUrlSchema, parseValidated } from "../../lib/schemas.js";
15
+ import { renderPublicPage } from "../../lib/render.js";
16
+ import { getNavigationData } from "../../lib/navigation.js";
17
+ import { AdminBreadcrumb } from "../../ui/shared/AdminBreadcrumb.js";
18
+ import { PagePagination } from "../../ui/shared/Pagination.js";
19
+ import { DEFAULT_PAGE_SIZE } from "../../lib/constants.js";
20
+
21
+ type Env = { Bindings: Bindings; Variables: AppVariables };
22
+
23
+ export const customUrlsRoutes = new Hono<Env>();
24
+
25
+ function targetBadge(targetType: CustomUrl["targetType"]) {
26
+ switch (targetType) {
27
+ case "post":
28
+ return "Post";
29
+ case "collection":
30
+ return "Collection";
31
+ case "redirect":
32
+ return "Redirect";
33
+ }
34
+ }
35
+
36
+ function CustomUrlsListContent({
37
+ customUrls,
38
+ targetSlugs,
39
+ currentPage,
40
+ totalPages,
41
+ }: {
42
+ customUrls: CustomUrl[];
43
+ targetSlugs: Record<string, string>;
44
+ currentPage: number;
45
+ totalPages: number;
46
+ }) {
47
+ const { t } = useLingui();
48
+
49
+ return (
50
+ <>
51
+ <div class="flex items-center justify-between mb-6">
52
+ <h2 class="text-lg font-medium">
53
+ {t({
54
+ message: "Custom URLs",
55
+ comment: "@context: Settings section heading",
56
+ })}
57
+ </h2>
58
+ <a href="/settings/custom-urls/new" class="btn">
59
+ {t({
60
+ message: "New Custom URL",
61
+ comment: "@context: Button to create new custom URL",
62
+ })}
63
+ </a>
64
+ </div>
65
+
66
+ {customUrls.length === 0 ? (
67
+ <EmptyState
68
+ message={t({
69
+ message:
70
+ "No custom URLs yet. Create one to add redirects or custom paths for posts.",
71
+ comment: "@context: Empty state message",
72
+ })}
73
+ ctaText={t({
74
+ message: "New Custom URL",
75
+ comment: "@context: Button to create new custom URL",
76
+ })}
77
+ ctaHref="/settings/custom-urls/new"
78
+ />
79
+ ) : (
80
+ <>
81
+ <div class="flex flex-col divide-y">
82
+ {customUrls.map((cu) => (
83
+ <ListItemRow
84
+ key={cu.id}
85
+ actions={
86
+ <ActionButtons
87
+ deleteAction={`/settings/custom-urls/${cu.id}/delete`}
88
+ deleteLabel={t({
89
+ message: "Delete",
90
+ comment: "@context: Button to delete custom URL",
91
+ })}
92
+ />
93
+ }
94
+ >
95
+ <div class="flex items-center gap-2">
96
+ <code class="text-sm bg-muted px-1 rounded">/{cu.path}</code>
97
+ <span class="text-muted-foreground">&rarr;</span>
98
+ {cu.targetType === "redirect" ? (
99
+ <code class="text-sm bg-muted px-1 rounded">
100
+ {cu.toPath}
101
+ </code>
102
+ ) : (
103
+ <code class="text-sm bg-muted px-1 rounded">
104
+ /
105
+ {cu.targetId
106
+ ? (targetSlugs[cu.targetId] ?? cu.targetId)
107
+ : "?"}
108
+ </code>
109
+ )}
110
+ <span class="badge-outline">
111
+ {targetBadge(cu.targetType)}
112
+ </span>
113
+ {cu.targetType === "redirect" && cu.redirectType && (
114
+ <span class="badge-outline">{cu.redirectType}</span>
115
+ )}
116
+ </div>
117
+ </ListItemRow>
118
+ ))}
119
+ </div>
120
+ <PagePagination
121
+ baseUrl="/settings/custom-urls"
122
+ currentPage={currentPage}
123
+ totalPages={totalPages}
124
+ />
125
+ </>
126
+ )}
127
+ </>
128
+ );
129
+ }
130
+
131
+ function NewCustomUrlContent() {
132
+ const { t } = useLingui();
133
+
134
+ return (
135
+ <>
136
+ <h2 class="text-lg font-medium mb-6">
137
+ {t({ message: "New Custom URL", comment: "@context: Page heading" })}
138
+ </h2>
139
+
140
+ <form
141
+ data-signals="{path: '', targetType: 'redirect', targetId: '', toPath: '', redirectType: '301'}"
142
+ data-on:submit__prevent="@post('/settings/custom-urls')"
143
+ data-indicator="_loading"
144
+ class="flex flex-col gap-4 max-w-lg"
145
+ >
146
+ <div class="field">
147
+ <label class="label">
148
+ {t({
149
+ message: "Path",
150
+ comment: "@context: Custom URL form field",
151
+ })}
152
+ </label>
153
+ <input
154
+ type="text"
155
+ data-bind="path"
156
+ class="input"
157
+ placeholder="blog/my-post"
158
+ required
159
+ />
160
+ <p class="text-xs text-muted-foreground mt-1">
161
+ {t({
162
+ message: "The custom URL path (without leading slash)",
163
+ comment: "@context: Custom URL path help text",
164
+ })}
165
+ </p>
166
+ </div>
167
+
168
+ <div class="field">
169
+ <label class="label">
170
+ {t({
171
+ message: "Type",
172
+ comment: "@context: Custom URL form field",
173
+ })}
174
+ </label>
175
+ <select data-bind="targetType" class="select">
176
+ <option value="redirect">
177
+ {t({
178
+ message: "Redirect",
179
+ comment: "@context: Custom URL type option",
180
+ })}
181
+ </option>
182
+ <option value="post">
183
+ {t({
184
+ message: "Post",
185
+ comment: "@context: Custom URL type option",
186
+ })}
187
+ </option>
188
+ <option value="collection">
189
+ {t({
190
+ message: "Collection",
191
+ comment: "@context: Custom URL type option",
192
+ })}
193
+ </option>
194
+ </select>
195
+ </div>
196
+
197
+ <div data-show="$targetType === 'redirect'" class="flex flex-col gap-4">
198
+ <div class="field">
199
+ <label class="label">
200
+ {t({
201
+ message: "Destination",
202
+ comment: "@context: Redirect destination field",
203
+ })}
204
+ </label>
205
+ <input
206
+ type="text"
207
+ data-bind="toPath"
208
+ class="input"
209
+ placeholder="/new-path or https://..."
210
+ />
211
+ </div>
212
+
213
+ <div class="field">
214
+ <label class="label">
215
+ {t({
216
+ message: "Redirect Type",
217
+ comment: "@context: Redirect type field",
218
+ })}
219
+ </label>
220
+ <select data-bind="redirectType" class="select">
221
+ <option value="301">
222
+ {t({
223
+ message: "301 (Permanent)",
224
+ comment: "@context: Redirect type option",
225
+ })}
226
+ </option>
227
+ <option value="302">
228
+ {t({
229
+ message: "302 (Temporary)",
230
+ comment: "@context: Redirect type option",
231
+ })}
232
+ </option>
233
+ </select>
234
+ </div>
235
+ </div>
236
+
237
+ <div
238
+ data-show="$targetType === 'post' || $targetType === 'collection'"
239
+ class="field"
240
+ >
241
+ <label class="label">
242
+ {t({
243
+ message: "Target Slug",
244
+ comment: "@context: Custom URL target slug field",
245
+ })}
246
+ </label>
247
+ <input
248
+ type="text"
249
+ data-bind="targetId"
250
+ class="input"
251
+ placeholder="my-post-slug"
252
+ />
253
+ <p class="text-xs text-muted-foreground mt-1">
254
+ {t({
255
+ message: "The slug of the target post or collection",
256
+ comment: "@context: Custom URL target slug help text",
257
+ })}
258
+ </p>
259
+ </div>
260
+
261
+ <div class="flex gap-2">
262
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
263
+ <svg
264
+ data-show="$_loading"
265
+ style="display:none"
266
+ class="animate-spin size-4"
267
+ xmlns="http://www.w3.org/2000/svg"
268
+ viewBox="0 0 24 24"
269
+ fill="none"
270
+ stroke="currentColor"
271
+ stroke-width="2"
272
+ stroke-linecap="round"
273
+ stroke-linejoin="round"
274
+ role="status"
275
+ >
276
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
277
+ </svg>
278
+ {t({
279
+ message: "Create Custom URL",
280
+ comment: "@context: Button to save new custom URL",
281
+ })}
282
+ </button>
283
+ <a href="/settings/custom-urls" class="btn-outline">
284
+ {t({
285
+ message: "Cancel",
286
+ comment: "@context: Button to cancel form",
287
+ })}
288
+ </a>
289
+ </div>
290
+ </form>
291
+ </>
292
+ );
293
+ }
294
+
295
+ // List custom URLs
296
+ customUrlsRoutes.get("/", async (c) => {
297
+ const pageParam = c.req.query("page");
298
+ const currentPage = Math.max(1, parseInt(pageParam || "1", 10) || 1);
299
+
300
+ const [total, customUrlsList] = await Promise.all([
301
+ c.var.services.customUrls.count(),
302
+ c.var.services.customUrls.list({
303
+ limit: DEFAULT_PAGE_SIZE,
304
+ offset: (currentPage - 1) * DEFAULT_PAGE_SIZE,
305
+ }),
306
+ ]);
307
+
308
+ const totalPages = Math.max(1, Math.ceil(total / DEFAULT_PAGE_SIZE));
309
+
310
+ // Resolve target UUIDs → slugs for display
311
+ const targetSlugs: Record<string, string> = {};
312
+ for (const cu of customUrlsList) {
313
+ if (!cu.targetId || cu.targetType === "redirect") continue;
314
+ if (cu.targetType === "post") {
315
+ const post = await c.var.services.posts.getById(cu.targetId);
316
+ if (post) targetSlugs[cu.targetId] = post.slug;
317
+ } else if (cu.targetType === "collection") {
318
+ const col = await c.var.services.collections.getById(cu.targetId);
319
+ if (col) targetSlugs[cu.targetId] = col.slug;
320
+ }
321
+ }
322
+
323
+ const navData = await getNavigationData(c);
324
+
325
+ return renderPublicPage(c, {
326
+ title: `Custom URLs - ${navData.siteName}`,
327
+ navData,
328
+ content: (
329
+ <>
330
+ <AdminBreadcrumb
331
+ parent="Settings"
332
+ parentHref="/settings"
333
+ current="Custom URLs"
334
+ />
335
+ <CustomUrlsListContent
336
+ customUrls={customUrlsList}
337
+ targetSlugs={targetSlugs}
338
+ currentPage={currentPage}
339
+ totalPages={totalPages}
340
+ />
341
+ </>
342
+ ),
343
+ });
344
+ });
345
+
346
+ // New custom URL form
347
+ customUrlsRoutes.get("/new", async (c) => {
348
+ const navData = await getNavigationData(c);
349
+
350
+ return renderPublicPage(c, {
351
+ title: `New Custom URL - ${navData.siteName}`,
352
+ navData,
353
+ content: (
354
+ <>
355
+ <AdminBreadcrumb
356
+ parent="Settings"
357
+ parentHref="/settings"
358
+ current="Custom URLs"
359
+ />
360
+ <NewCustomUrlContent />
361
+ </>
362
+ ),
363
+ });
364
+ });
365
+
366
+ // Create custom URL
367
+ customUrlsRoutes.post("/", async (c) => {
368
+ const body = parseValidated(CreateCustomUrlSchema, await c.req.json());
369
+
370
+ const redirectType = body.redirectType
371
+ ? (parseInt(body.redirectType, 10) as 301 | 302)
372
+ : undefined;
373
+
374
+ // Resolve slug → ID for post/collection targets
375
+ let targetId = body.targetId;
376
+ if (body.targetType === "post" && body.targetId) {
377
+ const post = await c.var.services.posts.getBySlug(body.targetId);
378
+ if (!post) {
379
+ return c.json(
380
+ { error: `Post with slug "${body.targetId}" not found` },
381
+ 404,
382
+ );
383
+ }
384
+ targetId = post.id;
385
+ }
386
+ if (body.targetType === "collection" && body.targetId) {
387
+ const col = await c.var.services.collections.getBySlug(body.targetId);
388
+ if (!col) {
389
+ return c.json(
390
+ { error: `Collection with slug "${body.targetId}" not found` },
391
+ 404,
392
+ );
393
+ }
394
+ targetId = col.id;
395
+ }
396
+
397
+ await c.var.services.customUrls.create({
398
+ path: body.path,
399
+ targetType: body.targetType,
400
+ targetId,
401
+ toPath: body.toPath,
402
+ redirectType,
403
+ });
404
+
405
+ return dsRedirect("/settings/custom-urls");
406
+ });
407
+
408
+ // Delete custom URL
409
+ customUrlsRoutes.post("/:id/delete", async (c) => {
410
+ const id = parseIdParam(c.req.param("id"));
411
+ await c.var.services.customUrls.delete(id);
412
+
413
+ return dsRedirect("/settings/custom-urls");
414
+ });