@jant/core 0.3.23 → 0.3.25

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 (248) hide show
  1. package/dist/app.js +50 -26
  2. package/dist/db/schema.js +72 -47
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +5 -11
  7. package/dist/lib/constants.js +2 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/nav-reorder.js +1 -1
  11. package/dist/lib/navigation.js +30 -6
  12. package/dist/lib/pagination.js +44 -0
  13. package/dist/lib/render.js +7 -11
  14. package/dist/lib/schemas.js +80 -38
  15. package/dist/lib/theme.js +4 -4
  16. package/dist/lib/time.js +56 -1
  17. package/dist/lib/timeline.js +95 -0
  18. package/dist/lib/view.js +61 -72
  19. package/dist/routes/api/collections.js +124 -0
  20. package/dist/routes/api/nav-items.js +104 -0
  21. package/dist/routes/api/pages.js +91 -0
  22. package/dist/routes/api/posts.js +27 -33
  23. package/dist/routes/api/search.js +4 -5
  24. package/dist/routes/api/settings.js +68 -0
  25. package/dist/routes/api/upload.js +13 -13
  26. package/dist/routes/compose.js +48 -0
  27. package/dist/routes/dash/collections.js +24 -42
  28. package/dist/routes/dash/index.js +3 -3
  29. package/dist/routes/dash/media.js +2 -2
  30. package/dist/routes/dash/pages.js +440 -106
  31. package/dist/routes/dash/posts.js +27 -37
  32. package/dist/routes/dash/redirects.js +2 -2
  33. package/dist/routes/dash/settings.js +79 -5
  34. package/dist/routes/feed/rss.js +4 -6
  35. package/dist/routes/feed/sitemap.js +11 -8
  36. package/dist/routes/pages/archive.js +13 -15
  37. package/dist/routes/pages/collection.js +12 -9
  38. package/dist/routes/pages/collections.js +28 -0
  39. package/dist/routes/pages/featured.js +32 -0
  40. package/dist/routes/pages/home.js +19 -68
  41. package/dist/routes/pages/page.js +57 -29
  42. package/dist/routes/pages/post.js +7 -17
  43. package/dist/routes/pages/search.js +5 -9
  44. package/dist/services/collection.js +52 -64
  45. package/dist/services/index.js +5 -3
  46. package/dist/services/navigation.js +29 -53
  47. package/dist/services/page.js +84 -0
  48. package/dist/services/post.js +102 -69
  49. package/dist/services/search.js +24 -18
  50. package/dist/types.js +24 -40
  51. package/dist/ui/compose/ComposeDialog.js +452 -0
  52. package/dist/ui/compose/ComposePrompt.js +55 -0
  53. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +3 -15
  54. package/dist/{theme/components → ui/dash}/PageForm.js +15 -15
  55. package/dist/{theme/components → ui/dash}/PostForm.js +117 -137
  56. package/dist/{theme/components → ui/dash}/PostList.js +18 -13
  57. package/dist/ui/dash/StatusBadge.js +46 -0
  58. package/dist/{theme/components → ui/dash}/index.js +3 -6
  59. package/dist/ui/feed/LinkCard.js +72 -0
  60. package/dist/ui/feed/NoteCard.js +58 -0
  61. package/dist/{themes/minimal/timeline → ui/feed}/QuoteCard.js +29 -14
  62. package/dist/{themes/minimal/timeline → ui/feed}/ThreadPreview.js +20 -18
  63. package/dist/ui/feed/TimelineFeed.js +41 -0
  64. package/dist/ui/feed/TimelineItem.js +27 -0
  65. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  66. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  67. package/dist/ui/layouts/SiteLayout.js +141 -0
  68. package/dist/{themes/minimal → ui}/pages/ArchivePage.js +37 -50
  69. package/dist/ui/pages/CollectionPage.js +70 -0
  70. package/dist/ui/pages/CollectionsPage.js +76 -0
  71. package/dist/ui/pages/FeaturedPage.js +24 -0
  72. package/dist/ui/pages/HomePage.js +24 -0
  73. package/dist/{themes/minimal → ui}/pages/PostPage.js +20 -12
  74. package/dist/{themes/minimal → ui}/pages/SearchPage.js +19 -18
  75. package/dist/{themes/minimal → ui}/pages/SinglePage.js +5 -4
  76. package/dist/ui/shared/MediaGallery.js +35 -0
  77. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  78. package/dist/{theme/components → ui/shared}/ThreadView.js +3 -3
  79. package/dist/ui/shared/index.js +5 -0
  80. package/package.json +2 -9
  81. package/src/__tests__/helpers/app.ts +4 -0
  82. package/src/__tests__/helpers/db.ts +53 -73
  83. package/src/app.tsx +56 -28
  84. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  85. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  86. package/src/db/migrations/meta/_journal.json +14 -0
  87. package/src/db/schema.ts +63 -46
  88. package/src/i18n/locales/en.po +443 -240
  89. package/src/i18n/locales/en.ts +1 -1
  90. package/src/i18n/locales/zh-Hans.po +443 -240
  91. package/src/i18n/locales/zh-Hans.ts +1 -1
  92. package/src/i18n/locales/zh-Hant.po +443 -240
  93. package/src/i18n/locales/zh-Hant.ts +1 -1
  94. package/src/index.ts +29 -42
  95. package/src/lib/__tests__/excerpt.test.ts +125 -0
  96. package/src/lib/__tests__/schemas.test.ts +201 -99
  97. package/src/lib/__tests__/time.test.ts +62 -0
  98. package/src/{routes/api → lib}/__tests__/timeline.test.ts +81 -75
  99. package/src/lib/__tests__/view.test.ts +204 -50
  100. package/src/lib/constants.ts +2 -4
  101. package/src/lib/excerpt.ts +87 -0
  102. package/src/lib/feed.ts +22 -7
  103. package/src/lib/nav-reorder.ts +1 -1
  104. package/src/lib/navigation.ts +45 -8
  105. package/src/lib/pagination.ts +50 -0
  106. package/src/lib/render.tsx +7 -14
  107. package/src/lib/schemas.ts +119 -51
  108. package/src/lib/theme.ts +5 -5
  109. package/src/lib/time.ts +64 -0
  110. package/src/lib/timeline.ts +141 -0
  111. package/src/lib/view.ts +80 -82
  112. package/src/preset.css +46 -0
  113. package/src/routes/__tests__/compose.test.ts +199 -0
  114. package/src/routes/api/__tests__/collections.test.ts +249 -0
  115. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  116. package/src/routes/api/__tests__/pages.test.ts +218 -0
  117. package/src/routes/api/__tests__/posts.test.ts +50 -108
  118. package/src/routes/api/__tests__/search.test.ts +2 -3
  119. package/src/routes/api/__tests__/settings.test.ts +132 -0
  120. package/src/routes/api/collections.ts +143 -0
  121. package/src/routes/api/nav-items.ts +115 -0
  122. package/src/routes/api/pages.ts +101 -0
  123. package/src/routes/api/posts.ts +28 -28
  124. package/src/routes/api/search.ts +3 -3
  125. package/src/routes/api/settings.ts +91 -0
  126. package/src/routes/api/upload.ts +16 -6
  127. package/src/routes/compose.ts +63 -0
  128. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  129. package/src/routes/dash/collections.tsx +20 -42
  130. package/src/routes/dash/index.tsx +3 -3
  131. package/src/routes/dash/media.tsx +2 -2
  132. package/src/routes/dash/pages.tsx +480 -122
  133. package/src/routes/dash/posts.tsx +42 -54
  134. package/src/routes/dash/redirects.tsx +2 -2
  135. package/src/routes/dash/settings.tsx +83 -5
  136. package/src/routes/feed/rss.ts +4 -3
  137. package/src/routes/feed/sitemap.ts +15 -5
  138. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  139. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  140. package/src/routes/pages/archive.tsx +15 -15
  141. package/src/routes/pages/collection.tsx +16 -9
  142. package/src/routes/pages/collections.tsx +36 -0
  143. package/src/routes/pages/featured.tsx +38 -0
  144. package/src/routes/pages/home.tsx +21 -92
  145. package/src/routes/pages/page.tsx +62 -27
  146. package/src/routes/pages/post.tsx +6 -18
  147. package/src/routes/pages/search.tsx +3 -7
  148. package/src/services/__tests__/collection.test.ts +257 -158
  149. package/src/services/__tests__/media.test.ts +18 -18
  150. package/src/services/__tests__/navigation.test.ts +161 -87
  151. package/src/services/__tests__/page.test.ts +106 -0
  152. package/src/services/__tests__/post-timeline.test.ts +92 -88
  153. package/src/services/__tests__/post.test.ts +432 -197
  154. package/src/services/__tests__/search.test.ts +19 -25
  155. package/src/services/collection.ts +71 -113
  156. package/src/services/index.ts +9 -8
  157. package/src/services/navigation.ts +38 -71
  158. package/src/services/page.ts +136 -0
  159. package/src/services/post.ts +141 -101
  160. package/src/services/search.ts +38 -27
  161. package/src/styles/tokens.css +47 -0
  162. package/src/styles/ui.css +491 -0
  163. package/src/types.ts +212 -198
  164. package/src/ui/compose/ComposeDialog.tsx +395 -0
  165. package/src/ui/compose/ComposePrompt.tsx +55 -0
  166. package/src/ui/dash/FormatBadge.tsx +28 -0
  167. package/src/{theme/components → ui/dash}/PageForm.tsx +21 -21
  168. package/src/{theme/components → ui/dash}/PostForm.tsx +110 -131
  169. package/src/ui/dash/PostList.tsx +101 -0
  170. package/src/ui/dash/StatusBadge.tsx +61 -0
  171. package/src/ui/dash/index.ts +10 -0
  172. package/src/ui/feed/LinkCard.tsx +72 -0
  173. package/src/ui/feed/NoteCard.tsx +63 -0
  174. package/src/ui/feed/QuoteCard.tsx +68 -0
  175. package/src/ui/feed/ThreadPreview.tsx +48 -0
  176. package/src/ui/feed/TimelineFeed.tsx +49 -0
  177. package/src/ui/feed/TimelineItem.tsx +45 -0
  178. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  179. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  180. package/src/ui/layouts/SiteLayout.tsx +150 -0
  181. package/src/ui/pages/ArchivePage.tsx +162 -0
  182. package/src/ui/pages/CollectionPage.tsx +70 -0
  183. package/src/ui/pages/CollectionsPage.tsx +73 -0
  184. package/src/ui/pages/FeaturedPage.tsx +31 -0
  185. package/src/ui/pages/HomePage.tsx +37 -0
  186. package/src/ui/pages/PostPage.tsx +56 -0
  187. package/src/{themes/minimal → ui}/pages/SearchPage.tsx +24 -20
  188. package/src/{themes/minimal → ui}/pages/SinglePage.tsx +5 -5
  189. package/src/ui/shared/MediaGallery.tsx +59 -0
  190. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  191. package/src/{theme/components → ui/shared}/ThreadView.tsx +6 -3
  192. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  193. package/src/ui/shared/index.ts +12 -0
  194. package/bin/jant.js +0 -185
  195. package/dist/lib/theme-components.js +0 -49
  196. package/dist/routes/api/timeline.js +0 -120
  197. package/dist/routes/dash/navigation.js +0 -288
  198. package/dist/theme/components/MediaGallery.js +0 -107
  199. package/dist/theme/components/VisibilityBadge.js +0 -37
  200. package/dist/theme/index.js +0 -18
  201. package/dist/theme/layouts/index.js +0 -2
  202. package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
  203. package/dist/themes/minimal/index.js +0 -65
  204. package/dist/themes/minimal/pages/CollectionPage.js +0 -65
  205. package/dist/themes/minimal/pages/HomePage.js +0 -25
  206. package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
  207. package/dist/themes/minimal/timeline/ImageCard.js +0 -67
  208. package/dist/themes/minimal/timeline/LinkCard.js +0 -47
  209. package/dist/themes/minimal/timeline/NoteCard.js +0 -34
  210. package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
  211. package/dist/themes/minimal/timeline/TimelineItem.js +0 -44
  212. package/src/lib/__tests__/theme-components.test.ts +0 -126
  213. package/src/lib/theme-components.ts +0 -68
  214. package/src/routes/api/timeline.tsx +0 -159
  215. package/src/routes/dash/navigation.tsx +0 -316
  216. package/src/theme/components/MediaGallery.tsx +0 -128
  217. package/src/theme/components/PostList.tsx +0 -92
  218. package/src/theme/components/TypeBadge.tsx +0 -37
  219. package/src/theme/components/VisibilityBadge.tsx +0 -45
  220. package/src/theme/components/index.ts +0 -23
  221. package/src/theme/index.ts +0 -22
  222. package/src/theme/layouts/index.ts +0 -7
  223. package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
  224. package/src/themes/minimal/index.ts +0 -83
  225. package/src/themes/minimal/pages/ArchivePage.tsx +0 -157
  226. package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
  227. package/src/themes/minimal/pages/HomePage.tsx +0 -41
  228. package/src/themes/minimal/pages/PostPage.tsx +0 -43
  229. package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
  230. package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
  231. package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
  232. package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
  233. package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
  234. package/src/themes/minimal/timeline/ThreadPreview.tsx +0 -47
  235. package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
  236. package/src/themes/minimal/timeline/TimelineItem.tsx +0 -75
  237. /package/dist/{theme → ui}/color-themes.js +0 -0
  238. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  239. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  240. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  241. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  242. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  243. /package/src/{theme → ui}/color-themes.ts +0 -0
  244. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  245. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  246. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  247. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  248. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -1,103 +1,225 @@
