@jant/core 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
  3. package/dist/app-DaxS_Cz-.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-C6peCkkD.css +2 -0
  6. package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
  7. package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
  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-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
  13. package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.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__/json.test.ts +94 -0
  22. package/src/client/__tests__/note-expand.test.ts +130 -0
  23. package/src/client/archive-nav.js +2 -1
  24. package/src/client/audio-player.ts +7 -3
  25. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  26. package/src/client/components/__tests__/jant-compose-dialog.test.ts +357 -0
  27. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  29. package/src/client/components/compose-format-convert.ts +255 -0
  30. package/src/client/components/compose-types.ts +2 -0
  31. package/src/client/components/jant-collection-directory.ts +1 -0
  32. package/src/client/components/jant-collection-form.ts +1 -0
  33. package/src/client/components/jant-command-palette.ts +4 -0
  34. package/src/client/components/jant-compose-dialog.ts +106 -44
  35. package/src/client/components/jant-compose-editor.ts +65 -11
  36. package/src/client/components/jant-compose-fullscreen.ts +3 -0
  37. package/src/client/components/jant-nav-manager.ts +4 -0
  38. package/src/client/components/jant-post-menu.ts +3 -0
  39. package/src/client/components/jant-repo-picker.ts +3 -0
  40. package/src/client/components/jant-settings-general.ts +3 -0
  41. package/src/client/compose-bridge.ts +17 -0
  42. package/src/client/feed-video-player.ts +1 -1
  43. package/src/client/hydrate-partial.ts +25 -0
  44. package/src/client/json.ts +56 -2
  45. package/src/client/multipart-upload.ts +17 -7
  46. package/src/client/note-expand.ts +63 -0
  47. package/src/client/upload-session.ts +17 -9
  48. package/src/client.ts +1 -0
  49. package/src/i18n/locales/public/en.po +41 -0
  50. package/src/i18n/locales/public/en.ts +1 -1
  51. package/src/i18n/locales/public/zh-Hans.po +41 -0
  52. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  53. package/src/i18n/locales/public/zh-Hant.po +41 -0
  54. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  55. package/src/i18n/locales/settings/en.po +12 -12
  56. package/src/i18n/locales/settings/en.ts +1 -1
  57. package/src/i18n/locales/settings/zh-Hans.po +12 -12
  58. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  59. package/src/i18n/locales/settings/zh-Hant.po +12 -12
  60. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  61. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  62. package/src/lib/__tests__/markdown.test.ts +1 -1
  63. package/src/lib/__tests__/summary.test.ts +87 -0
  64. package/src/lib/__tests__/timeline.test.ts +48 -1
  65. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  66. package/src/lib/__tests__/url.test.ts +44 -0
  67. package/src/lib/__tests__/view.test.ts +168 -1
  68. package/src/lib/navigation.ts +1 -0
  69. package/src/lib/resolve-config.ts +2 -2
  70. package/src/lib/summary.ts +42 -3
  71. package/src/lib/tiptap-render.ts +6 -2
  72. package/src/lib/upload.ts +2 -2
  73. package/src/lib/url.ts +41 -0
  74. package/src/lib/view.ts +102 -40
  75. package/src/preset.css +7 -1
  76. package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
  77. package/src/routes/api/internal/sites.ts +77 -1
  78. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  79. package/src/routes/api/public/archive.ts +22 -6
  80. package/src/routes/api/telegram.ts +2 -1
  81. package/src/routes/dash/custom-urls.tsx +1 -1
  82. package/src/routes/dash/settings.tsx +8 -5
  83. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  84. package/src/routes/pages/archive.tsx +116 -20
  85. package/src/routes/pages/collections.tsx +1 -0
  86. package/src/services/__tests__/media.test.ts +83 -0
  87. package/src/services/__tests__/post.test.ts +81 -0
  88. package/src/services/export-theme/assets/client-site.js +1 -1
  89. package/src/services/export-theme/styles/main.css +49 -15
  90. package/src/services/media.ts +31 -1
  91. package/src/services/post.ts +22 -2
  92. package/src/services/search.ts +4 -4
  93. package/src/services/site-admin.ts +121 -0
  94. package/src/services/upload-session.ts +18 -0
  95. package/src/styles/tokens.css +1 -1
  96. package/src/styles/ui.css +163 -34
  97. package/src/types/config.ts +1 -1
  98. package/src/types/props.ts +3 -0
  99. package/src/ui/compose/ComposeDialog.tsx +13 -0
  100. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  101. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  102. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  103. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  104. package/src/ui/feed/NoteCard.tsx +54 -5
  105. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  106. package/src/ui/pages/ArchivePage.tsx +89 -6
  107. package/src/ui/pages/CollectionsPage.tsx +7 -1
  108. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  109. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  110. package/src/ui/shared/CollectionsManager.tsx +3 -0
  111. package/dist/app-CL2PC1Fl.js +0 -6
  112. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -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", () => {
@@ -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>
@@ -69,4 +69,41 @@ describe("ArchivePage", () => {
69
69
 
70
70
  expect(html).toContain('title="Published on Mar 30, 2026 at 20:00"');
71
71
  });
