@jant/core 0.3.36 → 0.3.38

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 (271) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -0,0 +1,54 @@
1
+ import type { FC } from "hono/jsx";
2
+ import { useLingui } from "@lingui/react/macro";
3
+ import type { Collection } from "../../types.js";
4
+ import { ComposeForm } from "../compose/ComposeDialog.js";
5
+
6
+ export interface ComposePageProps {
7
+ collections?: Collection[];
8
+ uploadMaxFileSize?: number;
9
+ closeHref?: string;
10
+ }
11
+
12
+ export const ComposePage: FC<ComposePageProps> = ({
13
+ collections,
14
+ uploadMaxFileSize,
15
+ closeHref = "/",
16
+ }) => {
17
+ const { t } = useLingui();
18
+ const backLabel = t({
19
+ message: "Back",
20
+ comment: "@context: Link back from the new post page",
21
+ });
22
+
23
+ return (
24
+ <section class="compose-page" data-page="compose">
25
+ <div class="compose-page-shell">
26
+ <div class="compose-page-intro">
27
+ <div class="compose-page-intro-row">
28
+ <h1 class="compose-page-title">
29
+ {t({
30
+ message: "New post",
31
+ comment: "@context: Page title for the new post page",
32
+ })}
33
+ </h1>
34
+ <button
35
+ type="button"
36
+ class="compose-page-back-link"
37
+ aria-label={backLabel}
38
+ data-on:click="el.closest('.compose-page-shell')?.querySelector('jant-compose-dialog')?.requestCloseAndLeave()"
39
+ >
40
+ <span>{`← ${backLabel}`}</span>
41
+ </button>
42
+ </div>
43
+ </div>
44
+ <ComposeForm
45
+ collections={collections}
46
+ uploadMaxFileSize={uploadMaxFileSize}
47
+ pageMode
48
+ closeHref={closeHref}
49
+ autoRestoreDraft
50
+ />
51
+ </div>
52
+ </section>
53
+ );
54
+ };
@@ -2,55 +2,40 @@
2
2
  * Single Post Page
3
3
  *
4
4
  * Single post view — clean, no card border, with divider footer.
5
+ * When `threadPosts` is provided, renders the full thread with the current
6
+ * post highlighted and scroll-targeted.
5
7
  */
6
8
 
7
9
  import type { FC } from "hono/jsx";