1
1
  import { getSiteName } from "../../lib/config.js";
2
2
  /**
3
- * Dashboard Pages Routes
3
+ * Dashboard Pages & Navigation Routes
4
4
  *
5
- * Management for custom pages (posts with type="page")
5
+ * Unified management for pages and navigation items (pika.page style).
6
+ * Two sections: "Your site navigation" (draggable) and "Other pages".
6
7
  */
7
8
 
8
9
  import { Hono } from "hono";
9
10
  import { useLingui } from "@lingui/react/macro";
10
- import type { Bindings, Post } from "../../types.js";
11
+ import type { Bindings, Page, NavItem } from "../../types.js";
11
12
  import type { AppVariables } from "../../app.js";
12
- import { DashLayout } from "../../theme/layouts/index.js";
13
+ import { DashLayout } from "../../ui/layouts/DashLayout.js";
13
14
  import {
14
15
  PageForm,
15
- VisibilityBadge,
16
- EmptyState,
17
16
  ListItemRow,
18
17
  ActionButtons,
19
18
  CrudPageHeader,
20
19
  DangerZone,
21
- } from "../../theme/components/index.js";
22
- import * as sqid from "../../lib/sqid.js";
23
- import * as time from "../../lib/time.js";
24
- import { dsRedirect } from "../../lib/sse.js";
20
+ } from "../../ui/dash/index.js";
21
+ import { dsRedirect, dsToast } from "../../lib/sse.js";
25
22
 
