@jant/core 0.6.15 → 0.6.16
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/import-site.js +7 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +1 -1
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/client/__tests__/compose-bridge.test.ts +252 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/toast.test.ts +65 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
- package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +47 -3
- package/src/client/components/jant-command-palette.ts +12 -6
- package/src/client/components/jant-compose-dialog.ts +1464 -680
- package/src/client/components/jant-compose-editor.ts +225 -93
- package/src/client/components/jant-compose-fullscreen.ts +3 -5
- package/src/client/components/jant-post-menu.ts +91 -21
- package/src/client/compose-bridge.ts +143 -122
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/post-refresh.ts +135 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/toast.ts +130 -38
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +20 -0
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/locales/public/en.po +89 -21
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +88 -20
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +88 -20
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/hugo-markdown.ts +5 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/post-display.ts +42 -9
- package/src/lib/timeline.ts +39 -9
- package/src/lib/tiptap-render.ts +4 -2
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +28 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/posts.ts +13 -9
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/posts.ts +7 -0
- package/src/routes/compose.tsx +6 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/archive.tsx +65 -17
- package/src/routes/pages/page.tsx +5 -2
- package/src/services/__tests__/collection.test.ts +32 -1
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +277 -0
- package/src/services/collection.ts +8 -10
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export.ts +3 -5
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +297 -100
- package/src/services/search.ts +9 -0
- package/src/styles/components.css +0 -14
- package/src/styles/site-media.css +4 -4
- package/src/styles/tokens.css +62 -1
- package/src/styles/ui.css +1363 -1283
- package/src/types/entities.ts +5 -0
- package/src/types/props.ts +12 -0
- package/src/types/views.ts +6 -0
- package/src/ui/compose/ComposeDialog.tsx +17 -17
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/PostFooter.tsx +35 -12
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/post-article-attributes.ts +6 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
package/src/types/entities.ts
CHANGED
|
@@ -66,8 +66,13 @@ export interface Post {
|
|
|
66
66
|
previewProvider: string | null;
|
|
67
67
|
replyToId: string | null;
|
|
68
68
|
threadId: string;
|
|
69
|
+
/** Reply published without announcing it on Latest. Always false on roots. */
|
|
70
|
+
quietReply: boolean;
|
|
69
71
|
publishedAt: number | null;
|
|
72
|
+
/** Root only: newest published post in the Thread, quiet replies excluded. */
|
|
70
73
|
lastActivityAt: number;
|
|
74
|
+
/** Root only: newest published post in the Thread, quiet replies included. */
|
|
75
|
+
threadUpdatedAt: number;
|
|
71
76
|
createdAt: number;
|
|
72
77
|
updatedAt: number;
|
|
73
78
|
}
|
package/src/types/props.ts
CHANGED
|
@@ -52,6 +52,16 @@ export type ArchiveVisibility =
|
|
|
52
52
|
/** View mode for the archive page. */
|
|
53
53
|
export type ArchiveView = "grid" | "list";
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Time axis for the archive page.
|
|
57
|
+
*
|
|
58
|
+
* `published` (default) orders and buckets by when a thread was first
|
|
59
|
+
* published — the stable historical record. `updated` switches the whole axis
|
|
60
|
+
* to thread activity, so a thread moves to the month it last gained a post.
|
|
61
|
+
* Edits are not activity.
|
|
62
|
+
*/
|
|
63
|
+
export type ArchiveSort = "published" | "updated";
|
|
64
|
+
|
|
55
65
|
/** Filters currently active on the archive page */
|
|
56
66
|
export interface ArchiveFilters {
|
|
57
67
|
year?: number;
|
|
@@ -65,6 +75,8 @@ export interface ArchiveFilters {
|
|
|
65
75
|
hasReplies?: boolean;
|
|
66
76
|
visibility?: ArchiveVisibility;
|
|
67
77
|
view?: ArchiveView;
|
|
78
|
+
/** Omitted when the default `published` axis is active */
|
|
79
|
+
sort?: ArchiveSort;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
/** Props for the archive page component */
|
package/src/types/views.ts
CHANGED
|
@@ -102,6 +102,12 @@ export interface PostView {
|
|
|
102
102
|
threadRootId?: string;
|
|
103
103
|
/** Whether this post is the last (most recent) in its thread. Controls reply button visibility. */
|
|
104
104
|
isLastInThread: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* TypeID of an unpublished draft that already ends this thread, set only for
|
|
107
|
+
* the signed-in author on surfaces that do not render drafts. Replying here
|
|
108
|
+
* would fork the chain, so the reply affordance resumes that draft instead.
|
|
109
|
+
*/
|
|
110
|
+
draftTailId?: string;
|
|
105
111
|
/** Number of published replies in this thread when relevant to the current view. */
|
|
106
112
|
replyCount?: number;
|
|
107
113
|
|
|
@@ -126,8 +126,9 @@ export const ComposeForm: FC<ComposeFormProps> = ({
|
|
|
126
126
|
),
|
|
127
127
|
titlePlaceholder: i18n._(
|
|
128
128
|
msg({
|
|
129
|
-
message: "
|
|
130
|
-
comment:
|
|
129
|
+
message: "Untitled",
|
|
130
|
+
comment:
|
|
131
|
+
"@context: Compose note title placeholder. States what the note is while the field is empty rather than asking for a title — untitled notes are a normal kind of note here, and Archive files them under this same word.",
|
|
131
132
|
}),
|
|
132
133
|
),
|
|
133
134
|
bodyPlaceholder: i18n._(
|
|
@@ -237,7 +238,7 @@ export const ComposeForm: FC<ComposeFormProps> = ({
|
|
|
237
238
|
title: i18n._(
|
|
238
239
|
msg({
|
|
239
240
|
message: "Title",
|
|
240
|
-
comment: "@context: Compose toolbar - title
|
|
241
|
+
comment: "@context: Compose toolbar - show or hide the title field",
|
|
241
242
|
}),
|
|
242
243
|
),
|
|
243
244
|
fullscreen: i18n._(
|
|
@@ -649,9 +650,9 @@ export const ComposeForm: FC<ComposeFormProps> = ({
|
|
|
649
650
|
),
|
|
650
651
|
publishDateSummaryNow: i18n._(
|
|
651
652
|
msg({
|
|
652
|
-
message: "
|
|
653
|
+
message: "Now",
|
|
653
654
|
comment:
|
|
654
|
-
"@context:
|
|
655
|
+
"@context: Value shown on a post's date control when it will publish at the current time",
|
|
655
656
|
}),
|
|
656
657
|
),
|
|
657
658
|
publishDateSummaryAction: i18n._(
|
|
@@ -688,9 +689,9 @@ export const ComposeForm: FC<ComposeFormProps> = ({
|
|
|
688
689
|
),
|
|
689
690
|
publishSlugSummaryAuto: i18n._(
|
|
690
691
|
msg({
|
|
691
|
-
message: "
|
|
692
|
+
message: "/…",
|
|
692
693
|
comment:
|
|
693
|
-
"@context:
|
|
694
|
+
"@context: Value shown on a post's permalink control when the link is generated automatically — a stand-in for the path the server will assign. Sits next to real permalinks rendered as /my-post, so keep the leading slash",
|
|
694
695
|
}),
|
|
695
696
|
),
|
|
696
697
|
publishSlugSummaryAction: i18n._(
|
|
@@ -794,24 +795,24 @@ export const ComposeForm: FC<ComposeFormProps> = ({
|
|
|
794
795
|
"@context: Toast shown when a local draft is restored on compose open",
|
|
795
796
|
}),
|
|
796
797
|
),
|
|
797
|
-
|
|
798
|
+
closeCompose: i18n._(
|
|
798
799
|
msg({
|
|
799
|
-
message: "
|
|
800
|
+
message: "Close compose",
|
|
800
801
|
comment:
|
|
801
|
-
"@context:
|
|
802
|
+
"@context: Post options row that leaves the composer; prompts to save a draft first if there is unsaved work",
|
|
802
803
|
}),
|
|
803
804
|
),
|
|
804
|
-
|
|
805
|
+
editing: i18n._(
|
|
805
806
|
msg({
|
|
806
|
-
message: "
|
|
807
|
+
message: "Editing",
|
|
807
808
|
comment:
|
|
808
|
-
"@context:
|
|
809
|
+
"@context: Marker above the composer when changing a post that is already published",
|
|
809
810
|
}),
|
|
810
811
|
),
|
|
811
|
-
|
|
812
|
+
composeDialogLabel: i18n._(
|
|
812
813
|
msg({
|
|
813
|
-
message: "
|
|
814
|
-
comment: "@context:
|
|
814
|
+
message: "Compose",
|
|
815
|
+
comment: "@context: Accessible name for the compose dialog",
|
|
815
816
|
}),
|
|
816
817
|
),
|
|
817
818
|
slashHint: i18n._(
|
|
@@ -920,7 +921,6 @@ export const ComposeForm: FC<ComposeFormProps> = ({
|
|
|
920
921
|
>
|
|
921
922
|
{/* SSR fallback skeleton */}
|
|
922
923
|
<div class="compose-dialog-inner">
|
|
923
|
-
<div class="compose-dialog-header" />
|
|
924
924
|
<div class="compose-body skel-section-md" />
|
|
925
925
|
</div>
|
|
926
926
|
</jant-compose-dialog>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { FC } from "hono/jsx";
|
|
9
|
+
import { getLinkPreviewProviderLabel } from "../../lib/link-preview.js";
|
|
9
10
|
import { Icon } from "../shared/Icon.js";
|
|
10
11
|
|
|
11
12
|
interface LinkPreviewProps {
|
|
@@ -15,12 +16,6 @@ interface LinkPreviewProps {
|
|
|
15
16
|
provider?: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
const PROVIDER_LABELS: Record<string, string> = {
|
|
19
|
-
youtube: "YouTube",
|
|
20
|
-
vimeo: "Vimeo",
|
|
21
|
-
bilibili: "Bilibili",
|
|
22
|
-
};
|
|
23
|
-
|
|
24
19
|
export const LinkPreview: FC<LinkPreviewProps> = ({
|
|
25
20
|
imageUrl,
|
|
26
21
|
linkUrl,
|
|
@@ -28,7 +23,7 @@ export const LinkPreview: FC<LinkPreviewProps> = ({
|
|
|
28
23
|
provider,
|
|
29
24
|
}) => {
|
|
30
25
|
const isVideo = kind === "video";
|
|
31
|
-
const providerLabel = provider
|
|
26
|
+
const providerLabel = getLinkPreviewProviderLabel(provider);
|
|
32
27
|
|
|
33
28
|
return (
|
|
34
29
|
<a
|
|
@@ -8,24 +8,63 @@
|
|
|
8
8
|
* footer meta instead.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { msg } from "@lingui/core/macro";
|
|
11
12
|
import type { FC } from "hono/jsx";
|
|
13
|
+
import { useLingui } from "../../i18n/context.js";
|
|
12
14
|
import { Icon } from "../shared/Icon.js";
|
|
13
15
|
|
|
14
16
|
export const PostStatusBadges: FC = () => {
|
|
17
|
+
const { i18n } = useLingui();
|
|
18
|
+
|
|
19
|
+
const pinnedLabel = i18n._(
|
|
20
|
+
msg({
|
|
21
|
+
message: "Pinned",
|
|
22
|
+
comment: "@context: Post status badge for a pinned post",
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
|
|
15
26
|
return (
|
|
16
27
|
<div class="post-status-badges">
|
|
17
28
|
<span class="post-status-badge post-status-pinned">
|
|
18
29
|
<Icon name="post-status-pin" />
|
|
19
|
-
|
|
30
|
+
{pinnedLabel}
|
|
20
31
|
</span>
|
|
21
32
|
<span class="post-status-badge post-status-pinned-in-collection">
|
|
22
33
|
<Icon name="post-status-pin" />
|
|
23
|
-
|
|
34
|
+
{pinnedLabel}
|
|
24
35
|
</span>
|
|
25
36
|
<span class="post-status-badge post-status-private">
|
|
26
37
|
<Icon name="post-status-private" />
|
|
27
|
-
|
|
38
|
+
{i18n._(
|
|
39
|
+
msg({
|
|
40
|
+
message: "Private",
|
|
41
|
+
comment: "@context: Post status badge for a private post",
|
|
42
|
+
}),
|
|
43
|
+
)}
|
|
28
44
|
</span>
|
|
45
|
+
{/* The one badge that is also a control: a draft's most likely next
|
|
46
|
+
action is "finish it", so the label itself opens the editor. */}
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
class="post-status-badge post-status-draft"
|
|
50
|
+
data-draft-continue
|
|
51
|
+
title={i18n._(
|
|
52
|
+
msg({
|
|
53
|
+
message: "Continue writing this draft",
|
|
54
|
+
comment:
|
|
55
|
+
"@context: Tooltip on the draft badge, which opens the editor",
|
|
56
|
+
}),
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<Icon name="post-status-draft" />
|
|
60
|
+
{i18n._(
|
|
61
|
+
msg({
|
|
62
|
+
message: "Draft",
|
|
63
|
+
comment:
|
|
64
|
+
"@context: Post status badge for an unpublished draft, shown to the author only",
|
|
65
|
+
}),
|
|
66
|
+
)}
|
|
67
|
+
</button>
|
|
29
68
|
</div>
|
|
30
69
|
);
|
|
31
70
|
};
|
|
@@ -62,6 +62,7 @@ function buildFilterUrl(
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
if (merged.view && merged.view !== "grid") params.set("view", merged.view);
|
|
65
|
+
if (merged.sort === "updated") params.set("sort", "updated");
|
|
65
66
|
|
|
66
67
|
const qs = params.toString();
|
|
67
68
|
return qs
|
|
@@ -127,7 +128,7 @@ function getFormatLabelPlural(format: string): string {
|
|
|
127
128
|
const FORMAT_ICONS: Record<string, string> = {
|
|
128
129
|
note: "notepad-text",
|
|
129
130
|
link: "external-link",
|
|
130
|
-
quote: "
|
|
131
|
+
quote: "quote",
|
|
131
132
|
};
|
|
132
133
|
|
|
133
134
|
/** Icon name mapping for media kinds. */
|
|
@@ -463,34 +464,118 @@ const ChipMediaSelect: FC<{
|
|
|
463
464
|
// View Toggle
|
|
464
465
|
// =============================================================================
|
|
465
466
|
|
|
467
|
+
/**
|
|
468
|
+
* One option in a toolbar toggle. `label` is used for both the hover tooltip
|
|
469
|
+
* and the accessible name — an icon-only control has to explain itself to
|
|
470
|
+
* both audiences, and one string keeps them from drifting apart.
|
|
471
|
+
*/
|
|
472
|
+
const ToggleOption: FC<{
|
|
473
|
+
href: string;
|
|
474
|
+
icon: string;
|
|
475
|
+
label: string;
|
|
476
|
+
active: boolean;
|
|
477
|
+
}> = ({ href, icon, label, active }) => (
|
|
478
|
+
<a
|
|
479
|
+
href={href}
|
|
480
|
+
class={`archive-view-btn${active ? " archive-view-btn-active" : ""}`}
|
|
481
|
+
role="radio"
|
|
482
|
+
aria-checked={active ? "true" : "false"}
|
|
483
|
+
aria-label={label}
|
|
484
|
+
title={label}
|
|
485
|
+
>
|
|
486
|
+
<Icon name={icon} class="[&>svg]:size-4" />
|
|
487
|
+
</a>
|
|
488
|
+
);
|
|
489
|
+
|
|
466
490
|
const ViewToggle: FC<{
|
|
467
491
|
filters: ArchiveFilters;
|
|
468
492
|
sitePathPrefix?: string;
|
|
469
493
|
}> = ({ filters, sitePathPrefix = "" }) => {
|
|
494
|
+
const { i18n } = useLingui();
|
|
470
495
|
const currentView: ArchiveView = filters.view ?? "grid";
|
|
471
|
-
const gridUrl = buildFilterUrl(filters, { view: undefined }, sitePathPrefix);
|
|
472
|
-
const listUrl = buildFilterUrl(filters, { view: "list" }, sitePathPrefix);
|
|
473
496
|
|
|
474
497
|
return (
|
|
475
|
-
<div
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
<
|
|
486
|
-
href={
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
498
|
+
<div
|
|
499
|
+
class="archive-view-toggle"
|
|
500
|
+
role="radiogroup"
|
|
501
|
+
aria-label={i18n._(
|
|
502
|
+
msg({
|
|
503
|
+
message: "View mode",
|
|
504
|
+
comment: "@context: Archive grid/list toggle group label",
|
|
505
|
+
}),
|
|
506
|
+
)}
|
|
507
|
+
>
|
|
508
|
+
<ToggleOption
|
|
509
|
+
href={buildFilterUrl(filters, { view: undefined }, sitePathPrefix)}
|
|
510
|
+
icon="layout-grid"
|
|
511
|
+
active={currentView === "grid"}
|
|
512
|
+
label={i18n._(
|
|
513
|
+
msg({
|
|
514
|
+
message: "Show as a grid of tiles",
|
|
515
|
+
comment: "@context: Archive view option - grid",
|
|
516
|
+
}),
|
|
517
|
+
)}
|
|
518
|
+
/>
|
|
519
|
+
<ToggleOption
|
|
520
|
+
href={buildFilterUrl(filters, { view: "list" }, sitePathPrefix)}
|
|
521
|
+
icon="list"
|
|
522
|
+
active={currentView === "list"}
|
|
523
|
+
label={i18n._(
|
|
524
|
+
msg({
|
|
525
|
+
message: "Show as a list with full posts",
|
|
526
|
+
comment: "@context: Archive view option - list",
|
|
527
|
+
}),
|
|
528
|
+
)}
|
|
529
|
+
/>
|
|
530
|
+
</div>
|
|
531
|
+
);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Time-axis toggle. Sits next to the view toggle because it changes how the
|
|
536
|
+
* page is arranged, not which threads it contains — unlike the filter chips.
|
|
537
|
+
*/
|
|
538
|
+
const SortToggle: FC<{
|
|
539
|
+
filters: ArchiveFilters;
|
|
540
|
+
sitePathPrefix?: string;
|
|
541
|
+
}> = ({ filters, sitePathPrefix = "" }) => {
|
|
542
|
+
const { i18n } = useLingui();
|
|
543
|
+
const sortsByActivity = filters.sort === "updated";
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<div
|
|
547
|
+
class="archive-view-toggle"
|
|
548
|
+
role="radiogroup"
|
|
549
|
+
aria-label={i18n._(
|
|
550
|
+
msg({
|
|
551
|
+
message: "Sort order",
|
|
552
|
+
comment: "@context: Archive sort toggle group label",
|
|
553
|
+
}),
|
|
554
|
+
)}
|
|
555
|
+
>
|
|
556
|
+
<ToggleOption
|
|
557
|
+
href={buildFilterUrl(filters, { sort: undefined }, sitePathPrefix)}
|
|
558
|
+
icon="clock"
|
|
559
|
+
active={!sortsByActivity}
|
|
560
|
+
label={i18n._(
|
|
561
|
+
msg({
|
|
562
|
+
message: "Sort by when each thread was published",
|
|
563
|
+
comment: "@context: Archive sort option - newest published first",
|
|
564
|
+
}),
|
|
565
|
+
)}
|
|
566
|
+
/>
|
|
567
|
+
<ToggleOption
|
|
568
|
+
href={buildFilterUrl(filters, { sort: "updated" }, sitePathPrefix)}
|
|
569
|
+
icon="history"
|
|
570
|
+
active={sortsByActivity}
|
|
571
|
+
label={i18n._(
|
|
572
|
+
msg({
|
|
573
|
+
message: "Sort by when each thread was last added to",
|
|
574
|
+
comment:
|
|
575
|
+
"@context: Archive sort option - threads that recently gained a post first",
|
|
576
|
+
}),
|
|
577
|
+
)}
|
|
578
|
+
/>
|
|
494
579
|
</div>
|
|
495
580
|
);
|
|
496
581
|
};
|
|
@@ -948,7 +1033,10 @@ const FilterBar: FC<{
|
|
|
948
1033
|
)}
|
|
949
1034
|
</div>
|
|
950
1035
|
|
|
951
|
-
<
|
|
1036
|
+
<div class="archive-toolbar-toggles">
|
|
1037
|
+
<SortToggle filters={filters} sitePathPrefix={sitePathPrefix} />
|
|
1038
|
+
<ViewToggle filters={filters} sitePathPrefix={sitePathPrefix} />
|
|
1039
|
+
</div>
|
|
952
1040
|
</div>
|
|
953
1041
|
);
|
|
954
1042
|
};
|
|
@@ -1258,6 +1346,7 @@ export const ArchivePage: FC<ArchivePageProps> = ({
|
|
|
1258
1346
|
}) => {
|
|
1259
1347
|
const { i18n } = useLingui();
|
|
1260
1348
|
const currentView: ArchiveView = filters.view ?? "grid";
|
|
1349
|
+
const sortsByActivity = filters.sort === "updated";
|
|
1261
1350
|
const paginationBaseUrl = buildFilterUrl(filters, {}, sitePathPrefix);
|
|
1262
1351
|
const totalCountUnit =
|
|
1263
1352
|
totalCount === 1
|
|
@@ -1295,6 +1384,18 @@ export const ArchivePage: FC<ArchivePageProps> = ({
|
|
|
1295
1384
|
>
|
|
1296
1385
|
<span class="archive-page-summary-count">{totalCount}</span>{" "}
|
|
1297
1386
|
{totalCountUnit}
|
|
1387
|
+
{sortsByActivity && (
|
|
1388
|
+
<>
|
|
1389
|
+
{" · "}
|
|
1390
|
+
{i18n._(
|
|
1391
|
+
msg({
|
|
1392
|
+
message: "newest changes first",
|
|
1393
|
+
comment:
|
|
1394
|
+
"@context: Archive page meta note explaining the active sort order",
|
|
1395
|
+
}),
|
|
1396
|
+
)}
|
|
1397
|
+
</>
|
|
1398
|
+
)}
|
|
1298
1399
|
{feedHref && (
|
|
1299
1400
|
<>
|
|
1300
1401
|
{" "}
|
|
@@ -1347,7 +1448,13 @@ export const ArchivePage: FC<ArchivePageProps> = ({
|
|
|
1347
1448
|
<div key={`grid-${group.year}-${group.month}`} class="contents">
|
|
1348
1449
|
<ArchiveMonthHeader
|
|
1349
1450
|
class={`archive-month-header${groupIndex > 0 ? " archive-month-header-spaced" : ""}`}
|
|
1350
|
-
|
|
1451
|
+
// Not translated, on purpose: `group.label` is built with
|
|
1452
|
+
// a hardcoded en-US month name, so a translated wrapper
|
|
1453
|
+
// would only produce "更新于 August 2026". Both halves
|
|
1454
|
+
// become translatable together, or neither does.
|
|
1455
|
+
label={
|
|
1456
|
+
sortsByActivity ? `Updated ${group.label}` : group.label
|
|
1457
|
+
}
|
|
1351
1458
|
count={group.totalCount}
|
|
1352
1459
|
/>
|
|
1353
1460
|
{group.posts.map((post) => (
|
|
@@ -107,10 +107,73 @@ describe("ArchivePage", () => {
|
|
|
107
107
|
expect(html).toContain("Single posts");
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
it("uses a recognizable quote mark for the quote format filter", () => {
|
|
111
|
+
const html = renderArchivePage({ filters: { format: "quote" } });
|
|
112
|
+
|
|
113
|
+
expect(html).toContain('class="lucide lucide-quote"');
|
|
114
|
+
expect(html).not.toContain('class="lucide lucide-text-quote"');
|
|
115
|
+
});
|
|
116
|
+
|
|
110
117
|
it("renders the feed button only when a feed href is provided", () => {
|
|
111
118
|
expect(renderArchivePage()).not.toContain('class="feed-link"');
|
|
112
119
|
expect(renderArchivePage({ feedHref: "/archive/feed" })).toContain(
|
|
113
120
|
'class="feed-link"',
|
|
114
121
|
);
|
|
115
122
|
});
|
|
123
|
+
|
|
124
|
+
it("offers the updated sort and marks published as the default", () => {
|
|
125
|
+
const html = renderArchivePage();
|
|
126
|
+
|
|
127
|
+
expect(html).toContain('aria-label="Sort order"');
|
|
128
|
+
expect(html).toContain("sort=updated");
|
|
129
|
+
expect(html).toContain(
|
|
130
|
+
'<a href="/archive" class="archive-view-btn archive-view-btn-active" role="radio" aria-checked="true" aria-label="Sort by when each thread was published"',
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("marks the updated sort active and keeps a way back", () => {
|
|
135
|
+
const html = renderArchivePage({ filters: { sort: "updated" } });
|
|
136
|
+
|
|
137
|
+
expect(html).toContain(
|
|
138
|
+
'<a href="/archive?sort=updated" class="archive-view-btn archive-view-btn-active" role="radio" aria-checked="true" aria-label="Sort by when each thread was last added to"',
|
|
139
|
+
);
|
|
140
|
+
expect(html).toContain('<a href="/archive" class="archive-view-btn"');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Icon-only controls need a hover tooltip too — aria-label only reaches
|
|
144
|
+
// assistive tech, and a bare clock/history pair is unreadable without one.
|
|
145
|
+
it("gives every toolbar toggle a tooltip matching its accessible name", () => {
|
|
146
|
+
const html = renderArchivePage();
|
|
147
|
+
|
|
148
|
+
for (const label of [
|
|
149
|
+
"Sort by when each thread was published",
|
|
150
|
+
"Sort by when each thread was last added to",
|
|
151
|
+
"Show as a grid of tiles",
|
|
152
|
+
"Show as a list with full posts",
|
|
153
|
+
]) {
|
|
154
|
+
expect(html).toContain(`aria-label="${label}" title="${label}"`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("labels month headers as update months in the updated sort", () => {
|
|
159
|
+
expect(renderArchivePage()).toContain(
|
|
160
|
+
'<span class="archive-month-header-label">March 2026</span>',
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const updated = renderArchivePage({ filters: { sort: "updated" } });
|
|
164
|
+
expect(updated).toContain(
|
|
165
|
+
'<span class="archive-month-header-label">Updated March 2026</span>',
|
|
166
|
+
);
|
|
167
|
+
expect(updated).toContain("newest changes first");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps the sort selection in filter and view links", () => {
|
|
171
|
+
const html = renderArchivePage({
|
|
172
|
+
filters: { sort: "updated" },
|
|
173
|
+
availableCollections: [{ slug: "tech", title: "Tech" }],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(html).toContain("collection=tech&sort=updated");
|
|
177
|
+
expect(html).toContain("view=list&sort=updated");
|
|
178
|
+
});
|
|
116
179
|
});
|
|
@@ -2,8 +2,12 @@ import { msg } from "@lingui/core/macro";
|
|
|
2
2
|
import type { FC } from "hono/jsx";
|
|
3
3
|
import { useLingui } from "../../i18n/context.js";
|
|
4
4
|
|
|
5
|
+
interface DraftPreviewBarProps {
|
|
6
|
+
editHref: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
/** Persistent page chrome that distinguishes an authenticated draft preview. */
|
|
6
|
-
export const DraftPreviewBar: FC = () => {
|
|
10
|
+
export const DraftPreviewBar: FC<DraftPreviewBarProps> = ({ editHref }) => {
|
|
7
11
|
const { i18n } = useLingui();
|
|
8
12
|
const label = i18n._(
|
|
9
13
|
msg({
|
|
@@ -15,18 +19,30 @@ export const DraftPreviewBar: FC = () => {
|
|
|
15
19
|
return (
|
|
16
20
|
<aside class="draft-preview-bar" aria-label={label} data-preview-status>
|
|
17
21
|
<div class="draft-preview-bar-inner">
|
|
18
|
-
<
|
|
19
|
-
<span class="draft-preview-bar-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
<div class="draft-preview-bar-copy">
|
|
23
|
+
<span class="draft-preview-bar-label">
|
|
24
|
+
<span class="draft-preview-bar-dot" aria-hidden="true" />
|
|
25
|
+
{label}
|
|
26
|
+
</span>
|
|
27
|
+
<span class="draft-preview-bar-description">
|
|
28
|
+
{i18n._(
|
|
29
|
+
msg({
|
|
30
|
+
message: "This post isn’t published.",
|
|
31
|
+
comment:
|
|
32
|
+
"@context: Explanation in the draft preview status bar",
|
|
33
|
+
}),
|
|
34
|
+
)}
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
<a class="draft-preview-bar-action" href={editHref}>
|
|
23
38
|
{i18n._(
|
|
24
39
|
msg({
|
|
25
|
-
message: "
|
|
26
|
-
comment:
|
|
40
|
+
message: "Edit draft",
|
|
41
|
+
comment:
|
|
42
|
+
"@context: Action in the draft preview status bar that opens the editor",
|
|
27
43
|
}),
|
|
28
44
|
)}
|
|
29
|
-
</
|
|
45
|
+
</a>
|
|
30
46
|
</div>
|
|
31
47
|
</aside>
|
|
32
48
|
);
|
|
@@ -97,7 +97,11 @@ export const CompactCollectionTags: FC<{
|
|
|
97
97
|
interface PostPublishedLinkProps {
|
|
98
98
|
post: Pick<
|
|
99
99
|
PostView,
|
|
100
|
-
|
|
100
|
+
| "permalink"
|
|
101
|
+
| "publishedAt"
|
|
102
|
+
| "publishedAtFormatted"
|
|
103
|
+
| "publishedAtTime"
|
|
104
|
+
| "status"
|
|
101
105
|
>;
|
|
102
106
|
className: string;
|
|
103
107
|
}
|
|
@@ -107,17 +111,33 @@ export const PostPublishedLink: FC<PostPublishedLinkProps> = ({
|
|
|
107
111
|
className,
|
|
108
112
|
}) => {
|
|
109
113
|
const { i18n } = useLingui();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
114
|
+
// A draft has never been published; `publishedAt` is standing in for its
|
|
115
|
+
// last edit. The link still matters (it is how you open the post), so keep
|
|
116
|
+
// it and tell the truth about what the date means.
|
|
117
|
+
const publishedLabel =
|
|
118
|
+
post.status === "draft"
|
|
119
|
+
? i18n._(
|
|
120
|
+
msg({
|
|
121
|
+
message: "Last edited on {date} at {time}",
|
|
122
|
+
comment:
|
|
123
|
+
"@context: Tooltip text for the timestamp on an unpublished draft",
|
|
124
|
+
}),
|
|
125
|
+
{
|
|
126
|
+
date: post.publishedAtFormatted,
|
|
127
|
+
time: post.publishedAtTime,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
: i18n._(
|
|
131
|
+
msg({
|
|
132
|
+
message: "Published on {date} at {time}",
|
|
133
|
+
comment:
|
|
134
|
+
"@context: Tooltip text for the published timestamp in post metadata",
|
|
135
|
+
}),
|
|
136
|
+
{
|
|
137
|
+
date: post.publishedAtFormatted,
|
|
138
|
+
time: post.publishedAtTime,
|
|
139
|
+
},
|
|
140
|
+
);
|
|
121
141
|
|
|
122
142
|
return (
|
|
123
143
|
<a href={post.permalink} class={className}>
|
|
@@ -180,6 +200,9 @@ export const PostFooter: FC<PostFooterProps> = ({ post, detail, display }) => {
|
|
|
180
200
|
);
|
|
181
201
|
const safeExternalUrl =
|
|
182
202
|
post.format === "link" && post.url ? sanitizeUrl(post.url) : "";
|
|
203
|
+
// Drafts get an ordinary footer — the Draft badge above already says what
|
|
204
|
+
// they are, and their lifecycle actions live in the post menu next to Edit
|
|
205
|
+
// and Delete rather than in a bespoke button row.
|
|
183
206
|
const showTimestamp = !display?.hideTimestamp;
|
|
184
207
|
const hideActions = !!display?.hideActions;
|
|
185
208
|
const hideReply = !!display?.hideReply;
|
|
@@ -106,6 +106,11 @@ export const CUSTOM_SYMBOLS: Record<string, CustomSymbol> = {
|
|
|
106
106
|
viewBox: "0 0 24 24",
|
|
107
107
|
inner: `<line ${STROKE_POST_BADGE} x1="12" x2="12" y1="17" y2="22" /><path ${STROKE_POST_BADGE} 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" />`,
|
|
108
108
|
},
|
|
109
|
+
// Post status badge: draft (lucide pencil-line, thin 1.75 stroke).
|
|
110
|
+
"post-status-draft": {
|
|
111
|
+
viewBox: "0 0 24 24",
|
|
112
|
+
inner: `<path ${STROKE_POST_BADGE} d="M12 20h9" /><path ${STROKE_POST_BADGE} d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z" />`,
|
|
113
|
+
},
|
|
109
114
|
// Post status badge: private (lucide eye-off, thin 1.75 stroke).
|
|
110
115
|
"post-status-private": {
|
|
111
116
|
viewBox: "0 0 24 24",
|