@jant/core 0.3.35 → 0.3.37

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 (307) 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/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -6,13 +6,25 @@
6
6
 
7
7
  import type { FC } from "hono/jsx";
8
8
  import type { TimelineCardProps } from "../../types.js";
9
+ import { StarRating } from "../shared/StarRating.js";
10
+ import { PostFooter } from "../shared/PostFooter.js";
11
+ import { PostStatusBadges } from "./PostStatusBadges.js";
12
+ import { sanitizeUrl } from "../../lib/url.js";
9
13
 
10
- export const LinkCard: FC<TimelineCardProps> = ({ post, compact }) => {
11
- // Extract domain from URL for display
14
+ export const LinkCard: FC<TimelineCardProps> = ({
15
+ post,
16
+ mode = "feed",
17
+ display,
18
+ }) => {
19
+ const isCompact = mode === "compact";
20
+ const isDetail = mode === "detail";
21
+ const articleClass = `h-entry post-menu-target${isCompact ? " feed-compact" : isDetail ? " py-6" : " feed-card feed-card-link"}`;
22
+
23
+ const safeUrl = post.url ? sanitizeUrl(post.url) : "";
12
24
  let domain: string | undefined;
13
- if (post.url) {
25
+ if (safeUrl) {
14
26
  try {
15
- domain = new URL(post.url).hostname.replace(/^www\./, "");
27
+ domain = new URL(safeUrl).hostname.replace(/^www\./, "");
16
28
  } catch {
17
29
  // Invalid URL, skip domain display
18
30
  }
@@ -20,53 +32,90 @@ export const LinkCard: FC<TimelineCardProps> = ({ post, compact }) => {
20
32
 
21
33
  return (
22
34
  <article
23
- class={`h-entry${compact ? " feed-compact" : ""}`}
35
+ class={articleClass}
36
+ {...(isDetail ? { "data-page": "post" } : {})}
24
37
  data-post
25
38
  data-format="link"
39
+ data-post-id={post.id}
40
+ data-post-permalink={post.permalink}
41
+ {...(post.pinned ? { "data-post-pinned": "" } : {})}
42
+ {...(post.featured ? { "data-post-featured": "" } : {})}
43
+ data-post-visibility={post.visibility}
44
+ {...(!isDetail && post.threadRootId ? { "data-post-reply": "" } : {})}
26
45
  >
27
- {domain && (
28
- <div class="text-xs text-muted-foreground mb-1 flex items-center gap-1">
29
- <svg
30
- class="size-3"
31
- xmlns="http://www.w3.org/2000/svg"
32
- fill="none"
33
- viewBox="0 0 24 24"
34
- stroke-width="2"
35
- stroke="currentColor"
36
- >
37
- <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" />
38
- </svg>
39
- <span>{domain}</span>
40
- </div>
41
- )}
42
- {post.title && (
43
- <h2
44
- class={`p-name font-semibold ${compact ? "text-sm" : "text-base"} mb-1`}
45
- >
46
+ {!isCompact && !display?.hideStatusBadges && <PostStatusBadges />}
47
+ {domain &&
48
+ (safeUrl ? (
46
49
  <a
47
- href={post.url || post.permalink}
48
- class="u-url hover:underline"
49
- target={post.url ? "_blank" : undefined}
50
- rel={post.url ? "noopener noreferrer" : undefined}
50
+ href={safeUrl}
51
+ class="feed-link-domain"
52
+ target="_blank"
53
+ rel="noopener noreferrer"
51
54
  >
52
- {post.title}
55
+ <svg
56
+ class="feed-link-domain-icon"
57
+ xmlns="http://www.w3.org/2000/svg"
58
+ fill="none"
59
+ viewBox="0 0 24 24"
60
+ stroke-width="2"
61
+ stroke="currentColor"
62
+ >
63
+ <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" />
64
+ </svg>
65
+ <span>{domain}</span>
53
66
  </a>
54
- </h2>
55
- )}
56
- {!compact && post.bodyHtml && (
67
+ ) : (
68
+ <div class="feed-link-domain">
69
+ <svg
70
+ class="feed-link-domain-icon"
71
+ xmlns="http://www.w3.org/2000/svg"
72
+ fill="none"
73
+ viewBox="0 0 24 24"
74
+ stroke-width="2"
75
+ stroke="currentColor"
76
+ >
77
+ <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" />
78
+ </svg>
79
+ <span>{domain}</span>
80
+ </div>
81
+ ))}
82
+ {post.title &&
83
+ (isDetail ? (
84
+ <h1 class="p-name feed-link-title text-2xl font-semibold mb-4">
85
+ <a
86
+ href={safeUrl || post.permalink}
87
+ class="u-url feed-link-title-link"
88
+ target={safeUrl ? "_blank" : undefined}
89
+ rel={safeUrl ? "noopener noreferrer" : undefined}
90
+ >
91
+ {post.title}
92
+ </a>
93
+ </h1>
94
+ ) : (
95
+ <h2
96
+ class={`p-name feed-link-title font-semibold ${isCompact ? "text-sm" : ""} mb-1`}
97
+ >
98
+ <a
99
+ href={safeUrl || post.permalink}
100
+ class="u-url feed-link-title-link"
101
+ target={safeUrl ? "_blank" : undefined}
102
+ rel={safeUrl ? "noopener noreferrer" : undefined}
103
+ >
104
+ {post.title}
105
+ </a>
106
+ </h2>
107
+ ))}
108
+ {!isCompact && post.bodyHtml && (
57
109
  <div
58
- class="e-content prose text-muted-foreground"
110
+ class="e-content prose text-muted-foreground feed-link-summary"
59
111
  data-post-body
60
112
  dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
61
113
  />
62
114
  )}
63
- <footer class="mt-2 text-xs text-muted-foreground" data-post-meta>
64
- <a href={post.permalink} class="hover:underline">
65
- <time class="dt-published" datetime={post.publishedAt}>
66
- {post.publishedAtFormatted}
67
- </time>
68
- </a>
69
- </footer>
115
+ {!isCompact && !display?.hideRating && (
116
+ <StarRating rating={post.rating} />
117
+ )}
118
+ <PostFooter post={post} detail={isDetail} display={display?.footer} />
70
119
  </article>
71
120
  );
72
121
  };
@@ -8,39 +8,59 @@
8
8
  import type { FC } from "hono/jsx";
9
9
  import type { TimelineCardProps } from "../../types.js";
10
10
  import { MediaGallery } from "../shared/MediaGallery.js";
11
+ import { StarRating } from "../shared/StarRating.js";
12
+ import { PostFooter } from "../shared/PostFooter.js";
13
+ import { PostStatusBadges } from "./PostStatusBadges.js";
11
14
 
12
- export const NoteCard: FC<TimelineCardProps> = ({ post, compact }) => {
15
+ export const NoteCard: FC<TimelineCardProps> = ({
16
+ post,
17
+ mode = "feed",
18
+ display,
19
+ }) => {
20
+ const isCompact = mode === "compact";
21
+ const isDetail = mode === "detail";
13
22
  const isArticle = !!post.title;
14
- const displayHtml = isArticle ? post.summaryHtml : post.bodyHtml;
23
+ const displayHtml = isDetail || !isArticle ? post.bodyHtml : post.summaryHtml;
15
24
 
16
25
  return (
17
26
  <article
18
- class={`h-entry${compact ? " feed-compact" : ""}`}
27
+ class={`h-entry post-menu-target${isCompact ? " feed-compact" : isDetail ? " py-6" : ""}`}
28
+ {...(isDetail ? { "data-page": "post" } : {})}
19
29
  data-post
20
30
  data-format="note"
31
+ data-post-id={post.id}
32
+ data-post-permalink={post.permalink}
33
+ {...(post.pinned ? { "data-post-pinned": "" } : {})}
34
+ {...(post.featured ? { "data-post-featured": "" } : {})}
35
+ data-post-visibility={post.visibility}
36
+ {...(!isDetail && post.threadRootId ? { "data-post-reply": "" } : {})}
21
37
  >
22
- {isArticle && (
23
- <h2
24
- class={`p-name font-semibold ${compact ? "text-sm" : "text-base"} mb-1`}
25
- >
26
- <a href={post.permalink} class="u-url hover:underline">
27
- {post.title}
28
- </a>
29
- </h2>
30
- )}
38
+ {!isCompact && !display?.hideStatusBadges && <PostStatusBadges />}
39
+ {isArticle &&
40
+ (isDetail ? (
41
+ <h1 class="p-name text-2xl font-semibold mb-4">{post.title}</h1>
42
+ ) : (
43
+ <h2
44
+ class={`p-name font-semibold ${isCompact ? "text-sm" : "text-lg"} mb-1`}
45
+ >
46
+ <a href={post.permalink} class="u-url hover:underline">
47
+ {post.title}
48
+ </a>
49
+ </h2>
50
+ ))}
31
51
  {displayHtml && (
32
52
  <div
33
- class={`e-content prose ${compact ? "prose-sm" : isArticle ? "text-muted-foreground" : ""}`}
53
+ class={`e-content prose ${isCompact ? "prose-sm" : isArticle && !isDetail ? "text-muted-foreground" : ""}`}
34
54
  data-post-body
35
55
  dangerouslySetInnerHTML={{ __html: displayHtml }}
36
56
  />
37
57
  )}
38
- {!compact && post.media.length > 0 && (
58
+ {!isCompact && post.media.length > 0 && (
39
59
  <div class="mt-3" data-post-media>
40
60
  <MediaGallery attachments={post.media} />
41
61
  </div>
42
62
  )}
43
- {!compact && isArticle && post.summaryHasMore && (
63
+ {!isDetail && !isCompact && isArticle && post.summaryHasMore && (
44
64
  <a
45
65
  href={`${post.permalink}#continue`}
46
66
  class="text-sm text-muted-foreground hover:underline mt-1 inline-block"
@@ -48,16 +68,10 @@ export const NoteCard: FC<TimelineCardProps> = ({ post, compact }) => {
48
68
  Continue →
49
69
  </a>
50
70
  )}
51
- <footer class="mt-2" data-post-meta>
52
- <a
53
- href={post.permalink}
54
- class="u-url text-xs text-muted-foreground hover:underline"
55
- >
56
- <time class="dt-published" datetime={post.publishedAt}>
57
- {post.publishedAtFormatted}
58
- </time>
59
- </a>
60
- </footer>
71
+ {!isCompact && !display?.hideRating && (
72
+ <StarRating rating={post.rating} />
73
+ )}
74
+ <PostFooter post={post} detail={isDetail} display={display?.footer} />
61
75
  </article>
62
76
  );
63
77
  };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Post Status Badges
3
+ *
4
+ * Renders pinned / featured indicators at the top of a post card.
5
+ * All badges are always rendered in the DOM; visibility is driven by CSS
6
+ * selectors on the parent article's data attributes (data-post-pinned,
7
+ * data-post-featured). This lets the post menu toggle
8
+ * badges instantly without a page reload.
9
+ */
10
+
11
+ import type { FC } from "hono/jsx";
12
+
13
+ export const PostStatusBadges: FC = () => {
14
+ return (
15
+ <div class="post-status-badges">
16
+ <span class="post-status-badge post-status-pinned">
17
+ <svg
18
+ xmlns="http://www.w3.org/2000/svg"
19
+ viewBox="0 0 24 24"
20
+ fill="none"
21
+ stroke="currentColor"
22
+ stroke-width="1.75"
23
+ stroke-linecap="round"
24
+ stroke-linejoin="round"
25
+ >
26
+ <line x1="12" x2="12" y1="17" y2="22" />
27
+ <path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />
28
+ </svg>
29
+ Pinned
30
+ </span>
31
+ <span class="post-status-separator" aria-hidden="true">
32
+ &middot;
33
+ </span>
34
+ <span class="post-status-badge post-status-featured">
35
+ <svg
36
+ xmlns="http://www.w3.org/2000/svg"
37
+ viewBox="0 0 24 24"
38
+ fill="none"
39
+ stroke="currentColor"
40
+ stroke-width="1.75"
41
+ stroke-linecap="round"
42
+ stroke-linejoin="round"
43
+ >
44
+ <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
45
+ </svg>
46
+ Featured
47
+ </span>
48
+ <span class="post-status-badge post-status-private">
49
+ <svg
50
+ xmlns="http://www.w3.org/2000/svg"
51
+ viewBox="0 0 24 24"
52
+ fill="none"
53
+ stroke="currentColor"
54
+ stroke-width="1.75"
55
+ stroke-linecap="round"
56
+ stroke-linejoin="round"
57
+ >
58
+ <path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
59
+ <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
60
+ <path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
61
+ <path d="m2 2 20 20" />
62
+ </svg>
63
+ Private
64
+ </span>
65
+ </div>
66
+ );
67
+ };
@@ -12,30 +12,51 @@
12
12
 
13
13
  import type { FC } from "hono/jsx";
14
14
  import type { TimelineCardProps } from "../../types.js";
15
+ import { StarRating } from "../shared/StarRating.js";
16
+ import { PostFooter } from "../shared/PostFooter.js";
17
+ import { PostStatusBadges } from "./PostStatusBadges.js";
18
+ import { sanitizeUrl } from "../../lib/url.js";
19
+
20
+ export const QuoteCard: FC<TimelineCardProps> = ({
21
+ post,
22
+ mode = "feed",
23
+ display,
24
+ }) => {
25
+ const isCompact = mode === "compact";
26
+ const isDetail = mode === "detail";
27
+ const articleClass = `h-entry post-menu-target${isCompact ? " feed-compact" : isDetail ? " py-6" : " feed-quote-post"}`;
28
+ const safeUrl = post.url ? sanitizeUrl(post.url) : "";
29
+ const commentaryHtml = post.bodyHtml ?? null;
15
30
 
16
- export const QuoteCard: FC<TimelineCardProps> = ({ post, compact }) => {
17
31
  return (
18
32
  <article
19
- class={`h-entry${compact ? " feed-compact" : ""}`}
33
+ class={articleClass}
34
+ {...(isDetail ? { "data-page": "post" } : {})}
20
35
  data-post
21
36
  data-format="quote"
37
+ data-post-id={post.id}
38
+ data-post-permalink={post.permalink}
39
+ {...(post.pinned ? { "data-post-pinned": "" } : {})}
40
+ {...(post.featured ? { "data-post-featured": "" } : {})}
41
+ data-post-visibility={post.visibility}
42
+ {...(!isDetail && post.threadRootId ? { "data-post-reply": "" } : {})}
22
43
  >
44
+ {!isCompact && !display?.hideStatusBadges && <PostStatusBadges />}
23
45
  {post.quoteText && (
24
- <blockquote class="feed-quote">
46
+ <blockquote class={`feed-quote${isCompact ? "" : " feed-quote-card"}`}>
25
47
  <div
26
- class={`e-content ${compact ? "text-sm" : "text-base"} leading-relaxed`}
48
+ class={`e-content feed-quote-content${isCompact ? " text-sm" : ""}`}
27
49
  >
28
50
  {post.quoteText}
29
51
  </div>
30
52
  </blockquote>
31
53
  )}
32
- {!compact && (post.title || post.url) && (
33
- <div class="mt-2 text-sm text-muted-foreground">
34
- &mdash;{" "}
35
- {post.url ? (
54
+ {!isCompact && (post.title || safeUrl) && (
55
+ <div class="feed-quote-attribution">
56
+ {safeUrl ? (
36
57
  <a
37
- href={post.url}
38
- class="hover:underline"
58
+ href={safeUrl}
59
+ class="feed-quote-source"
39
60
  target="_blank"
40
61
  rel="noopener noreferrer"
41
62
  >
@@ -46,23 +67,17 @@ export const QuoteCard: FC<TimelineCardProps> = ({ post, compact }) => {
46
67
  )}
47
68
  </div>
48
69
  )}
49
- {!compact && post.bodyHtml && (
70
+ {!isCompact && commentaryHtml && (
50
71
  <div
51
- class="mt-3 prose text-muted-foreground"
72
+ class="feed-quote-commentary prose text-muted-foreground"
52
73
  data-post-body
53
- dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
74
+ dangerouslySetInnerHTML={{ __html: commentaryHtml }}
54
75
  />
55
76
  )}
56
- <footer class="mt-2" data-post-meta>
57
- <a
58
- href={post.permalink}
59
- class="u-url text-xs text-muted-foreground hover:underline"
60
- >
61
- <time class="dt-published" datetime={post.publishedAt}>
62
- {post.publishedAtFormatted}
63
- </time>
64
- </a>
65
- </footer>
77
+ {!isCompact && !display?.hideRating && (
78
+ <StarRating rating={post.rating} />
79
+ )}
80
+ <PostFooter post={post} detail={isDetail} display={display?.footer} />
66
81
  </article>
67
82
  );
68
83
  };
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Thread Preview
3
3
  *
4
- * Root post + vertical line connector + compact replies underneath.
4
+ * Shows latest reply as the hero post with faded ancestor context above.
5
+ * Thread line connects all posts via `.thread-group` / `.thread-item`.
5
6
  */
6
7
 
7
8
  import type { FC } from "hono/jsx";
@@ -9,40 +10,103 @@ import { useLingui } from "@lingui/react/macro";
9
10
  import type { ThreadPreviewProps } from "../../types.js";
10
11
  import { TimelineItem } from "./TimelineItem.js";
11
12
  import { TimelineItemFromPost } from "./TimelineItem.js";
13
+ import {
14
+ getThreadPreviewState,
15
+ isThreadContextLikelyOverflow,
16
+ } from "./thread-preview-state.js";
17
+
18
+ const THREAD_CONTEXT_DISPLAY = {
19
+ hideStatusBadges: true,
20
+ hideRating: true,
21
+ footer: {
22
+ hideActions: true,
23
+ },
24
+ } as const;
12
25
 
13
26
  export const ThreadPreview: FC<ThreadPreviewProps> = ({
14
27
  rootPost,
15
- previewReplies,
28
+ latestReply,
29
+ parentReply,
16
30
  totalReplyCount,
17
31
  }) => {
18
32
  const { t } = useLingui();
19
- const remainingCount = totalReplyCount - previewReplies.length;
33
+ const showMoreLabel = t({
34
+ message: "Show more",
35
+ comment: "@context: Button to expand faded thread context",
36
+ });
37
+ const showLessLabel = t({
38
+ message: "Show less",
39
+ comment: "@context: Button to collapse expanded thread context",
40
+ });
41
+ const { hiddenCount } = getThreadPreviewState({
42
+ hasParentReply: parentReply !== undefined,
43
+ totalReplyCount,
44
+ });
45
+ const startsCollapsedWithAffordances = isThreadContextLikelyOverflow({
46
+ rootPost,
47
+ parentReply,
48
+ hiddenCount,
49
+ });
20
50
 
21
51
  return (
22
- <div>
23
- <TimelineItem item={{ post: rootPost }} />
24
- {previewReplies.length > 0 && (
25
- <div class="feed-replies">
26
- {previewReplies.map((reply) => (
27
- <div key={reply.id} class="feed-reply">
28
- <TimelineItemFromPost post={reply} compact />
29
- </div>
30
- ))}
31
- {remainingCount > 0 && (
32
- <div class="feed-reply">
33
- <a
34
- href={rootPost.permalink}
35
- class="text-sm text-muted-foreground hover:text-foreground hover:underline"
36
- >
37
- {t({
38
- message: `Show ${remainingCount} more ${remainingCount === 1 ? "reply" : "replies"}`,
39
- comment: "@context: Link to show remaining thread replies",
40
- })}
41
- </a>
42
- </div>
43
- )}
52
+ <div class="thread-group thread-group-preview">
53
+ {/* Faded ancestor context */}
54
+ <div
55
+ class={`thread-context-shell thread-context-collapsed${startsCollapsedWithAffordances ? " thread-context-faded" : ""}`}
56
+ data-thread-context
57
+ >
58
+ {/* Root post */}
59
+ <div class="thread-item thread-item-context">
60
+ <TimelineItemFromPost
61
+ post={rootPost}
62
+ mode="feed"
63
+ display={THREAD_CONTEXT_DISPLAY}
64
+ />
44
65
  </div>
45
- )}
66
+
67
+ {/* Hidden posts gap */}
68
+ {hiddenCount > 0 && (
69
+ <div class="thread-item thread-item-gap">
70
+ <a href={latestReply.permalink} class="thread-gap-link">
71
+ {t({
72
+ message: `${hiddenCount} more ${hiddenCount === 1 ? "post" : "posts"}`,
73
+ comment:
74
+ "@context: Link showing count of hidden thread posts between root and latest",
75
+ })}
76
+ </a>
77
+ </div>
78
+ )}
79
+
80
+ {/* Parent of latest reply */}
81
+ {parentReply && (
82
+ <div class="thread-item thread-item-context">
83
+ <TimelineItemFromPost
84
+ post={parentReply}
85
+ mode="feed"
86
+ display={THREAD_CONTEXT_DISPLAY}
87
+ />
88
+ </div>
89
+ )}
90
+
91
+ <div class="thread-context-fade" />
92
+ </div>
93
+
94
+ {/* Toggle button */}
95
+ <button
96
+ type="button"
97
+ class={`thread-context-toggle text-xs text-muted-foreground hover:text-foreground${startsCollapsedWithAffordances ? "" : " hidden"}`}
98
+ data-thread-context-toggle
99
+ data-label-more={showMoreLabel}
100
+ data-label-less={showLessLabel}
101
+ aria-expanded="false"
102
+ >
103
+ {showMoreLabel}
104
+ </button>
105
+
106
+ {/* Latest reply (full card, hero) */}
107
+ <div class="thread-item thread-item-hero">
108
+ <TimelineItem item={{ post: latestReply }} />
109
+ </div>
46
110
  </div>
47
111
  );
48
112
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Timeline Feed
3
3
  *
4
- * Flat list of posts separated by simple dividers.
4
+ * Flat list of posts separated by lightweight dividers.
5
5
  */
6
6
 
7
7
  import type { FC } from "hono/jsx";
@@ -25,7 +25,8 @@ export const TimelineFeed: FC<TimelineFeedProps> = ({
25
25
  {item.threadPreview ? (
26
26
  <ThreadPreview
27
27
  rootPost={item.post}
28
- previewReplies={item.threadPreview.replies}
28
+ latestReply={item.threadPreview.latestReply}
29
+ parentReply={item.threadPreview.parentReply}
29
30
  totalReplyCount={item.threadPreview.totalReplyCount}
30
31
  />
31
32
  ) : (
@@ -8,8 +8,10 @@ import type { FC } from "hono/jsx";
8
8
  import type {
9
9
  TimelineItemView,
10
10
  TimelineCardProps,
11
+ TimelineCardDisplayOptions,
11
12
  PostView,
12
13
  Format,
14
+ CardMode,
13
15
  } from "../../types.js";
14
16
  import { NoteCard } from "./NoteCard.js";
15
17
  import { LinkCard } from "./LinkCard.js";
@@ -23,23 +25,30 @@ const CARD_MAP: Record<Format, FC<TimelineCardProps>> = {
23
25
 
24
26
  interface TimelineItemProps {
25
27
  item: TimelineItemView;
26
- compact?: boolean;
28
+ mode?: CardMode;
29
+ display?: TimelineCardDisplayOptions;
27
30
  }
28
31
 
29
32
  interface TimelineItemFromPostProps {
30
33
  post: PostView;
31
- compact?: boolean;
34
+ mode?: CardMode;
35
+ display?: TimelineCardDisplayOptions;
32
36
  }
33
37
 
34
- export const TimelineItem: FC<TimelineItemProps> = ({ item, compact }) => {
38
+ export const TimelineItem: FC<TimelineItemProps> = ({
39
+ item,
40
+ mode,
41
+ display,
42
+ }) => {
35
43
  const Card = CARD_MAP[item.post.format];
36
- return <Card post={item.post} compact={compact} />;
44
+ return <Card post={item.post} mode={mode} display={display} />;
37
45
  };
38
46
 
39
47
  export const TimelineItemFromPost: FC<TimelineItemFromPostProps> = ({
40
48
  post,
41
- compact,
49
+ mode,
50
+ display,
42
51
  }) => {
43
52
  const Card = CARD_MAP[post.format];
44
- return <Card post={post} compact={compact} />;
53
+ return <Card post={post} mode={mode} display={display} />;
45
54
  };