@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
@@ -2,9 +2,32 @@
2
2
  * Posts API Routes
3
3
  */ import { Hono } from "hono";
4
4
  import * as sqid from "../../lib/sqid.js";
5
- import { CreatePostSchema, UpdatePostSchema } from "../../lib/schemas.js";
5
+ import { CreatePostSchema, UpdatePostSchema, validateMediaForPostType } from "../../lib/schemas.js";
6
6
  import { requireAuthApi } from "../../middleware/auth.js";
7
+ import { getMediaUrl, getImageUrl } from "../../lib/image.js";
7
8
  export const postsApiRoutes = new Hono();
9
+ /**
10
+ * Converts a Media record to a MediaAttachment API response shape.
11
+ */ function toMediaAttachment(m, r2PublicUrl, imageTransformUrl) {
12
+ const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
13
+ const previewUrl = getImageUrl(url, imageTransformUrl, {
14
+ width: 400,
15
+ quality: 80,
16
+ format: "auto",
17
+ fit: "cover"
18
+ });
19
+ return {
20
+ id: m.id,
21
+ url,
22
+ previewUrl,
23
+ alt: m.alt,
24
+ blurhash: m.blurhash,
25
+ width: m.width,
26
+ height: m.height,
27
+ position: m.position,
28
+ mimeType: m.mimeType
29
+ };
30
+ }
8
31
  // List posts
9
32
  postsApiRoutes.get("/", async (c)=>{
10
33
  const type = c.req.query("type");
@@ -22,10 +45,16 @@ postsApiRoutes.get("/", async (c)=>{
22
45
  cursor: cursor ? sqid.decode(cursor) ?? undefined : undefined,
23
46
  limit
24
47
  });
48
+ // Batch load media for all posts
49
+ const postIds = posts.map((p)=>p.id);
50
+ const mediaMap = await c.var.services.media.getByPostIds(postIds);
51
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
52
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
25
53
  return c.json({
26
54
  posts: posts.map((p)=>({
27
55
  ...p,
28
- sqid: sqid.encode(p.id)
56
+ sqid: sqid.encode(p.id),
57
+ mediaAttachments: (mediaMap.get(p.id) ?? []).map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
29
58
  })),
30
59
  nextCursor: posts.length === limit ? sqid.encode(posts[posts.length - 1]?.id ?? 0) : null
31
60
  });
@@ -40,9 +69,13 @@ postsApiRoutes.get("/:id", async (c)=>{
40
69
  if (!post) return c.json({
41
70
  error: "Not found"
42
71
  }, 404);
72
+ const mediaList = await c.var.services.media.getByPostId(post.id);
73
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
74
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
43
75
  return c.json({
44
76
  ...post,
45
- sqid: sqid.encode(post.id)
77
+ sqid: sqid.encode(post.id),
78
+ mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
46
79
  });
47
80
  });
48
81
  // Create post (requires auth)
@@ -57,6 +90,24 @@ postsApiRoutes.post("/", requireAuthApi(), async (c)=>{
57
90
  }, 400);
58
91
  }
59
92
  const body = parseResult.data;
93
+ // Validate media for post type
94
+ if (body.mediaIds) {
95
+ const mediaError = validateMediaForPostType(body.type, body.mediaIds);
96
+ if (mediaError) {
97
+ return c.json({
98
+ error: mediaError
99
+ }, 400);
100
+ }
101
+ // Verify all media IDs exist
102
+ if (body.mediaIds.length > 0) {
103
+ const existing = await c.var.services.media.getByIds(body.mediaIds);
104
+ if (existing.length !== body.mediaIds.length) {
105
+ return c.json({
106
+ error: "One or more media IDs are invalid"
107
+ }, 400);
108
+ }
109
+ }
110
+ }
60
111
  const post = await c.var.services.posts.create({
61
112
  type: body.type,
62
113
  title: body.title,
@@ -68,9 +119,17 @@ postsApiRoutes.post("/", requireAuthApi(), async (c)=>{
68
119
  replyToId: body.replyToId ? sqid.decode(body.replyToId) ?? undefined : undefined,
69
120
  publishedAt: body.publishedAt
70
121
  });
122
+ // Attach media
123
+ if (body.mediaIds && body.mediaIds.length > 0) {
124
+ await c.var.services.media.attachToPost(post.id, body.mediaIds);
125
+ }
126
+ const mediaList = await c.var.services.media.getByPostId(post.id);
127
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
128
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
71
129
  return c.json({
72
130
  ...post,
73
- sqid: sqid.encode(post.id)
131
+ sqid: sqid.encode(post.id),
132
+ mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
74
133
  }, 201);
75
134
  });
76
135
  // Update post (requires auth)
@@ -89,6 +148,33 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
89
148
  }, 400);
