@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.
Files changed (112) hide show
  1. package/bin/commands/import-site.js +7 -0
  2. package/dist/app-C75Zc8dD.js +6 -0
  3. package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
  4. package/dist/client/.vite/manifest.json +8 -8
  5. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  6. package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
  7. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  8. package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
  9. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  10. package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
  11. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  12. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  13. package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
  14. package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
  15. package/dist/index.js +5 -5
  16. package/dist/node.js +6 -6
  17. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  18. package/package.json +1 -1
  19. package/src/__tests__/mise-config.test.ts +22 -0
  20. package/src/client/__tests__/compose-bridge.test.ts +252 -2
  21. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  22. package/src/client/__tests__/toast.test.ts +65 -1
  23. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
  28. package/src/client/components/compose-format-convert.ts +5 -1
  29. package/src/client/components/compose-types.ts +47 -3
  30. package/src/client/components/jant-command-palette.ts +12 -6
  31. package/src/client/components/jant-compose-dialog.ts +1464 -680
  32. package/src/client/components/jant-compose-editor.ts +225 -93
  33. package/src/client/components/jant-compose-fullscreen.ts +3 -5
  34. package/src/client/components/jant-post-menu.ts +91 -21
  35. package/src/client/compose-bridge.ts +143 -122
  36. package/src/client/compose-launch.ts +13 -0
  37. package/src/client/compose-shortcuts.ts +5 -1
  38. package/src/client/post-refresh.ts +135 -0
  39. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  40. package/src/client/tiptap/link-input-rules.ts +4 -4
  41. package/src/client/toast.ts +130 -38
  42. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  43. package/src/db/__tests__/thread-activity.test.ts +113 -0
  44. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  45. package/src/db/migrations/0031_many_ego.sql +3 -0
  46. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  47. package/src/db/migrations/meta/_journal.json +7 -0
  48. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  49. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  50. package/src/db/migrations/pg/meta/_journal.json +7 -0
  51. package/src/db/pg/schema.ts +18 -0
  52. package/src/db/schema.ts +20 -0
  53. package/src/db/thread-activity.ts +101 -0
  54. package/src/i18n/locales/public/en.po +89 -21
  55. package/src/i18n/locales/public/en.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hans.po +88 -20
  57. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/public/zh-Hant.po +88 -20
  59. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/feed.test.ts +168 -0
  61. package/src/lib/__tests__/markdown.test.ts +16 -0
  62. package/src/lib/__tests__/slug.test.ts +18 -0
  63. package/src/lib/__tests__/translit.test.ts +87 -0
  64. package/src/lib/__tests__/url.test.ts +53 -0
  65. package/src/lib/feed.ts +113 -11
  66. package/src/lib/hugo-markdown.ts +5 -0
  67. package/src/lib/link-preview.ts +25 -0
  68. package/src/lib/markdown-manager.ts +6 -1
  69. package/src/lib/post-display.ts +42 -9
  70. package/src/lib/timeline.ts +39 -9
  71. package/src/lib/tiptap-render.ts +4 -2
  72. package/src/lib/translit.ts +288 -0
  73. package/src/lib/url.ts +123 -12
  74. package/src/lib/view.ts +28 -0
  75. package/src/routes/api/__tests__/palette.test.ts +44 -0
  76. package/src/routes/api/posts.ts +13 -9
  77. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  78. package/src/routes/api/public/posts.ts +7 -0
  79. package/src/routes/compose.tsx +6 -2
  80. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  81. package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
  82. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  83. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  84. package/src/routes/pages/archive.tsx +65 -17
  85. package/src/routes/pages/page.tsx +5 -2
  86. package/src/services/__tests__/collection.test.ts +32 -1
  87. package/src/services/__tests__/post-timeline.test.ts +142 -11
  88. package/src/services/__tests__/post.test.ts +277 -0
  89. package/src/services/collection.ts +8 -10
  90. package/src/services/export-theme/assets/client-site.css +1 -1
  91. package/src/services/export.ts +3 -5
  92. package/src/services/path.ts +4 -1
  93. package/src/services/post.ts +297 -100
  94. package/src/services/search.ts +9 -0
  95. package/src/styles/components.css +0 -14
  96. package/src/styles/site-media.css +4 -4
  97. package/src/styles/tokens.css +62 -1
  98. package/src/styles/ui.css +1363 -1283
  99. package/src/types/entities.ts +5 -0
  100. package/src/types/props.ts +12 -0
  101. package/src/types/views.ts +6 -0
  102. package/src/ui/compose/ComposeDialog.tsx +17 -17
  103. package/src/ui/feed/LinkPreview.tsx +2 -7
  104. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  105. package/src/ui/pages/ArchivePage.tsx +131 -24
  106. package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
  107. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  108. package/src/ui/shared/PostFooter.tsx +35 -12
  109. package/src/ui/shared/custom-icons.ts +5 -0
  110. package/src/ui/shared/post-article-attributes.ts +6 -0
  111. package/dist/app-BxCOR3Uc.js +0 -6
  112. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
