@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
@@ -1,60 +1,61 @@
1
- import { getSiteName } from "../../lib/config.js";
2
1
  /**
3
2
  * Single Post Page Route
4
3
  */
5
4
 
6
5
  import { Hono } from "hono";
7
6
  import { useLingui } from "@lingui/react/macro";
8
- import type { Bindings, Post } from "../../types.js";
7
+ import type { Bindings, Post, MediaAttachment } from "../../types.js";
9
8
  import type { AppVariables } from "../../app.js";
10
- import { BaseLayout } from "../../theme/layouts/index.js";
9
+ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
10
+ import { MediaGallery } from "../../theme/components/index.js";
11
11
  import * as sqid from "../../lib/sqid.js";
12
12
  import * as time from "../../lib/time.js";
13
+ import { getMediaUrl, getImageUrl } from "../../lib/image.js";
14
+ import { getNavigationData } from "../../lib/navigation.js";
13
15
 
14
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
17
 
16
18
  export const postRoutes = new Hono<Env>();
17
19
 
18
- function PostContent({ post }: { post: Post }) {
20
+ function PostContent({
21
+ post,
22
+ mediaAttachments,
23
+ }: {
24
+ post: Post;
25
+ mediaAttachments: MediaAttachment[];
26
+ }) {
19
27
  const { t } = useLingui();
20
28
 
21
29
  return (
22
- <div class="container py-8">
23
- <article class="h-entry">
24
- {post.title && (
25
- <h1 class="p-name text-2xl font-semibold mb-4">{post.title}</h1>
26
- )}
27
-
28
- <div
29
- class="e-content prose"
30
- dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
31
- />
32
-
33
- <footer class="mt-6 pt-4 border-t text-sm text-muted-foreground">
34
- <time
35
- class="dt-published"
36
- datetime={time.toISOString(post.publishedAt)}
37
- >
38
- {time.formatDate(post.publishedAt)}
39
- </time>
40
- <a href={`/p/${sqid.encode(post.id)}`} class="u-url ml-4">
41
- {t({
42
- message: "Permalink",
43
- comment: "@context: Link to permanent URL of post",
44
- })}
45
- </a>
46
- </footer>
47
- </article>
48
-
49
- <nav class="mt-8">
50
- <a href="/" class="text-sm hover:underline">
30
+ <article class="h-entry">
31
+ {post.title && (
32
+ <h1 class="p-name text-2xl font-semibold mb-4">{post.title}</h1>
33
+ )}
34
+
35
+ <div
36
+ class="e-content prose"
37
+ dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
38
+ />
39
+
40
+ {mediaAttachments.length > 0 && (
41
+ <MediaGallery attachments={mediaAttachments} />
42
+ )}
43
+
44
+ <footer class="mt-6 pt-4 border-t text-sm text-muted-foreground">
45
+ <time
46
+ class="dt-published"
47
+ datetime={time.toISOString(post.publishedAt)}
48
+ >
49
+ {time.formatDate(post.publishedAt)}
50
+ </time>
51
+ <a href={`/p/${sqid.encode(post.id)}`} class="u-url ml-4">
51
52
  {t({
52
- message: "← Back to home",
53
- comment: "@context: Navigation link",
53
+ message: "Permalink",
54
+ comment: "@context: Link to permanent URL of post",
54
55
  })}
55
56
  </a>
56
- </nav>
57
- </div>
57
+ </footer>
58
+ </article>
58
59
  );
59
60
  }
60
61
 
@@ -82,12 +83,35 @@ postRoutes.get("/:id", async (c) => {
82
83
  return c.notFound();
83
84
  }
84
85
 
85
- const siteName = await getSiteName(c);
86
- const title = post.title || siteName;
86
+ // Load media attachments
87
+ const rawMedia = await c.var.services.media.getByPostId(post.id);
88
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
89
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
90
+
91
+ const mediaAttachments: MediaAttachment[] = rawMedia.map((m) => ({
92
+ id: m.id,
93
+ url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
94
+ previewUrl: getImageUrl(
95
+ getMediaUrl(m.id, m.r2Key, r2PublicUrl),
96
+ imageTransformUrl,
97
+ { width: 400, quality: 80, format: "auto", fit: "cover" },
98
+ ),
99
+ alt: m.alt,
100
+ blurhash: m.blurhash,
101
+ width: m.width,
102
+ height: m.height,
103
+ position: m.position,
104
+ mimeType: m.mimeType,
105
+ }));
106
+
107
+ const navData = await getNavigationData(c);
108
+ const title = post.title || navData.siteName;
87
109
 
88
110
  return c.html(
89
111
  <BaseLayout title={title} description={post.content?.slice(0, 160)} c={c}>
90
- <PostContent post={post} />
112
+ <SiteLayout {...navData}>
113
+ <PostContent post={post} mediaAttachments={mediaAttachments} />
114
+ </SiteLayout>
91
115
  </BaseLayout>,
92
116
  );
93
117
  });