90
149
  }
91
150
  const body = parseResult.data;
151
+ // Validate media for post type if mediaIds is provided
152
+ if (body.mediaIds !== undefined) {
153
+ // Need the post type — use the new type if provided, else fetch existing
154
+ let postType = body.type;
155
+ if (!postType) {
156
+ const existing = await c.var.services.posts.getById(id);
157
+ if (!existing) return c.json({
158
+ error: "Not found"
159
+ }, 404);
160
+ postType = existing.type;
161
+ }
162
+ const mediaError = validateMediaForPostType(postType, body.mediaIds);
163
+ if (mediaError) {
164
+ return c.json({
165
+ error: mediaError
166
+ }, 400);
167
+ }
168
+ // Verify all media IDs exist
169
+ if (body.mediaIds.length > 0) {
170
+ const existing = await c.var.services.media.getByIds(body.mediaIds);
171
+ if (existing.length !== body.mediaIds.length) {
172
+ return c.json({
173
+ error: "One or more media IDs are invalid"
174
+ }, 400);
175
+ }
176
+ }
177
+ }
92
178
  const post = await c.var.services.posts.update(id, {
93
179
  type: body.type,
94
180
  title: body.title,
@@ -102,9 +188,17 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
102
188
  if (!post) return c.json({
103
189
  error: "Not found"
104
190
  }, 404);
191
+ // Update media attachments if provided (including empty array to clear)
192
+ if (body.mediaIds !== undefined) {
193
+ await c.var.services.media.attachToPost(post.id, body.mediaIds);
194
+ }
195
+ const mediaList = await c.var.services.media.getByPostId(post.id);
196
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
197
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
105
198
  return c.json({
106
199
  ...post,
107
- sqid: sqid.encode(post.id)
200
+ sqid: sqid.encode(post.id),
201
+ mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
108
202
  });
109
203
  });
110
204
  // Delete post (requires auth)
