@jant/core 0.3.6 → 0.3.8

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 (264) hide show
  1. package/dist/app.js +11 -21
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +15 -0
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/image.js +3 -3
  9. package/dist/lib/media-helpers.js +43 -0
  10. package/dist/lib/nav-reorder.js +27 -0
  11. package/dist/lib/navigation.js +35 -0
  12. package/dist/lib/schemas.js +32 -2
  13. package/dist/lib/sse.js +7 -8
  14. package/dist/lib/theme-components.js +49 -0
  15. package/dist/routes/api/posts.js +101 -5
  16. package/dist/routes/api/timeline.js +115 -0
  17. package/dist/routes/api/upload.js +9 -5
  18. package/dist/routes/dash/media.js +38 -0
  19. package/dist/routes/dash/navigation.js +274 -0
  20. package/dist/routes/dash/posts.js +45 -6
  21. package/dist/routes/feed/rss.js +10 -1
  22. package/dist/routes/pages/archive.js +14 -27
  23. package/dist/routes/pages/collection.js +10 -19
  24. package/dist/routes/pages/home.js +88 -98
  25. package/dist/routes/pages/page.js +19 -38
  26. package/dist/routes/pages/post.js +61 -48
  27. package/dist/routes/pages/search.js +13 -26
  28. package/dist/services/collection.js +13 -0
  29. package/dist/services/index.js +3 -1
  30. package/dist/services/media.js +55 -2
  31. package/dist/services/navigation.js +115 -0
  32. package/dist/services/post.js +26 -1
  33. package/dist/theme/components/MediaGallery.js +107 -0
  34. package/dist/theme/components/PostForm.js +158 -2
  35. package/dist/theme/components/PostList.js +5 -0
  36. package/dist/theme/components/index.js +3 -0
  37. package/dist/theme/components/timeline/ArticleCard.js +50 -0
  38. package/dist/theme/components/timeline/ImageCard.js +86 -0
  39. package/dist/theme/components/timeline/LinkCard.js +62 -0
  40. package/dist/theme/components/timeline/NoteCard.js +37 -0
  41. package/dist/theme/components/timeline/QuoteCard.js +51 -0
  42. package/dist/theme/components/timeline/ThreadPreview.js +52 -0
  43. package/dist/theme/components/timeline/TimelineFeed.js +43 -0
  44. package/dist/theme/components/timeline/TimelineItem.js +25 -0
  45. package/dist/theme/components/timeline/index.js +8 -0
  46. package/dist/theme/layouts/DashLayout.js +8 -0
  47. package/dist/theme/layouts/SiteLayout.js +160 -0
  48. package/dist/theme/layouts/index.js +1 -0
  49. package/dist/types/sortablejs.d.js +5 -0
  50. package/dist/types.js +27 -0
  51. package/package.json +3 -2
  52. package/src/__tests__/helpers/app.ts +6 -1
  53. package/src/__tests__/helpers/db.ts +20 -0
  54. package/src/app.tsx +11 -25
  55. package/src/client.ts +1 -0
  56. package/src/db/migrations/0002_add_media_attachments.sql +3 -0
  57. package/src/db/migrations/0003_add_navigation_links.sql +8 -0
  58. package/src/db/migrations/meta/0003_snapshot.json +821 -0
  59. package/src/db/migrations/meta/_journal.json +14 -0
  60. package/src/db/schema.ts +15 -0
  61. package/src/i18n/locales/en.po +170 -58
  62. package/src/i18n/locales/en.ts +1 -1
  63. package/src/i18n/locales/zh-Hans.po +162 -71
  64. package/src/i18n/locales/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/zh-Hant.po +162 -71
  66. package/src/i18n/locales/zh-Hant.ts +1 -1
  67. package/src/index.ts +13 -1
  68. package/src/lib/__tests__/schemas.test.ts +89 -1
  69. package/src/lib/__tests__/sse.test.ts +13 -1
  70. package/src/lib/__tests__/theme-components.test.ts +107 -0
  71. package/src/lib/image.ts +3 -3
  72. package/src/lib/media-helpers.ts +54 -0
  73. package/src/lib/nav-reorder.ts +26 -0
  74. package/src/lib/navigation.ts +46 -0
  75. package/src/lib/schemas.ts +47 -1
  76. package/src/lib/sse.ts +10 -11
  77. package/src/lib/theme-components.ts +76 -0
  78. package/src/routes/api/__tests__/posts.test.ts +239 -0
  79. package/src/routes/api/__tests__/timeline.test.ts +242 -0
  80. package/src/routes/api/posts.ts +134 -5
  81. package/src/routes/api/timeline.tsx +145 -0
  82. package/src/routes/api/upload.ts +9 -5
  83. package/src/routes/dash/media.tsx +50 -0
  84. package/src/routes/dash/navigation.tsx +306 -0
  85. package/src/routes/dash/posts.tsx +79 -7
  86. package/src/routes/feed/rss.ts +14 -1
  87. package/src/routes/pages/archive.tsx +15 -23
  88. package/src/routes/pages/collection.tsx +8 -15
  89. package/src/routes/pages/home.tsx +121 -88
  90. package/src/routes/pages/page.tsx +17 -30
  91. package/src/routes/pages/post.tsx +64 -40
  92. package/src/routes/pages/search.tsx +18 -22
  93. package/src/services/__tests__/collection.test.ts +102 -0
  94. package/src/services/__tests__/media.test.ts +282 -7
  95. package/src/services/__tests__/navigation.test.ts +213 -0
  96. package/src/services/__tests__/post-timeline.test.ts +220 -0
  97. package/src/services/collection.ts +19 -0
  98. package/src/services/index.ts +7 -0
  99. package/src/services/media.ts +78 -2
  100. package/src/services/navigation.ts +165 -0
  101. package/src/services/post.ts +48 -1
  102. package/src/styles/components.css +59 -0
  103. package/src/theme/components/MediaGallery.tsx +128 -0
  104. package/src/theme/components/PostForm.tsx +170 -2
  105. package/src/theme/components/PostList.tsx +7 -0
  106. package/src/theme/components/index.ts +13 -0
  107. package/src/theme/components/timeline/ArticleCard.tsx +57 -0
  108. package/src/theme/components/timeline/ImageCard.tsx +80 -0
  109. package/src/theme/components/timeline/LinkCard.tsx +66 -0
  110. package/src/theme/components/timeline/NoteCard.tsx +41 -0
  111. package/src/theme/components/timeline/QuoteCard.tsx +55 -0
  112. package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
  113. package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
  114. package/src/theme/components/timeline/TimelineItem.tsx +39 -0
  115. package/src/theme/components/timeline/index.ts +8 -0
  116. package/src/theme/layouts/DashLayout.tsx +10 -0
  117. package/src/theme/layouts/SiteLayout.tsx +184 -0
  118. package/src/theme/layouts/index.ts +1 -0
  119. package/src/types/sortablejs.d.ts +23 -0
  120. package/src/types.ts +97 -0
  121. package/dist/app.d.ts +0 -38
  122. package/dist/app.d.ts.map +0 -1
  123. package/dist/auth.d.ts +0 -25
  124. package/dist/auth.d.ts.map +0 -1
  125. package/dist/db/index.d.ts +0 -10
  126. package/dist/db/index.d.ts.map +0 -1
  127. package/dist/db/schema.d.ts +0 -1507
  128. package/dist/db/schema.d.ts.map +0 -1
  129. package/dist/i18n/Trans.d.ts +0 -25
  130. package/dist/i18n/Trans.d.ts.map +0 -1
  131. package/dist/i18n/context.d.ts +0 -69
  132. package/dist/i18n/context.d.ts.map +0 -1
  133. package/dist/i18n/detect.d.ts +0 -20
  134. package/dist/i18n/detect.d.ts.map +0 -1
  135. package/dist/i18n/i18n.d.ts +0 -32
  136. package/dist/i18n/i18n.d.ts.map +0 -1
  137. package/dist/i18n/index.d.ts +0 -41
  138. package/dist/i18n/index.d.ts.map +0 -1
  139. package/dist/i18n/locales/en.d.ts +0 -3
  140. package/dist/i18n/locales/en.d.ts.map +0 -1
  141. package/dist/i18n/locales/zh-Hans.d.ts +0 -3
  142. package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
  143. package/dist/i18n/locales/zh-Hant.d.ts +0 -3
  144. package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
  145. package/dist/i18n/locales.d.ts +0 -11
  146. package/dist/i18n/locales.d.ts.map +0 -1
  147. package/dist/i18n/middleware.d.ts +0 -21
  148. package/dist/i18n/middleware.d.ts.map +0 -1
  149. package/dist/index.d.ts +0 -16
  150. package/dist/index.d.ts.map +0 -1
  151. package/dist/lib/config.d.ts +0 -83
  152. package/dist/lib/config.d.ts.map +0 -1
  153. package/dist/lib/constants.d.ts +0 -37
  154. package/dist/lib/constants.d.ts.map +0 -1
  155. package/dist/lib/image.d.ts +0 -73
  156. package/dist/lib/image.d.ts.map +0 -1
  157. package/dist/lib/index.d.ts +0 -9
  158. package/dist/lib/index.d.ts.map +0 -1
  159. package/dist/lib/markdown.d.ts +0 -60
  160. package/dist/lib/markdown.d.ts.map +0 -1
  161. package/dist/lib/schemas.d.ts +0 -113
  162. package/dist/lib/schemas.d.ts.map +0 -1
  163. package/dist/lib/sqid.d.ts +0 -60
  164. package/dist/lib/sqid.d.ts.map +0 -1
  165. package/dist/lib/sse.d.ts +0 -192
  166. package/dist/lib/sse.d.ts.map +0 -1
  167. package/dist/lib/theme.d.ts +0 -44
  168. package/dist/lib/theme.d.ts.map +0 -1
  169. package/dist/lib/time.d.ts +0 -90
  170. package/dist/lib/time.d.ts.map +0 -1
  171. package/dist/lib/url.d.ts +0 -82
  172. package/dist/lib/url.d.ts.map +0 -1
  173. package/dist/middleware/auth.d.ts +0 -24
  174. package/dist/middleware/auth.d.ts.map +0 -1
  175. package/dist/middleware/onboarding.d.ts +0 -26
  176. package/dist/middleware/onboarding.d.ts.map +0 -1
  177. package/dist/routes/api/posts.d.ts +0 -13
  178. package/dist/routes/api/posts.d.ts.map +0 -1
  179. package/dist/routes/api/search.d.ts +0 -13
  180. package/dist/routes/api/search.d.ts.map +0 -1
  181. package/dist/routes/api/upload.d.ts +0 -16
  182. package/dist/routes/api/upload.d.ts.map +0 -1
  183. package/dist/routes/dash/collections.d.ts +0 -13
  184. package/dist/routes/dash/collections.d.ts.map +0 -1
  185. package/dist/routes/dash/index.d.ts +0 -15
  186. package/dist/routes/dash/index.d.ts.map +0 -1
  187. package/dist/routes/dash/media.d.ts +0 -16
  188. package/dist/routes/dash/media.d.ts.map +0 -1
  189. package/dist/routes/dash/pages.d.ts +0 -15
  190. package/dist/routes/dash/pages.d.ts.map +0 -1
  191. package/dist/routes/dash/posts.d.ts +0 -13
  192. package/dist/routes/dash/posts.d.ts.map +0 -1
  193. package/dist/routes/dash/redirects.d.ts +0 -13
  194. package/dist/routes/dash/redirects.d.ts.map +0 -1
  195. package/dist/routes/dash/settings.d.ts +0 -15
  196. package/dist/routes/dash/settings.d.ts.map +0 -1
  197. package/dist/routes/feed/rss.d.ts +0 -13
  198. package/dist/routes/feed/rss.d.ts.map +0 -1
  199. package/dist/routes/feed/sitemap.d.ts +0 -13
  200. package/dist/routes/feed/sitemap.d.ts.map +0 -1
  201. package/dist/routes/pages/archive.d.ts +0 -15
  202. package/dist/routes/pages/archive.d.ts.map +0 -1
  203. package/dist/routes/pages/collection.d.ts +0 -13
  204. package/dist/routes/pages/collection.d.ts.map +0 -1
  205. package/dist/routes/pages/home.d.ts +0 -13
  206. package/dist/routes/pages/home.d.ts.map +0 -1
  207. package/dist/routes/pages/page.d.ts +0 -15
  208. package/dist/routes/pages/page.d.ts.map +0 -1
  209. package/dist/routes/pages/post.d.ts +0 -13
  210. package/dist/routes/pages/post.d.ts.map +0 -1
  211. package/dist/routes/pages/search.d.ts +0 -13
  212. package/dist/routes/pages/search.d.ts.map +0 -1
  213. package/dist/services/collection.d.ts +0 -31
  214. package/dist/services/collection.d.ts.map +0 -1
  215. package/dist/services/index.d.ts +0 -28
  216. package/dist/services/index.d.ts.map +0 -1
  217. package/dist/services/media.d.ts +0 -27
  218. package/dist/services/media.d.ts.map +0 -1
  219. package/dist/services/post.d.ts +0 -31
  220. package/dist/services/post.d.ts.map +0 -1
  221. package/dist/services/redirect.d.ts +0 -15
  222. package/dist/services/redirect.d.ts.map +0 -1
  223. package/dist/services/search.d.ts +0 -26
  224. package/dist/services/search.d.ts.map +0 -1
  225. package/dist/services/settings.d.ts +0 -18
  226. package/dist/services/settings.d.ts.map +0 -1
  227. package/dist/theme/color-themes.d.ts +0 -30
  228. package/dist/theme/color-themes.d.ts.map +0 -1
  229. package/dist/theme/components/ActionButtons.d.ts +0 -43
  230. package/dist/theme/components/ActionButtons.d.ts.map +0 -1
  231. package/dist/theme/components/CrudPageHeader.d.ts +0 -23
  232. package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
  233. package/dist/theme/components/DangerZone.d.ts +0 -36
  234. package/dist/theme/components/DangerZone.d.ts.map +0 -1
  235. package/dist/theme/components/EmptyState.d.ts +0 -27
  236. package/dist/theme/components/EmptyState.d.ts.map +0 -1
  237. package/dist/theme/components/ListItemRow.d.ts +0 -15
  238. package/dist/theme/components/ListItemRow.d.ts.map +0 -1
  239. package/dist/theme/components/PageForm.d.ts +0 -14
  240. package/dist/theme/components/PageForm.d.ts.map +0 -1
  241. package/dist/theme/components/Pagination.d.ts +0 -46
  242. package/dist/theme/components/Pagination.d.ts.map +0 -1
  243. package/dist/theme/components/PostForm.d.ts +0 -11
  244. package/dist/theme/components/PostForm.d.ts.map +0 -1
  245. package/dist/theme/components/PostList.d.ts +0 -10
  246. package/dist/theme/components/PostList.d.ts.map +0 -1
  247. package/dist/theme/components/ThreadView.d.ts +0 -15
  248. package/dist/theme/components/ThreadView.d.ts.map +0 -1
  249. package/dist/theme/components/TypeBadge.d.ts +0 -12
  250. package/dist/theme/components/TypeBadge.d.ts.map +0 -1
  251. package/dist/theme/components/VisibilityBadge.d.ts +0 -12
  252. package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
  253. package/dist/theme/components/index.d.ts +0 -13
  254. package/dist/theme/components/index.d.ts.map +0 -1
  255. package/dist/theme/index.d.ts +0 -21
  256. package/dist/theme/index.d.ts.map +0 -1
  257. package/dist/theme/layouts/BaseLayout.d.ts +0 -23
  258. package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
  259. package/dist/theme/layouts/DashLayout.d.ts +0 -17
  260. package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
  261. package/dist/theme/layouts/index.d.ts +0 -3
  262. package/dist/theme/layouts/index.d.ts.map +0 -1
  263. package/dist/types.d.ts +0 -213
  264. package/dist/types.d.ts.map +0 -1
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Timeline API Routes
3
+ *
4
+ * Provides load-more functionality for the timeline feed via SSE.
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import type { Bindings, PostWithMedia, TimelineItemData } from "../../types.js";
9
+ import type { AppVariables } from "../../app.js";
10
+ import { sse } from "../../lib/sse.js";
11
+ import { buildMediaMap } from "../../lib/media-helpers.js";
12
+ import { TimelineItem } from "../../theme/components/timeline/TimelineItem.js";
13
+ import { ThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
14
+
15
+ type Env = { Bindings: Bindings; Variables: AppVariables };
16
+
17
+ const PAGE_SIZE = 20;
18
+
19
+ export const timelineApiRoutes = new Hono<Env>();
20
+
21
+ timelineApiRoutes.get("/", async (c) => {
22
+ const cursorParam = c.req.query("cursor");
23
+ const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
24
+
25
+ if (!cursor || isNaN(cursor)) {
26
+ return c.json({ error: "cursor parameter required" }, 400);
27
+ }
28
+
29
+ // Fetch one extra to determine if there are more
30
+ const posts = await c.var.services.posts.list({
31
+ visibility: ["featured", "quiet"],
32
+ excludeReplies: true,
33
+ excludeTypes: ["page"],
34
+ limit: PAGE_SIZE + 1,
35
+ cursor,
36
+ });
37
+
38
+ const hasMore = posts.length > PAGE_SIZE;
39
+ const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
40
+
41
+ if (displayPosts.length === 0) {
42
+ return sse(c, async (stream) => {
43
+ stream.remove("#load-more-container");
44
+ });
45
+ }
46
+
47
+ // Build media map
48
+ const postIds = displayPosts.map((p) => p.id);
49
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
50
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
51
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
52
+ const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
53
+
54
+ // Get reply counts to identify thread roots
55
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
56
+ const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
57
+
58
+ // Get thread previews
59
+ const threadPreviews = await c.var.services.posts.getThreadPreviews(
60
+ threadRootIds,
61
+ 3,
62
+ );
63
+
64
+ // Load media for preview replies
65
+ const previewReplyIds: number[] = [];
66
+ for (const replies of threadPreviews.values()) {
67
+ for (const reply of replies) {
68
+ previewReplyIds.push(reply.id);
69
+ }
70
+ }
71
+ const previewMediaMap =
72
+ previewReplyIds.length > 0
73
+ ? buildMediaMap(
74
+ await c.var.services.media.getByPostIds(previewReplyIds),
75
+ r2PublicUrl,
76
+ imageTransformUrl,
77
+ )
78
+ : new Map();
79
+
80
+ // Assemble timeline items
81
+ const items: TimelineItemData[] = displayPosts.map((post) => {
82
+ const postWithMedia: PostWithMedia = {
83
+ ...post,
84
+ mediaAttachments: mediaMap.get(post.id) ?? [],
85
+ };
86
+
87
+ const replyCount = replyCounts.get(post.id) ?? 0;
88
+ const previewReplies = threadPreviews.get(post.id);
89
+
90
+ if (replyCount > 0 && previewReplies) {
91
+ return {
92
+ post: postWithMedia,
93
+ threadPreview: {
94
+ replies: previewReplies.map((r) => ({
95
+ ...r,
96
+ mediaAttachments: previewMediaMap.get(r.id) ?? [],
97
+ })),
98
+ totalReplyCount: replyCount,
99
+ },
100
+ };
101
+ }
102
+
103
+ return { post: postWithMedia };
104
+ });
105
+
106
+ // Render items to HTML
107
+ const itemsHtml = items
108
+ .map((item) => {
109
+ if (item.threadPreview) {
110
+ return (
111
+ <ThreadPreview
112
+ rootPost={item.post}
113
+ previewReplies={item.threadPreview.replies}
114
+ totalReplyCount={item.threadPreview.totalReplyCount}
115
+ />
116
+ );
117
+ }
118
+ return <TimelineItem item={item} />;
119
+ })
120
+ .map((jsx) => jsx.toString())
121
+ .join("");
122
+
123
+ // Determine next cursor
124
+ const lastPost = displayPosts[displayPosts.length - 1];
125
+ const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
126
+
127
+ // Build load-more button HTML
128
+ const loadMoreHtml = nextCursor
129
+ ? `<div id="load-more-container" class="mt-6 text-center"><button class="btn btn-outline" data-on:click="@get('/api/timeline?cursor=${nextCursor}')">Load more</button></div>`
130
+ : "";
131
+
132
+ return sse(c, async (stream) => {
133
+ // Append new items to the feed
134
+ stream.patchElements(itemsHtml, {
135
+ mode: "append",
136
+ selector: "#timeline-feed",
137
+ });
138
+ // Replace or remove the load-more container
139
+ if (loadMoreHtml) {
140
+ stream.patchElements(loadMoreHtml);
141
+ } else {
142
+ stream.remove("#load-more-container");
143
+ }
144
+ });
145
+ });
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { Hono } from "hono";
9
9
  import { html } from "hono/html";
10
+ import { uuidv7 } from "uuidv7";
10
11
  import type { Bindings } from "../../types.js";
11
12
  import type { AppVariables } from "../../app.js";
12
13
  import { requireAuthApi } from "../../middleware/auth.js";
@@ -156,12 +157,14 @@ uploadApiRoutes.post("/", async (c) => {
156
157
  return c.json({ error: "File too large (max 10MB)" }, 400);
157
158
  }
158
159
 
159
- // Generate unique filename
160
+ // Generate unique filename using UUIDv7
160
161
  const ext = file.name.split(".").pop() || "bin";
161
- const timestamp = Date.now();
162
- const random = Math.random().toString(36).substring(2, 8);
163
- const filename = `${timestamp}-${random}.${ext}`;
164
- const r2Key = `uploads/${filename}`;
162
+ const id = uuidv7();
163
+ const date = new Date();
164
+ const year = date.getUTCFullYear();
165
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
166
+ const filename = `${id}.${ext}`;
167
+ const r2Key = `media/${year}/${month}/${filename}`;
165
168
 
166
169
  try {
167
170
  // Upload to R2
@@ -173,6 +176,7 @@ uploadApiRoutes.post("/", async (c) => {
173
176
 
174
177
  // Save to database
175
178
  const media = await c.var.services.media.create({
179
+ id,
176
180
  filename,
177
181
  originalName: file.name,
178
182
  mimeType: file.type,
@@ -418,6 +418,56 @@ mediaRoutes.get("/", async (c) => {
418
418
  );
419
419
  });
420
420
 
421
+ // Media picker (returns HTML fragment for PostForm dialog)
422
+ // Must be defined before /:id to avoid "picker" matching as an ID
423
+ mediaRoutes.get("/picker", async (c) => {
424
+ const mediaList = await c.var.services.media.list(100);
425
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
426
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
427
+
428
+ if (mediaList.length === 0) {
429
+ return c.html(
430
+ <p class="text-muted-foreground text-sm col-span-4">
431
+ No media uploaded yet. Upload media from the Media page first.
432
+ </p>,
433
+ );
434
+ }
435
+
436
+ return c.html(
437
+ <>
438
+ {mediaList
439
+ .filter((m) => m.mimeType.startsWith("image/"))
440
+ .map((m) => {
441
+ const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
442
+ const thumbUrl = getImageUrl(url, imageTransformUrl, {
443
+ width: 150,
444
+ quality: 80,
445
+ format: "auto",
446
+ fit: "cover",
447
+ });
448
+ return (
449
+ <button
450
+ key={m.id}
451
+ type="button"
452
+ class="aspect-square rounded-lg overflow-hidden border-2 hover:border-primary cursor-pointer transition-colors"
453
+ data-on:click={`$mediaIds.includes('${m.id}') ? ($mediaIds = $mediaIds.filter(id => id !== '${m.id}')) : ($mediaIds = [...$mediaIds, '${m.id}'])`}
454
+ data-class:border-primary={`$mediaIds.includes('${m.id}')`}
455
+ data-class:ring-2={`$mediaIds.includes('${m.id}')`}
456
+ data-class:ring-primary={`$mediaIds.includes('${m.id}')`}
457
+ >
458
+ <img
459
+ src={thumbUrl}
460
+ alt={m.alt || m.originalName}
461
+ class="w-full h-full object-cover"
462
+ loading="lazy"
463
+ />
464
+ </button>
465
+ );
466
+ })}
467
+ </>,
468
+ );
469
+ });
470
+
421
471
  // View single media
422
472
  mediaRoutes.get("/:id", async (c) => {
423
473
  const id = c.req.param("id");
@@ -0,0 +1,306 @@
1
+ import { getSiteName } from "../../lib/config.js";
2
+ /**
3
+ * Dashboard Navigation Links Routes
4
+ */
5
+
6
+ import { Hono } from "hono";
7
+ import { useLingui } from "@lingui/react/macro";
8
+ import type { Bindings, NavigationLink } from "../../types.js";
9
+ import type { AppVariables } from "../../app.js";
10
+ import { DashLayout } from "../../theme/layouts/index.js";
11
+ import {
12
+ EmptyState,
13
+ ListItemRow,
14
+ ActionButtons,
15
+ CrudPageHeader,
16
+ } from "../../theme/components/index.js";
17
+ import { dsRedirect, dsToast } from "../../lib/sse.js";
18
+
19
+ type Env = { Bindings: Bindings; Variables: AppVariables };
20
+
21
+ export const navigationRoutes = new Hono<Env>();
22
+
23
+ function NavigationListContent({ links }: { links: NavigationLink[] }) {
24
+ const { t } = useLingui();
25
+
26
+ return (
27
+ <>
28
+ <CrudPageHeader
29
+ title={t({
30
+ message: "Navigation",
31
+ comment: "@context: Dashboard heading",
32
+ })}
33
+ ctaLabel={t({
34
+ message: "New Link",
35
+ comment: "@context: Button to create new navigation link",
36
+ })}
37
+ ctaHref="/dash/navigation/new"
38
+ />
39
+
40
+ {links.length === 0 ? (
41
+ <EmptyState
42
+ message={t({
43
+ message: "No navigation links configured.",
44
+ comment: "@context: Empty state message",
45
+ })}
46
+ ctaText={t({
47
+ message: "New Link",
48
+ comment: "@context: Button to create new navigation link",
49
+ })}
50
+ ctaHref="/dash/navigation/new"
51
+ />
52
+ ) : (
53
+ <>
54
+ <div id="nav-links-list" class="flex flex-col divide-y">
55
+ {links.map((link) => (
56
+ <ListItemRow
57
+ key={link.id}
58
+ actions={
59
+ <ActionButtons
60
+ editHref={`/dash/navigation/${link.id}/edit`}
61
+ editLabel={t({
62
+ message: "Edit",
63
+ comment: "@context: Button to edit navigation link",
64
+ })}
65
+ deleteAction={`/dash/navigation/${link.id}/delete`}
66
+ deleteLabel={t({
67
+ message: "Delete",
68
+ comment: "@context: Button to delete navigation link",
69
+ })}
70
+ />
71
+ }
72
+ >
73
+ <div
74
+ class="flex items-center gap-3 cursor-grab"
75
+ data-id={link.id}
76
+ >
77
+ <span class="text-muted-foreground select-none">⠿</span>
78
+ <div class="flex items-center gap-2">
79
+ <span class="font-medium">{link.label}</span>
80
+ <code class="text-sm text-muted-foreground bg-muted px-1 rounded">
81
+ {link.url}
82
+ </code>
83
+ </div>
84
+ </div>
85
+ </ListItemRow>
86
+ ))}
87
+ </div>
88
+
89
+ {/* SortableJS is initialized by client.ts via lib/nav-reorder.ts */}
90
+ </>
91
+ )}
92
+ </>
93
+ );
94
+ }
95
+
96
+ function NavigationFormContent({
97
+ link,
98
+ isEdit,
99
+ }: {
100
+ link?: NavigationLink;
101
+ isEdit?: boolean;
102
+ }) {
103
+ const { t } = useLingui();
104
+ const title = isEdit
105
+ ? t({ message: "Edit Link", comment: "@context: Page heading" })
106
+ : t({ message: "New Link", comment: "@context: Page heading" });
107
+
108
+ const signals = JSON.stringify({
109
+ label: link?.label ?? "",
110
+ url: link?.url ?? "",
111
+ }).replace(/</g, "\\u003c");
112
+
113
+ const action = isEdit ? `/dash/navigation/${link?.id}` : "/dash/navigation";
114
+
115
+ return (
116
+ <>
117
+ <h1 class="text-2xl font-semibold mb-6">{title}</h1>
118
+
119
+ <form
120
+ data-signals={signals}
121
+ data-on:submit__prevent={`@post('${action}')`}
122
+ class="flex flex-col gap-4 max-w-lg"
123
+ >
124
+ <div class="field">
125
+ <label class="label">
126
+ {t({
127
+ message: "Label",
128
+ comment: "@context: Navigation link form field",
129
+ })}
130
+ </label>
131
+ <input
132
+ type="text"
133
+ data-bind="label"
134
+ class="input"
135
+ placeholder="Home"
136
+ required
137
+ />
138
+ <p class="text-xs text-muted-foreground mt-1">
139
+ {t({
140
+ message: "Display text for the link",
141
+ comment: "@context: Navigation label help text",
142
+ })}
143
+ </p>
144
+ </div>
145
+
146
+ <div class="field">
147
+ <label class="label">
148
+ {t({
149
+ message: "URL",
150
+ comment: "@context: Navigation link form field",
151
+ })}
152
+ </label>
153
+ <input
154
+ type="text"
155
+ data-bind="url"
156
+ class="input"
157
+ placeholder="/archive or https://..."
158
+ required
159
+ />
160
+ <p class="text-xs text-muted-foreground mt-1">
161
+ {t({
162
+ message:
163
+ "Path (e.g. /archive) or full URL (e.g. https://example.com)",
164
+ comment: "@context: Navigation URL help text",
165
+ })}
166
+ </p>
167
+ </div>
168
+
169
+ <div class="flex gap-2">
170
+ <button type="submit" class="btn">
171
+ {isEdit
172
+ ? t({
173
+ message: "Save Changes",
174
+ comment: "@context: Button to save edited navigation link",
175
+ })
176
+ : t({
177
+ message: "Create Link",
178
+ comment: "@context: Button to save new navigation link",
179
+ })}
180
+ </button>
181
+ <a href="/dash/navigation" class="btn-outline">
182
+ {t({
183
+ message: "Cancel",
184
+ comment: "@context: Button to cancel form",
185
+ })}
186
+ </a>
187
+ </div>
188
+ </form>
189
+ </>
190
+ );
191
+ }
192
+
193
+ // List navigation links
194
+ navigationRoutes.get("/", async (c) => {
195
+ const siteName = await getSiteName(c);
196
+ const links = await c.var.services.navigationLinks.list();
197
+
198
+ return c.html(
199
+ <DashLayout
200
+ c={c}
201
+ title="Navigation"
202
+ siteName={siteName}
203
+ currentPath="/dash/navigation"
204
+ >
205
+ <NavigationListContent links={links} />
206
+ </DashLayout>,
207
+ );
208
+ });
209
+
210
+ // New link form
211
+ navigationRoutes.get("/new", async (c) => {
212
+ const siteName = await getSiteName(c);
213
+
214
+ return c.html(
215
+ <DashLayout
216
+ c={c}
217
+ title="New Link"
218
+ siteName={siteName}
219
+ currentPath="/dash/navigation"
220
+ >
221
+ <NavigationFormContent />
222
+ </DashLayout>,
223
+ );
224
+ });
225
+
226
+ // Create link
227
+ navigationRoutes.post("/", async (c) => {
228
+ const body = await c.req.json<{ label: string; url: string }>();
229
+
230
+ if (!body.label || !body.url) {
231
+ return dsToast("Label and URL are required", "error");
232
+ }
233
+
234
+ await c.var.services.navigationLinks.create({
235
+ label: body.label,
236
+ url: body.url,
237
+ });
238
+
239
+ return dsRedirect("/dash/navigation");
240
+ });
241
+
242
+ // Reorder links (must be before /:id to avoid "reorder" matching as :id)
243
+ navigationRoutes.post("/reorder", async (c) => {
244
+ const body = await c.req.json<{ ids: number[] }>();
245
+
246
+ if (!Array.isArray(body.ids)) {
247
+ return dsToast("Invalid request", "error");
248
+ }
249
+
250
+ await c.var.services.navigationLinks.reorder(body.ids);
251
+
252
+ return dsToast("Order saved");
253
+ });
254
+
255
+ // Edit link form
256
+ navigationRoutes.get("/:id/edit", async (c) => {
257
+ const id = parseInt(c.req.param("id"), 10);
258
+ if (isNaN(id)) return c.notFound();
259
+
260
+ const link = await c.var.services.navigationLinks.getById(id);
261
+ if (!link) return c.notFound();
262
+
263
+ const siteName = await getSiteName(c);
264
+
265
+ return c.html(
266
+ <DashLayout
267
+ c={c}
268
+ title="Edit Link"
269
+ siteName={siteName}
270
+ currentPath="/dash/navigation"
271
+ >
272
+ <NavigationFormContent link={link} isEdit />
273
+ </DashLayout>,
274
+ );
275
+ });
276
+
277
+ // Update link
278
+ navigationRoutes.post("/:id", async (c) => {
279
+ const id = parseInt(c.req.param("id"), 10);
280
+ if (isNaN(id)) return c.notFound();
281
+
282
+ const body = await c.req.json<{ label: string; url: string }>();
283
+
284
+ if (!body.label || !body.url) {
285
+ return dsToast("Label and URL are required", "error");
286
+ }
287
+
288
+ const updated = await c.var.services.navigationLinks.update(id, {
289
+ label: body.label,
290
+ url: body.url,
291
+ });
292
+
293
+ if (!updated) return c.notFound();
294
+
295
+ return dsRedirect("/dash/navigation");
296
+ });
297
+
298
+ // Delete link
299
+ navigationRoutes.post("/:id/delete", async (c) => {
300
+ const id = parseInt(c.req.param("id"), 10);
301
+ if (!isNaN(id)) {
302
+ await c.var.services.navigationLinks.delete(id);
303
+ }
304
+
305
+ return dsRedirect("/dash/navigation");
306
+ });