@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.
- package/bin/commands/uploads/cleanup.js +1 -0
- package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
- package/dist/app-DaxS_Cz-.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-C6peCkkD.css +2 -0
- package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
- package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
- package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
- package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
- package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
- package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
- package/package.json +1 -1
- package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
- package/src/client/__tests__/compose-bridge.test.ts +105 -0
- package/src/client/__tests__/hydrate-partial.test.ts +27 -0
- package/src/client/__tests__/json.test.ts +94 -0
- package/src/client/__tests__/note-expand.test.ts +130 -0
- package/src/client/archive-nav.js +2 -1
- package/src/client/audio-player.ts +7 -3
- package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
- package/src/client/components/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-collection-directory.ts +1 -0
- package/src/client/components/jant-collection-form.ts +1 -0
- package/src/client/components/jant-command-palette.ts +4 -0
- package/src/client/components/jant-compose-dialog.ts +106 -44
- package/src/client/components/jant-compose-editor.ts +65 -11
- package/src/client/components/jant-compose-fullscreen.ts +3 -0
- package/src/client/components/jant-nav-manager.ts +4 -0
- package/src/client/components/jant-post-menu.ts +3 -0
- package/src/client/components/jant-repo-picker.ts +3 -0
- package/src/client/components/jant-settings-general.ts +3 -0
- package/src/client/compose-bridge.ts +17 -0
- package/src/client/feed-video-player.ts +1 -1
- package/src/client/hydrate-partial.ts +25 -0
- package/src/client/json.ts +56 -2
- package/src/client/multipart-upload.ts +17 -7
- package/src/client/note-expand.ts +63 -0
- package/src/client/upload-session.ts +17 -9
- package/src/client.ts +1 -0
- package/src/i18n/locales/public/en.po +41 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +41 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +41 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +12 -12
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +12 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +12 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
- package/src/lib/__tests__/markdown.test.ts +1 -1
- package/src/lib/__tests__/summary.test.ts +87 -0
- package/src/lib/__tests__/timeline.test.ts +48 -1
- package/src/lib/__tests__/tiptap-render.test.ts +4 -4
- package/src/lib/__tests__/url.test.ts +44 -0
- package/src/lib/__tests__/view.test.ts +168 -1
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +2 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +2 -2
- package/src/lib/url.ts +41 -0
- package/src/lib/view.ts +102 -40
- package/src/preset.css +7 -1
- package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
- package/src/routes/api/internal/sites.ts +77 -1
- package/src/routes/api/public/__tests__/archive.test.ts +66 -0
- package/src/routes/api/public/archive.ts +22 -6
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +8 -5
- package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
- package/src/routes/pages/archive.tsx +116 -20
- package/src/routes/pages/collections.tsx +1 -0
- package/src/services/__tests__/media.test.ts +83 -0
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +31 -1
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/site-admin.ts +121 -0
- package/src/services/upload-session.ts +18 -0
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +163 -34
- package/src/types/config.ts +1 -1
- package/src/types/props.ts +3 -0
- package/src/ui/compose/ComposeDialog.tsx +13 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
- package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
- package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
- package/src/ui/feed/NoteCard.tsx +54 -5
- package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
- package/src/ui/pages/ArchivePage.tsx +89 -6
- package/src/ui/pages/CollectionsPage.tsx +7 -1
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -3
- package/src/ui/shared/CollectionsManager.tsx +3 -0
- package/dist/app-CL2PC1Fl.js +0 -6
- 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
|
+
});
|
package/src/ui/feed/NoteCard.tsx
CHANGED
|
@@ -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 ||
|
|
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
|
-
|
|
109
|
-
|
|
146
|
+
post.summaryHasMore &&
|
|
147
|
+
(isArticle ? (
|
|
110
148
|
<a href={getContinueHref(post)} class="feed-continue-link">
|
|
111
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
|
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
|
-
|
|
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={
|
|
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>
|