8
- import { useLingui } from "@lingui/react/macro";
9
- import type { PostPageProps } from "../../types.js";
10
- import { MediaGallery } from "../shared/MediaGallery.js";
11
-
12
- export const PostPage: FC<PostPageProps> = ({ post }) => {
13
- const { t } = useLingui();
10
+ import type { PostPageProps, PostView } from "../../types.js";
11
+ import { TimelineItemFromPost } from "../feed/TimelineItem.js";
14
12
 
13
+ const ThreadDetail: FC<{ post: PostView; threadPosts: PostView[] }> = ({
14
+ post,
15
+ threadPosts,
16
+ }) => {
15
17
  return (
16
- <article
17
- class="h-entry py-6"
18
- data-page="post"
19
- data-post
20
- data-format={post.format}
21
- >
22
- {post.title && (
23
- <h1 class="p-name text-2xl font-semibold mb-4">{post.title}</h1>
24
- )}
25
-
26
- {post.bodyHtml && (
27
- <div
28
- class="e-content prose"
29
- data-post-body
30
- dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
31
- />
32
- )}
33
-
34
- {post.media.length > 0 && (
35
- <div class="mt-4" data-post-media>
36
- <MediaGallery attachments={post.media} />
37
- </div>
38
- )}
39
-
40
- <footer
41
- class="mt-6 pt-4 border-t text-sm text-muted-foreground"
42
- data-post-meta
43
- >
44
- <time class="dt-published" datetime={post.publishedAt}>
45
- {post.publishedAtFormatted}
46
- </time>
47
- <a href={post.permalink} class="u-url ml-4">
48
- {t({
49
- message: "Permalink",
50
- comment: "@context: Link to permanent URL of post",
51
- })}
52
- </a>
53
- </footer>
54
- </article>
18
+ <div class="thread-group thread-group-detail" data-page="post">
19
+ {threadPosts.map((tp) => {
20
+ const isCurrent = tp.id === post.id;
21
+ return (
22
+ <div
23
+ key={tp.id}
24
+ id={`post-${tp.id}`}
25
+ class={`thread-item thread-detail-item${isCurrent ? " thread-item-current" : ""}`}
26
+ {...(isCurrent ? { "data-post-current": "" } : {})}
27
+ >
28
+ <TimelineItemFromPost post={tp} />
29
+ </div>
30
+ );
31
+ })}
32
+ </div>
55
33
  );
56
34
  };
35
+
36
+ export const PostPage: FC<PostPageProps> = ({ post, threadPosts }) => {
37
+ if (threadPosts && threadPosts.length > 1) {
38
+ return <ThreadDetail post={post} threadPosts={threadPosts} />;
39
+ }
40
+ return <TimelineItemFromPost post={post} mode="detail" />;
41
+ };
@@ -1,14 +1,184 @@
1
1
  /**
2
2
  * Search Page
3
3
  *
4
- * Search form and resultsdivider-separated instead of bordered cards.
4
+ * Dedicated search result UIcompact per-type cards, not full timeline cards.
5
+ * Each card shows only what's relevant: title/domain/quote + FTS snippet.
5
6
  */
6
7
 
7
8
  import type { FC } from "hono/jsx";
8
9
  import { useLingui } from "@lingui/react/macro";
9
- import type { SearchPageProps } from "../../types.js";
10
+ import type { SearchPageProps, SearchResultView } from "../../types.js";
10
11
  import { PagePagination } from "../shared/Pagination.js";
11
12
 
13
+ // External link icon (shared by LinkCard)
14
+ const ExternalLinkIcon = () => (
15
+ <svg
16
+ class="size-3 shrink-0"
17
+ xmlns="http://www.w3.org/2000/svg"
18
+ fill="none"
19
+ viewBox="0 0 24 24"
20
+ stroke-width="2"
21
+ stroke="currentColor"
22
+ >
23
+ <path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
24
+ </svg>
25
+ );
26
+
27
+ const SearchResultCard: FC<{ result: SearchResultView }> = ({ result }) => {
28
+ const { post, snippet, titleHighlighted, quoteHighlighted } = result;
29
+
30
+ // Extract domain for link posts
31
+ let domain: string | undefined;
32
+ if (post.format === "link" && post.url) {
33
+ try {
34
+ domain = new URL(post.url).hostname.replace(/^www\./, "");
35
+ } catch {
36
+ // Invalid URL, skip
37
+ }
38
+ }
39
+
40
+ const footer = (
41
+ <footer class="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
42
+ <span class="badge-outline">{post.format}</span>
43
+ <a href={post.permalink} class="hover:underline">
44
+ <time datetime={post.publishedAt}>{post.publishedAtFormatted}</time>
45
+ </a>
46
+ </footer>
47
+ );
48
+
49
+ // ── Link ──────────────────────────────────────────────────────────────────
50
+ if (post.format === "link") {
51
+ return (
52
+ <article data-post data-format="link">
53
+ {domain && (
54
+ <div class="flex items-center gap-1 text-xs text-muted-foreground mb-1">
55
+ <ExternalLinkIcon />
56
+ <span>{domain}</span>
57
+ </div>
58
+ )}
59
+ {(titleHighlighted ?? post.title) && (
60
+ <h3 class="font-semibold text-base mb-1">
61
+ {titleHighlighted ? (
62
+ <a
63
+ href={post.url || post.permalink}
64
+ target={post.url ? "_blank" : undefined}
65
+ rel={post.url ? "noopener noreferrer" : undefined}
66
+ class="hover:underline"
67
+ dangerouslySetInnerHTML={{ __html: titleHighlighted }}
68
+ />
69
+ ) : (
70
+ <a
71
+ href={post.url || post.permalink}
72
+ target={post.url ? "_blank" : undefined}
73
+ rel={post.url ? "noopener noreferrer" : undefined}
74
+ class="hover:underline"
75
+ >
76
+ {post.title}
77
+ </a>
78
+ )}
79
+ </h3>
80
+ )}
81
+ {snippet && (
82
+ <p
83
+ class="search-snippet"
84
+ dangerouslySetInnerHTML={{ __html: snippet }}
85
+ />
86
+ )}
87
+ {footer}
88
+ </article>
89
+ );
90
+ }
91
+
92
+ // ── Quote ─────────────────────────────────────────────────────────────────
93
+ if (post.format === "quote") {
94
+ return (
95
+ <article data-post data-format="quote">
96
+ {quoteHighlighted && (
97
+ <blockquote class="feed-quote mb-1">
98
+ <p
99
+ class="text-sm"
100
+ dangerouslySetInnerHTML={{ __html: quoteHighlighted }}
101
+ />
102
+ {post.title && (
103
+ <footer class="text-xs text-muted-foreground mt-1">
104
+ {post.url ? (
105
+ <a
106
+ href={post.url}
107
+ target="_blank"
108
+ rel="noopener noreferrer"
109
+ class="hover:underline"
110
+ >
111
+ — {post.title}
112
+ </a>
113
+ ) : (
114
+ <span>— {post.title}</span>
115
+ )}
116
+ </footer>
117
+ )}
118
+ </blockquote>
119
+ )}
120
+ {snippet && (
121
+ <p
122
+ class="search-snippet"
123
+ dangerouslySetInnerHTML={{ __html: snippet }}
124
+ />
125
+ )}
126
+ {footer}
127
+ </article>
128
+ );
129
+ }
130
+
131
+ // ── Note with title (article) ─────────────────────────────────────────────
132
+ if (post.title) {
133
+ return (
134
+ <article data-post data-format="note">
135
+ <h3 class="font-semibold text-base mb-1">
136
+ {titleHighlighted ? (
137
+ <a
138
+ href={post.permalink}
139
+ class="hover:underline"
140
+ dangerouslySetInnerHTML={{ __html: titleHighlighted }}
141
+ />
142
+ ) : (
143
+ <a href={post.permalink} class="hover:underline">
144
+ {post.title}
145
+ </a>
146
+ )}
147
+ </h3>
148
+ {snippet && (
149
+ <p
150
+ class="search-snippet"
151
+ dangerouslySetInnerHTML={{ __html: snippet }}
152
+ />
153
+ )}
154
+ {footer}
155
+ </article>
156
+ );
157
+ }
158
+
159
+ // ── Untitled note ─────────────────────────────────────────────────────────
160
+ return (
161
+ <article data-post data-format="note">
162
+ {snippet ? (
163
+ <a href={post.permalink} class="block hover:opacity-80">
164
+ <p
165
+ class="search-snippet"
166
+ dangerouslySetInnerHTML={{ __html: snippet }}
167
+ />
168
+ </a>
169
+ ) : (
170
+ <a
171
+ href={post.permalink}
172
+ class="block text-sm text-muted-foreground hover:underline"
173
+ >
174
+ {post.publishedAtFormatted}
175
+ </a>
176
+ )}
177
+ {footer}
178
+ </article>
179
+ );
180
+ };
181
+
12
182
  export const SearchPage: FC<SearchPageProps> = ({
13
183
  query,
14
184
  results,
@@ -17,14 +187,12 @@ export const SearchPage: FC<SearchPageProps> = ({
17
187
  page,
18
188
  }) => {
19
189
  const { t } = useLingui();
20
- const searchTitle = t({
21
- message: "Search",
22
- comment: "@context: Search page title",
23
- });
24
190
 
25
191
  return (
26
192
  <div class="py-6" data-page="search">
27
- <h1 class="text-2xl font-semibold mb-6">{searchTitle}</h1>
193
+ <h1 class="text-2xl font-semibold mb-6">
194
+ {t({ message: "Search", comment: "@context: Search page title" })}
195
+ </h1>
28
196
 
29
197
  {/* Search form */}
30
198
  <form method="get" action="/search" class="mb-8">
@@ -78,36 +246,12 @@ export const SearchPage: FC<SearchPageProps> = ({
78
246
 
79
247
  {results.length > 0 && (
80
248
  <>
81
- <div class="divide-y divide-border">
82
- {results.map((result) => (
83
- <article
84
- key={result.post.id}
85
- class="py-4"
86
- data-post
87
- data-format={result.post.format}
88
- >
89
- <a href={result.post.permalink} class="block">
90
- <h2 class="font-medium hover:underline">
91
- {result.post.title ||
92
- result.post.excerpt?.slice(0, 60) ||
93
- "Post #" + result.post.id}
94
- </h2>
95
-
96
- {result.snippet && (
97
- <p
98
- class="text-sm text-muted-foreground mt-2 line-clamp-2"
99
- dangerouslySetInnerHTML={{ __html: result.snippet }}
100
- />
101
- )}
102
-
103
- <footer class="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
104
- <span class="badge-outline">{result.post.format}</span>
105
- <time datetime={result.post.publishedAt}>
106
- {result.post.publishedAtFormatted}
107
- </time>
108
- </footer>
109
- </a>
110
- </article>
249
+ <div class="flex flex-col">
250
+ {results.map((result, i) => (
251
+ <div key={result.post.id}>
252
+ {i > 0 && <hr class="feed-divider" />}
253
+ <SearchResultCard result={result} />
254
+ </div>
111
255
  ))}
112
256
  </div>
113
257
 
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Admin Breadcrumb Component
3
+ *
4
+ * Reuses the existing dash-breadcrumb CSS classes.
5
+ */
6
+
7
+ import type { FC } from "hono/jsx";
8
+
9
+ export interface AdminBreadcrumbProps {
10
+ parent: string;
11
+ parentHref: string;
12
+ current: string;
13
+ }
14
+
15
+ export const AdminBreadcrumb: FC<AdminBreadcrumbProps> = ({
16
+ parent,
17
+ parentHref,
18
+ current,
19
+ }) => {
20
+ return (
21
+ <nav class="dash-breadcrumb mb-6">
22
+ <a href={parentHref} class="dash-breadcrumb-parent">
23
+ {parent}
24
+ </a>
25
+ <span class="dash-breadcrumb-sep">/</span>
26
+ <span class="dash-breadcrumb-current">{current}</span>
27
+ </nav>
28
+ );
29
+ };
@@ -2,13 +2,13 @@
2
2
  * Collections Sidebar
3
3
  *
4
4
  * Shared sidebar navigation for public collection pages.
5
- * - Anonymous users: static nav with collections and dividers
5
+ * - Anonymous users: static nav with collections and dividers from sidebar items
6
6
  * - Authenticated users: interactive Lit component with CRUD, reorder, divider management
7
7
  */
8
8
 
9
9
  import type { FC } from "hono/jsx";
10
10
  import { useLingui } from "@lingui/react/macro";
11
- import type { Collection, CollectionDivider } from "../../types.js";
11
+ import type { Collection, SidebarItem } from "../../types.js";
12
12
  import { renderCollectionIcon } from "../../lib/icons.js";
13
13
 
14
14
  const escapeJson = (data: unknown) =>
@@ -16,15 +16,15 @@ const escapeJson = (data: unknown) =>
16
16
 
17
17
  export interface CollectionsSidebarProps {
18
18
  collections: Collection[];
19
- dividers: CollectionDivider[];
19
+ sidebarItems: SidebarItem[];
20
20
  activeSlug?: string;
21
21
  isAuthenticated?: boolean;
22
- postCounts?: Map<number, number>;
22
+ postCounts?: Map<string, number>;
23
23
  }
24
24
 
25
25
  export const CollectionsSidebar: FC<CollectionsSidebarProps> = ({
26
26
  collections,
27
- dividers,
27
+ sidebarItems,
28
28
  activeSlug,
29
29
  isAuthenticated,
30
30
  postCounts,
@@ -33,7 +33,7 @@ export const CollectionsSidebar: FC<CollectionsSidebarProps> = ({
33
33
  return (
34
34
  <AuthenticatedSidebar
35
35
  collections={collections}
36
- dividers={dividers}
36
+ sidebarItems={sidebarItems}
37
37
  activeSlug={activeSlug}
38
38
  postCounts={postCounts}
39
39
  />
@@ -43,7 +43,7 @@ export const CollectionsSidebar: FC<CollectionsSidebarProps> = ({
43
43
  return (
44
44
  <AnonymousSidebar
45
45
  collections={collections}
46
- dividers={dividers}
46
+ sidebarItems={sidebarItems}
47
47
  activeSlug={activeSlug}
48
48
  />
49
49
  );
@@ -55,20 +55,13 @@ export const CollectionsSidebar: FC<CollectionsSidebarProps> = ({
55
55
 
56
56
  const AnonymousSidebar: FC<{
57
57
  collections: Collection[];
58
- dividers: CollectionDivider[];
58
+ sidebarItems: SidebarItem[];
59
59
  activeSlug?: string;
60
- }> = ({ collections, dividers, activeSlug }) => {
60
+ }> = ({ collections, sidebarItems, activeSlug }) => {
61
61
  const { t } = useLingui();
62
62
 
63
- // Interleave collections and dividers by position
64
- type Item =
65
- | { kind: "collection"; data: Collection }
66
- | { kind: "divider"; data: CollectionDivider };
67
-
68
- const items: Item[] = [
69
- ...collections.map((c) => ({ kind: "collection" as const, data: c })),
70
- ...dividers.map((d) => ({ kind: "divider" as const, data: d })),
71
- ].sort((a, b) => a.data.position - b.data.position);
63
+ // Build collection lookup
64
+ const collectionMap = new Map(collections.map((c) => [c.id, c]));
72
65
 
73
66
  return (
74
67
  <nav class="flex flex-col gap-1 pt-6">
@@ -78,19 +71,22 @@ const AnonymousSidebar: FC<{
78
71
  comment: "@context: Sidebar heading for collections nav",
79
72
  })}
80
73
  </h2>
81
- {items.map((item) => {
82
- if (item.kind === "divider") {
74
+ {sidebarItems.map((item) => {
75
+ if (item.type === "divider") {
83
76
  return (
84
- <div key={`d-${item.data.id}`} class="px-3 py-1">
77
+ <div key={item.id} class="px-3 py-1">
85
78
  <hr class="border-border" />
86
79
  </div>
87
80
  );
88
81
  }
89
- const col = item.data;
82
+ const col = item.collectionId
83
+ ? collectionMap.get(item.collectionId)
84
+ : undefined;
85
+ if (!col) return null;
90
86
  const isActive = col.slug === activeSlug;
91
87
  return (
92
88
  <a
93
- key={col.id}
89
+ key={item.id}
94
90
  href={`/c/${col.slug}`}
95
91
  class={`flex items-center gap-2.5 px-3 py-2 text-sm rounded-md truncate ${
96
92
  isActive
@@ -121,21 +117,36 @@ const AnonymousSidebar: FC<{
121
117
 
122
118
  const AuthenticatedSidebar: FC<{
123
119
  collections: Collection[];
124
- dividers: CollectionDivider[];
120
+ sidebarItems: SidebarItem[];
125
121
  activeSlug?: string;
126
- postCounts?: Map<number, number>;
127
- }> = ({ collections, dividers, activeSlug, postCounts }) => {
122
+ postCounts?: Map<string, number>;
123
+ }> = ({ collections, sidebarItems, activeSlug, postCounts }) => {
128
124
  const { t } = useLingui();
129
125
 
130
- const sidebarCollections = collections.map((col) => ({
131
- id: col.id,
132
- slug: col.slug,
133
- title: col.title,
134
- description: col.description,
135
- icon: col.icon,
136
- sortOrder: col.sortOrder,
137
- position: col.position,
138
- postCount: postCounts?.get(col.id) ?? 0,
126
+ // Build collection lookup for enriching sidebar items
127
+ const collectionMap = new Map(
128
+ collections.map((col) => [
129
+ col.id,
130
+ {
131
+ id: col.id,
132
+ slug: col.slug,
133
+ title: col.title,
134
+ description: col.description,
135
+ icon: col.icon,
136
+ sortOrder: col.sortOrder,
137
+ postCount: postCounts?.get(col.id) ?? 0,
138
+ },
139
+ ]),
140
+ );
141
+
142
+ const clientSidebarItems = sidebarItems.map((item) => ({
143
+ id: item.id,
144
+ type: item.type,
145
+ collectionId: item.collectionId,
146
+ position: item.position,
147
+ collection: item.collectionId
148
+ ? collectionMap.get(item.collectionId)
149
+ : undefined,
139
150
  }));
140
151
 
141
152
  const labels = {
@@ -275,8 +286,7 @@ const AuthenticatedSidebar: FC<{
275
286
 
276
287
  return (
277
288
  <jant-collection-sidebar
278
- collections={escapeJson(sidebarCollections)}
279
- dividers={escapeJson(dividers)}
289
+ sidebar-items={escapeJson(clientSidebarItems)}
280
290
  labels={escapeJson(labels)}
281
291
  active-slug={activeSlug ?? ""}
282
292
  />