@@ -113,6 +207,8 @@ postsApiRoutes.delete("/:id", requireAuthApi(), async (c)=>{
113
207
  if (!id) return c.json({
114
208
  error: "Invalid ID"
115
209
  }, 400);
210
+ // Detach media before deleting
211
+ await c.var.services.media.detachFromPost(id);
116
212
  const success = await c.var.services.posts.delete(id);
117
213
  if (!success) return c.json({
118
214
  error: "Not found"
@@ -0,0 +1,115 @@
1
+ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
2
+ /**
3
+ * Timeline API Routes
4
+ *
5
+ * Provides load-more functionality for the timeline feed via SSE.
6
+ */ import { Hono } from "hono";
7
+ import { sse } from "../../lib/sse.js";
8
+ import { buildMediaMap } from "../../lib/media-helpers.js";
9
+ import { TimelineItem } from "../../theme/components/timeline/TimelineItem.js";
10
+ import { ThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
11
+ const PAGE_SIZE = 20;
12
+ export const timelineApiRoutes = new Hono();
13
+ timelineApiRoutes.get("/", async (c)=>{
14
+ const cursorParam = c.req.query("cursor");
15
+ const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
16
+ if (!cursor || isNaN(cursor)) {
17
+ return c.json({
18
+ error: "cursor parameter required"
19
+ }, 400);
20
+ }
21
+ // Fetch one extra to determine if there are more
22
+ const posts = await c.var.services.posts.list({
23
+ visibility: [
24
+ "featured",
25
+ "quiet"
26
+ ],
27
+ excludeReplies: true,
28
+ excludeTypes: [
29
+ "page"
30
+ ],
31
+ limit: PAGE_SIZE + 1,
32
+ cursor
33
+ });
34
+ const hasMore = posts.length > PAGE_SIZE;
35
+ const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
36
+ if (displayPosts.length === 0) {
37
+ return sse(c, async (stream)=>{
38
+ stream.remove("#load-more-container");
39
+ });
40
+ }
41
+ // Build media map
42
+ const postIds = displayPosts.map((p)=>p.id);
43
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
44
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
45
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
46
+ const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
47
+ // Get reply counts to identify thread roots
48
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
49
+ const threadRootIds = postIds.filter((id)=>(replyCounts.get(id) ?? 0) > 0);
50
+ // Get thread previews
51
+ const threadPreviews = await c.var.services.posts.getThreadPreviews(threadRootIds, 3);
52
+ // Load media for preview replies
53
+ const previewReplyIds = [];
54
+ for (const replies of threadPreviews.values()){
55
+ for (const reply of replies){
56
+ previewReplyIds.push(reply.id);
57
+ }
58
+ }
59
+ const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), r2PublicUrl, imageTransformUrl) : new Map();
60
+ // Assemble timeline items
61
+ const items = displayPosts.map((post)=>{
62
+ const postWithMedia = {
63
+ ...post,
64
+ mediaAttachments: mediaMap.get(post.id) ?? []
65
+ };
66
+ const replyCount = replyCounts.get(post.id) ?? 0;
67
+ const previewReplies = threadPreviews.get(post.id);
68
+ if (replyCount > 0 && previewReplies) {
69
+ return {
70
+ post: postWithMedia,
71
+ threadPreview: {
72
+ replies: previewReplies.map((r)=>({
73
+ ...r,
74
+ mediaAttachments: previewMediaMap.get(r.id) ?? []
75
+ })),
76
+ totalReplyCount: replyCount
77
+ }
78
+ };
79
+ }
80
+ return {
81
+ post: postWithMedia
82
+ };
83
+ });
84
+ // Render items to HTML
85
+ const itemsHtml = items.map((item)=>{
86
+ if (item.threadPreview) {
87
+ return /*#__PURE__*/ _jsx(ThreadPreview, {
88
+ rootPost: item.post,
89
+ previewReplies: item.threadPreview.replies,
90
+ totalReplyCount: item.threadPreview.totalReplyCount
91
+ });
92
+ }
93
+ return /*#__PURE__*/ _jsx(TimelineItem, {
94
+ item: item
95
+ });
96
+ }).map((jsx)=>jsx.toString()).join("");
97
+ // Determine next cursor
98
+ const lastPost = displayPosts[displayPosts.length - 1];
99
+ const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
100
+ // Build load-more button HTML
101
+ const loadMoreHtml = nextCursor ? `<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>` : "";
102
+ return sse(c, async (stream)=>{
103
+ // Append new items to the feed
104
+ stream.patchElements(itemsHtml, {
105
+ mode: "append",
106
+ selector: "#timeline-feed"
107
+ });
108
+ // Replace or remove the load-more container
109
+ if (loadMoreHtml) {
110
+ stream.patchElements(loadMoreHtml);
111
+ } else {
112
+ stream.remove("#load-more-container");
113
+ }
114
+ });
115
+ });
@@ -5,6 +5,7 @@
5
5
  * Supports both JSON and SSE (Datastar) responses.
6
6
  */ import { Hono } from "hono";
7
7
  import { html } from "hono/html";
8
+ import { uuidv7 } from "uuidv7";
8
9
  import { requireAuthApi } from "../../middleware/auth.js";
9
10
  import { getMediaUrl, getImageUrl } from "../../lib/image.js";
10
11
  import { sse, dsSignals } from "../../lib/sse.js";
@@ -138,12 +139,14 @@ uploadApiRoutes.post("/", async (c)=>{
138
139
  error: "File too large (max 10MB)"
139
140
  }, 400);
140
141
  }
141
- // Generate unique filename
142
+ // Generate unique filename using UUIDv7
142
143
  const ext = file.name.split(".").pop() || "bin";
143
- const timestamp = Date.now();
144
- const random = Math.random().toString(36).substring(2, 8);
145
- const filename = `${timestamp}-${random}.${ext}`;
146
- const r2Key = `uploads/${filename}`;
144
+ const id = uuidv7();
145
+ const date = new Date();
146
+ const year = date.getUTCFullYear();
147
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
148
+ const filename = `${id}.${ext}`;
149
+ const r2Key = `media/${year}/${month}/${filename}`;
147
150
  try {
148
151
  // Upload to R2
149
152
  await c.env.R2.put(r2Key, file.stream(), {
@@ -153,6 +156,7 @@ uploadApiRoutes.post("/", async (c)=>{
153
156
  });
154
157
  // Save to database
155
158
  const media = await c.var.services.media.create({
159
+ id,
156
160
  filename,
157
161
  originalName: file.name,
158
162
  mimeType: file.type,
@@ -398,6 +398,44 @@ mediaRoutes.get("/", async (c)=>{
398
398
  })
399
399
  }));
400
400
  });
401
+ // Media picker (returns HTML fragment for PostForm dialog)
402
+ // Must be defined before /:id to avoid "picker" matching as an ID
403
+ mediaRoutes.get("/picker", async (c)=>{
404
+ const mediaList = await c.var.services.media.list(100);
405
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
406
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
407
+ if (mediaList.length === 0) {
408
+ return c.html(/*#__PURE__*/ _jsx("p", {
409
+ class: "text-muted-foreground text-sm col-span-4",
410
+ children: "No media uploaded yet. Upload media from the Media page first."
411
+ }));
412
+ }
413
+ return c.html(/*#__PURE__*/ _jsx(_Fragment, {
414
+ children: mediaList.filter((m)=>m.mimeType.startsWith("image/")).map((m)=>{
415
+ const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
416
+ const thumbUrl = getImageUrl(url, imageTransformUrl, {
417
+ width: 150,
418
+ quality: 80,
419
+ format: "auto",
420
+ fit: "cover"
421
+ });
422
+ return /*#__PURE__*/ _jsx("button", {
423
+ type: "button",
424
+ class: "aspect-square rounded-lg overflow-hidden border-2 hover:border-primary cursor-pointer transition-colors",
425
+ "data-on:click": `$mediaIds.includes('${m.id}') ? ($mediaIds = $mediaIds.filter(id => id !== '${m.id}')) : ($mediaIds = [...$mediaIds, '${m.id}'])`,
426
+ "data-class:border-primary": `$mediaIds.includes('${m.id}')`,
427
+ "data-class:ring-2": `$mediaIds.includes('${m.id}')`,
428
+ "data-class:ring-primary": `$mediaIds.includes('${m.id}')`,
429
+ children: /*#__PURE__*/ _jsx("img", {
430
+ src: thumbUrl,
431
+ alt: m.alt || m.originalName,
432
+ class: "w-full h-full object-cover",
433
+ loading: "lazy"
434
+ })
435
+ }, m.id);
436
+ })
437
+ }));
438
+ });
401
439
  // View single media
402
440
  mediaRoutes.get("/:id", async (c)=>{
403
441
  const id = c.req.param("id");
@@ -0,0 +1,274 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "hono/jsx/jsx-runtime";
2
+ import { getSiteName } from "../../lib/config.js";
3
+ /**
4
+ * Dashboard Navigation Links Routes
5
+ */ import { Hono } from "hono";
6
+ import { useLingui as $_useLingui } from "@jant/core/i18n";
7
+ import { DashLayout } from "../../theme/layouts/index.js";
8
+ import { EmptyState, ListItemRow, ActionButtons, CrudPageHeader } from "../../theme/components/index.js";
9
+ import { dsRedirect, dsToast } from "../../lib/sse.js";
10
+ export const navigationRoutes = new Hono();
11
+ function NavigationListContent({ links }) {
12
+ const { i18n: $__i18n, _: $__ } = $_useLingui();
13
+ return /*#__PURE__*/ _jsxs(_Fragment, {
14
+ children: [
15
+ /*#__PURE__*/ _jsx(CrudPageHeader, {
16
+ title: $__i18n._({
17
+ id: "UxKoFf",
18
+ message: "Navigation"
19
+ }),
20
+ ctaLabel: $__i18n._({
21
+ id: "aaGV/9",
22
+ message: "New Link"
23
+ }),
24
+ ctaHref: "/dash/navigation/new"
25
+ }),
26
+ links.length === 0 ? /*#__PURE__*/ _jsx(EmptyState, {
27
+ message: $__i18n._({
28
+ id: "wdGjkd",
29
+ message: "No navigation links configured."
30
+ }),
31
+ ctaText: $__i18n._({
32
+ id: "aaGV/9",
33
+ message: "New Link"
34
+ }),
35
+ ctaHref: "/dash/navigation/new"
36
+ }) : /*#__PURE__*/ _jsx(_Fragment, {
37
+ children: /*#__PURE__*/ _jsx("div", {
38
+ id: "nav-links-list",
39
+ class: "flex flex-col divide-y",
40
+ children: links.map((link)=>/*#__PURE__*/ _jsx(ListItemRow, {
41
+ actions: /*#__PURE__*/ _jsx(ActionButtons, {
42
+ editHref: `/dash/navigation/${link.id}/edit`,
43
+ editLabel: $__i18n._({
44
+ id: "ePK91l",
45
+ message: "Edit"
46
+ }),
47
+ deleteAction: `/dash/navigation/${link.id}/delete`,
48
+ deleteLabel: $__i18n._({
49
+ id: "cnGeoo",
50
+ message: "Delete"
51
+ })
52
+ }),
53
+ children: /*#__PURE__*/ _jsxs("div", {
54
+ class: "flex items-center gap-3 cursor-grab",
55
+ "data-id": link.id,
56
+ children: [
57
+ /*#__PURE__*/ _jsx("span", {
58
+ class: "text-muted-foreground select-none",
59
+ children: "⠿"
60
+ }),
61
+ /*#__PURE__*/ _jsxs("div", {
62
+ class: "flex items-center gap-2",
63
+ children: [
64
+ /*#__PURE__*/ _jsx("span", {
65
+ class: "font-medium",
66
+ children: link.label
67
+ }),
68
+ /*#__PURE__*/ _jsx("code", {
69
+ class: "text-sm text-muted-foreground bg-muted px-1 rounded",
70
+ children: link.url
71
+ })
72
+ ]
73
+ })
74
+ ]
75
+ })
76
+ }, link.id))
77
+ })
78
+ })
79
+ ]
80
+ });
81
+ }
82
+ function NavigationFormContent({ link, isEdit }) {
83
+ const { i18n: $__i18n, _: $__ } = $_useLingui();
84
+ const title = isEdit ? $__i18n._({
85
+ id: "gDx5MG",
86
+ message: "Edit Link"
87
+ }) : $__i18n._({
88
+ id: "aaGV/9",
89
+ message: "New Link"
90
+ });
91
+ const signals = JSON.stringify({
92
+ label: link?.label ?? "",
93
+ url: link?.url ?? ""
94
+ }).replace(/</g, "\\u003c");
95
+ const action = isEdit ? `/dash/navigation/${link?.id}` : "/dash/navigation";
96
+ return /*#__PURE__*/ _jsxs(_Fragment, {
97
+ children: [
98
+ /*#__PURE__*/ _jsx("h1", {
99
+ class: "text-2xl font-semibold mb-6",
100
+ children: title
101
+ }),
102
+ /*#__PURE__*/ _jsxs("form", {
103
+ "data-signals": signals,
104
+ "data-on:submit__prevent": `@post('${action}')`,
105
+ class: "flex flex-col gap-4 max-w-lg",
106
+ children: [
107
+ /*#__PURE__*/ _jsxs("div", {
108
+ class: "field",
109
+ children: [
110
+ /*#__PURE__*/ _jsx("label", {
111
+ class: "label",
112
+ children: $__i18n._({
113
+ id: "87a/t/",
114
+ message: "Label"
115
+ })
116
+ }),
117
+ /*#__PURE__*/ _jsx("input", {
118
+ type: "text",
119
+ "data-bind": "label",
120
+ class: "input",
121
+ placeholder: "Home",
122
+ required: true
123
+ }),
124
+ /*#__PURE__*/ _jsx("p", {
125
+ class: "text-xs text-muted-foreground mt-1",
126
+ children: $__i18n._({
127
+ id: "+bHzpy",
128
+ message: "Display text for the link"
129
+ })
130
+ })
131
+ ]
132
+ }),
133
+ /*#__PURE__*/ _jsxs("div", {
134
+ class: "field",
135
+ children: [
136
+ /*#__PURE__*/ _jsx("label", {
137
+ class: "label",
138
+ children: $__i18n._({
139
+ id: "IagCbF",
140
+ message: "URL"
141
+ })
142
+ }),
143
+ /*#__PURE__*/ _jsx("input", {
144
+ type: "text",
145
+ "data-bind": "url",
146
+ class: "input",
147
+ placeholder: "/archive or https://...",
148
+ required: true
149
+ }),
150
+ /*#__PURE__*/ _jsx("p", {
151
+ class: "text-xs text-muted-foreground mt-1",
152
+ children: $__i18n._({
153
+ id: "QEbNBb",
154
+ message: "Path (e.g. /archive) or full URL (e.g. https://example.com)"
155
+ })
156
+ })
157
+ ]
158
+ }),
159
+ /*#__PURE__*/ _jsxs("div", {
160
+ class: "flex gap-2",
161
+ children: [
162
+ /*#__PURE__*/ _jsx("button", {
163
+ type: "submit",
164
+ class: "btn",
165
+ children: isEdit ? $__i18n._({
166
+ id: "IUwGEM",
167
+ message: "Save Changes"
168
+ }) : $__i18n._({
169
+ id: "kd7eBB",
170
+ message: "Create Link"
171
+ })
172
+ }),
173
+ /*#__PURE__*/ _jsx("a", {
174
+ href: "/dash/navigation",
175
+ class: "btn-outline",
176
+ children: $__i18n._({
177
+ id: "dEgA5A",
178
+ message: "Cancel"
179
+ })
180
+ })
181
+ ]
182
+ })
183
+ ]
184
+ })
185
+ ]
186
+ });
187
+ }
188
+ // List navigation links
189
+ navigationRoutes.get("/", async (c)=>{
190
+ const siteName = await getSiteName(c);
191
+ const links = await c.var.services.navigationLinks.list();
192
+ return c.html(/*#__PURE__*/ _jsx(DashLayout, {
193
+ c: c,
194
+ title: "Navigation",
195
+ siteName: siteName,
196
+ currentPath: "/dash/navigation",
197
+ children: /*#__PURE__*/ _jsx(NavigationListContent, {
198
+ links: links
199
+ })
200
+ }));
201
+ });
202
+ // New link form
203
+ navigationRoutes.get("/new", async (c)=>{
204
+ const siteName = await getSiteName(c);
205
+ return c.html(/*#__PURE__*/ _jsx(DashLayout, {
206
+ c: c,
207
+ title: "New Link",
208
+ siteName: siteName,
209
+ currentPath: "/dash/navigation",
210
+ children: /*#__PURE__*/ _jsx(NavigationFormContent, {})
211
+ }));
212
+ });
213
+ // Create link
214
+ navigationRoutes.post("/", async (c)=>{
215
+ const body = await c.req.json();
216
+ if (!body.label || !body.url) {
217
+ return dsToast("Label and URL are required", "error");
218
+ }
219
+ await c.var.services.navigationLinks.create({
220
+ label: body.label,
221
+ url: body.url
222
+ });
223
+ return dsRedirect("/dash/navigation");
224
+ });
225
+ // Reorder links (must be before /:id to avoid "reorder" matching as :id)
226
+ navigationRoutes.post("/reorder", async (c)=>{
227
+ const body = await c.req.json();
228
+ if (!Array.isArray(body.ids)) {
229
+ return dsToast("Invalid request", "error");
230
+ }
231
+ await c.var.services.navigationLinks.reorder(body.ids);
232
+ return dsToast("Order saved");
233
+ });
234
+ // Edit link form
235
+ navigationRoutes.get("/:id/edit", async (c)=>{
236
+ const id = parseInt(c.req.param("id"), 10);
237
+ if (isNaN(id)) return c.notFound();
238
+ const link = await c.var.services.navigationLinks.getById(id);
239
+ if (!link) return c.notFound();
240
+ const siteName = await getSiteName(c);
241
+ return c.html(/*#__PURE__*/ _jsx(DashLayout, {
242
+ c: c,
243
+ title: "Edit Link",
244
+ siteName: siteName,
245
+ currentPath: "/dash/navigation",
246
+ children: /*#__PURE__*/ _jsx(NavigationFormContent, {
247
+ link: link,
248
+ isEdit: true
249
+ })
250
+ }));
251
+ });
252
+ // Update link
253
+ navigationRoutes.post("/:id", async (c)=>{
254
+ const id = parseInt(c.req.param("id"), 10);
255
+ if (isNaN(id)) return c.notFound();
256
+ const body = await c.req.json();
257
+ if (!body.label || !body.url) {
258
+ return dsToast("Label and URL are required", "error");
259
+ }
260
+ const updated = await c.var.services.navigationLinks.update(id, {
261
+ label: body.label,
262
+ url: body.url
263
+ });
264
+ if (!updated) return c.notFound();
265
+ return dsRedirect("/dash/navigation");
266
+ });
267
+ // Delete link
268
+ navigationRoutes.post("/:id/delete", async (c)=>{
269
+ const id = parseInt(c.req.param("id"), 10);
270
+ if (!isNaN(id)) {
271
+ await c.var.services.navigationLinks.delete(id);
272
+ }
273
+ return dsRedirect("/dash/navigation");
274
+ });