26
23
  type Env = { Bindings: Bindings; Variables: AppVariables };
27
24
 
28
25
  export const pagesRoutes = new Hono<Env>();
29
26
 
30
- function PagesListContent({ pages }: { pages: Post[] }) {
27
+ // =============================================================================
28
+ // Components
29
+ // =============================================================================
30
+
31
+ function UnifiedPagesContent({
32
+ navItems,
33
+ otherPages,
34
+ }: {
35
+ navItems: NavItem[];
36
+ otherPages: Page[];
37
+ }) {
31
38
  const { t } = useLingui();
32
39
 
33
40
  return (
34
41
  <>
35
42
  <CrudPageHeader
36
- title={t({ message: "Pages", comment: "@context: Pages main heading" })}
37
- ctaLabel={t({
38
- message: "New Page",
39
- comment: "@context: Button to create new page",
43
+ title={t({
44
+ message: "Pages",
45
+ comment: "@context: Pages main heading",
40
46
  })}
41
- ctaHref="/dash/pages/new"
42
- />
43
-
44
- {pages.length === 0 ? (
45
- <EmptyState
46
- message={t({
47
- message: "No pages yet.",
48
- comment: "@context: Empty state message when no pages exist",
47
+ >
48
+ <div class="flex gap-2">
49
+ <a href="/dash/pages/links/new" class="btn-outline">
50
+ {t({
51
+ message: "Add Link",
52
+ comment: "@context: Button to add a navigation link",
53
+ })}
54
+ </a>
55
+ <a href="/dash/pages/new" class="btn">
56
+ {t({
57
+ message: "New Page",
58
+ comment: "@context: Button to create new page",
59
+ })}
60
+ </a>
61
+ </div>
62
+ </CrudPageHeader>
63
+
64
+ {/* Navigation section */}
65
+ <section class="mb-8">
66
+ <h2 class="text-lg font-medium mb-3">
67
+ {t({
68
+ message: "Your site navigation",
69
+ comment: "@context: Section heading for navigation items",
49
70
  })}
50
- ctaText={t({
51
- message: "Create your first page",
52
- comment: "@context: Button in empty state to create first page",
71
+ </h2>
72
+ {navItems.length === 0 ? (
73
+ <p class="text-sm text-muted-foreground py-4">
74
+ {t({
75
+ message:
76
+ "No navigation links yet. Add pages to navigation or create links.",
77
+ comment: "@context: Empty state for navigation section",
78
+ })}
79
+ </p>
80
+ ) : (
81
+ <div id="nav-links-list" class="flex flex-col divide-y">
82
+ {navItems.map((item) => (
83
+ <ListItemRow
84
+ key={item.id}
85
+ actions={
86
+ item.type === "page" ? (
87
+ <>
88
+ <ActionButtons
89
+ editHref={
90
+ item.pageId
91
+ ? `/dash/pages/${item.pageId}/edit`
92
+ : undefined
93
+ }
94
+ editLabel={t({
95
+ message: "Edit",
96
+ comment: "@context: Button to edit page",
97
+ })}
98
+ />
99
+ <button
100
+ type="button"
101
+ class="btn-sm-ghost"
102
+ data-on:click__prevent={`@post('/dash/pages/${item.pageId}/remove-from-nav')`}
103
+ >
104
+ {t({
105
+ message: "Un-nav",
106
+ comment:
107
+ "@context: Button to remove page from navigation",
108
+ })}
109
+ </button>
110
+ </>
111
+ ) : (
112
+ <>
113
+ <ActionButtons
114
+ editHref={`/dash/pages/links/${item.id}/edit`}
115
+ editLabel={t({
116
+ message: "Edit",
117
+ comment: "@context: Button to edit link",
118
+ })}
119
+ deleteAction={`/dash/pages/links/${item.id}/delete`}
120
+ deleteLabel={t({
121
+ message: "Delete",
122
+ comment: "@context: Button to delete link",
123
+ })}
124
+ />
125
+ </>
126
+ )
127
+ }
128
+ >
129
+ <div
130
+ class="flex items-center gap-3 cursor-grab"
131
+ data-id={item.id}
132
+ >
133
+ <span class="text-muted-foreground select-none">⠿</span>
134
+ <div class="flex items-center gap-2">
135
+ <span class="font-medium">{item.label}</span>
136
+ <code class="text-sm text-muted-foreground bg-muted px-1 rounded">
137
+ {item.url}
138
+ </code>
139
+ <span class="badge badge-sm">
140
+ {item.type === "page"
141
+ ? t({
142
+ message: "page",
143
+ comment: "@context: Nav item type badge",
144
+ })
145
+ : t({
146
+ message: "link",
147
+ comment: "@context: Nav item type badge",
148
+ })}
149
+ </span>
150
+ </div>
151
+ </div>
152
+ </ListItemRow>
153
+ ))}
154
+ </div>
155
+ )}
156
+ </section>
157
+
158
+ {/* Other pages section */}
159
+ <section>
160
+ <h2 class="text-lg font-medium mb-3">
161
+ {t({
162
+ message: "Other pages",
163
+ comment: "@context: Section heading for pages not in navigation",
53
164
  })}
54
- ctaHref="/dash/pages/new"
55
- />
56
- ) : (
57
- <div class="flex flex-col divide-y">
58
- {pages.map((page) => (
59
- <ListItemRow
60
- key={page.id}
61
- actions={
62
- <ActionButtons
63
- editHref={`/dash/pages/${sqid.encode(page.id)}/edit`}
64
- editLabel={t({
65
- message: "Edit",
66
- comment: "@context: Button to edit page",
67
- })}
68
- viewHref={
69
- page.visibility !== "draft" && page.path
70
- ? `/${page.path}`
71
- : undefined
72
- }
73
- viewLabel={t({
74
- message: "View",
75
- comment: "@context: Button to view page on public site",
76
- })}
77
- />
78
- }
79
- >
80
- <div class="flex items-center gap-2 mb-1">
81
- <VisibilityBadge visibility={page.visibility} />
82
- <span class="text-xs text-muted-foreground">
83
- {time.formatDate(page.updatedAt)}
84
- </span>
85
- </div>
86
- <a
87
- href={`/dash/pages/${sqid.encode(page.id)}`}
88
- class="font-medium hover:underline"
165
+ </h2>
166
+ {otherPages.length === 0 ? (
167
+ <p class="text-sm text-muted-foreground py-4">
168
+ {t({
169
+ message: "All pages are in your navigation.",
170
+ comment: "@context: Empty state when all pages are in nav",
171
+ })}
172
+ </p>
173
+ ) : (
174
+ <div class="flex flex-col divide-y">
175
+ {otherPages.map((page) => (
176
+ <ListItemRow
177
+ key={page.id}
178
+ actions={
179
+ <>
180
+ <button
181
+ type="button"
182
+ class="btn-sm-outline"
183
+ data-on:click__prevent={`@post('/dash/pages/${page.id}/add-to-nav')`}
184
+ >
185
+ {t({
186
+ message: "Add to nav",
187
+ comment: "@context: Button to add page to navigation",
188
+ })}
189
+ </button>
190
+ <ActionButtons
191
+ editHref={`/dash/pages/${page.id}/edit`}
192
+ editLabel={t({
193
+ message: "Edit",
194
+ comment: "@context: Button to edit page",
195
+ })}
196
+ viewHref={
197
+ page.status !== "draft" ? `/${page.slug}` : undefined
198
+ }
199
+ viewLabel={t({
200
+ message: "View",
201
+ comment: "@context: Button to view page on public site",
202
+ })}
203
+ />
204
+ </>
205
+ }
89
206
  >
90
- {page.title ||
91
- t({
92
- message: "Untitled",
93
- comment: "@context: Default title for untitled page",
94
- })}
95
- </a>
96
- <p class="text-sm text-muted-foreground mt-1">/{page.path}</p>
97
- </ListItemRow>
98
- ))}
99
- </div>
100
- )}
207
+ <a
208
+ href={`/dash/pages/${page.id}`}
209
+ class="font-medium hover:underline"
210
+ >
211
+ {page.title ||
212
+ t({
213
+ message: "Untitled",
214
+ comment: "@context: Default title for untitled page",
215
+ })}
216
+ </a>
217
+ <p class="text-sm text-muted-foreground mt-1">/{page.slug}</p>
218
+ </ListItemRow>
219
+ ))}
220
+ </div>
221
+ )}
222
+ </section>
101
223
  </>
102
224
  );
103
225
  }
@@ -114,7 +236,7 @@ function NewPageContent() {
114
236
  );
115
237
  }
116
238
 
117
- function ViewPageContent({ page }: { page: Post }) {
239
+ function ViewPageContent({ page }: { page: Page }) {
118
240
  const { t } = useLingui();
119
241
  return (
120
242
  <>
@@ -127,19 +249,15 @@ function ViewPageContent({ page }: { page: Post }) {
127
249
  comment: "@context: Default page heading when untitled",
128
250
  })}
129
251
  </h1>
130
- {page.path && <p class="text-muted-foreground mt-1">/{page.path}</p>}
252
+ <p class="text-muted-foreground mt-1">/{page.slug}</p>
131
253
  </div>
132
254
  <ActionButtons
133
- editHref={`/dash/pages/${sqid.encode(page.id)}/edit`}
255
+ editHref={`/dash/pages/${page.id}/edit`}
134
256
  editLabel={t({
135
257
  message: "Edit",
136
258
  comment: "@context: Button to edit page",
137
259
  })}
138
- viewHref={
139
- page.visibility !== "draft" && page.path
140
- ? `/${page.path}`
141
- : undefined
142
- }
260
+ viewHref={page.status !== "draft" ? `/${page.slug}` : undefined}
143
261
  viewLabel={t({
144
262
  message: "View",
145
263
  comment: "@context: Button to view page on public site",
@@ -151,7 +269,7 @@ function ViewPageContent({ page }: { page: Post }) {
151
269
  <section>
152
270
  <div
153
271
  class="prose"
154
- dangerouslySetInnerHTML={{ __html: page.contentHtml || "" }}
272
+ dangerouslySetInnerHTML={{ __html: page.bodyHtml || "" }}
155
273
  />
156
274
  </section>
157
275
  </div>
@@ -161,14 +279,14 @@ function ViewPageContent({ page }: { page: Post }) {
161
279
  message: "Delete Page",
162
280
  comment: "@context: Button to delete page",
163
281
  })}
164
- formAction={`/dash/pages/${sqid.encode(page.id)}/delete`}
282
+ formAction={`/dash/pages/${page.id}/delete`}
165
283
  confirmMessage="Are you sure you want to delete this page?"
166
284
  />
167
285
  </>
168
286
  );
169
287
  }
170
288
 
171
- function EditPageContent({ page }: { page: Post }) {
289
+ function EditPageContent({ page }: { page: Page }) {
172
290
  const { t } = useLingui();
173
291
  return (
174
292
  <>
@@ -178,18 +296,128 @@ function EditPageContent({ page }: { page: Post }) {
178
296
  comment: "@context: Edit page main heading",
179
297
  })}
180
298
  </h1>
181
- <PageForm page={page} action={`/dash/pages/${sqid.encode(page.id)}`} />
299
+ <PageForm page={page} action={`/dash/pages/${page.id}`} />
182
300
  </>
183
301
  );
184
302
  }
185
303
 
186
- // List pages
304
+ function LinkFormContent({
305
+ item,
306
+ isEdit,
307
+ }: {
308
+ item?: NavItem;
309
+ isEdit?: boolean;
310
+ }) {
311
+ const { t } = useLingui();
312
+ const title = isEdit
313
+ ? t({ message: "Edit Link", comment: "@context: Page heading" })
314
+ : t({ message: "New Link", comment: "@context: Page heading" });
315
+
316
+ const signals = JSON.stringify({
317
+ label: item?.label ?? "",
318
+ url: item?.url ?? "",
319
+ }).replace(/</g, "\\u003c");
320
+
321
+ const action = isEdit ? `/dash/pages/links/${item?.id}` : "/dash/pages/links";
322
+
323
+ return (
324
+ <>
325
+ <h1 class="text-2xl font-semibold mb-6">{title}</h1>
326
+
327
+ <form
328
+ data-signals={signals}
329
+ data-on:submit__prevent={`@post('${action}')`}
330
+ data-indicator="_loading"
331
+ class="flex flex-col gap-4 max-w-lg"
332
+ >
333
+ <div class="field">
334
+ <label class="label">
335
+ {t({
336
+ message: "Label",
337
+ comment: "@context: Navigation link form field",
338
+ })}
339
+ </label>
340
+ <input
341
+ type="text"
342
+ data-bind="label"
343
+ class="input"
344
+ placeholder="Home"
345
+ required
346
+ />
347
+ <p class="text-xs text-muted-foreground mt-1">
348
+ {t({
349
+ message: "Display text for the link",
350
+ comment: "@context: Navigation label help text",
351
+ })}
352
+ </p>
353
+ </div>
354
+
355
+ <div class="field">
356
+ <label class="label">
357
+ {t({
358
+ message: "URL",
359
+ comment: "@context: Navigation link form field",
360
+ })}
361
+ </label>
362
+ <input
363
+ type="text"
364
+ data-bind="url"
365
+ class="input"
366
+ placeholder="/archive or https://..."
367
+ required
368
+ />
369
+ <p class="text-xs text-muted-foreground mt-1">
370
+ {t({
371
+ message:
372
+ "Path (e.g. /archive) or full URL (e.g. https://example.com)",
373
+ comment: "@context: Navigation URL help text",
374
+ })}
375
+ </p>
376
+ </div>
377
+
378
+ <div class="flex gap-2">
379
+ <button type="submit" class="btn" data-attr-disabled="$_loading">
380
+ <span data-show="!$_loading">
381
+ {isEdit
382
+ ? t({
383
+ message: "Save Changes",
384
+ comment: "@context: Button to save edited navigation link",
385
+ })
386
+ : t({
387
+ message: "Create Link",
388
+ comment: "@context: Button to save new navigation link",
389
+ })}
390
+ </span>
391
+ <span data-show="$_loading">
392
+ {t({
393
+ message: "Processing...",
394
+ comment:
395
+ "@context: Loading text shown on submit button while request is in progress",
396
+ })}
397
+ </span>
398
+ </button>
399
+ <a href="/dash/pages" class="btn-outline">
400
+ {t({
401
+ message: "Cancel",
402
+ comment: "@context: Button to cancel form",
403
+ })}
404
+ </a>
405
+ </div>
406
+ </form>
407
+ </>
408
+ );
409
+ }
410
+
411
+ // =============================================================================
412
+ // Page Routes
413
+ // =============================================================================
414
+
415
+ // List pages (unified view)
187
416
  pagesRoutes.get("/", async (c) => {
188
- const pages = await c.var.services.posts.list({
189
- type: "page",
190
- visibility: ["unlisted", "draft"],
191
- limit: 100,
192
- });
417
+ const [navItems, otherPages] = await Promise.all([
418
+ c.var.services.navItems.list(),
419
+ c.var.services.pages.listNotInNav(),
420
+ ]);
193
421
  const siteName = await getSiteName(c);
194
422
 
195
423
  return c.html(
@@ -199,7 +427,7 @@ pagesRoutes.get("/", async (c) => {
199
427
  siteName={siteName}
200
428
  currentPath="/dash/pages"
201
429
  >
202
- <PagesListContent pages={pages} />
430
+ <UnifiedPagesContent navItems={navItems} otherPages={otherPages} />
203
431
  </DashLayout>,
204
432
  );
205
433
  });
@@ -220,33 +448,164 @@ pagesRoutes.get("/new", async (c) => {
220
448
  );
221
449
  });
222
450
 
451
+ // New link form
452
+ pagesRoutes.get("/links/new", async (c) => {
453
+ const siteName = await getSiteName(c);
454
+
455
+ return c.html(
456
+ <DashLayout
457
+ c={c}
458
+ title="New Link"
459
+ siteName={siteName}
460
+ currentPath="/dash/pages"
461
+ >
462
+ <LinkFormContent />
463
+ </DashLayout>,
464
+ );
465
+ });
466
+
467
+ // Create link
468
+ pagesRoutes.post("/links", async (c) => {
469
+ const body = await c.req.json<{ label: string; url: string }>();
470
+
471
+ if (!body.label || !body.url) {
472
+ return dsToast("Label and URL are required", "error");
473
+ }
474
+
475
+ await c.var.services.navItems.create({
476
+ type: "link",
477
+ label: body.label,
478
+ url: body.url,
479
+ });
480
+
481
+ return dsRedirect("/dash/pages");
482
+ });
483
+
484
+ // Reorder nav items (must be before /:id to avoid matching)
485
+ pagesRoutes.post("/reorder", async (c) => {
486
+ const body = await c.req.json<{ ids: number[] }>();
487
+
488
+ if (!Array.isArray(body.ids)) {
489
+ return dsToast("Invalid request", "error");
490
+ }
491
+
492
+ await c.var.services.navItems.reorder(body.ids);
493
+
494
+ return dsToast("Order saved");
495
+ });
496
+
497
+ // Edit link form
498
+ pagesRoutes.get("/links/:id/edit", async (c) => {
499
+ const id = parseInt(c.req.param("id"), 10);
500
+ if (isNaN(id)) return c.notFound();
501
+
502
+ const item = await c.var.services.navItems.getById(id);
503
+ if (!item) return c.notFound();
504
+
505
+ const siteName = await getSiteName(c);
506
+
507
+ return c.html(
508
+ <DashLayout
509
+ c={c}
510
+ title="Edit Link"
511
+ siteName={siteName}
512
+ currentPath="/dash/pages"
513
+ >
514
+ <LinkFormContent item={item} isEdit />
515
+ </DashLayout>,
516
+ );
517
+ });
518
+
519
+ // Update link
520
+ pagesRoutes.post("/links/:id", async (c) => {
521
+ const id = parseInt(c.req.param("id"), 10);
522
+ if (isNaN(id)) return c.notFound();
523
+
524
+ const body = await c.req.json<{ label: string; url: string }>();
525
+
526
+ if (!body.label || !body.url) {
527
+ return dsToast("Label and URL are required", "error");
528
+ }
529
+
530
+ const updated = await c.var.services.navItems.update(id, {
531
+ label: body.label,
532
+ url: body.url,
533
+ });
534
+
535
+ if (!updated) return c.notFound();
536
+
537
+ return dsRedirect("/dash/pages");
538
+ });
539
+
540
+ // Delete link
541
+ pagesRoutes.post("/links/:id/delete", async (c) => {
542
+ const id = parseInt(c.req.param("id"), 10);
543
+ if (!isNaN(id)) {
544
+ await c.var.services.navItems.delete(id);
545
+ }
546
+
547
+ return dsRedirect("/dash/pages");
548
+ });
549
+
223
550
  // Create page
224
551
  pagesRoutes.post("/", async (c) => {
225
552
  const body = await c.req.json<{
226
553
  title: string;
227
- content: string;
228
- visibility: string;
229
- path: string;
554
+ body: string;
555
+ status: string;
556
+ slug: string;
230
557
  }>();
231
558
 
232
- const page = await c.var.services.posts.create({
233
- type: "page",
559
+ const page = await c.var.services.pages.create({
234
560
  title: body.title,
235
- content: body.content,
236
- visibility: body.visibility as Post["visibility"],
237
- path: body.path.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
561
+ body: body.body,
562
+ status: body.status as Page["status"],
563
+ slug: body.slug.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
564
+ });
565
+
566
+ return dsRedirect(`/dash/pages/${page.id}`);
567
+ });
568
+
569
+ // Add page to navigation
570
+ pagesRoutes.post("/:id/add-to-nav", async (c) => {
571
+ const id = parseInt(c.req.param("id"), 10);
572
+ if (isNaN(id)) return c.notFound();
573
+
574
+ const page = await c.var.services.pages.getById(id);
575
+ if (!page) return c.notFound();
576
+
577
+ await c.var.services.navItems.create({
578
+ type: "page",
579
+ label: page.title || page.slug,
580
+ url: `/${page.slug}`,
581
+ pageId: page.id,
238
582
  });
239
583
 
240
- return dsRedirect(`/dash/pages/${sqid.encode(page.id)}`);
584
+ return dsRedirect("/dash/pages");
585
+ });
586
+
587
+ // Remove page from navigation (keeps the page, deletes the nav item)
588
+ pagesRoutes.post("/:id/remove-from-nav", async (c) => {
589
+ const pageId = parseInt(c.req.param("id"), 10);
590
+ if (isNaN(pageId)) return c.notFound();
591
+
592
+ // Find nav item by pageId
593
+ const navItems = await c.var.services.navItems.list();
594
+ const navItem = navItems.find((item) => item.pageId === pageId);
595
+ if (navItem) {
596
+ await c.var.services.navItems.delete(navItem.id);
597
+ }
598
+
599
+ return dsRedirect("/dash/pages");
241
600
  });
242
601
 
243
602
  // View single page
244
603
  pagesRoutes.get("/:id", async (c) => {
245
- const id = sqid.decode(c.req.param("id"));
246
- if (!id) return c.notFound();
604
+ const id = parseInt(c.req.param("id"), 10);
605
+ if (isNaN(id)) return c.notFound();
247
606
 
248
- const page = await c.var.services.posts.getById(id);
249
- if (!page || page.type !== "page") return c.notFound();
607
+ const page = await c.var.services.pages.getById(id);
608
+ if (!page) return c.notFound();
250
609
 
251
610
  const siteName = await getSiteName(c);
252
611
 
@@ -264,11 +623,11 @@ pagesRoutes.get("/:id", async (c) => {
264
623
 
265
624
  // Edit page form
266
625
  pagesRoutes.get("/:id/edit", async (c) => {
267
- const id = sqid.decode(c.req.param("id"));
268
- if (!id) return c.notFound();
626
+ const id = parseInt(c.req.param("id"), 10);
627
+ if (isNaN(id)) return c.notFound();
269
628
 
270
- const page = await c.var.services.posts.getById(id);
271
- if (!page || page.type !== "page") return c.notFound();
629
+ const page = await c.var.services.pages.getById(id);
630
+ if (!page) return c.notFound();
272
631
 
273
632
  const siteName = await getSiteName(c);
274
633
 
@@ -286,33 +645,32 @@ pagesRoutes.get("/:id/edit", async (c) => {
286
645
 
287
646
  // Update page
288
647
  pagesRoutes.post("/:id", async (c) => {
289
- const id = sqid.decode(c.req.param("id"));
290
- if (!id) return c.notFound();
648
+ const id = parseInt(c.req.param("id"), 10);
649
+ if (isNaN(id)) return c.notFound();
291
650
 
292
651
  const body = await c.req.json<{
293
652
  title: string;
294
- content: string;
295
- visibility: string;
296
- path: string;
653
+ body: string;
654
+ status: string;
655
+ slug: string;
297
656
  }>();
298
657
 
299
- await c.var.services.posts.update(id, {
300
- type: "page",
658
+ await c.var.services.pages.update(id, {
301
659
  title: body.title,
302
- content: body.content,
303
- visibility: body.visibility as Post["visibility"],
304
- path: body.path.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
660
+ body: body.body,
661
+ status: body.status as Page["status"],
662
+ slug: body.slug.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
305
663
  });
306
664
 
307
- return dsRedirect(`/dash/pages/${sqid.encode(id)}`);
665
+ return dsRedirect(`/dash/pages/${id}`);
308
666
  });
309
667
 
310
668
  // Delete page
311
669
  pagesRoutes.post("/:id/delete", async (c) => {
312
- const id = sqid.decode(c.req.param("id"));
313
- if (!id) return c.notFound();
670
+ const id = parseInt(c.req.param("id"), 10);
671
+ if (isNaN(id)) return c.notFound();
314
672
 
315
- await c.var.services.posts.delete(id);
673
+ await c.var.services.pages.delete(id);
316
674
 
317
675
  return dsRedirect("/dash/pages");
318
676
  });