@@ -1,4 +1,3 @@
1
- import { getSiteName } from "../../lib/config.js";
2
1
  /**
3
2
  * Search Page Route
4
3
  */
@@ -8,10 +7,11 @@ import { useLingui } from "@lingui/react/macro";
8
7
  import type { Bindings } from "../../types.js";
9
8
  import type { AppVariables } from "../../app.js";
10
9
  import type { SearchResult } from "../../services/search.js";
11
- import { BaseLayout } from "../../theme/layouts/index.js";
10
+ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
12
11
  import { PagePagination } from "../../theme/components/index.js";
13
12
  import * as sqid from "../../lib/sqid.js";
14
13
  import * as time from "../../lib/time.js";
14
+ import { getNavigationData } from "../../lib/navigation.js";
15
15
 
16
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
17
17
 
@@ -39,7 +39,7 @@ function SearchContent({
39
39
  });
40
40
 
41
41
  return (
42
- <div class="container py-8 max-w-2xl">
42
+ <div>
43
43
  <h1 class="text-2xl font-semibold mb-6">{searchTitle}</h1>
44
44
 
45
45
  {/* Search form */}
@@ -137,16 +137,6 @@ function SearchContent({
137
137
  )}
138
138
  </div>
139
139
  )}
140
-
141
- <nav class="mt-8 pt-6 border-t">
142
- <a href="/" class="text-sm hover:underline">
143
- ←{" "}
144
- {t({
145
- message: "Back to home",
146
- comment: "@context: Navigation link back to home page",
147
- })}
148
- </a>
149
- </nav>
150
140
  </div>
151
141
  );
152
142
  }
@@ -156,7 +146,7 @@ searchRoutes.get("/", async (c) => {
156
146
  const pageParam = c.req.query("page");
157
147
  const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
158
148
 
159
- const siteName = await getSiteName(c);
149
+ const navData = await getNavigationData(c);
160
150
 
161
151
  // Only search if there's a query
162
152
  let results: Awaited<ReturnType<typeof c.var.services.search.search>> = [];
@@ -185,16 +175,22 @@ searchRoutes.get("/", async (c) => {
185
175
 
186
176
  return c.html(
187
177
  <BaseLayout
188
- title={query ? `Search: ${query} - ${siteName}` : `Search - ${siteName}`}
178
+ title={
179
+ query
180
+ ? `Search: ${query} - ${navData.siteName}`
181
+ : `Search - ${navData.siteName}`
182
+ }
189
183
  c={c}
190
184
  >
191
- <SearchContent
192
- query={query}
193
- results={results}
194
- error={error}
195
- hasMore={hasMore}
196
- page={page}
197
- />
185
+ <SiteLayout {...navData}>
186
+ <SearchContent
187
+ query={query}
188
+ results={results}
189
+ error={error}
190
+ hasMore={hasMore}
191
+ page={page}
192
+ />
193
+ </SiteLayout>
198
194
  </BaseLayout>,
199
195
  );
200
196
  });
@@ -223,4 +223,106 @@ describe("CollectionService", () => {
223
223
  expect(posts).toEqual([]);
224
224
  });
225
225
  });