@@ -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
  }
@@ -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 */
@@ -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: "Title",
130
- comment: "@context: Compose note title placeholder",
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 tooltip",
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: "Publish now",
653
+ message: "Now",
653
654
  comment:
654
- "@context: Compose summary shown near the submit controls when an edited post no longer has a custom publish date",
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: "Auto link",
692
+ message: "/…",
692
693
  comment:
693
- "@context: Compose summary shown near the submit controls when a manual custom link is cleared",
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
- newThread: i18n._(
798
+ closeCompose: i18n._(
798
799
  msg({
799
- message: "New Thread",
800
+ message: "Close compose",
800
801
  comment:
801
- "@context: Compose dialog header title when composing a thread",
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
- replyTitle: i18n._(
805
+ editing: i18n._(
805
806
  msg({
806
- message: "Reply",
807
+ message: "Editing",
807
808
  comment:
808
- "@context: Compose dialog header title when replying to a post",
809
+ "@context: Marker above the composer when changing a post that is already published",
809
810
  }),
810
811
  ),
811
- editTitle: i18n._(
812
+ composeDialogLabel: i18n._(
812
813
  msg({
813
- message: "Edit",
814
- comment: "@context: Compose dialog header title when editing a post",
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 ? PROVIDER_LABELS[provider] : undefined;
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
- Pinned
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
- Pinned
34
+ {pinnedLabel}
24
35
  </span>
25
36
  <span class="post-status-badge post-status-private">
26
37
  <Icon name="post-status-private" />
27
- Private
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: "text-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 class="archive-view-toggle" role="radiogroup" aria-label="View mode">
476
- <a
477
- href={gridUrl}
478
- class={`archive-view-btn${currentView === "grid" ? " archive-view-btn-active" : ""}`}
479
- role="radio"
480
- aria-checked={currentView === "grid" ? "true" : "false"}
481
- aria-label="Grid view"
482
- >
483
- <Icon name="layout-grid" class="[&>svg]:size-4" />
484
- </a>
485
- <a
486
- href={listUrl}
487
- class={`archive-view-btn${currentView === "list" ? " archive-view-btn-active" : ""}`}
488
- role="radio"
489
- aria-checked={currentView === "list" ? "true" : "false"}
490
- aria-label="List view"
491
- >
492
- <Icon name="list" class="[&>svg]:size-4" />
493
- </a>
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
- <ViewToggle filters={filters} sitePathPrefix={sitePathPrefix} />
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
- label={group.label}
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&amp;sort=updated");
177
+ expect(html).toContain("view=list&amp;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
- <span class="draft-preview-bar-label">
19
- <span class="draft-preview-bar-dot" aria-hidden="true" />
20
- {label}
21
- </span>
22
- <span class="draft-preview-bar-description">
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: "This post isn’t published.",
26
- comment: "@context: Explanation in the draft preview status bar",
40
+ message: "Edit draft",
41
+ comment:
42
+ "@context: Action in the draft preview status bar that opens the editor",
27
43
  }),
28
44
  )}
29
- </span>
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
- "permalink" | "publishedAt" | "publishedAtFormatted" | "publishedAtTime"
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
- const publishedLabel = i18n._(
111
- msg({
112
- message: "Published on {date} at {time}",
113
- comment:
114
- "@context: Tooltip text for the published timestamp in post metadata",
115
- }),
116
- {
117
- date: post.publishedAtFormatted,
118
- time: post.publishedAtTime,
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",