@jant/core 0.6.7 → 0.6.9

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 (131) hide show
  1. package/bin/commands/uploads/cleanup.js +2 -0
  2. package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
  3. package/dist/app-DqHzOwL5.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-CGf2m3qp.css +2 -0
  6. package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
  7. package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
  13. package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/note-expand.test.ts +130 -0
  22. package/src/client/archive-nav.js +2 -1
  23. package/src/client/audio-player.ts +7 -3
  24. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
  29. package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
  30. package/src/client/components/compose-format-convert.ts +255 -0
  31. package/src/client/components/compose-types.ts +2 -0
  32. package/src/client/components/jant-compose-dialog.ts +110 -44
  33. package/src/client/components/jant-compose-editor.ts +64 -11
  34. package/src/client/components/jant-settings-general.ts +56 -18
  35. package/src/client/components/settings-types.ts +11 -0
  36. package/src/client/compose-bridge.ts +17 -0
  37. package/src/client/feed-video-player.ts +1 -1
  38. package/src/client/hydrate-partial.ts +25 -0
  39. package/src/client/note-expand.ts +63 -0
  40. package/src/client/settings-bridge.ts +3 -0
  41. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  42. package/src/client/tiptap/bubble-menu.ts +37 -4
  43. package/src/client.ts +1 -0
  44. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  45. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  46. package/src/db/migrations/meta/_journal.json +7 -0
  47. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  48. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  49. package/src/db/migrations/pg/meta/_journal.json +7 -0
  50. package/src/db/pg/schema.ts +36 -0
  51. package/src/db/schema.ts +36 -0
  52. package/src/i18n/__tests__/middleware.test.ts +46 -0
  53. package/src/i18n/locales/public/en.po +41 -0
  54. package/src/i18n/locales/public/en.ts +1 -1
  55. package/src/i18n/locales/public/zh-Hans.po +41 -0
  56. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  57. package/src/i18n/locales/public/zh-Hant.po +41 -0
  58. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  59. package/src/i18n/locales/settings/en.po +37 -22
  60. package/src/i18n/locales/settings/en.ts +1 -1
  61. package/src/i18n/locales/settings/zh-Hans.po +37 -22
  62. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hant.po +37 -22
  64. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  65. package/src/i18n/middleware.ts +17 -8
  66. package/src/i18n/supported-locales.ts +5 -4
  67. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  68. package/src/lib/__tests__/markdown.test.ts +1 -1
  69. package/src/lib/__tests__/summary.test.ts +87 -0
  70. package/src/lib/__tests__/timeline.test.ts +48 -1
  71. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  72. package/src/lib/__tests__/url.test.ts +44 -0
  73. package/src/lib/__tests__/view.test.ts +168 -1
  74. package/src/lib/ids.ts +1 -0
  75. package/src/lib/navigation.ts +1 -0
  76. package/src/lib/resolve-config.ts +3 -2
  77. package/src/lib/summary.ts +42 -3
  78. package/src/lib/tiptap-render.ts +6 -2
  79. package/src/lib/upload.ts +16 -2
  80. package/src/lib/url.ts +41 -0
  81. package/src/lib/view.ts +102 -40
  82. package/src/preset.css +7 -1
  83. package/src/routes/api/__tests__/settings.test.ts +1 -4
  84. package/src/routes/api/__tests__/upload.test.ts +2 -0
  85. package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
  86. package/src/routes/api/internal/sites.ts +44 -1
  87. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  88. package/src/routes/api/public/archive.ts +22 -6
  89. package/src/routes/api/settings.ts +2 -1
  90. package/src/routes/api/telegram.ts +2 -1
  91. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  92. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  93. package/src/routes/dash/custom-urls.tsx +1 -1
  94. package/src/routes/dash/settings.tsx +23 -7
  95. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  96. package/src/routes/pages/archive.tsx +116 -20
  97. package/src/routes/pages/collections.tsx +1 -0
  98. package/src/services/__tests__/media.test.ts +274 -30
  99. package/src/services/__tests__/post.test.ts +81 -0
  100. package/src/services/__tests__/settings.test.ts +55 -0
  101. package/src/services/bootstrap.ts +7 -0
  102. package/src/services/export-theme/assets/client-site.js +1 -1
  103. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  104. package/src/services/export-theme/styles/main.css +49 -15
  105. package/src/services/media.ts +199 -42
  106. package/src/services/post.ts +22 -2
  107. package/src/services/search.ts +4 -4
  108. package/src/services/settings.ts +49 -15
  109. package/src/services/upload-session.ts +28 -0
  110. package/src/styles/tokens.css +7 -5
  111. package/src/styles/ui.css +163 -34
  112. package/src/types/bindings.ts +1 -0
  113. package/src/types/config.ts +14 -1
  114. package/src/types/props.ts +3 -0
  115. package/src/ui/compose/ComposeDialog.tsx +13 -0
  116. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  117. package/src/ui/dash/settings/GeneralContent.tsx +38 -4
  118. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  119. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  120. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  121. package/src/ui/feed/NoteCard.tsx +54 -5
  122. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  123. package/src/ui/layouts/BaseLayout.tsx +1 -0
  124. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
  125. package/src/ui/pages/ArchivePage.tsx +89 -6
  126. package/src/ui/pages/CollectionsPage.tsx +7 -1
  127. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  128. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  129. package/src/ui/shared/CollectionsManager.tsx +3 -0
  130. package/dist/app-C1QgMNRY.js +0 -6
  131. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -15,6 +15,7 @@ export function GeneralContent({
15
15
  siteName,
16
16
  siteDescription,
17
17
  siteLanguage,
18
+ dashboardLanguage,
18
19
  cjkSerifFont,
19
20
  siteNameFallback,
20
21
  siteDescriptionFallback,
@@ -32,6 +33,7 @@ export function GeneralContent({
32
33
  siteName: string;
33
34
  siteDescription: string;
34
35
  siteLanguage: string;
36
+ dashboardLanguage: string;
35
37
  cjkSerifFont: string;
36
38
  siteNameFallback: string;
37
39
  siteDescriptionFallback: string;
@@ -101,15 +103,37 @@ export function GeneralContent({
101
103
  ),
102
104
  siteLanguage: i18n._(
103
105
  msg({
104
- message: "Language",
105
- comment: "@context: Settings form field for site/admin language",
106
+ message: "Content language",
107
+ comment:
108
+ "@context: Settings form field for the public content language",
106
109
  }),
107
110
  ),
108
111
  siteLanguageHelp: i18n._(
109
112
  msg({
110
113
  message:
111
- "Sets the content language announced to readers (HTML lang, RSS) and the dashboard language. Any BCP 47 tag is accepted; tags without a dashboard translation fall back to English.",
112
- comment: "@context: Help text under the site language input",
114
+ "The language your posts are written in. Announced to readers and search engines through HTML lang and your RSS feed. Any BCP 47 tag works.",
115
+ comment: "@context: Help text under the content language picker",
116
+ }),
117
+ ),
118
+ contentLanguagePreview: i18n._(
119
+ msg({
120
+ message: "Readers and search engines see",
121
+ comment:
122
+ "@context: Lead text before a live <html lang> preview of the content language",
123
+ }),
124
+ ),
125
+ dashboardLanguage: i18n._(
126
+ msg({
127
+ message: "Dashboard language",
128
+ comment:
129
+ "@context: Settings form field for the admin interface language",
130
+ }),
131
+ ),
132
+ dashboardLanguageHelp: i18n._(
133
+ msg({
134
+ message:
135
+ "The language this admin dashboard shows in. Available in English, 简体中文, and 繁體中文.",
136
+ comment: "@context: Help text under the dashboard language picker",
113
137
  }),
114
138
  ),
115
139
  siteLanguageSearchPlaceholder: i18n._(
@@ -308,6 +332,14 @@ export function GeneralContent({
308
332
  timezones.map((tz) => ({ value: tz.value, label: tz.label })),
309
333
  ).replace(/</g, "\\u003c");
310
334
 
335
+ // The 3 catalog locales Jant's dashboard is translated into. Native-script
336
+ // labels so each reads in its own language, like the CJK font options.
337
+ const dashboardLanguagesJson = JSON.stringify([
338
+ { value: "en", label: "English" },
339
+ { value: "zh-Hans", label: "简体中文" },
340
+ { value: "zh-Hant", label: "繁體中文" },
341
+ ]).replace(/</g, "\\u003c");
342
+
311
343
  const cjkFontsJson = JSON.stringify([
312
344
  { value: "off", label: "None" },
313
345
  {
@@ -326,6 +358,7 @@ export function GeneralContent({
326
358
  siteName,
327
359
  siteDescription,
328
360
  siteLanguage,
361
+ dashboardLanguage,
329
362
  cjkSerifFont,
330
363
  mainRssFeed,
331
364
  timeZone,
@@ -341,6 +374,7 @@ export function GeneralContent({
341
374
  labels={labels}
342
375
  timezones={timezonesJson}
343
376
  cjk-fonts={cjkFontsJson}
377
+ dashboard-languages={dashboardLanguagesJson}
344
378
  sitename-fallback={siteNameFallback}
345
379
  sitedescription-fallback={siteDescriptionFallback}
346
380
  main-feed-url={mainFeedUrl}
@@ -22,6 +22,28 @@ function ChevronRight() {
22
22
  );
23
23
  }
24
24
 
25
+ function ExternalLinkIndicator() {
26
+ return (
27
+ <svg
28
+ class="settings-directory-item-chevron"
29
+ xmlns="http://www.w3.org/2000/svg"
30
+ width="16"
31
+ height="16"
32
+ viewBox="0 0 24 24"
33
+ fill="none"
34
+ stroke="currentColor"
35
+ stroke-width="2"
36
+ stroke-linecap="round"
37
+ stroke-linejoin="round"
38
+ aria-hidden="true"
39
+ >
40
+ <path d="M15 3h6v6" />
41
+ <path d="M10 14 21 3" />
42
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
43
+ </svg>
44
+ );
45
+ }
46
+
25
47
  export function SettingsDirectorySection({
26
48
  title,
27
49
  tone = "default",
@@ -45,10 +67,12 @@ export function SettingsDirectoryItemContent({
45
67
  icon,
46
68
  name,
47
69
  description,
70
+ external = false,
48
71
  }: {
49
72
  icon: string;
50
73
  name: string;
51
74
  description: string;
75
+ external?: boolean;
52
76
  }) {
53
77
  return (
54
78
  <>
@@ -59,7 +83,7 @@ export function SettingsDirectoryItemContent({
59
83
  <span class="settings-directory-item-name">{name}</span>
60
84
  <span class="settings-directory-item-desc">{description}</span>
61
85
  </span>
62
- <ChevronRight />
86
+ {external ? <ExternalLinkIndicator /> : <ChevronRight />}
63
87
  </>
64
88
  );
65
89
  }
@@ -93,6 +117,7 @@ export function SettingsDirectoryLink({
93
117
  icon={icon}
94
118
  name={name}
95
119
  description={description}
120
+ external={target === "_blank"}
96
121
  />
97
122
  </a>
98
123
  );
@@ -3,8 +3,9 @@
3
3
  */
4
4
 
5
5
  import { msg } from "@lingui/core/macro";
6
+ import { coalesceDisplayText } from "../../../lib/display-text.js";
6
7
  import { useLingui } from "../../../i18n/context.js";
7
- import { toPublicPath } from "../../../lib/url.js";
8
+ import { extractDomain, toPublicPath } from "../../../lib/url.js";
8
9
  import {
9
10
  SettingsDirectoryLink,
10
11
  SettingsDirectorySection,
@@ -25,14 +26,19 @@ const ICONS = {
25
26
  shield: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>`,
26
27
  gitBranch: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>`,
27
28
  send: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>`,
29
+ cloud: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>`,
28
30
  };
29
31
 
30
32
  export function SettingsRootContent({
31
33
  sitePathPrefix = "",
32
34
  demoMode = false,
35
+ hostedControlPlaneSiteSettingsUrl,
36
+ hostedControlPlaneProviderLabel,
33
37
  }: {
34
38
  sitePathPrefix?: string;
35
39
  demoMode?: boolean;
40
+ hostedControlPlaneSiteSettingsUrl?: string | null;
41
+ hostedControlPlaneProviderLabel?: string | null;
36
42
  }) {
37
43
  const { i18n } = useLingui();
38
44
  const accountDescription = demoMode
@@ -50,6 +56,19 @@ export function SettingsRootContent({
50
56
  "@context: Settings item description for account settings on the settings home page",
51
57
  }),
52
58
  );
59
+ const hostingProviderLabel = hostedControlPlaneSiteSettingsUrl
60
+ ? (coalesceDisplayText(
61
+ hostedControlPlaneProviderLabel,
62
+ extractDomain(hostedControlPlaneSiteSettingsUrl),
63
+ ) ??
64
+ i18n._(
65
+ msg({
66
+ message: "Hosted account",
67
+ comment:
68
+ "@context: Generic hosted auth provider label when no explicit provider name is configured",
69
+ }),
70
+ ))
71
+ : null;
53
72
 
54
73
  return (
55
74
  <div class="settings-root">
@@ -320,6 +339,32 @@ export function SettingsRootContent({
320
339
  )}
321
340
  description={accountDescription}
322
341
  />
342
+ {hostedControlPlaneSiteSettingsUrl && hostingProviderLabel ? (
343
+ <SettingsDirectoryLink
344
+ href={hostedControlPlaneSiteSettingsUrl}
345
+ target="_blank"
346
+ rel="noopener noreferrer"
347
+ icon={ICONS.cloud}
348
+ tone="subtle"
349
+ name={i18n._(
350
+ msg({
351
+ message: "Manage Hosting",
352
+ comment:
353
+ "@context: Settings item label for opening this site's hosted management page (domains, plan, billing) in the connected control plane",
354
+ }),
355
+ )}
356
+ description={i18n._(
357
+ msg({
358
+ message: "Domains, plan, and billing in {providerLabel}",
359
+ comment:
360
+ "@context: Settings item description for the hosted site management external link",
361
+ }),
362
+ {
363
+ providerLabel: hostingProviderLabel,
364
+ },
365
+ )}
366
+ />
367
+ ) : null}
323
368
  </SettingsDirectorySection>
324
369
 
325
370
  <div class="settings-root-signout">
@@ -0,0 +1,55 @@
1
+ import type { Context } from "hono";
2
+ import { renderToString } from "hono/jsx/dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+ import { I18nProvider } from "../../../../i18n/context.js";
5
+ import { createI18n } from "../../../../i18n/i18n.js";
6
+ import { SettingsRootContent } from "../SettingsRootContent.js";
7
+
8
+ function renderSettingsRootContent(
9
+ props: Parameters<typeof SettingsRootContent>[0],
10
+ ): string {
11
+ const i18n = createI18n("en");
12
+ const c = {
13
+ get(key: string) {
14
+ if (key === "i18n") return i18n;
15
+ return undefined;
16
+ },
17
+ } as unknown as Context;
18
+
19
+ I18nProvider({ c, children: "" });
20
+ return renderToString(SettingsRootContent(props));
21
+ }
22
+
23
+ describe("SettingsRootContent", () => {
24
+ it("omits Manage Hosting when no hosted site settings URL is configured", () => {
25
+ const html = renderSettingsRootContent({});
26
+
27
+ expect(html).not.toContain("Manage Hosting");
28
+ });
29
+
30
+ it("renders the Manage Hosting external link with the configured provider label", () => {
31
+ const html = renderSettingsRootContent({
32
+ hostedControlPlaneSiteSettingsUrl:
33
+ "https://cloud.example/sites/core/site_123/settings",
34
+ hostedControlPlaneProviderLabel: "jant.me",
35
+ });
36
+
37
+ expect(html).toContain("Manage Hosting");
38
+ expect(html).toContain("Domains, plan, and billing in jant.me");
39
+ expect(html).toContain(
40
+ 'href="https://cloud.example/sites/core/site_123/settings"',
41
+ );
42
+ expect(html).toContain('target="_blank"');
43
+ expect(html).toContain('rel="noopener noreferrer"');
44
+ });
45
+
46
+ it("falls back to the hosted URL host when the provider label is blank", () => {
47
+ const html = renderSettingsRootContent({
48
+ hostedControlPlaneSiteSettingsUrl:
49
+ "https://cloud.example/sites/core/site_123/settings",
50
+ hostedControlPlaneProviderLabel: " ",
51
+ });
52
+
53
+ expect(html).toContain("Domains, plan, and billing in cloud.example");
54
+ });
55
+ });
@@ -5,7 +5,9 @@
5
5
  * With title: article-style rendering with summary excerpt and "Read more" link.
6
6
  */
7
7
 
8
+ import { msg } from "@lingui/core/macro";
8
9
  import type { FC } from "hono/jsx";
10
+ import { useLingui } from "../../i18n/context.js";
9
11
  import type { TimelineCardProps } from "../../types.js";
10
12
  import { MediaGallery } from "../shared/MediaGallery.js";
11
13
  import { StarRating } from "../shared/StarRating.js";
@@ -27,6 +29,7 @@ export const NoteCard: FC<TimelineCardProps> = ({
27
29
  mode = "feed",
28
30
  display,
29
31
  }) => {
32
+ const { i18n } = useLingui();
30
33
  const isCompact = mode === "compact";
31
34
  const isDetail = mode === "detail";
32
35
  const isArticle = !!post.title;
@@ -35,8 +38,42 @@ export const NoteCard: FC<TimelineCardProps> = ({
35
38
  const fullBodyHtml = showFullBody
36
39
  ? stripContinueAnchor(post.bodyHtml)
37
40
  : post.bodyHtml;
41
+ // Untitled notes only carry summaryHtml when their body was truncated; fall
42
+ // back to the full body so non-truncated notes render every block.
38
43
  const displayHtml =
39
- isDetail || !isArticle || showFullBody ? fullBodyHtml : post.summaryHtml;
44
+ isDetail || showFullBody
45
+ ? fullBodyHtml
46
+ : (post.summaryHtml ?? fullBodyHtml);
47
+ const continueLabel = i18n._(
48
+ msg({
49
+ message: "Continue →",
50
+ comment:
51
+ "@context: Feed link from a truncated article excerpt to its full page",
52
+ }),
53
+ );
54
+ const readMoreLabel = i18n._(
55
+ msg({
56
+ message: "Read more",
57
+ comment:
58
+ "@context: Expand the rest of a truncated untitled note in place in the feed",
59
+ }),
60
+ );
61
+ const readLessLabel = i18n._(
62
+ msg({
63
+ message: "Read less",
64
+ comment:
65
+ "@context: Collapse an expanded untitled note back to its preview in the feed",
66
+ }),
67
+ );
68
+ // Untitled notes long enough to truncate render their full body with a
69
+ // `data-note-break` marker; this flag tells CSS to clamp the tail until the
70
+ // reader expands it in place.
71
+ const clampNote =
72
+ !isArticle &&
73
+ !isDetail &&
74
+ !isCompact &&
75
+ !showFullBody &&
76
+ post.summaryHasMore === true;
40
77
  const hasVisibleRating =
41
78
  !!post.rating && post.rating > 0 && !display?.hideRating;
42
79
  const showHeaderRating = isDetail && isArticle && hasVisibleRating;
@@ -88,6 +125,7 @@ export const NoteCard: FC<TimelineCardProps> = ({
88
125
  <div
89
126
  class={`e-content prose ${isCompact ? "prose-sm" : isDetail || showFullBody ? "post-detail-body" : isArticle ? "post-body-summary" : ""}`}
90
127
  data-post-body
128
+ {...(clampNote ? { "data-note-clamp": "" } : {})}
91
129
  dangerouslySetInnerHTML={{ __html: displayHtml }}
92
130
  />
93
131
  )}
@@ -105,12 +143,23 @@ export const NoteCard: FC<TimelineCardProps> = ({
105
143
  {!isDetail &&
106
144
  !isCompact &&
107
145
  !showFullBody &&
108
- isArticle &&
109
- post.summaryHasMore && (
146
+ post.summaryHasMore &&
147
+ (isArticle ? (
110
148
  <a href={getContinueHref(post)} class="feed-continue-link">
111
- Continue →
149
+ {continueLabel}
150
+ </a>
151
+ ) : (
152
+ <a
153
+ href={getContinueHref(post)}
154
+ class="feed-continue-link"
155
+ data-note-expand
156
+ aria-expanded="false"
157
+ data-label-more={readMoreLabel}
158
+ data-label-less={readLessLabel}
159
+ >
160
+ {readMoreLabel}
112
161
  </a>
113
- )}
162
+ ))}
114
163
  {!isCompact && !showHeaderRating && !display?.hideRating && (
115
164
  <StarRating rating={post.rating} />
116
165
  )}
@@ -312,6 +312,79 @@ describe("timeline cards", () => {
312
312
 
313
313
  expect(html).toContain('href="/post-1"');
314
314
  expect(html).not.toContain("#continue");
315
+ // Titled articles link out; they do not expand in place.
316
+ expect(html).not.toContain("data-note-expand");
317
+ });
318
+
319
+ it("clamps the body and renders an expand control for truncated untitled notes", () => {
320
+ const post = createPostView({
321
+ format: "note",
322
+ title: undefined,
323
+ bodyHtml: "<p>Intro</p><span data-note-break></span><p>Rest</p>",
324
+ summaryHasMore: true,
325
+ });
326
+
327
+ const html = renderWithI18n(NoteCard({ post, mode: "feed" }));
328
+
329
+ expect(html).toContain("data-note-expand");
330
+ expect(html).toContain('href="/post-1"');
331
+ expect(html).toContain('aria-expanded="false"');
332
+ expect(html).toContain('data-label-more="Read more"');
333
+ expect(html).toContain('data-label-less="Read less"');
334
+ // The full body is rendered (the marker + CSS clamp hide the tail), and the
335
+ // body carries the clamp flag.
336
+ expect(html).toContain("data-note-clamp");
337
+ expect(html).toContain("data-note-break");
338
+ expect(html).toContain("<p>Intro</p>");
339
+ expect(html).toContain("<p>Rest</p>");
340
+ });
341
+
342
+ it("renders untitled notes in full without a control when not truncated", () => {
343
+ const post = createPostView({
344
+ format: "note",
345
+ title: undefined,
346
+ bodyHtml: "<p>Whole note</p>",
347
+ summaryHasMore: undefined,
348
+ });
349
+
350
+ const html = renderWithI18n(NoteCard({ post, mode: "feed" }));
351
+
352
+ expect(html).toContain("<p>Whole note</p>");
353
+ expect(html).not.toContain("data-note-expand");
354
+ expect(html).not.toContain("data-note-clamp");
355
+ expect(html).not.toContain("feed-continue-link");
356
+ });
357
+
358
+ it("shows the full untitled note body without clamping on the detail page", () => {
359
+ const post = createPostView({
360
+ format: "note",
361
+ title: undefined,
362
+ bodyHtml: "<p>Intro</p><span data-note-break></span><p>Rest</p>",
363
+ summaryHasMore: true,
364
+ });
365
+
366
+ const html = renderWithI18n(NoteCard({ post, mode: "detail" }));
367
+
368
+ expect(html).toContain("<p>Rest</p>");
369
+ expect(html).not.toContain("data-note-expand");
370
+ expect(html).not.toContain("data-note-clamp");
371
+ });
372
+
373
+ it("renders the full untitled body with showFullBody and no clamp", () => {
374
+ const post = createPostView({
375
+ format: "note",
376
+ title: undefined,
377
+ bodyHtml: "<p>Intro</p><span data-note-break></span><p>Rest</p>",
378
+ summaryHasMore: true,
379
+ });
380
+
381
+ const html = renderWithI18n(
382
+ NoteCard({ post, mode: "feed", display: { showFullBody: true } }),
383
+ );
384
+
385
+ expect(html).toContain("<p>Rest</p>");
386
+ expect(html).not.toContain("data-note-expand");
387
+ expect(html).not.toContain("data-note-clamp");
315
388
  });
316
389
 
317
390
  it("moves rated link detail cards into the title block without changing feed ordering", () => {
@@ -277,6 +277,7 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
277
277
  {raw("<!DOCTYPE html>")}
278
278
  <html
279
279
  lang={resolvedLang}
280
+ data-theme={appConfig?.themeId}
280
281
  data-theme-mode={themeMode}
281
282
  data-site-path-prefix={sitePathPrefix}
282
283
  data-asset-base-path={assetBasePath}
@@ -320,6 +320,19 @@ describe("BaseLayout", () => {
320
320
  expect(html).toContain('data-asset-base-path="/blog/_assets"');
321
321
  });
322
322
 
323
+ it("exposes the active theme id on the root html element", async () => {
324
+ const { BaseLayout } = await loadBaseLayout();
325
+ const html = renderToString(
326
+ BaseLayout({
327
+ title: "Jant",
328
+ c: createContext("featured", { themeId: "frost" }),
329
+ children: "Test",
330
+ }),
331
+ );
332
+
333
+ expect(html).toContain('data-theme="frost"');
334
+ });
335
+
323
336
  it("renders theme-color tags that follow the active theme in auto mode", async () => {
324
337
  const { BaseLayout } = await loadBaseLayout();
325
338
  const html = renderToString(
@@ -45,14 +45,22 @@ function buildFilterUrl(
45
45
  if (merged.format) params.set("format", merged.format);
46
46
  if (merged.mediaKinds && merged.mediaKinds.length > 0) {
47
47
  params.set("media", merged.mediaKinds.join(","));
48
- }
49
- if (merged.hasMedia !== undefined) {
50
- params.set("hasMedia", merged.hasMedia ? "1" : "0");
48
+ } else if (merged.hasMedia !== undefined) {
49
+ params.set("media", merged.hasMedia ? "any" : "none");
51
50
  }
52
51
  if (merged.hasTitle !== undefined) {
53
- params.set("hasTitle", merged.hasTitle ? "1" : "0");
52
+ params.set("title", merged.hasTitle ? "any" : "none");
53
+ }
54
+ if (merged.hasReplies !== undefined) {
55
+ params.set("replies", merged.hasReplies ? "any" : "none");
56
+ }
57
+ if (merged.visibility) {
58
+ // "hidden" is the URL spelling of the internal latest_hidden value
59
+ params.set(
60
+ "visibility",
61
+ merged.visibility === "latest_hidden" ? "hidden" : merged.visibility,
62
+ );
54
63
  }
55
- if (merged.visibility) params.set("visibility", merged.visibility);
56
64
  if (merged.view && merged.view !== "grid") params.set("view", merged.view);
57
65
 
58
66
  const qs = params.toString();
@@ -493,9 +501,9 @@ const ViewToggle: FC<{
493
501
 
494
502
  const ARCHIVE_VISIBILITIES: ArchiveVisibility[] = [
495
503
  "public",
504
+ "featured",
496
505
  "latest_hidden",
497
506
  "private",
498
- "featured",
499
507
  ];
500
508
 
501
509
  function getVisibilityLabel(v: ArchiveVisibility): string {
@@ -547,9 +555,16 @@ const FILTER_ICONS = {
547
555
  collection: "monitor",
548
556
  format: "shapes",
549
557
  media: "video",
558
+ thread: "git-branch",
550
559
  visibility: "scan-eye",
551
560
  } as const;
552
561
 
562
+ /** Icons for the thread filter options. */
563
+ const THREAD_ICONS = {
564
+ threads: "list-tree",
565
+ single: "git-commit-horizontal",
566
+ } as const;
567
+
553
568
  const FilterBar: FC<{
554
569
  filters: ArchiveFilters;
555
570
  availableYears: number[];
@@ -756,6 +771,63 @@ const FilterBar: FC<{
756
771
  })),
757
772
  ];
758
773
 
774
+ // --- Thread options ---------------------------------------------------------
775
+
776
+ const threadClearUrl = buildFilterUrl(
777
+ { ...filters, hasReplies: undefined },
778
+ { hasReplies: undefined },
779
+ sitePathPrefix,
780
+ );
781
+
782
+ const threadsLabel = i18n._(
783
+ msg({
784
+ message: "Threads",
785
+ comment: "@context: Archive thread filter - thread roots with replies",
786
+ }),
787
+ );
788
+ const singlePostsLabel = i18n._(
789
+ msg({
790
+ message: "Single posts",
791
+ comment: "@context: Archive thread filter - posts without replies",
792
+ }),
793
+ );
794
+
795
+ const threadOptions: ChipSelectOption[] = [
796
+ {
797
+ label: i18n._(
798
+ msg({
799
+ message: "All posts",
800
+ comment: "@context: Archive thread filter - threads and single posts",
801
+ }),
802
+ ),
803
+ icon: FILTER_ICONS.thread,
804
+ value: threadClearUrl,
805
+ },
806
+ {
807
+ label: threadsLabel,
808
+ icon: THREAD_ICONS.threads,
809
+ value: buildFilterUrl(filters, { hasReplies: true }, sitePathPrefix),
810
+ },
811
+ {
812
+ label: singlePostsLabel,
813
+ icon: THREAD_ICONS.single,
814
+ value: buildFilterUrl(filters, { hasReplies: false }, sitePathPrefix),
815
+ },
816
+ ];
817
+
818
+ const threadActiveLabel =
819
+ filters.hasReplies === true
820
+ ? threadsLabel
821
+ : filters.hasReplies === false
822
+ ? singlePostsLabel
823
+ : undefined;
824
+ const threadActiveIcon =
825
+ filters.hasReplies === true
826
+ ? THREAD_ICONS.threads
827
+ : filters.hasReplies === false
828
+ ? THREAD_ICONS.single
829
+ : undefined;
830
+
759
831
  const activeKinds = filters.mediaKinds ?? [];
760
832
  const mediaActiveLabel =
761
833
  filters.hasMedia === false
@@ -829,6 +901,17 @@ const FilterBar: FC<{
829
901
  iconOnly
830
902
  />
831
903
 
904
+ <ChipSelect
905
+ id="af-thread"
906
+ icon={FILTER_ICONS.thread}
907
+ options={threadOptions}
908
+ currentValue={currentUrl}
909
+ clearUrl={threadClearUrl}
910
+ activeLabel={threadActiveLabel}
911
+ activeIcon={threadActiveIcon}
912
+ iconOnly
913
+ />
914
+
832
915
  <ChipMediaSelect
833
916
  id="af-media"
834
917
  icon={FILTER_ICONS.media}
@@ -15,6 +15,7 @@ export const CollectionsPage: FC<CollectionsPageProps> = ({
15
15
  items,
16
16
  isAuthenticated,
17
17
  sitePathPrefix = "",
18
+ siteOrigin = "",
18
19
  }) => {
19
20
  const { i18n } = useLingui();
20
21
  const emptyMessage = i18n._(
@@ -27,7 +28,11 @@ export const CollectionsPage: FC<CollectionsPageProps> = ({
27
28
  if (isAuthenticated) {
28
29
  return (
29
30
  <div class="py-6" data-page="collections">
30
- <CollectionsManager items={items} sitePathPrefix={sitePathPrefix} />
31
+ <CollectionsManager
32
+ items={items}
33
+ sitePathPrefix={sitePathPrefix}
34
+ siteOrigin={siteOrigin}
35
+ />
31
36
  </div>
32
37
  );
33
38
  }
@@ -54,6 +59,7 @@ export const CollectionsPage: FC<CollectionsPageProps> = ({
54
59
  items={items}
55
60
  emptyMessage={emptyMessage}
56
61
  sitePathPrefix={sitePathPrefix}
62
+ siteOrigin={siteOrigin}
57
63
  />
58
64
  </div>
59
65
  </div>