226
+
227
+ describe("syncPostCollections", () => {
228
+ it("adds collections to a post with no existing collections", async () => {
229
+ const col1 = await collectionService.create({ title: "Col 1" });
230
+ const col2 = await collectionService.create({ title: "Col 2" });
231
+ const post = await postService.create({
232
+ type: "note",
233
+ content: "test",
234
+ });
235
+
236
+ await collectionService.syncPostCollections(post.id, [col1.id, col2.id]);
237
+
238
+ const collections = await collectionService.getCollectionsForPost(
239
+ post.id,
240
+ );
241
+ expect(collections).toHaveLength(2);
242
+ expect(collections.map((c) => c.id).sort()).toEqual(
243
+ [col1.id, col2.id].sort(),
244
+ );
245
+ });
246
+
247
+ it("removes collections no longer in the list", async () => {
248
+ const col1 = await collectionService.create({ title: "Col 1" });
249
+ const col2 = await collectionService.create({ title: "Col 2" });
250
+ const post = await postService.create({
251
+ type: "note",
252
+ content: "test",
253
+ });
254
+
255
+ await collectionService.addPost(col1.id, post.id);
256
+ await collectionService.addPost(col2.id, post.id);
257
+
258
+ // Sync with only col1 — col2 should be removed
259
+ await collectionService.syncPostCollections(post.id, [col1.id]);
260
+
261
+ const collections = await collectionService.getCollectionsForPost(
262
+ post.id,
263
+ );
264
+ expect(collections).toHaveLength(1);
265
+ expect(collections[0]?.id).toBe(col1.id);
266
+ });
267
+
268
+ it("handles mixed adds and removes", async () => {
269
+ const col1 = await collectionService.create({ title: "Col 1" });
270
+ const col2 = await collectionService.create({ title: "Col 2" });
271
+ const col3 = await collectionService.create({ title: "Col 3" });
272
+ const post = await postService.create({
273
+ type: "note",
274
+ content: "test",
275
+ });
276
+
277
+ // Start with col1 and col2
278
+ await collectionService.addPost(col1.id, post.id);
279
+ await collectionService.addPost(col2.id, post.id);
280
+
281
+ // Sync to col2 and col3 (remove col1, keep col2, add col3)
282
+ await collectionService.syncPostCollections(post.id, [col2.id, col3.id]);
283
+
284
+ const collections = await collectionService.getCollectionsForPost(
285
+ post.id,
286
+ );
287
+ expect(collections).toHaveLength(2);
288
+ expect(collections.map((c) => c.id).sort()).toEqual(
289
+ [col2.id, col3.id].sort(),
290
+ );
291
+ });
292
+
293
+ it("removes all collections when synced with empty array", async () => {
294
+ const col1 = await collectionService.create({ title: "Col 1" });
295
+ const post = await postService.create({
296
+ type: "note",
297
+ content: "test",
298
+ });
299
+
300
+ await collectionService.addPost(col1.id, post.id);
301
+
302
+ await collectionService.syncPostCollections(post.id, []);
303
+
304
+ const collections = await collectionService.getCollectionsForPost(
305
+ post.id,
306
+ );
307
+ expect(collections).toHaveLength(0);
308
+ });
309
+
310
+ it("is a no-op when already in sync", async () => {
311
+ const col1 = await collectionService.create({ title: "Col 1" });
312
+ const post = await postService.create({
313
+ type: "note",
314
+ content: "test",
315
+ });
316
+
317
+ await collectionService.addPost(col1.id, post.id);
318
+
319
+ await collectionService.syncPostCollections(post.id, [col1.id]);
320
+
321
+ const collections = await collectionService.getCollectionsForPost(
322
+ post.id,
323
+ );
324
+ expect(collections).toHaveLength(1);
325
+ expect(collections[0]?.id).toBe(col1.id);
326
+ });
327
+ });
226
328
  });
@@ -1,24 +1,28 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion -- Test assertions use ! for readability */
1
2
  import { describe, it, expect, beforeEach } from "vitest";
2
3
  import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
4
  import { createMediaService } from "../media.js";
5
+ import { createPostService } from "../post.js";
4
6
  import type { Database } from "../../db/index.js";
5
7
 