72
+
73
+ it("renders the collection filter alongside the thread filter", () => {
74
+ const html = renderArchivePage({
75
+ availableCollections: [{ slug: "tech", title: "Tech" }],
76
+ });
77
+
78
+ expect(html).toContain('id="af-collection"');
79
+ expect(html).toContain('id="af-thread"');
80
+ });
81
+
82
+ it("renders the thread filter with all options", () => {
83
+ const html = renderArchivePage();
84
+
85
+ expect(html).toContain('id="af-thread"');
86
+ expect(html).toContain("All posts");
87
+ expect(html).toContain("Threads");
88
+ expect(html).toContain("Single posts");
89
+ expect(html).toContain("replies=any");
90
+ expect(html).toContain("replies=none");
91
+ });
92
+
93
+ it("serializes visibility latest_hidden as the hidden URL alias", () => {
94
+ const html = renderArchivePage({
95
+ isAuthenticated: true,
96
+ filters: { visibility: "latest_hidden" },
97
+ });
98
+
99
+ expect(html).toContain("visibility=hidden");
100
+ expect(html).not.toContain("visibility=latest_hidden");
101
+ });
102
+
103
+ it("marks the thread filter active when filtering single posts", () => {
104
+ const html = renderArchivePage({ filters: { hasReplies: false } });
105
+
106
+ expect(html).toContain("archive-chip-active");
107
+ expect(html).toContain("Single posts");
108
+ });
72
109
  });
@@ -6,12 +6,13 @@ import { getCollectionSelectionPath } from "../../lib/collection-paths.js";
6
6
  import { getDividerCollectionGroup } from "../../lib/collection-groups.js";
7
7
  import { render as renderMarkdown } from "../../lib/markdown.js";
8
8
  import { formatRelativeAge, toISOString } from "../../lib/time.js";
9
- import { toPublicHref, toPublicPath } from "../../lib/url.js";
9
+ import { toPublicHref, toPublicPath, toSameSitePath } from "../../lib/url.js";
10
10
 
11
11
  export interface CollectionDirectoryProps {
12
12
  items: CollectionDirectoryItem[];
13
13
  emptyMessage?: string;
14
14
  sitePathPrefix?: string;
15
+ siteOrigin?: string;
15
16
  }
16
17
 
17
18
  const hasDirectoryContent = (items: CollectionDirectoryItem[]) =>
@@ -112,6 +113,7 @@ export const CollectionDirectory: FC<CollectionDirectoryProps> = ({
112
113
  items,
113
114
  emptyMessage,
114
115
  sitePathPrefix = "",
116
+ siteOrigin = "",
115
117
  }) => {
116
118
  const { i18n } = useLingui();
117
119
 
@@ -173,8 +175,16 @@ export const CollectionDirectory: FC<CollectionDirectoryProps> = ({
173
175
 
174
176
  if (item.type === "link" && item.label && item.url) {
175
177
  const sequence = sequenceLabels[index];
178
+ // A full URL pointing at this site's own origin is really internal,
179
+ // so render it without external-link affordances.
180
+ const sameSitePath = toSameSitePath(item.url, siteOrigin);
181
+ const linkHref =
182
+ sameSitePath !== null
183
+ ? toPublicHref(sameSitePath, sitePathPrefix)
184
+ : toPublicHref(item.url, sitePathPrefix);
176
185
  const isExternal =
177
- item.url.startsWith("http://") || item.url.startsWith("https://");
186
+ sameSitePath === null &&
187
+ (item.url.startsWith("http://") || item.url.startsWith("https://"));
178
188
 
179
189
  return (
180
190
  <div
@@ -187,7 +197,7 @@ export const CollectionDirectory: FC<CollectionDirectoryProps> = ({
187
197
  </span>
188
198
  <div class="collection-directory-title-row">
189
199
  <a
190
- href={toPublicHref(item.url, sitePathPrefix)}
200
+ href={linkHref}
191
201
  class="collection-directory-title-link"
192
202
  {...(isExternal
193
203
  ? { target: "_blank", rel: "noopener noreferrer" }
@@ -16,11 +16,13 @@ const escapeJson = (data: unknown) =>
16
16
  export interface CollectionsManagerProps {
17
17
  items: CollectionDirectoryItem[];
18
18
  sitePathPrefix?: string;
19
+ siteOrigin?: string;
19
20
  }
20
21
 
21
22
  export const CollectionsManager: FC<CollectionsManagerProps> = ({
22
23
  items,
23
24
  sitePathPrefix = "",
25
+ siteOrigin = "",
24
26
  }) => {
25
27
  const { i18n } = useLingui();
26
28
  const collectionsHref = toPublicPath(
@@ -315,6 +317,7 @@ export const CollectionsManager: FC<CollectionsManagerProps> = ({
315
317
  items={items}
316
318
  emptyMessage={labels.emptyState}
317
319
  sitePathPrefix={sitePathPrefix}
320
+ siteOrigin={siteOrigin}
318
321
  />
319
322
  </jant-collections-manager>
320
323
  </div>
@@ -1,6 +0,0 @@
1
- import "./url-XF0GbKGO.js";
2
- import { t as createApp } from "./app-BJkOcMbZ.js";
3
- import "./export-DLukCOO3.js";
4
- import "./env-CoSe-1y4.js";
5
- import "./github-sync-BtHY2AST.js";
6
- export { createApp };