6
8
  describe("MediaService", () => {
7
9
  let db: Database;
8
10
  let mediaService: ReturnType<typeof createMediaService>;
11
+ let postService: ReturnType<typeof createPostService>;
9
12
 
10
13
  beforeEach(() => {
11
14
  const testDb = createTestDatabase();
12
15
  db = testDb.db as unknown as Database;
13
16
  mediaService = createMediaService(db);
17
+ postService = createPostService(db);
14
18
  });
15
19
 
16
20
  const sampleMedia = {
17
- filename: "image-abc123.jpg",
21
+ filename: "0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
18
22
  originalName: "photo.jpg",
19
23
  mimeType: "image/jpeg",
20
24
  size: 102400,
21
- r2Key: "media/image-abc123.jpg",
25
+ r2Key: "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
22
26
  width: 1920,
23
27
  height: 1080,
24
28
  };
@@ -28,15 +32,19 @@ describe("MediaService", () => {
28
32
  const media = await mediaService.create(sampleMedia);
29
33
 
30
34
  expect(media.id).toBeTruthy(); // UUIDv7
31
- expect(media.filename).toBe("image-abc123.jpg");
35
+ expect(media.filename).toBe("0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg");
32
36
  expect(media.originalName).toBe("photo.jpg");
33
37
  expect(media.mimeType).toBe("image/jpeg");
34
38
  expect(media.size).toBe(102400);
35
- expect(media.r2Key).toBe("media/image-abc123.jpg");
39
+ expect(media.r2Key).toBe(
40
+ "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
41
+ );
36
42
  expect(media.width).toBe(1920);
37
43
  expect(media.height).toBe(1080);
38
44
  expect(media.postId).toBeNull();
39
45
  expect(media.alt).toBeNull();
46
+ expect(media.position).toBe(0);
47
+ expect(media.blurhash).toBeNull();
40
48
  });
41
49
 
42
50
  it("creates media with optional alt text", async () => {
@@ -48,17 +56,51 @@ describe("MediaService", () => {
48
56
  expect(media.alt).toBe("A beautiful sunset");
49
57
  });
50
58
 
59
+ it("creates media with position and blurhash", async () => {
60
+ const media = await mediaService.create({
61
+ ...sampleMedia,
62
+ position: 3,
63
+ blurhash: "LKO2?U%2Tw=w]~RBVZRi};RPxuwH",
64
+ });
65
+
66
+ expect(media.position).toBe(3);
67
+ expect(media.blurhash).toBe("LKO2?U%2Tw=w]~RBVZRi};RPxuwH");
68
+ });
69
+
51
70
  it("generates UUIDv7 IDs", async () => {
52
71
  const media1 = await mediaService.create(sampleMedia);
53
72
  const media2 = await mediaService.create({
54
73
  ...sampleMedia,
55
- r2Key: "media/other.jpg",
74
+ r2Key: "media/2025/01/other.jpg",
56
75
  });
57
76
 
58
77
  expect(media1.id).not.toBe(media2.id);
59
78
  // UUIDv7 should be sortable — later ID is lexicographically greater
60
79
  expect(media2.id > media1.id).toBe(true);
61
80
  });
81
+
82
+ it("uses provided id when given", async () => {
83
+ const customId = "0192a9f1-a2b7-7c3d-8e4f-custom000001";
84
+ const media = await mediaService.create({
85
+ ...sampleMedia,
86
+ id: customId,
87
+ });
88
+
89
+ expect(media.id).toBe(customId);
90
+ });
91
+
92
+ it("auto-generates id when not provided", async () => {
93
+ const media = await mediaService.create({
94
+ ...sampleMedia,
95
+ r2Key: "media/2025/01/auto.jpg",
96
+ });
97
+
98
+ expect(media.id).toBeTruthy();
99
+ // UUIDv7 format: 8-4-4-4-12 hex chars
100
+ expect(media.id).toMatch(
101
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
102
+ );
103
+ });
62
104
  });
63
105
 
64
106
  describe("getById", () => {
@@ -67,7 +109,7 @@ describe("MediaService", () => {
67
109
 
68
110
  const found = await mediaService.getById(created.id);
69
111
  expect(found).not.toBeNull();
70
- expect(found?.filename).toBe("image-abc123.jpg");
112
+ expect(found?.filename).toBe("0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg");
71
113
  });
72
114
 
73
115
  it("returns null for non-existent ID", async () => {
@@ -76,11 +118,142 @@ describe("MediaService", () => {
76
118
  });
77
119
  });
78
120
 
121
+ describe("getByIds", () => {
122
+ it("returns media for valid IDs", async () => {
123
+ const m1 = await mediaService.create({
124
+ ...sampleMedia,
125
+ r2Key: "media/a.jpg",
126
+ });
127
+ const m2 = await mediaService.create({
128
+ ...sampleMedia,
129
+ r2Key: "media/b.jpg",
130
+ });
131
+
132
+ const results = await mediaService.getByIds([m1.id, m2.id]);
133
+ expect(results).toHaveLength(2);
134
+ expect(results.map((r) => r.id).sort()).toEqual([m1.id, m2.id].sort());
135
+ });
136
+
137
+ it("returns empty array for empty input", async () => {
138
+ const results = await mediaService.getByIds([]);
139
+ expect(results).toEqual([]);
140
+ });
141
+
142
+ it("ignores non-existent IDs", async () => {
143
+ const m1 = await mediaService.create(sampleMedia);
144
+
145
+ const results = await mediaService.getByIds([m1.id, "nonexistent"]);
146
+ expect(results).toHaveLength(1);
147
+ expect(results[0]!.id).toBe(m1.id);
148
+ });
149
+ });
150
+
151
+ describe("getByPostId", () => {
152
+ it("returns media ordered by position", async () => {
153
+ const post = await postService.create({
154
+ type: "note",
155
+ content: "test",
156
+ });
157
+
158
+ const m1 = await mediaService.create({
159
+ ...sampleMedia,
160
+ r2Key: "media/a.jpg",
161
+ });
162
+ const m2 = await mediaService.create({
163
+ ...sampleMedia,
164
+ r2Key: "media/b.jpg",
165
+ });
166
+
167
+ await mediaService.attachToPost(post.id, [m2.id, m1.id]);
168
+
169
+ const results = await mediaService.getByPostId(post.id);
170
+ expect(results).toHaveLength(2);
171
+ expect(results[0]!.id).toBe(m2.id);
172
+ expect(results[0]!.position).toBe(0);
173
+ expect(results[1]!.id).toBe(m1.id);
174
+ expect(results[1]!.position).toBe(1);
175
+ });
176
+
177
+ it("returns empty array for post with no media", async () => {
178
+ const post = await postService.create({
179
+ type: "note",
180
+ content: "test",
181
+ });
182
+
183
+ const results = await mediaService.getByPostId(post.id);
184
+ expect(results).toEqual([]);
185
+ });
186
+ });
187
+
188
+ describe("getByPostIds", () => {
189
+ it("returns Map grouped by postId", async () => {
190
+ const post1 = await postService.create({
191
+ type: "note",
192
+ content: "post 1",
193
+ });
194
+ const post2 = await postService.create({
195
+ type: "note",
196
+ content: "post 2",
197
+ });
198
+
199
+ const m1 = await mediaService.create({
200
+ ...sampleMedia,
201
+ r2Key: "media/a.jpg",
202
+ });
203
+ const m2 = await mediaService.create({
204
+ ...sampleMedia,
205
+ r2Key: "media/b.jpg",
206
+ });
207
+ const m3 = await mediaService.create({
208
+ ...sampleMedia,
209
+ r2Key: "media/c.jpg",
210
+ });
211
+
212
+ await mediaService.attachToPost(post1.id, [m1.id, m2.id]);
213
+ await mediaService.attachToPost(post2.id, [m3.id]);
214
+
215
+ const results = await mediaService.getByPostIds([post1.id, post2.id]);
216
+ expect(results.size).toBe(2);
217
+ expect(results.get(post1.id)).toHaveLength(2);
218
+ expect(results.get(post2.id)).toHaveLength(1);
219
+ });
220
+
221
+ it("returns empty Map for empty input", async () => {
222
+ const results = await mediaService.getByPostIds([]);
223
+ expect(results.size).toBe(0);
224
+ });
225
+
226
+ it("returns ordered by position within each post", async () => {
227
+ const post = await postService.create({
228
+ type: "note",
229
+ content: "test",
230
+ });
231
+
232
+ const m1 = await mediaService.create({
233
+ ...sampleMedia,
234
+ r2Key: "media/a.jpg",
235
+ });
236
+ const m2 = await mediaService.create({
237
+ ...sampleMedia,
238
+ r2Key: "media/b.jpg",
239
+ });
240
+
241
+ await mediaService.attachToPost(post.id, [m2.id, m1.id]);
242
+
243
+ const results = await mediaService.getByPostIds([post.id]);
244
+ const postMedia = results.get(post.id)!;
245
+ expect(postMedia[0]!.id).toBe(m2.id);
246
+ expect(postMedia[1]!.id).toBe(m1.id);
247
+ });
248
+ });
249
+
79
250
  describe("getByR2Key", () => {
80
251
  it("returns media by R2 key", async () => {
81
252
  await mediaService.create(sampleMedia);
82
253
 
83
- const found = await mediaService.getByR2Key("media/image-abc123.jpg");
254
+ const found = await mediaService.getByR2Key(
255
+ "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
256
+ );
84
257
  expect(found).not.toBeNull();
85
258
  expect(found?.originalName).toBe("photo.jpg");
86
259
  });
@@ -115,6 +288,108 @@ describe("MediaService", () => {
115
288
  });
116
289
  });
117
290
 
291
+ describe("attachToPost", () => {
292
+ it("sets postId and position for each media", async () => {
293
+ const post = await postService.create({
294
+ type: "note",
295
+ content: "test",
296
+ });
297
+
298
+ const m1 = await mediaService.create({
299
+ ...sampleMedia,
300
+ r2Key: "media/a.jpg",
301
+ });
302
+ const m2 = await mediaService.create({
303
+ ...sampleMedia,
304
+ r2Key: "media/b.jpg",
305
+ });
306
+
307
+ await mediaService.attachToPost(post.id, [m1.id, m2.id]);
308
+
309
+ const attached = await mediaService.getByPostId(post.id);
310
+ expect(attached).toHaveLength(2);
311
+ expect(attached[0]!.id).toBe(m1.id);
312
+ expect(attached[0]!.position).toBe(0);
313
+ expect(attached[1]!.id).toBe(m2.id);
314
+ expect(attached[1]!.position).toBe(1);
315
+ });
316
+
317
+ it("replaces existing attachments", async () => {
318
+ const post = await postService.create({
319
+ type: "note",
320
+ content: "test",
321
+ });
322
+
323
+ const m1 = await mediaService.create({
324
+ ...sampleMedia,
325
+ r2Key: "media/a.jpg",
326
+ });
327
+ const m2 = await mediaService.create({
328
+ ...sampleMedia,
329
+ r2Key: "media/b.jpg",
330
+ });
331
+ const m3 = await mediaService.create({
332
+ ...sampleMedia,
333
+ r2Key: "media/c.jpg",
334
+ });
335
+
336
+ await mediaService.attachToPost(post.id, [m1.id, m2.id]);
337
+ await mediaService.attachToPost(post.id, [m3.id]);
338
+
339
+ const attached = await mediaService.getByPostId(post.id);
340
+ expect(attached).toHaveLength(1);
341
+ expect(attached[0]!.id).toBe(m3.id);
342
+ expect(attached[0]!.position).toBe(0);
343
+
344
+ // Verify old media is detached
345
+ const old1 = await mediaService.getById(m1.id);
346
+ expect(old1!.postId).toBeNull();
347
+ expect(old1!.position).toBe(0);
348
+ });
349
+
350
+ it("handles empty array by clearing all attachments", async () => {
351
+ const post = await postService.create({
352
+ type: "note",
353
+ content: "test",
354
+ });
355
+
356
+ const m1 = await mediaService.create({
357
+ ...sampleMedia,
358
+ r2Key: "media/a.jpg",
359
+ });
360
+
361
+ await mediaService.attachToPost(post.id, [m1.id]);
362
+ await mediaService.attachToPost(post.id, []);
363
+
364
+ const attached = await mediaService.getByPostId(post.id);
365
+ expect(attached).toHaveLength(0);
366
+ });
367
+ });
368
+
369
+ describe("detachFromPost", () => {
370
+ it("clears postId and resets position", async () => {
371
+ const post = await postService.create({
372
+ type: "note",
373
+ content: "test",
374
+ });
375
+
376
+ const m1 = await mediaService.create({
377
+ ...sampleMedia,
378
+ r2Key: "media/a.jpg",
379
+ });
380
+
381
+ await mediaService.attachToPost(post.id, [m1.id]);
382
+ await mediaService.detachFromPost(post.id);
383
+
384
+ const attached = await mediaService.getByPostId(post.id);
385
+ expect(attached).toHaveLength(0);
386
+
387
+ const detached = await mediaService.getById(m1.id);
388
+ expect(detached!.postId).toBeNull();
389
+ expect(detached!.position).toBe(0);
390
+ });
391
+ });
392
+
118
393
  describe("delete", () => {
119
394
  it("deletes a media record", async () => {
120
395
  const media = await mediaService.create(sampleMedia);