@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
@@ -1,9 +1,9 @@
1
- import { _ as toPublicPath, a as getSitePathPrefix, b as __exportAll, c as normalizePath, d as sanitizeUrl, f as slugify, g as toPublicHref, h as toAbsoluteSiteUrl, i as getSiteOrigin, l as normalizeSitePathPrefix, m as toAbsoluteAssetUrl, n as extractDisplayDomain, o as isFullUrl, p as stripSitePathPrefix, r as extractDomain, s as isSafeInternalRedirect, t as buildSiteUrl, u as normalizeSiteUrl, v as toSameSitePath } from "./url-BMYO-Zlt.js";
2
- import { $ as JANT_POSITIVE_LOGO_PNG_FILENAME, A as DEFAULT_NAVIGATION_PROFILE, B as STATUSES$2, C as now, D as THEME_MODES, E as CONFIG_FIELDS, F as MAX_SITE_DESCRIPTION_LENGTH, G as VISIBILITIES$2, H as SYSTEM_NAV_KEYS, I as MAX_SITE_FOOTER_LENGTH, J as getPublicUrlForProvider, K as getImageUrl, L as MEDIA_KINDS, M as MAX_COLLECTION_DESCRIPTION_LENGTH, O as COLLECTION_FRESHNESS_WINDOW_SECONDS, Q as JANT_HOME_URL, R as NAV_ITEM_TYPES$2, S as formatYearMonth, T as toISOString, U as SYSTEM_NAV_KEY_VALUES, V as STORAGE_DRIVERS, W as TEXT_ATTACHMENT_CONTENT_FORMATS, X as HOME_BRANDING_PREFIX, Y as HOME_BRANDING_LINK_LABEL, Z as JANT_BRAND_PACK_FILENAME, _ as upgradeLegacyFootnotes, at as getJantIconHref, b as formatRelativeTime, ct as getJantLogoHref, d as extractSummaryHtml, dt as JANT_LOGO_VIEW_BOX, et as getDefaultJantAppleTouchIconBytes, f as renderTiptapDocument, ft as arrayBufferToBase64, g as escapeHtml, h as trimTiptapBody, i as tiptapJsonToMarkdown, it as getJantIconFilename, j as FORMATS$2, k as COLLECTION_SORT_ORDERS$2, l as extractBodyText, lt as getJantPositiveLogoPngHref, m as renderTiptapJson, nt as getJantBrandPackHref, o as render, ot as getJantLogoFilename, p as renderTiptapDocumentAroundBoundary, pt as base64ToUint8Array, q as getMediaUrl, rt as getJantBundledAsset, s as toPlainText, st as getJantLogoFills, t as createExportService, tt as getDefaultJantFaviconIcoBytes, u as extractSummary, ut as JANT_LOGO_PATH_DATA, v as formatDate, x as formatTime, y as formatRelativeAge, z as SORT_ORDERS } from "./export-CsFx6F_M.js";
3
- import { S as getTelegramWebhookSecret, T as coalesceDisplayText, _ as getInternalAdminToken, a as getConfiguredSingleSiteUrl, b as getSiteResolutionMode, c as getDevApiToken, d as getHostedControlPlaneBaseUrl, f as getHostedControlPlaneDomainCheckSecret, g as getHostedControlPlaneSsoSecret, h as getHostedControlPlaneProviderLabel$1, i as getConfiguredSingleSitePathPrefix, l as getEnvString, m as getHostedControlPlaneInternalToken, n as getAuthSecret, o as getConfiguredStorageDriver, p as getHostedControlPlaneInternalBaseUrl, r as getConfiguredSingleSiteOrigin, s as getCorsOrigins, u as getGitHubAppConfig, v as getLocalStoragePath, w as shouldUseSecureCookies, x as getTelegramBotPool } from "./env-OHRKGcMj.js";
4
- import { l as markdownToTiptapJson, o as createGitHubSyncService } from "./github-sync-Cq1pzzOI.js";
5
- import { a as listInstallationReposPage, n as getInstallation, o as searchInstallationRepos, t as buildInstallUrl } from "./github-app-BbklkFmU.js";
6
- import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BgSiE71w.js";
1
+ import { _ as toPublicHref, a as getSitePathPrefix, c as normalizePath, f as sanitizeUrl, g as toAbsoluteSiteUrl, h as toAbsoluteAssetUrl, i as getSiteOrigin, l as normalizeSitePathPrefix, m as stripSitePathPrefix, n as extractDisplayDomain, o as isFullUrl, p as slugify, r as extractDomain, s as isSafeInternalRedirect, t as buildSiteUrl, u as normalizeSiteUrl, v as toPublicPath, x as __exportAll, y as toSameSitePath } from "./url-CbLAtlZe.js";
2
+ import { $ as JANT_BRAND_PACK_FILENAME, A as COLLECTION_FRESHNESS_WINDOW_SECONDS, B as NAV_ITEM_TYPES$2, C as formatTime, D as toISOString, G as SYSTEM_NAV_KEY_VALUES, H as STATUSES$2, J as getImageUrl, K as TEXT_ATTACHMENT_CONTENT_FORMATS, L as MAX_SITE_DESCRIPTION_LENGTH, M as DEFAULT_NAVIGATION_PROFILE, N as FORMATS$2, O as CONFIG_FIELDS, P as MAX_COLLECTION_DESCRIPTION_LENGTH, Q as HOME_BRANDING_PREFIX, R as MAX_SITE_FOOTER_LENGTH, S as formatRelativeTime, T as now, U as STORAGE_DRIVERS, V as SORT_ORDERS, W as SYSTEM_NAV_KEYS, X as getPublicUrlForProvider, Y as getMediaUrl, Z as HOME_BRANDING_LINK_LABEL, _ as trimTiptapBody, a as rootActivityColumns, at as getJantBundledAsset, b as formatDate, c as render, ct as getJantLogoFilename, d as extractBodyText, dt as getJantPositiveLogoPngHref, et as JANT_HOME_URL, f as extractSummary, ft as JANT_LOGO_PATH_DATA, g as renderTiptapJson, h as renderTiptapDocumentAroundBoundary, ht as base64ToUint8Array, i as buildRootActivityExpr, it as getJantBrandPackHref, j as COLLECTION_SORT_ORDERS$2, k as THEME_MODES, l as toPlainText, lt as getJantLogoFills, m as renderTiptapDocument, mt as arrayBufferToBase64, nt as getDefaultJantAppleTouchIconBytes, o as tiptapJsonToMarkdown, ot as getJantIconFilename, p as extractSummaryHtml, pt as JANT_LOGO_VIEW_BOX, q as VISIBILITIES$2, rt as getDefaultJantFaviconIcoBytes, st as getJantIconHref, t as createExportService, tt as JANT_POSITIVE_LOGO_PNG_FILENAME, ut as getJantLogoHref, v as escapeHtml, w as formatYearMonth, x as formatRelativeAge, y as upgradeLegacyFootnotes, z as MEDIA_KINDS } from "./export-Dl5KCiEs.js";
3
+ import { S as getTelegramWebhookSecret, T as coalesceDisplayText, _ as getInternalAdminToken, a as getConfiguredSingleSiteUrl, b as getSiteResolutionMode, c as getDevApiToken, d as getHostedControlPlaneBaseUrl, f as getHostedControlPlaneDomainCheckSecret, g as getHostedControlPlaneSsoSecret, h as getHostedControlPlaneProviderLabel$1, i as getConfiguredSingleSitePathPrefix, l as getEnvString, m as getHostedControlPlaneInternalToken, n as getAuthSecret, o as getConfiguredStorageDriver, p as getHostedControlPlaneInternalBaseUrl, r as getConfiguredSingleSiteOrigin, s as getCorsOrigins, u as getGitHubAppConfig, v as getLocalStoragePath, w as shouldUseSecureCookies, x as getTelegramBotPool } from "./env-BPYViDJJ.js";
4
+ import { l as markdownToTiptapJson, o as createGitHubSyncService } from "./github-sync-HdK2EgvJ.js";
5
+ import { a as listInstallationReposPage, n as getInstallation, o as searchInstallationRepos, t as buildInstallUrl } from "./github-app-DdiD-bC4.js";
6
+ import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BNF35Lkx.js";
7
7
  import { I18n } from "@lingui/core";
8
8
  import * as lucideIcons from "lucide-static";
9
9
  import { z } from "zod";
@@ -1913,7 +1913,7 @@ var Hono = class extends Hono$1 {
1913
1913
  }
1914
1914
  //#endregion
1915
1915
  //#region src/i18n/locales/public/en.ts
1916
- var messages$3 = JSON.parse("{\"+4u2g6\":[\"A ready-made 1:1 PNG for decks, mockups, directories, and other square placements.\"],\"+DPYOZ\":[\"Add a link to your main RSS feed. Change what /feed returns in General.\"],\"+G8qqW\":[\"Collection saved.\"],\"+IJm1Z\":[\"Muted\"],\"+Irvp3\":[\"Everything on this page is ready to use for articles, launch posts, directories, and product coverage.\"],\"+Qaboy\":[\"Favicon\"],\"+fWu2O\":[\"A calmer, warmer accent makes the default theme feel quieter and more intentional.\"],\"+jw/c1\":[\"Delete image\"],\"+nHhRH\":[\"Use \",[\"brandColorName\"]],\"+siMqD\":[\"Journal\"],\"/DFKdU\":[\"Type the quote...\"],\"/PfPLc\":[\"Label (optional)\"],\"/PoNoq\":[\"Edit link\"],\"/Ui2OV\":[\"Use the reverse logo on dark backgrounds.\"],\"/Ybds4\":[\"Primary Jant logo for websites, docs, press coverage, and editorial layouts.\"],\"/rTz0M\":[\"Audio\"],\"0EcUWz\":[\"Discard changes?\"],\"0Lj7or\":[\"Save text attachment?\"],\"0Vlj/m\":[\"This post isn’t published.\"],\"0XDp7X\":[\"Links should read clearly without glowing against the page.\"],\"0cspe/\":[\"Delete row\"],\"0ieXE7\":[\"Highest rated\"],\"11h9eK\":[\"Includes\"],\"15++NM\":[\"Inline emphasis\"],\"1DBGsz\":[\"Notes\"],\"1NeeWI\":[\"Square assets for avatars, apps, browsers, and shared links\"],\"1THMr2\":[\"Brand pack\"],\"1njn7W\":[\"Light\"],\"27KTPP\":[\"Draft preview\"],\"2B7HLH\":[\"New post\"],\"2C7mSG\":[\"Collection link\"],\"2ETv7R\":[\"Tune color in a real reading context\"],\"2HbvFp\":[\"Real post components\"],\"2MXb5X\":[\"Field notes on quiet design\"],\"2koDOQ\":[\"Thread accents\"],\"2lKpcz\":[\"Write your first post to get started.\"],\"2q/Q7x\":[\"Visibility\"],\"2sCqzD\":[\"Use this for websites, docs, articles, and other light or neutral surfaces.\"],\"3Cw1AI\":[\"Add Collection\"],\"3lJk5u\":[\"Keep the artwork unchanged.\"],\"3mdteM\":[\"before deciding whether the accent is carrying too much product energy.\"],\"3neqtf\":[\"Thread accent\"],\"3qkggm\":[\"Fullscreen\"],\"3vMdv3\":[\"This link is reserved. Choose something else.\"],\"3wKq0C\":[\"Couldn't save. Try again in a moment.\"],\"3xi01/\":[\"Look at the footer metadata last, to make sure the accent is not fighting the typography.\"],\"47iMgt\":[\"Editorial interfaces worth borrowing from\"],\"4D09NB\":[\"Link to your collections page\"],\"4GQThz\":[\"No collections yet. Start one to organize threads by topic.\"],\"4HLTdq\":[\"without media\"],\"4J/OYU\":[\"Collection created.\"],\"4eiXo+\":[\"Leave blank to generate one automatically.\"],\"4pV0kE\":[\"Avatar-ready\"],\"51EYZX\":[\"without title\"],\"5dcjwM\":[\"Choose today or an earlier date, or leave it blank to publish now.\"],\"5pAjd8\":[\"Accent should feel present, not loud.\"],\"5sEkBi\":[\"Open raw asset\"],\"61i3cO\":[\"Toggle header row\"],\"6QCZMo\":[\"Add row below\"],\"6UTABI\":[\"Collection order updated.\"],\"6WAK+2\":[\"Use current date\"],\"6Y4BBO\":[\"An abstract editorial layout in warm paper colors\"],\"6cjUDB\":[\"Brand assets\"],\"6lGV3K\":[\"Show less\"],\"6p0JeQ\":[\"to make sure both still feel like they belong to the same product.\"],\"6sVyMq\":[\"Add a URL before posting this link.\"],\"6yCv8j\":[\"Save these changes to the text attachment, discard them, or keep editing.\"],\"74kJNs\":[\"View earlier notes in this thread\"],\"7DvUqV\":[\"Read the page from top to bottom without looking at the swatches.\"],\"7aris6\":[\"March 15\"],\"7d1a0d\":[\"Public\"],\"7hYXO0\":[\"Use this on dark backgrounds, image-backed surfaces, and any placement where the green logo would lose contrast.\"],\"7ibPpM\":[\"Add column after\"],\"7kMW54\":[\"Open raw SVG\"],\"7nGhhM\":[\"What's on your mind?\"],\"7vhWI8\":[\"New Password\"],\"87a/t/\":[\"Label\"],\"8Btgys\":[\"Draft deleted.\"],\"8WX0J+\":[\"Your thoughts (optional)\"],\"8ZsakT\":[\"Password\"],\"8bpHix\":[\"Couldn't create your account. Check the details and try again.\"],\"8eC78s\":[\"A calmer accent makes\"],\"8tM8+a\":[\"Save as draft\"],\"90IRF2\":[\"This article is here to answer a specific question: does the default accent still feel calm once it has to carry a full reading experience?\"],\"9SHZas\":[\"Shows 'Settings' when logged in, 'Sign in' when logged out\"],\"9aloPG\":[\"References\"],\"9dr9Nh\":[\"Open external link\"],\"9j5qCS\":[\"Table options\"],\"9qWoxS\":[\"Feed\"],\"A1D8Yt\":[\"What the accent should do\"],\"A1taO8\":[\"Search\"],\"A2Vg/u\":[\"Navigation and reading states\"],\"AjHkcv\":[\"Default preview image for social shares and link unfurls.\"],\"AyHO4m\":[\"What's this collection about?\"],\"B1FFMj\":[\"Download Brand Pack\"],\"B495Gs\":[\"Archive\"],\"BdjLtf\":[\"thread\"],\"Bmaby2\":[\"All formats\"],\"C+9df9\":[\"Quoted or highlighted passages should feel like annotations, not warnings.\"],\"C0/57J\":[\"This is the last part of the collection link.\"],\"C4TjpG\":[\"Read less\"],\"CAh1km\":[\"collections\"],\"CH3bgf\":[\"RSS feed for this view\"],\"CmBCXY\":[\"Link updated.\"],\"D4em/+\":[\"Logos\"],\"DHhJ7s\":[\"Previous\"],\"DJLY+/\":[\" and try again.\"],\"DOx286\":[\"Draft restored.\"],\"DPfwMq\":[\"Done\"],\"DSJXZM\":[\"Enter a valid date.\"],\"DYlMYF\":[\"Built-in background\"],\"DoJzLz\":[\"Collections\"],\"Du2B9f\":[\"The default accent should support reading first. Start by comparing it against the\"],\"DxwUcG\":[\"Read the palette as content first\"],\"E3NcGH\":[\"Square logo PNG\"],\"EEYbdt\":[\"Publish\"],\"EGwzOK\":[\"Complete Setup\"],\"EHWwm1\":[\"The default accent should feel written, not branded.\"],\"EO3I6h\":[\"Upload didn't go through. Try again in a moment.\"],\"EQNPYo\":[\"Featured on \",[\"date\"],\" at \",[\"time\"]],\"EQtz4D\":[\"Open a few links and check whether they still feel native to the page.\"],\"EU3tBD\":[\"Link removed.\"],\"EetoJL\":[\"Guide the eye without taking over the layout.\"],\"Eiv3bO\":[\"Buttons can stay steady, but links, thread markers, and subtle emphasis should feel closer to ink on paper than dashboard chrome.\"],\"ElTnWL\":[\"Published on\"],\"EmQw8O\":[\"If this article still feels like a page you want to keep reading, the palette is probably close.\"],\"EsJdRp\":[\"Save theme\"],\"FESYvt\":[\"Describe this for people with visual impairments...\"],\"FEr96N\":[\"Theme\"],\"FGySZL\":[\"The default accent works best when it reads like a fountain-pen underline. Compare it against the\"],\"FM+KeU\":[\"No drafts yet. Save a draft to find it here.\"],\"Fdv5k7\":[\"What to look for while tuning it\"],\"FkMol5\":[\"Featured\"],\"FqCHF/\":[\"Threads\"],\"Fxf4jq\":[\"Description (optional)\"],\"G2u/aQ\":[\"Download official Jant logos, icons, and preview assets.\"],\"GAohqx\":[\"Delete column\"],\"GBJzTZ\":[\"Archive\"],\"GX2VMa\":[\"Create your admin account.\"],\"GY/1J4\":[\"Jant fallback canary string\"],\"GbIOhd\":[\"Reply quietly\"],\"GiRWtR\":[\"Why the default accent should feel written, not branded\"],\"GkpIs2\":[\"Remove this link from Collections? The destination won't change.\"],\"GorKul\":[\"Welcome to Jant\"],\"GxkJXS\":[\"Uploading...\"],\"H29JXm\":[\"+ ALT\"],\"H4lgRd\":[\"Authentication isn't set up. Check your server config.\"],\"HFPGej\":[\"No threads match these filters. Try adjusting your selection or clear all filters.\"],\"HG79RB\":[\"Post as Private\"],\"HNEHJP\":[\"Demo credentials are pre-filled — hit Sign In to continue.\"],\"HSI88F\":[\"Delete table\"],\"HbAIQc\":[\"A reference link for checking whether the accent feels editorial instead of promotional.\"],\"HrC0ab\":[\"All posts\"],\"Ht1V3q\":[\"For the same reason, inline code should stay neutral. Something like theme.siteAccent = soften(green, 12%) should not suddenly become the loudest thing on the page.\"],\"I22eN0\":[\"Shared links\"],\"I6zLrz\":[\"Use these when you need a transparent square logo, a shaped tile with a built-in background, a browser icon, or a default preview image.\"],\"ICsA6P\":[\"You have unsaved changes\"],\"IUX7p+\":[\"White logo on the Jant green rounded tile for app icon mockups, touch icons, directory listings, and other square placements that should feel softer.\"],\"IagCbF\":[\"URL\"],\"IjnQHI\":[\"with title\"],\"ImOQa9\":[\"Reply\"],\"J+2Rls\":[\"Leave blank to publish now. Use an earlier date when importing older posts.\"],\"J4tAHl\":[\"Headings should keep their hierarchy even when the accent gets softer.\"],\"JYj5R2\":[\"Browse files\"],\"JcD7qf\":[\"More actions\"],\"JqJ5Xv\":[\"Latest\"],\"JuN5GC\":[\"No file selected. Choose a file to upload.\"],\"JwLPQ/\":[\"This sign-in link has expired. Return to \"],\"KOqvXP\":[\"Do not recolor, stretch, rotate, outline, or add effects to the logo.\"],\"KbS2K9\":[\"Reset Password\"],\"KdSsVl\":[\"Author (optional)\"],\"Khu3PV\":[\"Publish settings\"],\"KiJn9B\":[\"Note\"],\"KlZ+t+\":[\"%name% + %count% more\"],\"KsvRin\":[\"Hide from Latest\"],\"KzmC5L\":[\"Controls\"],\"L7svJg\":[\"Reading\"],\"Lbkbwy\":[\"A quote card for judging accent color against softer, citation-heavy content.\"],\"LcvzvX\":[\"Tap to retry\"],\"LkA8jz\":[\"Add alt text\"],\"LxRg6f\":[\"live theme controls\"],\"M4tzVU\":[\"Latest posts\"],\"M8kJqa\":[\"Drafts\"],\"MHrjPM\":[\"Title\"],\"MILa7n\":[\"Square tile\"],\"MRYGql\":[\"Open image URL\"],\"MSc/Yq\":[\"Do you want to publish your changes or discard them?\"],\"MZTcRq\":[\"Image unavailable\"],\"Mc7+6G\":[\"Enter a valid URL starting with http://, https://, or mailto:.\"],\"MdMyne\":[\"Source link (optional)\"],\"MiMY3Q\":[\"Apple touch icon\"],\"MiyoI7\":[\"default note sample\"],\"MqghUt\":[\"Search posts...\"],\"Myqkib\":[\"Create a collection to get started.\"],\"N8UzTV\":[\"Replies\"],\"NAFbuE\":[\"Search snippet\"],\"NH9Z1R\":[\"Start here\"],\"NqsRbb\":[\"Jant logo\"],\"NvXuWk\":[\"Won't move the thread to the top of latest.\"],\"O1367B\":[\"All collections\"],\"O3oNi5\":[\"Email\"],\"OEdMhi\":[\"The best default color is the one you notice only after reading for a while.\"],\"OEt/to\":[\"Guidelines\"],\"OJxdgi\":[\"Keep this link under 200 characters.\"],\"OaoJcz\":[\"Social preview\"],\"OmfDbR\":[\"Site accent\"],\"Ovks1h\":[\"A softer blue feels more like ink than product chrome.\"],\"P/sHNL\":[\"Use this page to judge buttons, links, cards, forms, thread accents, and quiet surfaces before changing a theme globally.\"],\"PBxg/E\":[\"Not now\"],\"Q/uoSA\":[\"Quiet here for now.\"],\"Q2mGA7\":[\"Clear filter\"],\"QBqVyM\":[\"Home screen icon for iPhone and iPad shortcuts.\"],\"QebAts\":[\"Link added.\"],\"Qgbxdw\":[\"Designing a calmer default accent for Jant\"],\"Qn9Ao8\":[\"Circle tile\"],\"Qoq+GP\":[\"Read more\"],\"QyDt3L\":[\"File uploaded.\"],\"R5CMuK\":[\"Jant looks best when the accent feels editorial. Buttons can stay sturdy, but inline emphasis should feel like a pen mark, not a dashboard highlight.\"],\"R8AthW\":[\"Divider\"],\"R9Khdg\":[\"Auto\"],\"RAv3u7\":[\"Compare it against the theme controls\"],\"ROa4Ti\":[\"Interfaces for reading should guide the eye, not keep asking for attention.\"],\"RZOWDv\":[\"Add a custom shortcut to any page or site.\"],\"RdmNnl\":[\"Browser tab\"],\"RfGczC\":[\"Square logo\"],\"Rj01Fz\":[\"Links\"],\"S37om9\":[\"Included assets\"],\"S8NCfs\":[\"Save to drafts to edit and post at a later time.\"],\"SJGVAw\":[\"Feel editorial and slightly quieter.\"],\"SJmfuf\":[\"Site Name\"],\"SSsoa4\":[\"Add to Navigation\"],\"SaNhJE\":[\"feel deliberate instead of washed out.\"],\"SpTWH3\":[\"Download SVG\"],\"SvRuJt\":[\"Field Notes on Interface Tone\"],\"T/R+Qz\":[\"Primary\"],\"TNZKpI\":[\"Danger\"],\"Tn1w2R\":[\"Draft actions\"],\"TvaTxw\":[\"Doesn't appear in Latest. Still appears in collections you add it to.\"],\"UIMXHD\":[\"Remove Divider\"],\"UaZwcz\":[\"More options are available after you create it.\"],\"Uc5y7o\":[\"Choose the standard logo for websites, docs, directories, and editorial layouts.\"],\"V0fyg5\":[\"Collection added to navigation.\"],\"V18SVO\":[\"Use the logo on light backgrounds.\"],\"V4WsyL\":[\"Add Link\"],\"VCA6B2\":[\"These are actual feed components with real footers, summaries, and inline links. Use this section to judge whether the theme still feels calm once it is applied to realistic content.\"],\"VNqFYa\":[\"Loading post...\"],\"WCOanD\":[\"This reference is useful because it treats links and citations as part of the reading rhythm. Keep that in mind while tuning the\"],\"WbIbzR\":[\"Checking link...\"],\"WcWS//\":[\"Download file\"],\"WhsN3P\":[\"A good default accent in Jant should feel like editorial structure, not product branding. That means links, emphasis, and thread cues can be visible without turning the page into UI chrome.\"],\"Wn+/rH\":[\"Transparent square\"],\"XU7b+L\":[\"Primary logo files\"],\"XV1mAn\":[\"Only visible when signed in.\"],\"XrnWzN\":[\"Published!\"],\"Y7WAtz\":[\"An image couldn't be saved to your library — its original link was kept.\"],\"YIix5Y\":[\"Search...\"],\"YOzD/a\":[\"Replace image\"],\"YUglt2\":[\"Generating a link...\"],\"YXiA6e\":[\"Primary button\"],\"Ygx3Yl\":[\"Small browser icon used in tabs and bookmarks.\"],\"Z4OdvO\":[\"Adding…\"],\"Z6NwTi\":[\"Save as Draft\"],\"ZV5ykW\":[\"Download PNG\"],\"ZhhOwV\":[\"Quote\"],\"ZmSeP+\":[\"Save to drafts?\"],\"ZxFuun\":[[\"count\",\"plural\",{\"one\":[\"Found \",\"#\",\" result\"],\"other\":[\"Found \",\"#\",\" results\"]}]],\"a5j82I\":[\"No collections match that search. Try a different name.\"],\"aBFXGQ\":[\"This collection is empty. Add threads from the editor.\"],\"aHTB7P\":[\"Supplementary content attached to your post\"],\"aMEyv0\":[\"Stay sturdy and readable.\"],\"aN6wx0\":[\"Nothing in Featured yet. Mark a post as featured to show it here.\"],\"aYpXKS\":[\"and checking whether the accent is guiding attention or pulling too hard.\"],\"aaGV/9\":[\"New Link\"],\"af+9p6\":[\"Quiet metadata\"],\"an5hVd\":[\"Images\"],\"ao77hr\":[[\"count\",\"plural\",{\"one\":[\"#\",\" hidden post\"],\"other\":[\"#\",\" hidden posts\"]}]],\"auFlOr\":[\"Icons and previews\"],\"avuFKG\":[\"threads\"],\"b+dane\":[\"Insert %rows% by %cols% table\"],\"bFpC86\":[\"Everything in one download\"],\"bGtMpA\":[\"Add a label and URL.\"],\"bHOiy1\":[\"Password changes are off in demo mode. Sign in with the shared demo credentials.\"],\"bZ9ges\":[\"Add column before\"],\"bbdNeX\":[\"Sign in\"],\"bfCbdi\":[\"Current post\"],\"bkBJmZ\":[\"This is useful as a color check because it puts the accent next to quotation styling, metadata, and a quieter explanatory paragraph. Compare it back to the\"],\"bkMuwo\":[\"Link to posts you've marked as featured.\"],\"bzSI52\":[\"Discard\"],\"c2JRUS\":[\"Generate automatically\"],\"cIoW7X\":[\"Inline link\"],\"cTUByn\":[\"Newest first\"],\"cb7FR8\":[\"White logo on the Jant green square tile for platforms and layouts that expect a true edge-to-edge square.\"],\"cgmi4V\":[\"Delete Draft\"],\"cnGeoo\":[\"Delete\"],\"d+F4pf\":[\"The image should sit quietly inside the article instead of feeling like a card preview.\"],\"d/o/BH\":[\"Couldn't publish. Saved as draft.\"],\"d0DHp4\":[\"Delete this collection permanently? Threads inside won't be removed.\"],\"dD7NPy\":[\"Outline\"],\"dEgA5A\":[\"Cancel\"],\"dHko2w\":[\"single posts\"],\"dUsGbd\":[\"The right accent should disappear into the writing until you need it.\"],\"dXoieq\":[\"Summary\"],\"dYKrp3\":[\"Hidden from Latest\"],\"dbUuAj\":[\"Appears in Latest.\"],\"df4a/r\":[\"Couldn't load this post. Try again.\"],\"ePK91l\":[\"Edit\"],\"eWLklq\":[\"Quotes\"],\"f4MAoA\":[\"Some uploads failed. Saved as draft.\"],\"f5s9EI\":[\"Press N to write\"],\"f6Hub0\":[\"Sort\"],\"f8fH8W\":[\"Design\"],\"fD+f7T\":[\"RSS feed\"],\"fKrDxS\":[\"Brand tile\"],\"fMPkxb\":[\"Show more\"],\"fqDzSu\":[\"Rate\"],\"fttd2R\":[\"My Collection\"],\"fuWzQl\":[\"Nothing here yet. Add threads to one of these collections to fill this view.\"],\"gCcxP/\":[\"Threads can include up to \",[\"count\"],\" posts.\"],\"gFdWl+\":[\"A long-form article sample for checking the default palette in a true reading context.\"],\"gNKz6Z\":[\"Collection deleted.\"],\"gXH9r/\":[\"Open raw PNG\"],\"gpaPhA\":[\"Helps screen readers describe the image\"],\"h5RcXU\":[\"Post hidden\"],\"hLlWo5\":[\"A few simple rules.\"],\"hWpUeY\":[\"Auto link\"],\"hXzOVo\":[\"Next\"],\"heSQoS\":[\"Paste a URL...\"],\"hjeS3W\":[\"Link to your latest posts. Your homepage shows this feed.\"],\"hqeXKW\":[\"Single posts\"],\"hrkGms\":[\"Search\"],\"i0vDGK\":[\"Sort Order\"],\"i5+Y7d\":[\"Download the official Jant logo, icons, and preview files.\"],\"i6kro6\":[\"Edit custom link\"],\"i6nDCI\":[\"Choose a new password.\"],\"iG7KNr\":[\"Logo\"],\"iH8pgl\":[\"Back\"],\"ilSmIt\":[\"Hard edge\"],\"iu7tUI\":[\"Breadcrumb\"],\"jAXE5p\":[\"Reverse logo\"],\"jAqB/k\":[\"Post privately\"],\"jQflRT\":[\"This uses the real single-post detail rendering with a longer article, inline image, tables, lists, quotes, and code. The content column stays at the same width as the live site.\"],\"jd+8Mm\":[\"Social preview image\"],\"jdJOV1\":[\"Settings\"],\"ji7oVU\":[\"Edit post\"],\"jpctdh\":[\"View\"],\"jrsUoG\":[\"Type / for commands\"],\"jvyYZG\":[\"What's on your mind...\"],\"k3Iw35\":[\"Switch to the white logo when the standard green version would lose contrast.\"],\"kPMIr+\":[\"Give it a title...\"],\"kzvWob\":[\"Link to the post archive\"],\"laT1IJ\":[\"iOS home screen\"],\"lb+Xwx\":[\"Custom link\"],\"m16xKo\":[\"Add\"],\"mKT7g0\":[\"Text attachment\"],\"mc/vLq\":[\"This link is already in use. Choose something else.\"],\"muKqfV\":[\"Featured\"],\"n1ekoW\":[\"Sign In\"],\"n3ReIn\":[\"Collections\"],\"n6QD94\":[\"Oldest first\"],\"nFukaP\":[\"Wrong email or password. Check your credentials and try again.\"],\"nJ4U1U\":[[\"count\"],\" images couldn't be saved to your library — their original links were kept.\"],\"nV6twc\":[\"Organize\"],\"nd8Puv\":[\"White logo on the Jant green circle for profile images, badges, and other round placements where you want a ready-made asset.\"],\"ndrEYW\":[\"When the accent is slightly warmer and less literal, the whole page feels more like a writing space and less like product UI.\"],\"oO0hKx\":[[\"count\",\"plural\",{\"one\":[\"#\",\" more post\"],\"other\":[\"#\",\" more posts\"]}]],\"oTu7Wt\":[\"Combined Collections\"],\"ode0+L\":[\"Theme sample\"],\"ogssnn\":[\"with media\"],\"ovBPCi\":[\"Default\"],\"p1Z67P\":[\"When primary is too rigid, the whole page starts reading like product UI instead of writing space.\"],\"p2/GCq\":[\"Confirm Password\"],\"pB0OKE\":[\"New Divider\"],\"pBHx39\":[\"Dark backgrounds\"],\"pVrU5x\":[\"If this page feels too branded, the first place to soften is the default theme’s site accent, not the border or body text.\"],\"pvnfJD\":[\"Dark\"],\"q+bMmy\":[\"Table controls\"],\"q+hNag\":[\"Collection\"],\"q5YRzz\":[\"Color check\"],\"q8RviX\":[\"Titled\"],\"qc+12k\":[\"Choose table size\"],\"qcawwg\":[\"Publish now\"],\"qiN9NB\":[\"Surface\"],\"qt89I8\":[\"Draft saved.\"],\"quvfGs\":[\"instead of judging it as an isolated swatch.\"],\"r7kcaA\":[\"Drag collections, links, and dividers into the order you want.\"],\"rA2TFI\":[\"Switch the palette and mode without opening settings or changing the active site theme.\"],\"rV8ZnP\":[\"Edit publish date\"],\"rdUucN\":[\"Preview\"],\"s8G5Or\":[\"This upload would exceed your shared hosted media limit. Remove files or upgrade storage to continue.\"],\"s9gHf5\":[\"your-post-link\"],\"sER+bs\":[\"Files\"],\"sQpDn6\":[\"Exit fullscreen\"],\"sgr2wQ\":[\"collection\"],\"slujBW\":[\"Use lowercase letters, numbers, and hyphens only.\"],\"syiAKf\":[\"note treatment\"],\"t42hIC\":[\"Everything most people need is in one ZIP.\"],\"tCctex\":[\"The brand pack includes SVG logos, a transparent square PNG, rounded, square, and circle tiles, plus favicon, Apple touch icon, and the default social preview image.\"],\"tKlWWY\":[\"Emoji\"],\"tSWVu5\":[\"Published on \",[\"date\"],\" at \",[\"time\"]],\"tT/bSk\":[\"Add row above\"],\"tZ1tJc\":[\"Edit Navigation\"],\"tfDRzk\":[\"Save\"],\"tg5MRw\":[\"Sign in to start writing.\"],\"tgSBSE\":[\"Remove Link\"],\"uNPC+z\":[\"Couldn't add this collection to navigation. Try again.\"],\"uowbPn\":[\"Remove attachment\"],\"v3E8iS\":[\"A practical checklist\"],\"vSJd18\":[\"Video\"],\"vSYKYI\":[\"Main feed\"],\"vXCC6J\":[\"Something doesn't look right. Check the form and try again.\"],\"vcpc5o\":[\"Close menu\"],\"vdFnYM\":[\"Reset link\"],\"vdvpU5\":[\"/archive?format=quote or https://example.com\"],\"vgpfCi\":[\"Save draft\"],\"vpSPA1\":[\"Auth secret is missing. Check your environment variables.\"],\"vzU4k9\":[\"New Collection\"],\"w0Emel\":[\"Suggested link\"],\"w6mlns\":[\"Article detail page\"],\"wJ+GRy\":[\"All visibility\"],\"wL3cK8\":[\"Latest\"],\"wja8aL\":[\"Untitled\"],\"wlnK1t\":[\"A single ZIP with the main logo, reverse logo, square PNG, rounded, square, and circle tiles, plus favicon, Apple touch icon, and social preview image.\"],\"wm3Zlr\":[\"All years\"],\"xCWek4\":[\"File storage isn't set up. Check your server config.\"],\"xVrkxi\":[\"quiet design\"],\"xVvw1i\":[\"This reset link is no longer valid. Request a new one to continue.\"],\"xYilR2\":[\"Media\"],\"xeiujy\":[\"Text\"],\"xhTx3y\":[\"Choose the standard logo for most placements and the reverse logo when you need more contrast.\"],\"xjHB3/\":[\"Continue →\"],\"y28hnO\":[\"Post\"],\"y2o/Y0\":[\"This Link Has Expired\"],\"yGZVl1\":[\"More\"],\"yQ2kGp\":[\"Load more\"],\"yUtAh2\":[\"New Thread\"],\"ycM1Xg\":[\"No results. Try different keywords.\"],\"ynMAhG\":[\"Default logo\"],\"yzF66j\":[\"Link\"],\"zBFr9G\":[\"Paste a long article, AI response, or any text...\\n\\nMarkdown formatting will be preserved.\"],\"zJDAbh\":[\"Don't save\"],\"zcDmsG\":[\"Featured posts\"],\"zoK+eO\":[\"Add a title before posting this link.\"],\"zucql+\":[\"Menu\"],\"zwBp5t\":[\"Private\"]}");
1916
+ var messages$3 = JSON.parse("{\"+4u2g6\":[\"A ready-made 1:1 PNG for decks, mockups, directories, and other square placements.\"],\"+DPYOZ\":[\"Add a link to your main RSS feed. Change what /feed returns in General.\"],\"+G8qqW\":[\"Collection saved.\"],\"+IJm1Z\":[\"Muted\"],\"+Irvp3\":[\"Everything on this page is ready to use for articles, launch posts, directories, and product coverage.\"],\"+Qaboy\":[\"Favicon\"],\"+fWu2O\":[\"A calmer, warmer accent makes the default theme feel quieter and more intentional.\"],\"+jw/c1\":[\"Delete image\"],\"+nHhRH\":[\"Use \",[\"brandColorName\"]],\"+siMqD\":[\"Journal\"],\"/DFKdU\":[\"Type the quote...\"],\"/PfPLc\":[\"Label (optional)\"],\"/PoNoq\":[\"Edit link\"],\"/Ui2OV\":[\"Use the reverse logo on dark backgrounds.\"],\"/Ybds4\":[\"Primary Jant logo for websites, docs, press coverage, and editorial layouts.\"],\"/rTz0M\":[\"Audio\"],\"0EcUWz\":[\"Discard changes?\"],\"0Lj7or\":[\"Save text attachment?\"],\"0Vlj/m\":[\"This post isn’t published.\"],\"0XDp7X\":[\"Links should read clearly without glowing against the page.\"],\"0cspe/\":[\"Delete row\"],\"0ieXE7\":[\"Highest rated\"],\"11h9eK\":[\"Includes\"],\"15++NM\":[\"Inline emphasis\"],\"1DBGsz\":[\"Notes\"],\"1NeeWI\":[\"Square assets for avatars, apps, browsers, and shared links\"],\"1THMr2\":[\"Brand pack\"],\"1njn7W\":[\"Light\"],\"27KTPP\":[\"Draft preview\"],\"2B7HLH\":[\"New post\"],\"2C7mSG\":[\"Collection link\"],\"2ETv7R\":[\"Tune color in a real reading context\"],\"2HbvFp\":[\"Real post components\"],\"2MXb5X\":[\"Field notes on quiet design\"],\"2koDOQ\":[\"Thread accents\"],\"2lKpcz\":[\"Write your first post to get started.\"],\"2q/Q7x\":[\"Visibility\"],\"2sCqzD\":[\"Use this for websites, docs, articles, and other light or neutral surfaces.\"],\"3Cw1AI\":[\"Add Collection\"],\"3lJk5u\":[\"Keep the artwork unchanged.\"],\"3mdteM\":[\"before deciding whether the accent is carrying too much product energy.\"],\"3neqtf\":[\"Thread accent\"],\"3qkggm\":[\"Fullscreen\"],\"3vMdv3\":[\"This link is reserved. Choose something else.\"],\"3wKq0C\":[\"Couldn't save. Try again in a moment.\"],\"3xi01/\":[\"Look at the footer metadata last, to make sure the accent is not fighting the typography.\"],\"47iMgt\":[\"Editorial interfaces worth borrowing from\"],\"4D09NB\":[\"Link to your collections page\"],\"4GQThz\":[\"No collections yet. Start one to organize threads by topic.\"],\"4HLTdq\":[\"without media\"],\"4J/OYU\":[\"Collection created.\"],\"4eiXo+\":[\"Leave blank to generate one automatically.\"],\"4pV0kE\":[\"Avatar-ready\"],\"51EYZX\":[\"without title\"],\"5dcjwM\":[\"Choose today or an earlier date, or leave it blank to publish now.\"],\"5pAjd8\":[\"Accent should feel present, not loud.\"],\"5sEkBi\":[\"Open raw asset\"],\"61i3cO\":[\"Toggle header row\"],\"6QCZMo\":[\"Add row below\"],\"6UTABI\":[\"Collection order updated.\"],\"6WAK+2\":[\"Use current date\"],\"6Y4BBO\":[\"An abstract editorial layout in warm paper colors\"],\"6cjUDB\":[\"Brand assets\"],\"6lGV3K\":[\"Show less\"],\"6p0JeQ\":[\"to make sure both still feel like they belong to the same product.\"],\"6sVyMq\":[\"Add a URL before posting this link.\"],\"6yCv8j\":[\"Save these changes to the text attachment, discard them, or keep editing.\"],\"74kJNs\":[\"View earlier notes in this thread\"],\"7DvUqV\":[\"Read the page from top to bottom without looking at the swatches.\"],\"7aris6\":[\"March 15\"],\"7d1a0d\":[\"Public\"],\"7hYXO0\":[\"Use this on dark backgrounds, image-backed surfaces, and any placement where the green logo would lose contrast.\"],\"7ibPpM\":[\"Add column after\"],\"7kMW54\":[\"Open raw SVG\"],\"7nGhhM\":[\"What's on your mind?\"],\"7vhWI8\":[\"New Password\"],\"87a/t/\":[\"Label\"],\"8Btgys\":[\"Draft deleted.\"],\"8WX0J+\":[\"Your thoughts (optional)\"],\"8ZsakT\":[\"Password\"],\"8bpHix\":[\"Couldn't create your account. Check the details and try again.\"],\"8eC78s\":[\"A calmer accent makes\"],\"8tM8+a\":[\"Save as draft\"],\"90IRF2\":[\"This article is here to answer a specific question: does the default accent still feel calm once it has to carry a full reading experience?\"],\"9SHZas\":[\"Shows 'Settings' when logged in, 'Sign in' when logged out\"],\"9aloPG\":[\"References\"],\"9dr9Nh\":[\"Open external link\"],\"9j5qCS\":[\"Table options\"],\"9qWoxS\":[\"Feed\"],\"A1D8Yt\":[\"What the accent should do\"],\"A1taO8\":[\"Search\"],\"A2Vg/u\":[\"Navigation and reading states\"],\"AjHkcv\":[\"Default preview image for social shares and link unfurls.\"],\"AmQ/h/\":[\"Sort order\"],\"AyHO4m\":[\"What's this collection about?\"],\"B1FFMj\":[\"Download Brand Pack\"],\"B495Gs\":[\"Archive\"],\"BdjLtf\":[\"thread\"],\"Bmaby2\":[\"All formats\"],\"C+9df9\":[\"Quoted or highlighted passages should feel like annotations, not warnings.\"],\"C0/57J\":[\"This is the last part of the collection link.\"],\"C4TjpG\":[\"Read less\"],\"CAh1km\":[\"collections\"],\"CH3bgf\":[\"RSS feed for this view\"],\"CmBCXY\":[\"Link updated.\"],\"D4em/+\":[\"Logos\"],\"DHhJ7s\":[\"Previous\"],\"DJLY+/\":[\" and try again.\"],\"DOx286\":[\"Draft restored.\"],\"DPfwMq\":[\"Done\"],\"DSJXZM\":[\"Enter a valid date.\"],\"DYlMYF\":[\"Built-in background\"],\"DoJzLz\":[\"Collections\"],\"Du2B9f\":[\"The default accent should support reading first. Start by comparing it against the\"],\"DxwUcG\":[\"Read the palette as content first\"],\"E3NcGH\":[\"Square logo PNG\"],\"EEYbdt\":[\"Publish\"],\"EGwzOK\":[\"Complete Setup\"],\"EHWwm1\":[\"The default accent should feel written, not branded.\"],\"EO3I6h\":[\"Upload didn't go through. Try again in a moment.\"],\"EQNPYo\":[\"Featured on \",[\"date\"],\" at \",[\"time\"]],\"EQtz4D\":[\"Open a few links and check whether they still feel native to the page.\"],\"EU3tBD\":[\"Link removed.\"],\"EetoJL\":[\"Guide the eye without taking over the layout.\"],\"Eiv3bO\":[\"Buttons can stay steady, but links, thread markers, and subtle emphasis should feel closer to ink on paper than dashboard chrome.\"],\"ElTnWL\":[\"Published on\"],\"EmQw8O\":[\"If this article still feels like a page you want to keep reading, the palette is probably close.\"],\"EsJdRp\":[\"Save theme\"],\"FESYvt\":[\"Describe this for people with visual impairments...\"],\"FEr96N\":[\"Theme\"],\"FGySZL\":[\"The default accent works best when it reads like a fountain-pen underline. Compare it against the\"],\"FM+KeU\":[\"No drafts yet. Save a draft to find it here.\"],\"Fdv5k7\":[\"What to look for while tuning it\"],\"FkMol5\":[\"Featured\"],\"FqCHF/\":[\"Threads\"],\"Fxf4jq\":[\"Description (optional)\"],\"G2u/aQ\":[\"Download official Jant logos, icons, and preview assets.\"],\"GAohqx\":[\"Delete column\"],\"GBJzTZ\":[\"Archive\"],\"GX2VMa\":[\"Create your admin account.\"],\"GY/1J4\":[\"Jant fallback canary string\"],\"GbIOhd\":[\"Reply quietly\"],\"GiRWtR\":[\"Why the default accent should feel written, not branded\"],\"GkpIs2\":[\"Remove this link from Collections? The destination won't change.\"],\"GorKul\":[\"Welcome to Jant\"],\"GxkJXS\":[\"Uploading...\"],\"H29JXm\":[\"+ ALT\"],\"H4lgRd\":[\"Authentication isn't set up. Check your server config.\"],\"HFPGej\":[\"No threads match these filters. Try adjusting your selection or clear all filters.\"],\"HG79RB\":[\"Post as Private\"],\"HNEHJP\":[\"Demo credentials are pre-filled — hit Sign In to continue.\"],\"HSI88F\":[\"Delete table\"],\"HbAIQc\":[\"A reference link for checking whether the accent feels editorial instead of promotional.\"],\"HrC0ab\":[\"All posts\"],\"Ht1V3q\":[\"For the same reason, inline code should stay neutral. Something like theme.siteAccent = soften(green, 12%) should not suddenly become the loudest thing on the page.\"],\"I22eN0\":[\"Shared links\"],\"I6zLrz\":[\"Use these when you need a transparent square logo, a shaped tile with a built-in background, a browser icon, or a default preview image.\"],\"ICsA6P\":[\"You have unsaved changes\"],\"IUX7p+\":[\"White logo on the Jant green rounded tile for app icon mockups, touch icons, directory listings, and other square placements that should feel softer.\"],\"IagCbF\":[\"URL\"],\"IjnQHI\":[\"with title\"],\"ImOQa9\":[\"Reply\"],\"IvUVJl\":[\"Show as a list with full posts\"],\"J+2Rls\":[\"Leave blank to publish now. Use an earlier date when importing older posts.\"],\"J4tAHl\":[\"Headings should keep their hierarchy even when the accent gets softer.\"],\"JYj5R2\":[\"Browse files\"],\"JcD7qf\":[\"More actions\"],\"JqJ5Xv\":[\"Latest\"],\"JuN5GC\":[\"No file selected. Choose a file to upload.\"],\"JwLPQ/\":[\"This sign-in link has expired. Return to \"],\"KOqvXP\":[\"Do not recolor, stretch, rotate, outline, or add effects to the logo.\"],\"KbS2K9\":[\"Reset Password\"],\"KdSsVl\":[\"Author (optional)\"],\"Khu3PV\":[\"Publish settings\"],\"KiJn9B\":[\"Note\"],\"KlZ+t+\":[\"%name% + %count% more\"],\"KsvRin\":[\"Hide from Latest\"],\"KzmC5L\":[\"Controls\"],\"L7svJg\":[\"Reading\"],\"Lbkbwy\":[\"A quote card for judging accent color against softer, citation-heavy content.\"],\"LcvzvX\":[\"Tap to retry\"],\"LkA8jz\":[\"Add alt text\"],\"LxRg6f\":[\"live theme controls\"],\"M4tzVU\":[\"Latest posts\"],\"M8kJqa\":[\"Drafts\"],\"MHrjPM\":[\"Title\"],\"MILa7n\":[\"Square tile\"],\"MRYGql\":[\"Open image URL\"],\"MSc/Yq\":[\"Do you want to publish your changes or discard them?\"],\"MZTcRq\":[\"Image unavailable\"],\"Mc7+6G\":[\"Enter a valid URL starting with http://, https://, or mailto:.\"],\"MdMyne\":[\"Source link (optional)\"],\"MiMY3Q\":[\"Apple touch icon\"],\"MiyoI7\":[\"default note sample\"],\"MqghUt\":[\"Search posts...\"],\"Myqkib\":[\"Create a collection to get started.\"],\"N8UzTV\":[\"Replies\"],\"NAFbuE\":[\"Search snippet\"],\"NH9Z1R\":[\"Start here\"],\"NqsRbb\":[\"Jant logo\"],\"NvXuWk\":[\"Won't move the thread to the top of latest.\"],\"O1367B\":[\"All collections\"],\"O3oNi5\":[\"Email\"],\"OEdMhi\":[\"The best default color is the one you notice only after reading for a while.\"],\"OEt/to\":[\"Guidelines\"],\"OJxdgi\":[\"Keep this link under 200 characters.\"],\"OaiMaf\":[\"View mode\"],\"OaoJcz\":[\"Social preview\"],\"OmfDbR\":[\"Site accent\"],\"Oo7//P\":[\"Edit draft\"],\"Ovks1h\":[\"A softer blue feels more like ink than product chrome.\"],\"P/sHNL\":[\"Use this page to judge buttons, links, cards, forms, thread accents, and quiet surfaces before changing a theme globally.\"],\"PBxg/E\":[\"Not now\"],\"Q/uoSA\":[\"Quiet here for now.\"],\"Q2mGA7\":[\"Clear filter\"],\"QBqVyM\":[\"Home screen icon for iPhone and iPad shortcuts.\"],\"QOhkyl\":[\"Compose\"],\"QebAts\":[\"Link added.\"],\"Qgbxdw\":[\"Designing a calmer default accent for Jant\"],\"Qn9Ao8\":[\"Circle tile\"],\"Qoq+GP\":[\"Read more\"],\"QyDt3L\":[\"File uploaded.\"],\"R5CMuK\":[\"Jant looks best when the accent feels editorial. Buttons can stay sturdy, but inline emphasis should feel like a pen mark, not a dashboard highlight.\"],\"R8AthW\":[\"Divider\"],\"R9Khdg\":[\"Auto\"],\"RAv3u7\":[\"Compare it against the theme controls\"],\"ROa4Ti\":[\"Interfaces for reading should guide the eye, not keep asking for attention.\"],\"RZOWDv\":[\"Add a custom shortcut to any page or site.\"],\"RdmNnl\":[\"Browser tab\"],\"RfGczC\":[\"Square logo\"],\"Rj01Fz\":[\"Links\"],\"S37om9\":[\"Included assets\"],\"S8NCfs\":[\"Save to drafts to edit and post at a later time.\"],\"SJGVAw\":[\"Feel editorial and slightly quieter.\"],\"SJmfuf\":[\"Site Name\"],\"SSsoa4\":[\"Add to Navigation\"],\"SaNhJE\":[\"feel deliberate instead of washed out.\"],\"SbNisn\":[\"Sort by when each thread was last added to\"],\"SpTWH3\":[\"Download SVG\"],\"SvRuJt\":[\"Field Notes on Interface Tone\"],\"T/R+Qz\":[\"Primary\"],\"TNZKpI\":[\"Danger\"],\"Tn1w2R\":[\"Draft actions\"],\"TvaTxw\":[\"Doesn't appear in Latest. Still appears in collections you add it to.\"],\"UIMXHD\":[\"Remove Divider\"],\"UaZwcz\":[\"More options are available after you create it.\"],\"Uc5y7o\":[\"Choose the standard logo for websites, docs, directories, and editorial layouts.\"],\"V0fyg5\":[\"Collection added to navigation.\"],\"V18SVO\":[\"Use the logo on light backgrounds.\"],\"V4WsyL\":[\"Add Link\"],\"VCA6B2\":[\"These are actual feed components with real footers, summaries, and inline links. Use this section to judge whether the theme still feels calm once it is applied to realistic content.\"],\"VNqFYa\":[\"Loading post...\"],\"WCOanD\":[\"This reference is useful because it treats links and citations as part of the reading rhythm. Keep that in mind while tuning the\"],\"WIF6ui\":[\"Continue writing this draft\"],\"WbIbzR\":[\"Checking link...\"],\"WcWS//\":[\"Download file\"],\"WhsN3P\":[\"A good default accent in Jant should feel like editorial structure, not product branding. That means links, emphasis, and thread cues can be visible without turning the page into UI chrome.\"],\"Wn+/rH\":[\"Transparent square\"],\"XU7b+L\":[\"Primary logo files\"],\"XV1mAn\":[\"Only visible when signed in.\"],\"XrnWzN\":[\"Published!\"],\"Y7WAtz\":[\"An image couldn't be saved to your library — its original link was kept.\"],\"YIix5Y\":[\"Search...\"],\"YOzD/a\":[\"Replace image\"],\"YUglt2\":[\"Generating a link...\"],\"YXiA6e\":[\"Primary button\"],\"Ygx3Yl\":[\"Small browser icon used in tabs and bookmarks.\"],\"Z4OdvO\":[\"Adding…\"],\"Z6NwTi\":[\"Save as Draft\"],\"ZBH98o\":[\"Show as a grid of tiles\"],\"ZV5ykW\":[\"Download PNG\"],\"ZhhOwV\":[\"Quote\"],\"ZmSeP+\":[\"Save to drafts?\"],\"ZxFuun\":[[\"count\",\"plural\",{\"one\":[\"Found \",\"#\",\" result\"],\"other\":[\"Found \",\"#\",\" results\"]}]],\"a5j82I\":[\"No collections match that search. Try a different name.\"],\"aBFXGQ\":[\"This collection is empty. Add threads from the editor.\"],\"aHTB7P\":[\"Supplementary content attached to your post\"],\"aHd4P7\":[\"Close compose\"],\"aMEyv0\":[\"Stay sturdy and readable.\"],\"aN6wx0\":[\"Nothing in Featured yet. Mark a post as featured to show it here.\"],\"aYpXKS\":[\"and checking whether the accent is guiding attention or pulling too hard.\"],\"aaGV/9\":[\"New Link\"],\"af+9p6\":[\"Quiet metadata\"],\"an5hVd\":[\"Images\"],\"ao77hr\":[[\"count\",\"plural\",{\"one\":[\"#\",\" hidden post\"],\"other\":[\"#\",\" hidden posts\"]}]],\"auFlOr\":[\"Icons and previews\"],\"avuFKG\":[\"threads\"],\"b+dane\":[\"Insert %rows% by %cols% table\"],\"bFpC86\":[\"Everything in one download\"],\"bGtMpA\":[\"Add a label and URL.\"],\"bHOiy1\":[\"Password changes are off in demo mode. Sign in with the shared demo credentials.\"],\"bZ9ges\":[\"Add column before\"],\"bbdNeX\":[\"Sign in\"],\"bfCbdi\":[\"Current post\"],\"bkBJmZ\":[\"This is useful as a color check because it puts the accent next to quotation styling, metadata, and a quieter explanatory paragraph. Compare it back to the\"],\"bkMuwo\":[\"Link to posts you've marked as featured.\"],\"bzSI52\":[\"Discard\"],\"c2JRUS\":[\"Generate automatically\"],\"cH5kXP\":[\"Now\"],\"cIoW7X\":[\"Inline link\"],\"cTUByn\":[\"Newest first\"],\"cb7FR8\":[\"White logo on the Jant green square tile for platforms and layouts that expect a true edge-to-edge square.\"],\"cgmi4V\":[\"Delete Draft\"],\"cnGeoo\":[\"Delete\"],\"d+F4pf\":[\"The image should sit quietly inside the article instead of feeling like a card preview.\"],\"d/o/BH\":[\"Couldn't publish. Saved as draft.\"],\"d0DHp4\":[\"Delete this collection permanently? Threads inside won't be removed.\"],\"dD7NPy\":[\"Outline\"],\"dEgA5A\":[\"Cancel\"],\"dHko2w\":[\"single posts\"],\"dUsGbd\":[\"The right accent should disappear into the writing until you need it.\"],\"dXoieq\":[\"Summary\"],\"dYKrp3\":[\"Hidden from Latest\"],\"dbUuAj\":[\"Appears in Latest.\"],\"df4a/r\":[\"Couldn't load this post. Try again.\"],\"ePK91l\":[\"Edit\"],\"eWLklq\":[\"Quotes\"],\"eYZPF3\":[\"/…\"],\"eneWvv\":[\"Draft\"],\"f4MAoA\":[\"Some uploads failed. Saved as draft.\"],\"f5s9EI\":[\"Press N to write\"],\"f6Hub0\":[\"Sort\"],\"f8fH8W\":[\"Design\"],\"fD+f7T\":[\"RSS feed\"],\"fKrDxS\":[\"Brand tile\"],\"fMPkxb\":[\"Show more\"],\"fqDzSu\":[\"Rate\"],\"fttd2R\":[\"My Collection\"],\"fuWzQl\":[\"Nothing here yet. Add threads to one of these collections to fill this view.\"],\"gCcxP/\":[\"Threads can include up to \",[\"count\"],\" posts.\"],\"gFdWl+\":[\"A long-form article sample for checking the default palette in a true reading context.\"],\"gGx5tM\":[\"Editing\"],\"gNKz6Z\":[\"Collection deleted.\"],\"gXH9r/\":[\"Open raw PNG\"],\"gpaPhA\":[\"Helps screen readers describe the image\"],\"h5RcXU\":[\"Post hidden\"],\"hLlWo5\":[\"A few simple rules.\"],\"hXzOVo\":[\"Next\"],\"heSQoS\":[\"Paste a URL...\"],\"hjeS3W\":[\"Link to your latest posts. Your homepage shows this feed.\"],\"hqeXKW\":[\"Single posts\"],\"hrkGms\":[\"Search\"],\"i0vDGK\":[\"Sort Order\"],\"i5+Y7d\":[\"Download the official Jant logo, icons, and preview files.\"],\"i6kro6\":[\"Edit custom link\"],\"i6nDCI\":[\"Choose a new password.\"],\"iG7KNr\":[\"Logo\"],\"iH8pgl\":[\"Back\"],\"iZYiBK\":[\"newest changes first\"],\"ilSmIt\":[\"Hard edge\"],\"iu7tUI\":[\"Breadcrumb\"],\"jAXE5p\":[\"Reverse logo\"],\"jAqB/k\":[\"Post privately\"],\"jQflRT\":[\"This uses the real single-post detail rendering with a longer article, inline image, tables, lists, quotes, and code. The content column stays at the same width as the live site.\"],\"jd+8Mm\":[\"Social preview image\"],\"jdJOV1\":[\"Settings\"],\"ji7oVU\":[\"Edit post\"],\"jpctdh\":[\"View\"],\"jrsUoG\":[\"Type / for commands\"],\"jvyYZG\":[\"What's on your mind...\"],\"k3Iw35\":[\"Switch to the white logo when the standard green version would lose contrast.\"],\"kNiQp6\":[\"Pinned\"],\"kPMIr+\":[\"Give it a title...\"],\"kzvWob\":[\"Link to the post archive\"],\"laT1IJ\":[\"iOS home screen\"],\"lb+Xwx\":[\"Custom link\"],\"m16xKo\":[\"Add\"],\"mKT7g0\":[\"Text attachment\"],\"mc/vLq\":[\"This link is already in use. Choose something else.\"],\"muKqfV\":[\"Featured\"],\"n1ekoW\":[\"Sign In\"],\"n3ReIn\":[\"Collections\"],\"n6QD94\":[\"Oldest first\"],\"nFukaP\":[\"Wrong email or password. Check your credentials and try again.\"],\"nJ4U1U\":[[\"count\"],\" images couldn't be saved to your library — their original links were kept.\"],\"nV6twc\":[\"Organize\"],\"nd8Puv\":[\"White logo on the Jant green circle for profile images, badges, and other round placements where you want a ready-made asset.\"],\"ndrEYW\":[\"When the accent is slightly warmer and less literal, the whole page feels more like a writing space and less like product UI.\"],\"oO0hKx\":[[\"count\",\"plural\",{\"one\":[\"#\",\" more post\"],\"other\":[\"#\",\" more posts\"]}]],\"oTu7Wt\":[\"Combined Collections\"],\"ode0+L\":[\"Theme sample\"],\"ogssnn\":[\"with media\"],\"ovBPCi\":[\"Default\"],\"p1Z67P\":[\"When primary is too rigid, the whole page starts reading like product UI instead of writing space.\"],\"p2/GCq\":[\"Confirm Password\"],\"pB0OKE\":[\"New Divider\"],\"pBHx39\":[\"Dark backgrounds\"],\"pKeaoC\":[\"Sort by when each thread was published\"],\"pVrU5x\":[\"If this page feels too branded, the first place to soften is the default theme’s site accent, not the border or body text.\"],\"pvnfJD\":[\"Dark\"],\"q+bMmy\":[\"Table controls\"],\"q+hNag\":[\"Collection\"],\"q5YRzz\":[\"Color check\"],\"q8RviX\":[\"Titled\"],\"qc+12k\":[\"Choose table size\"],\"qiN9NB\":[\"Surface\"],\"qt89I8\":[\"Draft saved.\"],\"quvfGs\":[\"instead of judging it as an isolated swatch.\"],\"r7kcaA\":[\"Drag collections, links, and dividers into the order you want.\"],\"rA2TFI\":[\"Switch the palette and mode without opening settings or changing the active site theme.\"],\"rV8ZnP\":[\"Edit publish date\"],\"rdUucN\":[\"Preview\"],\"s8G5Or\":[\"This upload would exceed your shared hosted media limit. Remove files or upgrade storage to continue.\"],\"s9gHf5\":[\"your-post-link\"],\"sER+bs\":[\"Files\"],\"sQpDn6\":[\"Exit fullscreen\"],\"sgr2wQ\":[\"collection\"],\"slujBW\":[\"Use lowercase letters, numbers, and hyphens only.\"],\"syiAKf\":[\"note treatment\"],\"t42hIC\":[\"Everything most people need is in one ZIP.\"],\"tCctex\":[\"The brand pack includes SVG logos, a transparent square PNG, rounded, square, and circle tiles, plus favicon, Apple touch icon, and the default social preview image.\"],\"tKlWWY\":[\"Emoji\"],\"tSWVu5\":[\"Published on \",[\"date\"],\" at \",[\"time\"]],\"tT/bSk\":[\"Add row above\"],\"tZ1tJc\":[\"Edit Navigation\"],\"tfDRzk\":[\"Save\"],\"tg5MRw\":[\"Sign in to start writing.\"],\"tgSBSE\":[\"Remove Link\"],\"uNPC+z\":[\"Couldn't add this collection to navigation. Try again.\"],\"uowbPn\":[\"Remove attachment\"],\"v3E8iS\":[\"A practical checklist\"],\"vSJd18\":[\"Video\"],\"vSYKYI\":[\"Main feed\"],\"vXCC6J\":[\"Something doesn't look right. Check the form and try again.\"],\"vcpc5o\":[\"Close menu\"],\"vdFnYM\":[\"Reset link\"],\"vdvpU5\":[\"/archive?format=quote or https://example.com\"],\"vgpfCi\":[\"Save draft\"],\"vpSPA1\":[\"Auth secret is missing. Check your environment variables.\"],\"vzU4k9\":[\"New Collection\"],\"w0Emel\":[\"Suggested link\"],\"w1bUkO\":[\"Last edited on \",[\"date\"],\" at \",[\"time\"]],\"w6mlns\":[\"Article detail page\"],\"wJ+GRy\":[\"All visibility\"],\"wL3cK8\":[\"Latest\"],\"wja8aL\":[\"Untitled\"],\"wlnK1t\":[\"A single ZIP with the main logo, reverse logo, square PNG, rounded, square, and circle tiles, plus favicon, Apple touch icon, and social preview image.\"],\"wm3Zlr\":[\"All years\"],\"xCWek4\":[\"File storage isn't set up. Check your server config.\"],\"xVrkxi\":[\"quiet design\"],\"xVvw1i\":[\"This reset link is no longer valid. Request a new one to continue.\"],\"xYilR2\":[\"Media\"],\"xeiujy\":[\"Text\"],\"xhTx3y\":[\"Choose the standard logo for most placements and the reverse logo when you need more contrast.\"],\"xjHB3/\":[\"Continue →\"],\"y28hnO\":[\"Post\"],\"y2o/Y0\":[\"This Link Has Expired\"],\"yGZVl1\":[\"More\"],\"yQ2kGp\":[\"Load more\"],\"ycM1Xg\":[\"No results. Try different keywords.\"],\"ynMAhG\":[\"Default logo\"],\"yzF66j\":[\"Link\"],\"zBFr9G\":[\"Paste a long article, AI response, or any text...\\n\\nMarkdown formatting will be preserved.\"],\"zJDAbh\":[\"Don't save\"],\"zcDmsG\":[\"Featured posts\"],\"zoK+eO\":[\"Add a title before posting this link.\"],\"zucql+\":[\"Menu\"],\"zwBp5t\":[\"Private\"]}");
1917
1917
  //#endregion
1918
1918
  //#region src/i18n/locales/settings/en.ts
1919
1919
  var messages$2 = JSON.parse("{\"++YsxG\":[\"Readers and search engines see\"],\"+4Z6iP\":[\"Create the repository on GitHub first — it can be empty.\"],\"+9JI/F\":[\"Connecting will sync your site onto \",[\"repo\"],\"'s default branch on top of its existing history. Existing files outside Jant's managed paths are kept. This can't be undone.\"],\"+AXdXp\":[\"Label and URL are required\"],\"+K0AvT\":[\"Disconnect\"],\"+wgt7C\":[\"Muted red-brown background, earthy and warm.\"],\"+zy2Nq\":[\"Type\"],\"/0D1Xp\":[\"Edit Collection\"],\"/3H2/s\":[\"This hosted site signs in through \",[\"providerLabel\"],\". Manage password and hosted access there.\"],\"/PXXBT\":[\"Nearly white, with a touch of warmth.\"],\"/PoNoq\":[\"Edit link\"],\"/zOUxl\":[\"QR code linking to the Telegram bot\"],\"05DXsb\":[\"Connect or manage the bot used to post from Telegram.\"],\"0OGSSc\":[\"Avatar display updated.\"],\"0UzCUX\":[\"Update the password you use to sign in\"],\"0bdA9b\":[\"Open Telegram to connect\"],\"0gECZD\":[\"Want to write a fuller introduction?\"],\"0uIjy/\":[\"Light sage-green with a matching green accent.\"],\"1DahsC\":[\"Search settings\"],\"1F6Mzc\":[\"No navigation items yet. Add links or enable system items below.\"],\"1H7gng\":[\"Dashboard language\"],\"1MYU3o\":[\"Couldn't create the page. Check the details and try again.\"],\"1mbBbL\":[\"Connect manually\"],\"1njn7W\":[\"Light\"],\"1qGdnL\":[\"The default. Calm and easy for long reading.\"],\"21mg6u\":[\"Choose a titled note that isn't already in navigation, or create a new page.\"],\"2C7mSG\":[\"Collection link\"],\"2DoBvq\":[\"Feeds\"],\"2Et8jU\":[\"Set results per search page; reset to inherit the main page size.\"],\"2FYpfJ\":[\"More\"],\"2Ithfh\":[\"Message the bot any text and it's published as a note.\"],\"2PTjMB\":[\"I want to delete \",[\"siteName\"]],\"2cFU6q\":[\"Site Footer\"],\"2fPEPI\":[\"A calm, cool backdrop.\"],\"2lkk2l\":[\"Yellow-tinted paper with an olive-green accent.\"],\"2oWZo7\":[\"Last commit\"],\"2r8pkt\":[\"Couldn't load pages. Try again in a moment.\"],\"2uuy4H\":[\"Connected via Personal Access Token\"],\"2wK4BX\":[\"Edit About page\"],\"35x8eZ\":[\"Showing \",[\"shown\"],\" of \",[\"total\"]],\"39QGku\":[\"Open the bot and send the binding code, then anything you message it becomes a note.\"],\"3B0RD1\":[\"Upload the profile image and generated site icons.\"],\"3Cw1AI\":[\"Add Collection\"],\"3VrybB\":[\"Redirect\"],\"3Yvsaz\":[\"302 (Temporary)\"],\"3n0zbB\":[\"Session management is off in demo mode. Use the shared demo session instead.\"],\"3sYJi5\":[\"Download a Hugo-compatible archive — host it statically or move to another Jant.\"],\"3vMdv3\":[\"This link is reserved. Choose something else.\"],\"3wKq0C\":[\"Couldn't save. Try again in a moment.\"],\"49Bsal\":[\"Feed settings updated.\"],\"4J/OYU\":[\"Collection created.\"],\"4Jge8E\":[\"Active Sessions\"],\"4KIa+q\":[\"Export downloaded.\"],\"4Q0iPK\":[\"Follow the device appearance or force a light or dark theme.\"],\"4cEClj\":[\"Sessions\"],\"4yyXWu\":[\"Pale bone-white with a muted green accent.\"],\"4zGJ5E\":[\"Delete Account Permanently\"],\"5QlUIt\":[\"Empty repository. Ready to connect.\"],\"5VQnR3\":[\"Use these when you want a feed URL that never changes.\"],\"5dpcN1\":[\"type to search all\"],\"5f1Wo9\":[\"Connected as \",[\"account\"]],\"5o8R81\":[\"Very light ivory with a faint tea-green accent.\"],\"6ArdBh\":[\"Uses featured posts for /feed.\"],\"6DjeBT\":[\"Demo sites always stay hidden from search engines.\"],\"6E3aK4\":[\"Manage Hosting\"],\"6FFB7q\":[\"Uses the latest public posts for /feed.\"],\"6K1Vef\":[\"Delete this blog permanently? This cannot be undone.\"],\"6NpNLc\":[\"This repository has existing content.\"],\"6Pa5AP\":[\"Connect or manage automatic content backups to GitHub.\"],\"6V3Ea3\":[\"Copied\"],\"6WdDG7\":[\"Page\"],\"71WIgc\":[\"Get a new code\"],\"71of4k\":[\"Limit posts included in each RSS feed.\"],\"746NHh\":[\"this blog\"],\"7811AW\":[\"This repository is already backing up this site.\"],\"7FCZPo\":[\"Content language\"],\"7FaY4u\":[\"Usage\"],\"7G9YLi\":[\"Allow search engines to index my site\"],\"7GISOt\":[\"Save bot token\"],\"7MZxzw\":[\"Password changed.\"],\"7vhWI8\":[\"New Password\"],\"81nFIS\":[\"Passwords don't match. Make sure both fields are identical.\"],\"87a/t/\":[\"Label\"],\"89Upyo\":[\"That theme isn't available. Pick another one.\"],\"8BfEpW\":[\"Hosted account\"],\"8N/Mcp\":[\"Archive filter parameters (e.g. format=note&view=list)\"],\"8T46pB\":[\"Bot token\"],\"8U2Z7f\":[\"New Custom URL\"],\"8ZsakT\":[\"Password\"],\"8gEghq\":[\"Bright warm-white with a moss-green accent.\"],\"9+vGLh\":[\"Custom CSS\"],\"9As8Nu\":[\"Create one on GitHub\"],\"9EjI4j\":[\"Warm sand\"],\"9FctRm\":[\"Use lowercase letters, numbers, and hyphens.\"],\"9J6wUO\":[\"Suggested links\"],\"9Lsvt5\":[\"Signed in \",[\"date\"]],\"9T7Cwm\":[\"Redirects, vanity paths, and URL control\"],\"9aUyym\":[\"See where you're signed in and revoke old sessions\"],\"9mqQnx\":[\"Limit paragraphs used in automatically generated summaries.\"],\"A1taO8\":[\"Search\"],\"A7tRC9\":[\"Which post stream the canonical /feed URL returns.\"],\"ADTn/D\":[\"Allow published content to be read without an API token.\"],\"AELGTd\":[\"Add it to navigation now or open the editor to add details.\"],\"ANzR96\":[\"Config Editor\"],\"AeXO77\":[\"Account\"],\"AnY+O9\":[\"Show \\\"Build with Jant\\\" at the bottom of the home page\"],\"ApZDMk\":[\"This is used for your favicon and apple-touch-icon. For best results, upload a square PNG with a solid background at least 512x512 pixels.\"],\"Aysjjh\":[\"Choose the color palette used across Jant.\"],\"B495Gs\":[\"Archive\"],\"B4ESok\":[\"API reference\"],\"BJ0tsF\":[\"Search runtime settings and open advanced configuration\"],\"BTYdze\":[\"Link added to navigation.\"],\"BzEFor\":[\"or\"],\"C0/57J\":[\"This is the last part of the collection link.\"],\"CDAdlf\":[\"Remove bot\"],\"CTAEes\":[\"Select a repository\"],\"CjZZgz\":[\"This repository already has commits\"],\"Cl92kR\":[\"Create new collection\"],\"D8k2s6\":[\"Connect Telegram\"],\"DCKkhU\":[\"Current Password\"],\"DKKKeF\":[\"Manage password and hosted access in \",[\"providerLabel\"]],\"DVFXa6\":[\"The cleanest, most neutral option.\"],\"DdXUay\":[\"Show the site avatar in the public header.\"],\"EO3I6h\":[\"Upload didn't go through. Try again in a moment.\"],\"Eax/et\":[\"Accent\"],\"EbDLD+\":[\"Choose the typography used across Jant.\"],\"Enslfm\":[\"Destination\"],\"F53UDu\":[\"Cool white\"],\"F7FKwe\":[\"Anything you paste here has full access to your visitors' browsers. Only use code from sources you trust.\"],\"F8SNqr\":[\"Warm orange\"],\"FMUJSP\":[\"Account & Data\"],\"Fe1cvJ\":[\"Delay new posts and replies before they appear in feeds. Use 0 to turn this off.\"],\"Fk3SSD\":[\"Calm and natural, easy on the eyes.\"],\"FkMol5\":[\"Featured\"],\"G/1oP+\":[\"Remove the webhook and stop syncing. Your repository content will not be deleted.\"],\"G0qJsQ\":[\"Security token missing. Refresh the page and try again.\"],\"G0tHaW\":[\"Create a public page that won't appear in Latest.\"],\"G2fuEb\":[\"Locked\"],\"G39wnK\":[\"Back up and sync content with a GitHub repository\"],\"GMMWcy\":[\"Name, metadata, language, and search defaults\"],\"GXsAby\":[\"Revoke\"],\"GxkJXS\":[\"Uploading...\"],\"GzKzUa\":[\"Demo limits\"],\"H4x7Sk\":[\"The language this admin dashboard shows in. Available in English, 简体中文, and 繁體中文.\"],\"HCNlq3\":[\"Clean and high-contrast, close to white.\"],\"HHDyGw\":[\"Soft green\"],\"HKH+W+\":[\"Data\"],\"Hp1l6f\":[\"Current\"],\"HxlY7t\":[\"Changing this updates what subscribers get from /feed.\"],\"HxuOlm\":[\"Site Header\"],\"I4HRxU\":[\"Every published post, including ones hidden from Latest.\"],\"I6gXOa\":[\"Path\"],\"I76CzF\":[\"Neutral gray\"],\"ID38tA\":[\"Account deletion is off in demo mode. The shared demo resets separately.\"],\"IF9tPu\":[\"When to use site export, database backups, and recovery drills.\"],\"IW5PBo\":[\"Copy Token\"],\"IagCbF\":[\"URL\"],\"IfB3m6\":[\"The language your posts are written in. Announced to readers and search engines through HTML lang and your RSS feed. Any BCP 47 tag works.\"],\"IreQBq\":[\"Repository\"],\"J6bLeg\":[\"Add a custom link to any URL\"],\"JL7LF5\":[\"available CSS variables, data attributes, and examples.\"],\"JcD7qf\":[\"More actions\"],\"JjX0OO\":[\"Copy your token now — it won't be shown again.\"],\"JrFTcr\":[\"Connecting…\"],\"JuN5GC\":[\"No file selected. Choose a file to upload.\"],\"K+0Hu0\":[\"No matching pages available to add. Try another title or create a new page.\"],\"K/F6pa\":[\"Saving…\"],\"KDw4GX\":[\"Try again\"],\"KSgo21\":[\"Pick a repository\"],\"KVVYBh\":[\"Add collection to navigation\"],\"KiJn9B\":[\"Note\"],\"Kk7jwL\":[\"Set posts per archive page; reset to inherit the main page size.\"],\"KwOLJF\":[\"The original Jant palette, and a solid everyday pick.\"],\"L+rMC9\":[\"Reset to default\"],\"L27RpE\":[\"Page created.\"],\"L3DEwT\":[\"Remove this avatar? Your favicon and header icon will go back to the default.\"],\"L4t4/q\":[\"March 14\"],\"L86zmP\":[\"Edit the multi-line introduction used on your home page and in metadata.\"],\"LJOxNn\":[\"CJK fallback\"],\"LdyooL\":[\"link\"],\"LjwaJ/\":[\"Sign-in, export, and deletion\"],\"M/D8PK\":[\"+ Install on another account\"],\"M/haSd\":[\"Always show the light version of the theme.\"],\"M1co/O\":[\"Configured\"],\"M2kIWU\":[\"Font theme\"],\"M4tzVU\":[\"Latest posts\"],\"M6CbAU\":[\"Toggle edit panel\"],\"MHrjPM\":[\"Title\"],\"MKIM3K\":[\"Search pages\"],\"MaYYE6\":[\"Post notes by messaging a Telegram bot\"],\"Me5t5H\":[\"Connect a GitHub repository to automatically back up your posts as Markdown files. Edits on GitHub sync back to your site.\"],\"MnbH31\":[\"page\"],\"Mq9FZ1\":[\"Creating page…\"],\"Mr4QPw\":[\"Disconnect Telegram? You can reconnect any time with a new binding code.\"],\"MtENL9\":[\"Tune how your site looks, reads, and runs.\"],\"N/8NPV\":[\"Before deleting, download a site export. You won't be able to recover this account after deletion.\"],\"N7UNHY\":[\"Featured feed\"],\"NHnUHF\":[\"Favicon and the profile mark in your header\"],\"NU2Fqi\":[\"Save CSS\"],\"NVjhde\":[\"Warm parchment\"],\"Nldjdr\":[\"No custom URLs yet. Create one to add redirects or custom paths for posts.\"],\"O7rgs6\":[\"Header RSS points to your \",[\"feed\"],\" feed (/feed). Change what /feed returns in General.\"],\"OJxdgi\":[\"Keep this link under 200 characters.\"],\"OSJXFg\":[\"Applies to your entire site, including admin pages. Pick a palette, then choose whether it follows the system or stays fixed.\"],\"OeUWA7\":[\"Add Page\"],\"OuuMXJ\":[\"Add site-wide HTML before the closing body tag.\"],\"Ox3+3h\":[\"No matches.\"],\"PEUV5I\":[\"Code injection updated.\"],\"PHh52z\":[\"Warm and rich, with strong brown tones.\"],\"PXj9lw\":[\"Stop accepting posts from Telegram. Your existing notes stay published.\"],\"PZ7HJ8\":[\"Blog Avatar\"],\"Pbm2/N\":[\"Create Collection\"],\"Pwqkdw\":[\"Loading…\"],\"PxJ9W6\":[\"Generate Token\"],\"Q/6Y+2\":[\"Needs Contents (read/write) and Webhooks (read/write) on the target repository.\"],\"Q/O0X4\":[\"This setting wasn't saved. Check the value and try again.\"],\"Q30z/l\":[\"Remove this collection from navigation? The collection itself won't be deleted.\"],\"Q99OtV\":[\"Pin a collection to your navigation bar. An asterisk (*) appears next to collections updated in the last 48 hours.\"],\"QCwsv1\":[\"Warm off-white\"],\"QKvrmL\":[\"Warm-neutral and easy for long reading.\"],\"QZmz0H\":[\"Built-in links\"],\"Qnrzvb\":[\"Active Tokens\"],\"R6Z4LE\":[\"Download failed. Please try again.\"],\"R9Khdg\":[\"Auto\"],\"RcdDOS\":[\"Create a bot by messaging @BotFather on Telegram, then paste the token it gives you.\"],\"RdVIcf\":[\"Cream and coffee brown\"],\"Rn2p1h\":[\"Nothing matches this search. Try a different name or description.\"],\"RxsRD6\":[\"Time Zone\"],\"SDND4q\":[\"Not configured\"],\"SJmfuf\":[\"Site Name\"],\"SKZhW9\":[\"Token name\"],\"SSsoa4\":[\"Add to Navigation\"],\"SVQQPe\":[\"Couldn't connect. Check the error and try again.\"],\"SWb0z+\":[\"That address is reserved. Choose another one.\"],\"SchpMp\":[\"Telegram\"],\"SqKp3o\":[\"The title used across your site, browser tabs, and feeds.\"],\"SrGs4T\":[\"Light blue-gray background, cool and even.\"],\"T41PG1\":[\"Limit characters used in automatically generated summaries.\"],\"TN0mN4\":[\"Search and edit runtime settings. Changes apply immediately; reset restores the environment or built-in default.\"],\"TSCeuF\":[\"Couldn't create the collection. Check the details and try again.\"],\"TpF3v+\":[\"Injected before </head>. Use for analytics, custom meta tags, and styles that must load early.\"],\"Tu6bMZ\":[\"Create About page\"],\"Tz0i8g\":[\"Settings\"],\"U5v6Gh\":[\"Edit Page\"],\"UFK415\":[\"Site-wide HTML for analytics and widgets\"],\"UTvFQq\":[\"Open \",[\"linkOpen\"],\"@\",[\"botUsername\"],[\"linkClose\"],\" and send:\"],\"UUn+Y5\":[\"Open setting\"],\"UaZwcz\":[\"More options are available after you create it.\"],\"Uj/btJ\":[\"Display avatar in my site header\"],\"UsODUn\":[\"Select an account\"],\"UxKoFf\":[\"Navigation\"],\"V+bhUy\":[\"Install GitHub App\"],\"V0fyg5\":[\"Collection added to navigation.\"],\"V4WsyL\":[\"Add Link\"],\"V5pZwT\":[\"Search settings updated.\"],\"VXUPla\":[\"Connect with GitHub App\"],\"VhMDMg\":[\"Change Password\"],\"Vn3jYy\":[\"Navigation items\"],\"VoZYGU\":[\"This will permanently delete all your data — posts, media, collections, settings, and your account. Your blog will be reset to its initial setup state. This cannot be undone.\"],\"WUnFK2\":[\"Pale cool-white with deep indigo text. High contrast.\"],\"Wa9q4P\":[\"Pure white\"],\"Wb7EHo\":[\"About page\"],\"Weq9zb\":[\"General\"],\"Wi9i06\":[\"Follow each visitor's system preference.\"],\"Wildi8\":[\"No pages available. Create one to add it to navigation.\"],\"Wx1M8N\":[\"Install the GitHub App to grant access without managing personal tokens. Permissions are scoped per repository and revocable from GitHub.\"],\"X+8FMk\":[\"Current password doesn't match. Try again.\"],\"X1G9eY\":[\"Navigation Preview\"],\"X2Cbc2\":[\"Archive feed\"],\"X5P6yv\":[\"Recently updated\"],\"X9Hujr\":[\"Manual Push\"],\"Xsu5WL\":[\"Add site-wide CSS in the dedicated code editor.\"],\"XtBJV8\":[\"Checking repository…\"],\"Xtc16w\":[\"Refresh repository list\"],\"Y+7JGK\":[\"Create Page\"],\"Y/F35r\":[\"Create a post with curl:\"],\"Y/N5N7\":[\"Soft cream background with a muted green accent.\"],\"YF6zHf\":[\"Site settings updated.\"],\"YdG2RF\":[\"Export Site\"],\"YkgZi7\":[\"Connect a Telegram bot, then anything you message it gets published as a note.\"],\"YwhjRx\":[\"Manage Account\"],\"Yxp859\":[\"Warm cream\"],\"ZDY7Fy\":[\"Syncing…\"],\"ZQKLI1\":[\"Danger Zone\"],\"ZS/CBL\":[\"Delete this navigation link? Visitors won't see it in your site header anymore.\"],\"ZXZrAo\":[\"Keep the page address under 200 characters.\"],\"ZgZX+d\":[\"Pure white with neutral grays. No color tint.\"],\"Zgq+c2\":[\"Set the default number of items shown per page.\"],\"ZhhOwV\":[\"Quote\"],\"ZiooJI\":[\"API Tokens\"],\"Zm7Qb0\":[\"Backup & Restore Guide\"],\"ZmUkwN\":[\"Add custom link to navigation\"],\"a14mj8\":[\"Unknown device\"],\"a1iEgy\":[\"Warm cream background with deep coffee-brown accents.\"],\"a3LDKx\":[\"Security\"],\"aAIQg2\":[\"Appearance\"],\"aFkzVF\":[\"The slug of the target post or collection\"],\"aiSl4C\":[\"The serif fallback used when the content language has no built-in profile.\"],\"alKG0+\":[\"Font Theme\"],\"anibOb\":[\"About this blog\"],\"any7NR\":[\"Theming guide\"],\"b+/jO6\":[\"301 (Permanent)\"],\"b+FyBD\":[\"Add page to navigation\"],\"bHOiy1\":[\"Password changes are off in demo mode. Sign in with the shared demo credentials.\"],\"bHYIks\":[\"Sign Out\"],\"bV2vng\":[\"Warm terracotta\"],\"bbR5vW\":[\"This palette\"],\"bbzY7X\":[\"Manage sign-in security, site exports, and irreversible actions.\"],\"bfHZ7r\":[\"Choose the time zone used to display dates and times.\"],\"bi10qS\":[\"Add it to navigation now or open the editor to add content.\"],\"bmrL08\":[\"Demo mode hides sessions, password changes, and account deletion. Export still works.\"],\"brfpw9\":[\"Edit the multi-line footer rendered at the bottom of public pages.\"],\"bviiKV\":[\"Headings, body text, and links in this theme.\"],\"c3MN2z\":[\"all available endpoints and request formats.\"],\"cS7/bk\":[\"Remove the saved bot token? Its webhook is deleted and any connected account is disconnected.\"],\"cSDy01\":[\"Custom CSS updated.\"],\"clzoNp\":[\"Always show the dark version of the theme.\"],\"cnGeoo\":[\"Delete\"],\"cwaLCZ\":[\"Page added to navigation.\"],\"d3FRkY\":[\"Could not copy. Try again.\"],\"d5oGUo\":[\"Create a new repository on GitHub\"],\"dEgA5A\":[\"Cancel\"],\"dTXUY+\":[\"Confirm account deletion\"],\"dYKrp3\":[\"Hidden from Latest\"],\"dk7TCH\":[\"Permanently delete all data and reset the blog\"],\"ds0nJk\":[\"Add site-wide HTML before the closing head tag.\"],\"dsWkIw\":[\"Disconnect from GitHub? The webhook will be removed. Your repository content will not be deleted.\"],\"e/tSI5\":[\"Navigation order updated.\"],\"eOL7vn\":[\"Minimal color, low distraction.\"],\"ePK91l\":[\"Edit\"],\"ebQKK7\":[\"Site\"],\"egK+Yy\":[\"Bearer tokens for scripts and automation\"],\"ehj/zN\":[\"Redirect Type\"],\"eneWvv\":[\"Draft\"],\"erTMh7\":[\"Last synced\"],\"f+m8jj\":[\"Feed URL copied.\"],\"f8fH8W\":[\"Design\"],\"fDz6PV\":[\"Choose the repository used for content backups.\"],\"fKRAwQ\":[\"Sandy background with a green accent.\"],\"fWYqkz\":[\"Code Injection\"],\"fYXQnC\":[\"Cozy and bold among the warm tones.\"],\"fttd2R\":[\"My Collection\"],\"gZ5owP\":[\"Search repositories\"],\"gbqbh6\":[\"Safe to leave this page — syncing continues in the background.\"],\"gkFvVN\":[\"Injected before </body>. Use for chat widgets and scripts that should not block page load.\"],\"gtQsRO\":[\"Create Custom URL\"],\"hBO/y4\":[\"Security token expired. Refresh the page and try again.\"],\"hGmyDl\":[\"Tokens let you access the API from scripts, shortcuts, and other tools without signing in.\"],\"hIHkRy\":[\"Connected via GitHub App\"],\"hJHHsU\":[\"Publish Atom feeds for the site, archive, and collections.\"],\"hdSi1b\":[\"Type \",[\"repo\"],\" to confirm\"],\"he3ygx\":[\"Copy\"],\"hvGGMK\":[\"Remove this page from navigation? The page itself won't be deleted.\"],\"i0qMbr\":[\"Home\"],\"iEUzMn\":[\"system\"],\"iH8pgl\":[\"Back\"],\"iSLIjg\":[\"Connect\"],\"iVOMRi\":[\"Home settings updated.\"],\"icB4Cv\":[\"Drag links here to show them under the More menu\"],\"id3vuh\":[\"Telegram is set up, but the bot couldn't be reached. Check the bot token and try again.\"],\"idD8Ev\":[\"Saved\"],\"ihn4zD\":[\"Search…\"],\"iiDXZc\":[\"Displayed at the bottom of all posts and pages.\"],\"itS31B\":[\"A warmer look, like slightly aged paper.\"],\"iwQZvS\":[\"Cool blue-gray\"],\"j4VrG6\":[\"Download Export ZIP\"],\"j5nQL2\":[\"e.g. iOS Shortcuts\"],\"jUV7CU\":[\"Upload Avatar\"],\"jVUmOK\":[\"Markdown supported\"],\"jdVYS8\":[\"Pick the one that fits your writing.\"],\"jgBjXJ\":[\"Revoke this token? Any scripts using it will stop working.\"],\"jpctdh\":[\"View\"],\"k1ifdL\":[\"Processing...\"],\"kLw03Y\":[\"Bright paper white\"],\"kMXclu\":[\"Download a site export\"],\"kNiQp6\":[\"Pinned\"],\"kQ3Otm\":[\"Create new page\"],\"kRhzWq\":[\"GitHub Sync\"],\"kVQs7s\":[\"Fine-grained styling overrides\"],\"ke1gWS\":[\"Custom URLs\"],\"kfcRb0\":[\"Avatar\"],\"kp8wiR\":[\"Checking address…\"],\"kxDZ2i\":[\"This code runs on every page of your site.\"],\"l2Op2p\":[\"Query Parameters\"],\"lLW3vJ\":[\"Target Slug\"],\"lV04bQ\":[\"Warm and earthy, but still light.\"],\"lYHJih\":[\"Revoke this session? That device will need to sign in again.\"],\"m16xKo\":[\"Add\"],\"mLOk1i\":[\"Push all posts to GitHub right now instead of waiting for the next automatic sync.\"],\"mSNmrX\":[\"List posts:\"],\"n8+EXe\":[\"Add common destinations that already exist on your site.\"],\"nG2qTk\":[\"Example link\"],\"nK07ni\":[\"Choose a typographic direction for your site. Each theme changes both the font pairing and the reading rhythm.\"],\"nbfdhU\":[\"Integrations\"],\"ntJYyh\":[\"Domains, plan, and billing in \",[\"providerLabel\"]],\"o/vNDE\":[\"lets you override any theme variable.\"],\"o5rj+L\":[\"No collections yet. Create one here to add it to navigation.\"],\"oGC9uP\":[\"owner/repo\"],\"oH2JHg\":[\"We'll prefill the name \",[\"name\"],\". The list refreshes on return.\"],\"oKOOsY\":[\"Color Theme\"],\"oL535e\":[\"Not synced yet\"],\"oNA4If\":[\"All collections are already in your navigation.\"],\"oUn9Z7\":[\"Near-neutral light gray with a steel-blue accent.\"],\"ofdy2l\":[\"Orange-tinted background. The warmest palette.\"],\"pNXxtS\":[\"Choose the content language announced to readers and search engines.\"],\"pZq3aX\":[\"Upload failed. Please try again.\"],\"pgTIrt\":[\"Choose the GitHub account and repository to sync with this site.\"],\"psoxDF\":[\"That font theme isn't available. Pick another one.\"],\"pt4OhQ\":[\"/about is already used. Rename that item before creating an About page.\"],\"pvnfJD\":[\"Dark\"],\"q+hNag\":[\"Collection\"],\"q/T5GS\":[\"The language used by the private Jant dashboard.\"],\"qSgO/Y\":[\"Follow content language\"],\"qdcESc\":[\"Create a new repository\"],\"qkSJzV\":[\"Searching pages…\"],\"r5EW6f\":[\"This repository is already backing up another Jant site (\",[\"host\"],\"). Pick a different repository.\"],\"rEspiY\":[\"Navigation placement updated.\"],\"rFmBG3\":[\"Color theme\"],\"re0mF0\":[\"The page is public but stays out of Latest.\"],\"rlonmB\":[\"Couldn't delete. Try again in a moment.\"],\"s3jOk7\":[\"Enter a page title.\"],\"satWc6\":[\"Main RSS feed\"],\"sgr2wQ\":[\"collection\"],\"sj4LLc\":[\"Show only modified\"],\"slujBW\":[\"Use lowercase letters, numbers, and hyphens only.\"],\"soRdOu\":[\"Off-white paper with a deep forest-green accent.\"],\"sqxcaY\":[\"Created \",[\"date\"]],\"sxkWRg\":[\"Advanced\"],\"t/YqKh\":[\"Remove\"],\"t3hvHq\":[\"Sync Now\"],\"tJ4H0O\":[\"your Telegram account\"],\"tfDRzk\":[\"Save\"],\"tgWuMB\":[\"Modified\"],\"tvgAq5\":[\"No accounts authorized yet\"],\"u1VTd3\":[\"Palette, surface tone, and overall mood\"],\"u3wRF+\":[\"Published\"],\"u6KOjV\":[\"Want more control?\"],\"uKLe0J\":[[\"count\"],\" settings shown\"],\"uVZcIA\":[\"That address is already in use. Choose another one.\"],\"udPwLB\":[\"Header\"],\"ui6aMF\":[\"These devices are currently signed in to your account. Revoke any session you don't recognize.\"],\"vBEKwo\":[\"Manage this site's active sessions here. Password and hosted access are managed through \",[\"providerLabel\"],\".\"],\"vOnuOa\":[\"Show or hide built-in destinations in the header and More menu.\"],\"vRldcl\":[\"Typography choices and reading texture\"],\"vSYKYI\":[\"Main feed\"],\"vTuib7\":[\"This controls what /feed returns.\"],\"vdFnYM\":[\"Reset link\"],\"vmQmHx\":[\"Add custom CSS to override any styles. Use data attributes like [data-page], [data-post], [data-format] to target specific elements.\"],\"vzX5FB\":[\"Delete Account\"],\"w8Rv8T\":[\"Label is required\"],\"wL3cK8\":[\"Latest\"],\"wLSxGY\":[\"Used when the content language has no built-in font profile. Your font theme still controls serif and sans styling.\"],\"wW6NCp\":[\"Last error\"],\"wc+17X\":[\"/* Your custom CSS here */\"],\"wuLtXn\":[\"No active sessions right now. Signed-in devices show up here.\"],\"x+HGBk\":[\"Ask search engines not to index public pages or include them in results.\"],\"xCWek4\":[\"File storage isn't set up. Check your server config.\"],\"xHt036\":[\"Personal Access Token\"],\"xKeZ0l\":[\"An earthier, warmer tone.\"],\"xYbozN\":[\"Soft ivory\"],\"xqViCq\":[\"Warm ivory\"],\"xxaahd\":[\"When you want a cooler, sharper look.\"],\"y/awtV\":[\"Header links, built-in destinations, and overflow menu\"],\"y28hnO\":[\"Post\"],\"y8Md/V\":[\"Language and time updated.\"],\"y9W9vo\":[\"Creating collection…\"],\"yNCqOt\":[\"Latest feed\"],\"yQ3kNF\":[\"Type the following phrase to confirm:\"],\"ydq1k2\":[\"Pick an account first\"],\"yjjCV8\":[\"Fixed feed URLs\"],\"yjkELF\":[\"Confirm New Password\"],\"ym+ccl\":[\"Show the Jant credit on the home page.\"],\"yzF66j\":[\"Link\"],\"z6wakA\":[\"A short intro shown on your home page.\"],\"zEizrk\":[\"Last used \",[\"date\"]],\"zSURJW\":[\"No repositories match.\"],\"zUlHGd\":[\"Page address\"],\"zXH2jX\":[\"Language & Time\"],\"zcDmsG\":[\"Featured posts\"],\"zlcDd2\":[\"Delete this custom URL? Visitors using it won't be redirected anymore.\"],\"zwBp5t\":[\"Private\"]}");
@@ -3456,10 +3456,10 @@ function normalizeThemeColorForMeta(color) {
3456
3456
  * internal paths (e.g. `/_assets/client-HASH.js`) embedded by the Worker build
3457
3457
  * from the Vite client manifest. Used only in production (IS_VITE_DEV=false).
3458
3458
  */ var IS_VITE_DEV = typeof __JANT_DEV__ !== "undefined" && __JANT_DEV__ === true;
3459
- var CORE_VERSION = "0.6.15-9fc2292fcaa08e09";
3460
- var CLIENT_JS_FILE = "/_assets/client-DxY5BFe8.js";
3461
- var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-CakPESv9.js";
3462
- var CLIENT_CSS_FILE = "/_assets/client-B_eGKN5p.css";
3459
+ var CORE_VERSION = "0.6.16-31fe43de750ab168";
3460
+ var CLIENT_JS_FILE = "/_assets/client-Bq0vre8Y.js";
3461
+ var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-F2rb9WfF.js";
3462
+ var CLIENT_CSS_FILE = "/_assets/client-D3VZyG4L.css";
3463
3463
  var CLIENT_CJK_CSS_FILE = "/_assets/client-cjk-B7Z0snDu.css";
3464
3464
  var CLIENT_CJK_TC_CSS_FILE = "/_assets/client-cjk-tc-BesJYrb2.css";
3465
3465
  var CLIENT_CJK_JP_CSS_FILE = "/_assets/client-cjk-jp-DZwrTzQC.css";
@@ -3819,6 +3819,10 @@ var CUSTOM_SYMBOLS = {
3819
3819
  viewBox: "0 0 24 24",
3820
3820
  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" />`
3821
3821
  },
3822
+ "post-status-draft": {
3823
+ viewBox: "0 0 24 24",
3824
+ 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" />`
3825
+ },
3822
3826
  "post-status-private": {
3823
3827
  viewBox: "0 0 24 24",
3824
3828
  inner: `<path ${STROKE_POST_BADGE} d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" /><path ${STROKE_POST_BADGE} d="M14.084 14.158a3 3 0 0 1-4.242-4.242" /><path ${STROKE_POST_BADGE} d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" /><path ${STROKE_POST_BADGE} d="m2 2 20 20" />`
@@ -4004,7 +4008,7 @@ var IconSprite = () => {
4004
4008
  const cjkFontProfile = resolveCjkFontProfile(appConfig?.siteLanguage ?? resolvedLang, cjkSerifFont);
4005
4009
  const cjkStylesheetPath = cjkFontProfile === "zh-Hans" ? IS_VITE_DEV ? assetPath("/src/style-cjk.css") : toPublicAssetPath(CLIENT_CJK_CSS_FILE, assetBasePath) : cjkFontProfile === "zh-Hant" ? IS_VITE_DEV ? assetPath("/src/style-cjk-tc.css") : toPublicAssetPath(CLIENT_CJK_TC_CSS_FILE, assetBasePath) : cjkFontProfile === "ja" ? IS_VITE_DEV ? assetPath("/src/style-cjk-jp.css") : toPublicAssetPath(CLIENT_CJK_JP_CSS_FILE, assetBasePath) : cjkFontProfile === "ko" ? IS_VITE_DEV ? assetPath("/src/style-cjk-kr.css") : toPublicAssetPath(CLIENT_CJK_KR_CSS_FILE, assetBasePath) : null;
4006
4010
  const clientScriptPath = IS_VITE_DEV ? resolvedClientBundle === "full" ? assetPath("/src/client-auth.ts") : assetPath("/src/client.ts") : toPublicAssetPath(resolvedClientBundle === "full" ? CLIENT_AUTH_JS_FILE : CLIENT_JS_FILE, assetBasePath);
4007
- const faviconAssetVersion = resolvedFaviconVersion || "0.6.15-9fc2292fcaa08e09";
4011
+ const faviconAssetVersion = resolvedFaviconVersion || "0.6.16-31fe43de750ab168";
4008
4012
  const resolvedFaviconHref = faviconHref ?? (faviconAssetVersion ? toPublicPath(`/favicon.ico?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/favicon.ico", sitePathPrefix));
4009
4013
  const resolvedAppleTouchHref = appleTouchHref ?? (faviconAssetVersion ? toPublicPath(`/apple-touch-icon.png?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/apple-touch-icon.png", sitePathPrefix));
4010
4014
  const socialImageHref = resolvedSocialImagePath ? toAbsoluteAssetUrl(resolvedSocialImagePath, appConfig?.siteUrl || "", sitePathPrefix) : "";
@@ -6731,6 +6735,28 @@ function clipPreviewText(text, maxChars) {
6731
6735
  };
6732
6736
  }
6733
6737
  /**
6738
+ * Resolves the unpublished draft sitting at the end of a Thread, if any.
6739
+ *
6740
+ * Both maps come from the same ordering over the same rows, differing only in
6741
+ * whether drafts are eligible — so when they disagree, the draft-inclusive
6742
+ * answer is by definition unpublished. No status lookup needed.
6743
+ *
6744
+ * @param threadId - Thread root ID to look up
6745
+ * @param publishedTails - Tails from `getThreadTailIds(ids)`
6746
+ * @param draftInclusiveTails - Tails from `getThreadTailIds(ids, { includeDrafts: true })`
6747
+ * @returns The trailing draft's Post ID, or undefined when the Thread ends
6748
+ * on a published Post
6749
+ * @example
6750
+ * ```ts
6751
+ * const draftTailId = resolveDraftTailId(post.threadId, tails, draftTails);
6752
+ * if (draftTailId) view.draftTailId = draftTailId;
6753
+ * ```
6754
+ */ function resolveDraftTailId(threadId, publishedTails, draftInclusiveTails) {
6755
+ const withDrafts = draftInclusiveTails.get(threadId);
6756
+ if (!withDrafts) return void 0;
6757
+ return withDrafts === publishedTails.get(threadId) ? void 0 : withDrafts;
6758
+ }
6759
+ /**
6734
6760
  * Batch converts PostWithMedia[] to PostView[].
6735
6761
  *
6736
6762
  * @param posts - Posts with media attachments
@@ -7128,7 +7154,7 @@ var getCollectionMutationLabels = (i18n) => ({
7128
7154
  saveDraft: i18n._({ id: "Z6NwTi" }),
7129
7155
  saveAsDraft: i18n._({ id: "8tM8+a" }),
7130
7156
  discard: i18n._({ id: "bzSI52" }),
7131
- titlePlaceholder: i18n._({ id: "MHrjPM" }),
7157
+ titlePlaceholder: i18n._({ id: "wja8aL" }),
7132
7158
  bodyPlaceholder: i18n._({ id: "jvyYZG" }),
7133
7159
  urlPlaceholder: i18n._({ id: "heSQoS" }),
7134
7160
  urlInvalid: i18n._({ id: "Mc7+6G" }),
@@ -7209,13 +7235,13 @@ var getCollectionMutationLabels = (i18n) => ({
7209
7235
  publishDateReset: i18n._({ id: "6WAK+2" }),
7210
7236
  publishDateInvalid: i18n._({ id: "DSJXZM" }),
7211
7237
  publishDateFutureError: i18n._({ id: "5dcjwM" }),
7212
- publishDateSummaryNow: i18n._({ id: "qcawwg" }),
7238
+ publishDateSummaryNow: i18n._({ id: "cH5kXP" }),
7213
7239
  publishDateSummaryAction: i18n._({ id: "rV8ZnP" }),
7214
7240
  publishSlugLabel: i18n._({ id: "lb+Xwx" }),
7215
7241
  publishSlugPlaceholder: i18n._({ id: "s9gHf5" }),
7216
7242
  publishSlugHint: i18n._({ id: "4eiXo+" }),
7217
7243
  publishSlugAuto: i18n._({ id: "c2JRUS" }),
7218
- publishSlugSummaryAuto: i18n._({ id: "hWpUeY" }),
7244
+ publishSlugSummaryAuto: i18n._({ id: "eYZPF3" }),
7219
7245
  publishSlugSummaryAction: i18n._({ id: "i6kro6" }),
7220
7246
  publishSlugReset: i18n._({ id: "vdFnYM" }),
7221
7247
  publishSlugSuggested: i18n._({ id: "w0Emel" }),
@@ -7231,9 +7257,9 @@ var getCollectionMutationLabels = (i18n) => ({
7231
7257
  addCollection: i18n._({ id: "3Cw1AI" }),
7232
7258
  collectionCountLabel: i18n._({ id: "KlZ+t+" }),
7233
7259
  draftRestored: i18n._({ id: "DOx286" }),
7234
- newThread: i18n._({ id: "yUtAh2" }),
7235
- replyTitle: i18n._({ id: "ImOQa9" }),
7236
- editTitle: i18n._({ id: "ePK91l" }),
7260
+ closeCompose: i18n._({ id: "aHd4P7" }),
7261
+ editing: i18n._({ id: "gGx5tM" }),
7262
+ composeDialogLabel: i18n._({ id: "QOhkyl" }),
7237
7263
  slashHint: i18n._({ id: "jrsUoG" }),
7238
7264
  tableControls: {
7239
7265
  toolbarLabel: i18n._({ id: "q+bMmy" }),
@@ -7266,7 +7292,7 @@ var getCollectionMutationLabels = (i18n) => ({
7266
7292
  ...slashCommandDiscovered ? { "slash-command-discovered": "" } : {},
7267
7293
  children: /* @__PURE__ */ jsxDEV$1("div", {
7268
7294
  class: "compose-dialog-inner",
7269
- children: [/* @__PURE__ */ jsxDEV$1("div", { class: "compose-dialog-header" }), /* @__PURE__ */ jsxDEV$1("div", { class: "compose-body skel-section-md" })]
7295
+ children: /* @__PURE__ */ jsxDEV$1("div", { class: "compose-body skel-section-md" })
7270
7296
  })
7271
7297
  });
7272
7298
  };
@@ -8007,11 +8033,14 @@ var SiteLayout = ({ siteName, links, currentPath, sitePathPrefix = "", isAuthent
8007
8033
  if (posts.length === 0) return [];
8008
8034
  const postIds = posts.map((p) => p.id);
8009
8035
  const mediaCtx = createMediaContext(c.var.appConfig);
8010
- const [rawMediaMap, collectionsMap, threadContexts, aliasesMap] = await Promise.all([
8036
+ const threadIds = posts.map((p) => p.threadId);
8037
+ const [rawMediaMap, collectionsMap, threadContexts, aliasesMap, publishedTails, draftInclusiveTails] = await Promise.all([
8011
8038
  c.var.services.media.getByPostIds(postIds),
8012
8039
  c.var.services.collections.getCollectionsByPostIds(postIds),
8013
8040
  c.var.services.posts.getThreadTimelineContext(postIds),
8014
- c.var.services.paths.getPostAliases(postIds)
8041
+ c.var.services.paths.getPostAliases(postIds),
8042
+ c.var.isAuthenticated ? c.var.services.posts.getThreadTailIds(threadIds) : Promise.resolve(/* @__PURE__ */ new Map()),
8043
+ c.var.isAuthenticated ? c.var.services.posts.getThreadTailIds(threadIds, { includeDrafts: true }) : Promise.resolve(/* @__PURE__ */ new Map())
8015
8044
  ]);
8016
8045
  const mediaMap = buildMediaMap(rawMediaMap, mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl, mediaCtx.localPublicUrl, mediaCtx.sitePathPrefix);
8017
8046
  const contextPostIds = /* @__PURE__ */ new Set();
@@ -8035,6 +8064,7 @@ var SiteLayout = ({ siteName, links, currentPath, sitePathPrefix = "", isAuthent
8035
8064
  ...post,
8036
8065
  mediaAttachments: mediaMap.get(post.id) ?? []
8037
8066
  }, mediaCtx, collectionsMap.get(post.id), void 0, firstAlias(post.id));
8067
+ const draftTailId = resolveDraftTailId(post.threadId, publishedTails, draftInclusiveTails);
8038
8068
  const threadCtx = threadContexts.get(post.id);
8039
8069
  if (threadCtx) {
8040
8070
  postView.isLastInThread = false;
@@ -8042,19 +8072,24 @@ var SiteLayout = ({ siteName, links, currentPath, sitePathPrefix = "", isAuthent
8042
8072
  ...reply,
8043
8073
  mediaAttachments: contextMediaMap.get(reply.id) ?? []
8044
8074
  }, mediaCtx, contextCollectionsMap.get(reply.id), false, firstContextAlias(reply.id));
8075
+ const leadingReplyViews = threadCtx.leadingReplies.map(toContextPostView);
8076
+ const trailingReplyViews = threadCtx.trailingReplies.map(toContextPostView);
8077
+ const latestReplyView = toPostView({
8078
+ ...threadCtx.latestReply,
8079
+ mediaAttachments: contextMediaMap.get(threadCtx.latestReply.id) ?? []
8080
+ }, mediaCtx, contextCollectionsMap.get(threadCtx.latestReply.id), true, firstContextAlias(threadCtx.latestReply.id));
8081
+ if (draftTailId) latestReplyView.draftTailId = draftTailId;
8045
8082
  return {
8046
8083
  post: postView,
8047
8084
  threadPreview: {
8048
- leadingReplies: threadCtx.leadingReplies.map(toContextPostView),
8049
- trailingReplies: threadCtx.trailingReplies.map(toContextPostView),
8050
- latestReply: toPostView({
8051
- ...threadCtx.latestReply,
8052
- mediaAttachments: contextMediaMap.get(threadCtx.latestReply.id) ?? []
8053
- }, mediaCtx, contextCollectionsMap.get(threadCtx.latestReply.id), true, firstContextAlias(threadCtx.latestReply.id)),
8085
+ leadingReplies: leadingReplyViews,
8086
+ trailingReplies: trailingReplyViews,
8087
+ latestReply: latestReplyView,
8054
8088
  totalReplyCount: threadCtx.totalReplyCount
8055
8089
  }
8056
8090
  };
8057
8091
  }
8092
+ if (draftTailId) postView.draftTailId = draftTailId;
8058
8093
  return { post: postView };
8059
8094
  });
8060
8095
  }
@@ -9571,6 +9606,8 @@ var MediaGallery = ({ attachments, postPermalink }) => {
9571
9606
  ...post.pinned ? { "data-post-pinned": "" } : {},
9572
9607
  ...post.pinnedInCollection ? { "data-post-pinned-in-collection": "" } : {},
9573
9608
  ...post.featured ? { "data-post-featured": "" } : {},
9609
+ ...post.status === "draft" ? { "data-post-draft": "" } : {},
9610
+ ...post.draftTailId ? { "data-thread-draft-tail-id": post.draftTailId } : {},
9574
9611
  "data-post-visibility": post.visibility,
9575
9612
  ...isChildPost ? { "data-post-reply": "" } : {}
9576
9613
  };
@@ -9673,7 +9710,10 @@ var MediaGallery = ({ attachments, postPermalink }) => {
9673
9710
  };
9674
9711
  var PostPublishedLink = ({ post, className }) => {
9675
9712
  const { i18n } = useLingui();
9676
- const publishedLabel = i18n._({ id: "tSWVu5" }, {
9713
+ const publishedLabel = post.status === "draft" ? i18n._({ id: "w1bUkO" }, {
9714
+ date: post.publishedAtFormatted,
9715
+ time: post.publishedAtTime
9716
+ }) : i18n._({ id: "tSWVu5" }, {
9677
9717
  date: post.publishedAtFormatted,
9678
9718
  time: post.publishedAtTime
9679
9719
  });
@@ -9774,20 +9814,29 @@ var PostFooter = ({ post, detail, display }) => {
9774
9814
  * toggle badges instantly without a page reload. Featured is rendered in the
9775
9815
  * footer meta instead.
9776
9816
  */ var PostStatusBadges = () => {
9817
+ const { i18n } = useLingui();
9818
+ const pinnedLabel = i18n._({ id: "kNiQp6" });
9777
9819
  return /* @__PURE__ */ jsxDEV$1("div", {
9778
9820
  class: "post-status-badges",
9779
9821
  children: [
9780
9822
  /* @__PURE__ */ jsxDEV$1("span", {
9781
9823
  class: "post-status-badge post-status-pinned",
9782
- children: [/* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-status-pin" }), "Pinned"]
9824
+ children: [/* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-status-pin" }), pinnedLabel]
9783
9825
  }),
9784
9826
  /* @__PURE__ */ jsxDEV$1("span", {
9785
9827
  class: "post-status-badge post-status-pinned-in-collection",
9786
- children: [/* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-status-pin" }), "Pinned"]
9828
+ children: [/* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-status-pin" }), pinnedLabel]
9787
9829
  }),
9788
9830
  /* @__PURE__ */ jsxDEV$1("span", {
9789
9831
  class: "post-status-badge post-status-private",
9790
- children: [/* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-status-private" }), "Private"]
9832
+ children: [/* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-status-private" }), i18n._({ id: "zwBp5t" })]
9833
+ }),
9834
+ /* @__PURE__ */ jsxDEV$1("button", {
9835
+ type: "button",
9836
+ class: "post-status-badge post-status-draft",
9837
+ "data-draft-continue": true,
9838
+ title: i18n._({ id: "WIF6ui" }),
9839
+ children: [/* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-status-draft" }), i18n._({ id: "eneWvv" })]
9791
9840
  })
9792
9841
  ]
9793
9842
  });
@@ -9899,20 +9948,36 @@ var NoteCard = ({ post, mode = "feed", display }) => {
9899
9948
  });
9900
9949
  };
9901
9950
  //#endregion
9951
+ //#region src/lib/link-preview.ts
9952
+ var LINK_PREVIEW_PROVIDER_LABELS = {
9953
+ youtube: "YouTube",
9954
+ vimeo: "Vimeo",
9955
+ bilibili: "Bilibili"
9956
+ };
9957
+ /**
9958
+ * Return the display label for a recognized Link preview provider.
9959
+ *
9960
+ * @param provider - Stored provider identifier, such as `youtube`
9961
+ * @returns A stable display label, or undefined for unknown providers
9962
+ * @example
9963
+ * ```ts
9964
+ * getLinkPreviewProviderLabel("youtube"); // "YouTube"
9965
+ * getLinkPreviewProviderLabel("unknown"); // undefined
9966
+ * ```
9967
+ */ function getLinkPreviewProviderLabel(provider) {
9968
+ const normalizedProvider = provider?.trim().toLowerCase();
9969
+ return normalizedProvider ? LINK_PREVIEW_PROVIDER_LABELS[normalizedProvider] : void 0;
9970
+ }
9971
+ //#endregion
9902
9972
  //#region src/ui/feed/LinkPreview.tsx
9903
9973
  /**
9904
9974
  * Link Preview
9905
9975
  *
9906
9976
  * Renders a preview thumbnail for link posts with recognized external content.
9907
9977
  * For video providers (YouTube, etc.) shows a play button overlay and provider badge.
9908
- */ var PROVIDER_LABELS = {
9909
- youtube: "YouTube",
9910
- vimeo: "Vimeo",
9911
- bilibili: "Bilibili"
9912
- };
9913
- var LinkPreview = ({ imageUrl, linkUrl, kind, provider }) => {
9978
+ */ var LinkPreview = ({ imageUrl, linkUrl, kind, provider }) => {
9914
9979
  const isVideo = kind === "video";
9915
- const providerLabel = provider ? PROVIDER_LABELS[provider] : void 0;
9980
+ const providerLabel = getLinkPreviewProviderLabel(provider);
9916
9981
  return /* @__PURE__ */ jsxDEV$1("a", {
9917
9982
  href: linkUrl,
9918
9983
  class: "link-preview",
@@ -10679,7 +10744,7 @@ var PostPage = ({ post, threadPosts, isPreview = false }) => {
10679
10744
  };
10680
10745
  //#endregion
10681
10746
  //#region src/ui/shared/DraftPreviewBar.tsx
10682
- /** Persistent page chrome that distinguishes an authenticated draft preview. */ var DraftPreviewBar = () => {
10747
+ /** Persistent page chrome that distinguishes an authenticated draft preview. */ var DraftPreviewBar = ({ editHref }) => {
10683
10748
  const { i18n } = useLingui();
10684
10749
  const label = i18n._({ id: "27KTPP" });
10685
10750
  return /* @__PURE__ */ jsxDEV$1("aside", {
@@ -10688,15 +10753,22 @@ var PostPage = ({ post, threadPosts, isPreview = false }) => {
10688
10753
  "data-preview-status": true,
10689
10754
  children: /* @__PURE__ */ jsxDEV$1("div", {
10690
10755
  class: "draft-preview-bar-inner",
10691
- children: [/* @__PURE__ */ jsxDEV$1("span", {
10692
- class: "draft-preview-bar-label",
10756
+ children: [/* @__PURE__ */ jsxDEV$1("div", {
10757
+ class: "draft-preview-bar-copy",
10693
10758
  children: [/* @__PURE__ */ jsxDEV$1("span", {
10694
- class: "draft-preview-bar-dot",
10695
- "aria-hidden": "true"
10696
- }), label]
10697
- }), /* @__PURE__ */ jsxDEV$1("span", {
10698
- class: "draft-preview-bar-description",
10699
- children: i18n._({ id: "0Vlj/m" })
10759
+ class: "draft-preview-bar-label",
10760
+ children: [/* @__PURE__ */ jsxDEV$1("span", {
10761
+ class: "draft-preview-bar-dot",
10762
+ "aria-hidden": "true"
10763
+ }), label]
10764
+ }), /* @__PURE__ */ jsxDEV$1("span", {
10765
+ class: "draft-preview-bar-description",
10766
+ children: i18n._({ id: "0Vlj/m" })
10767
+ })]
10768
+ }), /* @__PURE__ */ jsxDEV$1("a", {
10769
+ class: "draft-preview-bar-action",
10770
+ href: editHref,
10771
+ children: i18n._({ id: "Oo7//P" })
10700
10772
  })]
10701
10773
  })
10702
10774
  });
@@ -10810,17 +10882,21 @@ function canViewPost(post, options = {}) {
10810
10882
  const post = await c.var.services.posts.getById(postId);
10811
10883
  if (!post || !canViewPost(post, options)) return null;
10812
10884
  const mediaCtx = createMediaContext(c.var.appConfig);
10813
- const [rawMediaMap, collectionsMap, lastPostMap, aliasesMap] = await Promise.all([
10885
+ const [rawMediaMap, collectionsMap, lastPostMap, draftTailMap, aliasesMap] = await Promise.all([
10814
10886
  c.var.services.media.getByPostIds([post.id]),
10815
10887
  c.var.services.collections.getCollectionsByPostIds([post.id]),
10816
- c.var.services.posts.getLastPostIdsByThread([post.threadId]),
10888
+ c.var.services.posts.getThreadTailIds([post.threadId]),
10889
+ c.var.isAuthenticated ? c.var.services.posts.getThreadTailIds([post.threadId], { includeDrafts: true }) : Promise.resolve(/* @__PURE__ */ new Map()),
10817
10890
  c.var.services.paths.getPostAliases([post.id])
10818
10891
  ]);
10819
10892
  const mediaMap = buildMediaMap(rawMediaMap, mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl, mediaCtx.localPublicUrl, mediaCtx.sitePathPrefix);
10820
- return toPostView({
10893
+ const view = toPostView({
10821
10894
  ...post,
10822
10895
  mediaAttachments: mediaMap.get(post.id) ?? []
10823
10896
  }, mediaCtx, collectionsMap.get(post.id), lastPostMap.get(post.threadId) === post.id, aliasesMap.get(post.id)?.[0]);
10897
+ const draftTailId = resolveDraftTailId(post.threadId, lastPostMap, draftTailMap);
10898
+ if (draftTailId) view.draftTailId = draftTailId;
10899
+ return view;
10824
10900
  }
10825
10901
  /**
10826
10902
  * Assembles the post permalink view, including the full thread when needed.
@@ -10833,7 +10909,8 @@ function canViewPost(post, options = {}) {
10833
10909
  const post = typeof postOrId === "string" ? await c.var.services.posts.getById(postOrId) : postOrId;
10834
10910
  if (!post || !canViewPost(post, options)) return null;
10835
10911
  const mediaCtx = createMediaContext(c.var.appConfig);
10836
- const threadPosts = (await c.var.services.posts.getThread(post.threadId)).filter((threadPost) => threadPost.status === "published" || options?.includeDraftThread && threadPost.status === "draft");
10912
+ const includeDrafts = Boolean(options?.includeDraftThread || c.var.isAuthenticated);
10913
+ const threadPosts = (await c.var.services.posts.getThread(post.threadId)).filter((threadPost) => threadPost.status === "published" || includeDrafts && threadPost.status === "draft");
10837
10914
  const allPostIds = threadPosts.length > 1 ? threadPosts.map((p) => p.id) : [post.id];
10838
10915
  const [rawMediaMap, collectionsMap, aliasesMap] = await Promise.all([
10839
10916
  c.var.services.media.getByPostIds(allPostIds),
@@ -10850,9 +10927,11 @@ function canViewPost(post, options = {}) {
10850
10927
  ...threadPost,
10851
10928
  mediaAttachments: mediaMap.get(threadPost.id) ?? []
10852
10929
  }, mediaCtx, collectionsMap.get(threadPost.id), index === threadPosts.length - 1, firstAlias(threadPost.id))) : void 0;
10853
- const socialImage = resolvePostSocialImage(postView, threadPostViews);
10854
- const rootView = threadPostViews?.[0] ?? postView;
10855
- const articleModifiedTime = (threadPostViews ?? [postView]).map((p) => p.updatedAt).reduce((latest, t) => t > latest ? t : latest);
10930
+ const publishedViews = threadPostViews?.filter((view) => view.status === "published");
10931
+ const metaViews = publishedViews?.length ? publishedViews : [postView];
10932
+ const socialImage = resolvePostSocialImage(postView, metaViews);
10933
+ const rootView = metaViews[0];
10934
+ const articleModifiedTime = metaViews.map((p) => p.updatedAt).reduce((latest, t) => t > latest ? t : latest);
10856
10935
  return {
10857
10936
  postView,
10858
10937
  threadPostViews,
@@ -11076,8 +11155,10 @@ var posts$1 = sqliteTable("post", {
11076
11155
  previewProvider: text("preview_provider"),
11077
11156
  replyToId: text("reply_to_id"),
11078
11157
  threadId: text("thread_id").notNull(),
11158
+ quietReply: integer("quiet_reply", { mode: "boolean" }).notNull().default(false),
11079
11159
  publishedAt: integer("published_at"),
11080
11160
  lastActivityAt: integer("last_activity_at"),
11161
+ threadUpdatedAt: integer("thread_updated_at"),
11081
11162
  createdAt: integer("created_at").notNull(),
11082
11163
  updatedAt: integer("updated_at").notNull()
11083
11164
  }, (table) => [
@@ -11103,6 +11184,7 @@ var posts$1 = sqliteTable("post", {
11103
11184
  index("idx_post_site_status_published").on(table.siteId, table.status, table.publishedAt),
11104
11185
  index("idx_post_site_status_activity").on(table.siteId, table.status, table.lastActivityAt),
11105
11186
  index("idx_post_site_root_published_activity").on(table.siteId, table.lastActivityAt, table.id).where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
11187
+ index("idx_post_site_root_thread_updated").on(table.siteId, table.threadUpdatedAt, table.id).where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
11106
11188
  index("idx_post_site_root_draft_updated").on(table.siteId, table.updatedAt, table.id).where(sql`${table.replyToId} IS NULL AND ${table.status} = 'draft'`),
11107
11189
  index("idx_post_site_reply_thread_created").on(table.siteId, table.threadId, table.createdAt, table.id).where(sql`${table.replyToId} IS NOT NULL AND ${table.status} = 'published'`),
11108
11190
  index("idx_post_site_featured_thread_published").on(table.siteId, table.threadId, table.publishedAt, table.id).where(sql`${table.status} = 'published' AND ${table.featuredAt} IS NOT NULL`)
@@ -11700,8 +11782,10 @@ var posts = pgTable("post", {
11700
11782
  previewProvider: text$1("preview_provider"),
11701
11783
  replyToId: text$1("reply_to_id"),
11702
11784
  threadId: text$1("thread_id").notNull(),
11785
+ quietReply: boolean("quiet_reply").notNull().default(false),
11703
11786
  publishedAt: integer$1("published_at"),
11704
11787
  lastActivityAt: integer$1("last_activity_at"),
11788
+ threadUpdatedAt: integer$1("thread_updated_at"),
11705
11789
  createdAt: integer$1("created_at").notNull(),
11706
11790
  updatedAt: integer$1("updated_at").notNull()
11707
11791
  }, (table) => [
@@ -11727,6 +11811,7 @@ var posts = pgTable("post", {
11727
11811
  index$1("idx_post_site_status_published").on(table.siteId, table.status, table.publishedAt),
11728
11812
  index$1("idx_post_site_status_activity").on(table.siteId, table.status, table.lastActivityAt),
11729
11813
  index$1("idx_post_site_root_published_activity").on(table.siteId, table.lastActivityAt, table.id).where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
11814
+ index$1("idx_post_site_root_thread_updated").on(table.siteId, table.threadUpdatedAt, table.id).where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
11730
11815
  index$1("idx_post_site_root_draft_updated").on(table.siteId, table.updatedAt, table.id).where(sql`${table.replyToId} IS NULL AND ${table.status} = 'draft'`),
11731
11816
  index$1("idx_post_site_reply_thread_created").on(table.siteId, table.threadId, table.createdAt, table.id).where(sql`${table.replyToId} IS NOT NULL AND ${table.status} = 'published'`),
11732
11817
  index$1("idx_post_site_featured_thread_published").on(table.siteId, table.threadId, table.publishedAt, table.id).where(sql`${table.status} = 'published' AND ${table.featuredAt} IS NOT NULL`),
@@ -13605,6 +13690,7 @@ function createMediaService(db, siteId, databaseSchema = sqliteSchemaBundle, dat
13605
13690
  if (merged.hasReplies !== void 0) params.set("replies", merged.hasReplies ? "any" : "none");
13606
13691
  if (merged.visibility) params.set("visibility", merged.visibility === "latest_hidden" ? "hidden" : merged.visibility);
13607
13692
  if (merged.view && merged.view !== "grid") params.set("view", merged.view);
13693
+ if (merged.sort === "updated") params.set("sort", "updated");
13608
13694
  const qs = params.toString();
13609
13695
  return qs ? toPublicPath(`/archive?${qs}`, sitePathPrefix) : toPublicPath("/archive", sitePathPrefix);
13610
13696
  }
@@ -13627,7 +13713,7 @@ function getFormatLabelPlural(format) {
13627
13713
  /** Icon name mapping for post formats. */ var FORMAT_ICONS = {
13628
13714
  note: "notepad-text",
13629
13715
  link: "external-link",
13630
- quote: "text-quote"
13716
+ quote: "quote"
13631
13717
  };
13632
13718
  /** Icon name mapping for media kinds. */ var MEDIA_KIND_ICONS = {
13633
13719
  image: "image",
@@ -13842,34 +13928,62 @@ var ChipClearLink = ({ href, label }) => /* @__PURE__ */ jsxDEV$1("a", {
13842
13928
  ]
13843
13929
  });
13844
13930
  };
13931
+ /**
13932
+ * One option in a toolbar toggle. `label` is used for both the hover tooltip
13933
+ * and the accessible name — an icon-only control has to explain itself to
13934
+ * both audiences, and one string keeps them from drifting apart.
13935
+ */ var ToggleOption = ({ href, icon, label, active }) => /* @__PURE__ */ jsxDEV$1("a", {
13936
+ href,
13937
+ class: `archive-view-btn${active ? " archive-view-btn-active" : ""}`,
13938
+ role: "radio",
13939
+ "aria-checked": active ? "true" : "false",
13940
+ "aria-label": label,
13941
+ title: label,
13942
+ children: /* @__PURE__ */ jsxDEV$1(Icon, {
13943
+ name: icon,
13944
+ class: "[&>svg]:size-4"
13945
+ })
13946
+ });
13845
13947
  var ViewToggle = ({ filters, sitePathPrefix = "" }) => {
13948
+ const { i18n } = useLingui();
13846
13949
  const currentView = filters.view ?? "grid";
13847
- const gridUrl = buildFilterUrl(filters, { view: void 0 }, sitePathPrefix);
13848
- const listUrl = buildFilterUrl(filters, { view: "list" }, sitePathPrefix);
13849
13950
  return /* @__PURE__ */ jsxDEV$1("div", {
13850
13951
  class: "archive-view-toggle",
13851
13952
  role: "radiogroup",
13852
- "aria-label": "View mode",
13853
- children: [/* @__PURE__ */ jsxDEV$1("a", {
13854
- href: gridUrl,
13855
- class: `archive-view-btn${currentView === "grid" ? " archive-view-btn-active" : ""}`,
13856
- role: "radio",
13857
- "aria-checked": currentView === "grid" ? "true" : "false",
13858
- "aria-label": "Grid view",
13859
- children: /* @__PURE__ */ jsxDEV$1(Icon, {
13860
- name: "layout-grid",
13861
- class: "[&>svg]:size-4"
13862
- })
13863
- }), /* @__PURE__ */ jsxDEV$1("a", {
13864
- href: listUrl,
13865
- class: `archive-view-btn${currentView === "list" ? " archive-view-btn-active" : ""}`,
13866
- role: "radio",
13867
- "aria-checked": currentView === "list" ? "true" : "false",
13868
- "aria-label": "List view",
13869
- children: /* @__PURE__ */ jsxDEV$1(Icon, {
13870
- name: "list",
13871
- class: "[&>svg]:size-4"
13872
- })
13953
+ "aria-label": i18n._({ id: "OaiMaf" }),
13954
+ children: [/* @__PURE__ */ jsxDEV$1(ToggleOption, {
13955
+ href: buildFilterUrl(filters, { view: void 0 }, sitePathPrefix),
13956
+ icon: "layout-grid",
13957
+ active: currentView === "grid",
13958
+ label: i18n._({ id: "ZBH98o" })
13959
+ }), /* @__PURE__ */ jsxDEV$1(ToggleOption, {
13960
+ href: buildFilterUrl(filters, { view: "list" }, sitePathPrefix),
13961
+ icon: "list",
13962
+ active: currentView === "list",
13963
+ label: i18n._({ id: "IvUVJl" })
13964
+ })]
13965
+ });
13966
+ };
13967
+ /**
13968
+ * Time-axis toggle. Sits next to the view toggle because it changes how the
13969
+ * page is arranged, not which threads it contains — unlike the filter chips.
13970
+ */ var SortToggle = ({ filters, sitePathPrefix = "" }) => {
13971
+ const { i18n } = useLingui();
13972
+ const sortsByActivity = filters.sort === "updated";
13973
+ return /* @__PURE__ */ jsxDEV$1("div", {
13974
+ class: "archive-view-toggle",
13975
+ role: "radiogroup",
13976
+ "aria-label": i18n._({ id: "AmQ/h/" }),
13977
+ children: [/* @__PURE__ */ jsxDEV$1(ToggleOption, {
13978
+ href: buildFilterUrl(filters, { sort: void 0 }, sitePathPrefix),
13979
+ icon: "clock",
13980
+ active: !sortsByActivity,
13981
+ label: i18n._({ id: "pKeaoC" })
13982
+ }), /* @__PURE__ */ jsxDEV$1(ToggleOption, {
13983
+ href: buildFilterUrl(filters, { sort: "updated" }, sitePathPrefix),
13984
+ icon: "history",
13985
+ active: sortsByActivity,
13986
+ label: i18n._({ id: "SbNisn" })
13873
13987
  })]
13874
13988
  });
13875
13989
  };
@@ -14114,9 +14228,15 @@ var FilterBar = ({ filters, availableYears, availableCollections, isAuthenticate
14114
14228
  iconOnly: true
14115
14229
  })
14116
14230
  ]
14117
- }), /* @__PURE__ */ jsxDEV$1(ViewToggle, {
14118
- filters,
14119
- sitePathPrefix
14231
+ }), /* @__PURE__ */ jsxDEV$1("div", {
14232
+ class: "archive-toolbar-toggles",
14233
+ children: [/* @__PURE__ */ jsxDEV$1(SortToggle, {
14234
+ filters,
14235
+ sitePathPrefix
14236
+ }), /* @__PURE__ */ jsxDEV$1(ViewToggle, {
14237
+ filters,
14238
+ sitePathPrefix
14239
+ })]
14120
14240
  })]
14121
14241
  });
14122
14242
  };
@@ -14309,6 +14429,7 @@ var ArchiveTile = ({ post, timeZone = "UTC" }) => {
14309
14429
  var ArchivePage = ({ groups, items, totalCount, currentPage, totalPages, filters, availableYears, availableCollections, isAuthenticated, sitePathPrefix = "", timeZone = "UTC", feedHref }) => {
14310
14430
  const { i18n } = useLingui();
14311
14431
  const currentView = filters.view ?? "grid";
14432
+ const sortsByActivity = filters.sort === "updated";
14312
14433
  const paginationBaseUrl = buildFilterUrl(filters, {}, sitePathPrefix);
14313
14434
  const totalCountUnit = totalCount === 1 ? i18n._({ id: "BdjLtf" }) : i18n._({ id: "avuFKG" });
14314
14435
  return /* @__PURE__ */ jsxDEV$1("div", {
@@ -14335,6 +14456,7 @@ var ArchivePage = ({ groups, items, totalCount, currentPage, totalPages, filters
14335
14456
  }),
14336
14457
  " ",
14337
14458
  totalCountUnit,
14459
+ sortsByActivity && /* @__PURE__ */ jsxDEV$1(Fragment$1, { children: [" · ", i18n._({ id: "iZYiBK" })] }),
14338
14460
  feedHref && /* @__PURE__ */ jsxDEV$1(Fragment$1, { children: [" ", /* @__PURE__ */ jsxDEV$1("a", {
14339
14461
  href: toPublicPath(feedHref, sitePathPrefix),
14340
14462
  class: "feed-link",
@@ -14364,7 +14486,7 @@ var ArchivePage = ({ groups, items, totalCount, currentPage, totalPages, filters
14364
14486
  class: "contents",
14365
14487
  children: [/* @__PURE__ */ jsxDEV$1(ArchiveMonthHeader, {
14366
14488
  class: `archive-month-header${groupIndex > 0 ? " archive-month-header-spaced" : ""}`,
14367
- label: group.label,
14489
+ label: sortsByActivity ? `Updated ${group.label}` : group.label,
14368
14490
  count: group.totalCount
14369
14491
  }), group.posts.map((post) => /* @__PURE__ */ jsxDEV$1(ArchiveTile, {
14370
14492
  post,
@@ -14421,6 +14543,33 @@ var ArchivePage = ({ groups, items, totalCount, currentPage, totalPages, filters
14421
14543
  return str.replaceAll("]]>", "]]]]><![CDATA[>");
14422
14544
  }
14423
14545
  /**
14546
+ * Resolve a URL for use outside the feed document's browser context.
14547
+ *
14548
+ * Feed readers do not consistently resolve root-relative URLs found inside
14549
+ * Atom HTML content or enclosure attributes, so every non-fragment URL must
14550
+ * carry its own origin.
14551
+ */ function toAbsoluteFeedUrl(url, siteUrl) {
14552
+ const normalizedUrl = url.trim();
14553
+ if (!normalizedUrl || normalizedUrl.startsWith("#")) return normalizedUrl;
14554
+ try {
14555
+ const baseUrl = siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`;
14556
+ return new URL(normalizedUrl, baseUrl).toString();
14557
+ } catch {
14558
+ return normalizedUrl;
14559
+ }
14560
+ }
14561
+ /**
14562
+ * Resolve navigational and media URL attributes inside trusted post HTML.
14563
+ *
14564
+ * Fragment-only links stay local to the rendered feed entry so footnotes and
14565
+ * other in-entry references continue to work.
14566
+ */ function absolutizeFeedHtmlUrls(html, siteUrl) {
14567
+ return html.replaceAll(/(\s)(href|poster|src)=(["'])([^"']*)\3/gi, (match, whitespace, attribute, quote, url) => {
14568
+ const absoluteUrl = toAbsoluteFeedUrl(url, siteUrl);
14569
+ return absoluteUrl ? `${whitespace}${attribute}=${quote}${absoluteUrl}${quote}` : match;
14570
+ });
14571
+ }
14572
+ /**
14424
14573
  * Strip embedded content that is unsafe or unsupported in feed readers.
14425
14574
  *
14426
14575
  * - `<figure class="tiptap-embed-figure">` is replaced by its fallback link
@@ -14465,6 +14614,24 @@ function renderInlinePostHeader(post, permalinkUrl) {
14465
14614
  */ function renderRatingHtml(rating) {
14466
14615
  return `<p>${"★".repeat(rating)}${"☆".repeat(5 - rating)} ${rating}/5</p>`;
14467
14616
  }
14617
+ /**
14618
+ * Render a Link post preview as feed-safe HTML.
14619
+ *
14620
+ * Feed readers commonly strip CSS overlays and embedded players, so video
14621
+ * previews use a linked thumbnail plus a visible provider-aware action.
14622
+ * Non-video Link previews keep the linked thumbnail without a video label.
14623
+ */ function renderLinkPreviewForFeed(post, siteUrl) {
14624
+ if (post.format !== "link") return "";
14625
+ const imageUrl = post.previewImageUrl?.trim();
14626
+ const linkUrl = post.url?.trim();
14627
+ if (!imageUrl || !linkUrl) return "";
14628
+ const isVideo = post.previewKind?.trim().toLowerCase() === "video";
14629
+ const providerLabel = getLinkPreviewProviderLabel(post.previewProvider);
14630
+ const fallbackAlt = isVideo ? providerLabel ? `${providerLabel} video` : "Video preview" : "Link preview";
14631
+ const altText = post.title?.trim() || fallbackAlt;
14632
+ const caption = isVideo ? `<figcaption><a href="${escapeXml(linkUrl)}">▶ ${providerLabel ? `Watch on ${providerLabel}` : "Watch video"}</a></figcaption>` : "";
14633
+ return `<figure><a href="${escapeXml(linkUrl)}"><img src="${escapeXml(toAbsoluteFeedUrl(imageUrl, siteUrl))}" alt="${escapeXml(altText)}"/></a>${caption}</figure>`;
14634
+ }
14468
14635
  function formatFeedBytes(bytes) {
14469
14636
  if (bytes < 1024) return `${bytes} B`;
14470
14637
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
@@ -14508,9 +14675,9 @@ function getMediaMeta(item) {
14508
14675
  * - Audio, text, and document attachments render as plain links with size
14509
14676
  * and duration metadata when known. Text attachments link to the rendered
14510
14677
  * preview page when a post permalink is available.
14511
- */ function renderMediaItem(item, postPermalinkUrl) {
14678
+ */ function renderMediaItem(item, siteUrl, postPermalinkUrl) {
14512
14679
  const category = getMediaCategory(item.mimeType);
14513
- const url = escapeXml(item.url);
14680
+ const url = escapeXml(toAbsoluteFeedUrl(item.url, siteUrl));
14514
14681
  const name = item.originalName ?? "";
14515
14682
  const altText = item.altText ?? "";
14516
14683
  const caption = item.altText?.trim() || "";
@@ -14521,7 +14688,7 @@ function getMediaMeta(item) {
14521
14688
  return `<figure><a href="${url}"><img src="${url}" alt="${escapeXml(altText)}"${dims}/></a>${figcaption}</figure>`;
14522
14689
  }
14523
14690
  if (category === "video") {
14524
- const poster = item.posterUrl || item.thumbnailUrl;
14691
+ const poster = toAbsoluteFeedUrl(item.posterUrl || item.thumbnailUrl, siteUrl);
14525
14692
  const dims = item.width && item.height ? ` width="${item.width}" height="${item.height}"` : "";
14526
14693
  const metaSuffix = meta ? ` (${escapeXml(meta)})` : "";
14527
14694
  return `<figure><a href="${url}"><img src="${escapeXml(poster)}" alt="${escapeXml(altText || name)}"${dims}/></a><figcaption><a href="${url}">▶ Watch video</a>${metaSuffix}</figcaption></figure>`;
@@ -14540,9 +14707,9 @@ function getMediaMeta(item) {
14540
14707
  /**
14541
14708
  * Render all media attachments for a post as HTML for embedding in feed
14542
14709
  * content. Returns an empty string when the post has no media.
14543
- */ function renderMediaForFeed(media, postPermalinkUrl) {
14710
+ */ function renderMediaForFeed(media, siteUrl, postPermalinkUrl) {
14544
14711
  if (media.length === 0) return "";
14545
- return media.map((item) => renderMediaItem(item, postPermalinkUrl)).join("\n");
14712
+ return media.map((item) => renderMediaItem(item, siteUrl, postPermalinkUrl)).join("\n");
14546
14713
  }
14547
14714
  /**
14548
14715
  * Build the HTML content for a single post (root or reply).
@@ -14550,7 +14717,7 @@ function getMediaMeta(item) {
14550
14717
  * @param post - Post view data
14551
14718
  * @param permalinkUrl - Absolute permalink URL back to the blog post
14552
14719
  * @param options - Rendering options for top-level versus inline posts
14553
- */ function buildSinglePostContent(post, permalinkUrl, options = {}) {
14720
+ */ function buildSinglePostContent(post, siteUrl, permalinkUrl, options = {}) {
14554
14721
  const parts = [];
14555
14722
  if (options.inline) parts.push(...renderInlinePostHeader(post, permalinkUrl));
14556
14723
  if (post.format === "quote" && post.quoteText) {
@@ -14564,8 +14731,10 @@ function getMediaMeta(item) {
14564
14731
  parts.push(`<p>— ${source}</p>`);
14565
14732
  }
14566
14733
  }
14567
- if (post.bodyHtml) parts.push(stripUnsafeFeedHtml(post.bodyHtml));
14568
- const mediaHtml = renderMediaForFeed(post.media, permalinkUrl);
14734
+ const linkPreviewHtml = renderLinkPreviewForFeed(post, siteUrl);
14735
+ if (linkPreviewHtml) parts.push(linkPreviewHtml);
14736
+ if (post.bodyHtml) parts.push(absolutizeFeedHtmlUrls(stripUnsafeFeedHtml(post.bodyHtml), siteUrl));
14737
+ const mediaHtml = renderMediaForFeed(post.media, siteUrl, permalinkUrl);
14569
14738
  if (mediaHtml) parts.push(mediaHtml);
14570
14739
  if (post.rating && post.rating > 0) parts.push(renderRatingHtml(post.rating));
14571
14740
  if (parts.length === 0) parts.push(`<p>${escapeXml(getFeedSummaryText(post))}</p>`);
@@ -14579,7 +14748,7 @@ function getMediaMeta(item) {
14579
14748
  * @param siteUrl - Site base URL for building absolute permalinks
14580
14749
  * @param permalinkUrl - Absolute permalink URL for the root post
14581
14750
  */ function buildFeedContent(post, siteUrl, permalinkUrl) {
14582
- const rootContent = buildSinglePostContent(post, permalinkUrl);
14751
+ const rootContent = buildSinglePostContent(post, siteUrl, permalinkUrl);
14583
14752
  const replies = post.threadReplies;
14584
14753
  if (!replies || replies.length === 0) return rootContent;
14585
14754
  const parts = [rootContent];
@@ -14587,7 +14756,7 @@ function getMediaMeta(item) {
14587
14756
  const replyPermalink = new URL(reply.permalink, siteUrl).toString();
14588
14757
  parts.push("<hr/>");
14589
14758
  parts.push(`<p><small><time datetime="${escapeXml(reply.publishedAt)}">${escapeXml(reply.publishedAtFormatted)}</time></small></p>`);
14590
- parts.push(buildSinglePostContent(reply, replyPermalink, { inline: true }));
14759
+ parts.push(buildSinglePostContent(reply, siteUrl, replyPermalink, { inline: true }));
14591
14760
  }
14592
14761
  return parts.join("\n");
14593
14762
  }
@@ -14617,7 +14786,7 @@ function getEntryMedia(post) {
14617
14786
  const enclosureLinks = getEntryMedia(post).map((m) => {
14618
14787
  const lengthAttr = m.size != null && m.size > 0 ? ` length="${m.size}"` : "";
14619
14788
  const titleAttr = m.originalName ? ` title="${escapeXml(m.originalName)}"` : "";
14620
- return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(m.url)}"${lengthAttr}${titleAttr}/>`;
14789
+ return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(toAbsoluteFeedUrl(m.url, siteUrl))}"${lengthAttr}${titleAttr}/>`;
14621
14790
  }).join("");
14622
14791
  return `
14623
14792
  <entry>
@@ -14630,7 +14799,7 @@ function getEntryMedia(post) {
14630
14799
  <content type="html"><![CDATA[${escapeCdata(buildFeedContent(post, siteUrl, permalinkUrl))}]]></content>
14631
14800
  </entry>`;
14632
14801
  }).join("");
14633
- const now = (/* @__PURE__ */ new Date()).toISOString();
14802
+ const feedUpdated = posts.map((post) => post.feedUpdatedAt ?? post.updatedAt).reduce((latest, value) => latest === null || value > latest ? value : latest, null) ?? (/* @__PURE__ */ new Date()).toISOString();
14634
14803
  return `<?xml version="1.0" encoding="UTF-8"?>
14635
14804
  <feed xmlns="http://www.w3.org/2005/Atom">
14636
14805
  <title>${escapeXml(feedTitle)}</title>
@@ -14638,7 +14807,7 @@ function getEntryMedia(post) {
14638
14807
  <link href="${escapeXml(siteUrl)}" rel="alternate"/>
14639
14808
  <link href="${escapeXml(selfUrl)}" rel="self"/>
14640
14809
  <id>${escapeXml(selfUrl)}</id>
14641
- <updated>${now}</updated>
14810
+ <updated>${feedUpdated}</updated>
14642
14811
  ${entries}
14643
14812
  </feed>`;
14644
14813
  }
@@ -14758,6 +14927,7 @@ ${entries.map((entry) => {
14758
14927
  const visibility = visibilityParam && VALID_VISIBILITIES.includes(visibilityParam) ? visibilityParam : void 0;
14759
14928
  const viewParam = q("view");
14760
14929
  const view = viewParam && (viewParam === "grid" || viewParam === "list") ? viewParam : void 0;
14930
+ const sort = q("sort") === "updated" ? "updated" : "published";
14761
14931
  const pageParam = c.req.query("page");
14762
14932
  const currentPage = Math.max(1, parseInt(pageParam || "1", 10) || 1);
14763
14933
  return {
@@ -14771,6 +14941,7 @@ ${entries.map((entry) => {
14771
14941
  visibility,
14772
14942
  visibilityAll,
14773
14943
  view,
14944
+ sort,
14774
14945
  currentPage
14775
14946
  };
14776
14947
  }
@@ -14783,12 +14954,11 @@ ${entries.map((entry) => {
14783
14954
  */ function buildArchivePostFilters(params, opts) {
14784
14955
  const { isAuthenticated, collectionId } = opts;
14785
14956
  const effectiveVisibility = isAuthenticated ? params.visibilityAll ? void 0 : params.visibility ?? void 0 : void 0;
14786
- let publishedAfter;
14787
- let publishedBefore;
14788
- if (params.validYear) {
14789
- publishedAfter = Date.UTC(params.validYear, 0, 1) / 1e3;
14790
- publishedBefore = Date.UTC(params.validYear + 1, 0, 1) / 1e3;
14791
- }
14957
+ const yearRange = params.validYear ? {
14958
+ after: Date.UTC(params.validYear, 0, 1) / 1e3,
14959
+ before: Date.UTC(params.validYear + 1, 0, 1) / 1e3
14960
+ } : void 0;
14961
+ const sortsByActivity = params.sort === "updated";
14792
14962
  return {
14793
14963
  format: params.format,
14794
14964
  status: "published",
@@ -14797,19 +14967,26 @@ ${entries.map((entry) => {
14797
14967
  excludeLatestHidden: false,
14798
14968
  ...effectiveVisibility === "featured" ? { featured: true } : effectiveVisibility ? { visibility: effectiveVisibility } : {},
14799
14969
  collectionId,
14800
- publishedAfter,
14801
- publishedBefore,
14970
+ ...yearRange ? sortsByActivity ? {
14971
+ axisAfter: yearRange.after,
14972
+ axisBefore: yearRange.before
14973
+ } : {
14974
+ publishedAfter: yearRange.after,
14975
+ publishedBefore: yearRange.before
14976
+ } : {},
14802
14977
  mediaKinds: params.mediaKinds,
14803
14978
  hasMedia: params.hasMedia,
14804
14979
  hasTitle: params.hasTitle,
14805
14980
  hasReplies: params.hasReplies,
14806
- sortBy: "published",
14981
+ sortBy: sortsByActivity ? "thread_updated" : "published",
14807
14982
  ignorePinnedSort: true
14808
14983
  };
14809
14984
  }
14810
14985
  /**
14811
14986
  * Build a query string from parsed archive params (for feed self-URL and
14812
- * archive page feed link). Omits page-only params (view, page).
14987
+ * archive page feed link). Omits view and page — those shape the rendered
14988
+ * page, not the result set — but carries `sort`, so the feed button on an
14989
+ * updated-sorted page hands back the matching feed.
14813
14990
  */ function buildArchiveFeedQuery(params) {
14814
14991
  const qs = new URLSearchParams();
14815
14992
  if (params.format) qs.set("format", params.format);
@@ -14819,6 +14996,7 @@ ${entries.map((entry) => {
14819
14996
  else if (params.hasMedia !== void 0) qs.set("media", params.hasMedia ? "any" : "none");
14820
14997
  if (params.hasTitle !== void 0) qs.set("title", params.hasTitle ? "any" : "none");
14821
14998
  if (params.hasReplies !== void 0) qs.set("replies", params.hasReplies ? "any" : "none");
14999
+ if (params.sort === "updated") qs.set("sort", "updated");
14822
15000
  const str = qs.toString();
14823
15001
  return str ? `?${str}` : "";
14824
15002
  }
@@ -14886,7 +15064,8 @@ var archiveRoutes = new Hono();
14886
15064
  }),
14887
15065
  services.posts.getDistinctYears({
14888
15066
  status: "published",
14889
- excludeReplies: true
15067
+ excludeReplies: true,
15068
+ sortBy: filters.sortBy
14890
15069
  }),
14891
15070
  services.collections.list()
14892
15071
  ]);
@@ -14902,7 +15081,7 @@ var archiveRoutes = new Hono();
14902
15081
  else {
14903
15082
  const grouped = /* @__PURE__ */ new Map();
14904
15083
  for (const post of posts) {
14905
- const key = formatYearMonth(post.publishedAt ?? post.updatedAt, appConfig.timeZone);
15084
+ const key = formatYearMonth(params.sort === "updated" ? post.threadUpdatedAt : post.publishedAt ?? post.updatedAt, appConfig.timeZone);
14906
15085
  if (!grouped.has(key)) grouped.set(key, []);
14907
15086
  grouped.get(key).push({
14908
15087
  ...post,
@@ -14937,7 +15116,8 @@ var archiveRoutes = new Hono();
14937
15116
  hasTitle: params.hasTitle,
14938
15117
  hasReplies: params.hasReplies,
14939
15118
  visibility: effectiveVisibility,
14940
- view: params.view
15119
+ view: params.view,
15120
+ sort: params.sort === "updated" ? "updated" : void 0
14941
15121
  };
14942
15122
  const feedQuery = buildArchiveFeedQuery(params);
14943
15123
  const availableCollectionsList = allCollections.map((col) => ({
@@ -14945,7 +15125,7 @@ var archiveRoutes = new Hono();
14945
15125
  title: col.title
14946
15126
  }));
14947
15127
  return renderPublicPage(c, {
14948
- title: buildPageTitle("Archive", navData.siteName),
15128
+ title: buildPageTitle("Archive", params.sort === "updated" ? "Recently updated" : void 0, navData.siteName),
14949
15129
  navData,
14950
15130
  content: /* @__PURE__ */ jsxDEV$1(ArchivePage, {
14951
15131
  groups,
@@ -15005,6 +15185,7 @@ async function buildArchiveFeedData(c, selfPath) {
15005
15185
  const collection = params.collectionSlug ? await services.collections.getBySlug(params.collectionSlug) : void 0;
15006
15186
  const rssPublishedBefore = getRssPublishedBefore(appConfig.rssPublishDelaySeconds);
15007
15187
  const yearPublishedBefore = params.validYear ? Date.UTC(params.validYear + 1, 0, 1) / 1e3 : void 0;
15188
+ const sortsByActivity = params.sort === "updated";
15008
15189
  const filters = {
15009
15190
  format: params.format,
15010
15191
  status: "published",
@@ -15016,9 +15197,13 @@ async function buildArchiveFeedData(c, selfPath) {
15016
15197
  hasMedia: params.hasMedia,
15017
15198
  hasTitle: params.hasTitle,
15018
15199
  hasReplies: params.hasReplies,
15200
+ sortBy: sortsByActivity ? "thread_updated" : "published",
15019
15201
  ignorePinnedSort: true,
15020
- ...params.validYear ? { publishedAfter: Date.UTC(params.validYear, 0, 1) / 1e3 } : {},
15021
- publishedBefore: yearPublishedBefore === void 0 ? rssPublishedBefore : Math.min(yearPublishedBefore, rssPublishedBefore),
15202
+ ...params.validYear ? sortsByActivity ? {
15203
+ axisAfter: Date.UTC(params.validYear, 0, 1) / 1e3,
15204
+ axisBefore: yearPublishedBefore
15205
+ } : { publishedAfter: Date.UTC(params.validYear, 0, 1) / 1e3 } : {},
15206
+ publishedBefore: yearPublishedBefore === void 0 || sortsByActivity ? rssPublishedBefore : Math.min(yearPublishedBefore, rssPublishedBefore),
15022
15207
  limit: appConfig.rssFeedLimit
15023
15208
  };
15024
15209
  const posts = await services.posts.list(filters);
@@ -15985,6 +16170,7 @@ async function renderPost(c, post, options = {}) {
15985
16170
  } : threadPost) : display.threadPostViews;
15986
16171
  const previewTitle = options.isPreview ? getI18n(c)._({ id: "27KTPP" }) : null;
15987
16172
  const canonicalHref = buildPostCanonicalHref(display.postView, display.threadPostViews, c.var.appConfig.siteUrl);
16173
+ const draftEditHref = `${toPublicPath(`/preview/${post.slug}`, c.var.appConfig.sitePathPrefix)}?edit=1`;
15988
16174
  return renderPublicPage(c, {
15989
16175
  title: previewTitle ? buildPageTitle(previewTitle, meta.title, navData.siteName) : buildPageTitle(meta.title, navData.siteName),
15990
16176
  description: meta.description,
@@ -16001,8 +16187,7 @@ async function renderPost(c, post, options = {}) {
16001
16187
  } : {},
16002
16188
  navData,
16003
16189
  ...options.isPreview ? {
16004
- pageChrome: /* @__PURE__ */ jsxDEV$1(DraftPreviewBar, {}),
16005
- showComposeDialog: false,
16190
+ pageChrome: /* @__PURE__ */ jsxDEV$1(DraftPreviewBar, { editHref: draftEditHref }),
16006
16191
  noindex: true
16007
16192
  } : {},
16008
16193
  content: /* @__PURE__ */ jsxDEV$1(PostPage, {
@@ -23279,8 +23464,8 @@ async function syncHostedControlPlaneSiteAvatar(input) {
23279
23464
  return;
23280
23465
  }
23281
23466
  await markSyncPending(settings);
23282
- const { createGitHubSyncService } = await import("./github-sync-ppWXN3jb.js");
23283
- const { getGitHubAppConfig } = await import("./env-OHRKGcMj.js").then((n) => n.t);
23467
+ const { createGitHubSyncService } = await import("./github-sync-Bqg62IvV.js");
23468
+ const { getGitHubAppConfig } = await import("./env-BPYViDJJ.js").then((n) => n.t);
23284
23469
  const run = runBackgroundSync(settings, createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
23285
23470
  storage: c.var.storage,
23286
23471
  githubApp: getGitHubAppConfig(c.env)
@@ -24290,7 +24475,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
24290
24475
  if (getGitHubAppConfig(c.env)) return dsToast("This deployment uses GitHub App authentication. Use Install GitHub App instead.", "error");
24291
24476
  const body = await c.req.json();
24292
24477
  if (!body.token?.trim() || !body.repo?.trim()) return dsToast("Token and repository are required.", "error");
24293
- const { parseRepoSlug, createGitHubClient } = await import("./github-api-BgSiE71w.js").then((n) => n.n);
24478
+ const { parseRepoSlug, createGitHubClient } = await import("./github-api-BNF35Lkx.js").then((n) => n.n);
24294
24479
  const parsed = parseRepoSlug(body.repo);
24295
24480
  if (!parsed) return dsToast("Invalid repository format. Use owner/repo.", "error");
24296
24481
  const client = createGitHubClient(body.token);
@@ -24309,7 +24494,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
24309
24494
  await c.var.services.settings.set("GITHUB_SYNC_AUTH_MODE", "pat");
24310
24495
  await c.var.services.settings.set("GITHUB_SYNC_APP_INSTALLATION_ID", "");
24311
24496
  await c.var.services.settings.set("GITHUB_SYNC_ENABLED", "true");
24312
- const { createGitHubSyncService } = await import("./github-sync-ppWXN3jb.js");
24497
+ const { createGitHubSyncService } = await import("./github-sync-Bqg62IvV.js");
24313
24498
  const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
24314
24499
  storage: c.var.storage,
24315
24500
  githubApp: getGitHubAppConfig(c.env)
@@ -24328,7 +24513,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
24328
24513
  return dsRedirect(publicPath(c, "/settings/github-sync"));
24329
24514
  });
24330
24515
  settingsRoutes.post("/github-sync/push", async (c) => {
24331
- const { createGitHubSyncService } = await import("./github-sync-ppWXN3jb.js");
24516
+ const { createGitHubSyncService } = await import("./github-sync-Bqg62IvV.js");
24332
24517
  const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
24333
24518
  storage: c.var.storage,
24334
24519
  githubApp: getGitHubAppConfig(c.env)
@@ -24348,7 +24533,7 @@ settingsRoutes.post("/github-sync/push", async (c) => {
24348
24533
  });
24349
24534
  });
24350
24535
  settingsRoutes.post("/github-sync/disconnect", async (c) => {
24351
- const { createGitHubSyncService } = await import("./github-sync-ppWXN3jb.js");
24536
+ const { createGitHubSyncService } = await import("./github-sync-Bqg62IvV.js");
24352
24537
  await createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), { githubApp: getGitHubAppConfig(c.env) }).teardownWebhook();
24353
24538
  return dsRedirect(publicPath(c, "/settings/github-sync"));
24354
24539
  });
@@ -24536,10 +24721,10 @@ function buildRepoPickerLabels(c) {
24536
24721
  const repo = String(body.repo ?? "").trim();
24537
24722
  const confirmForeign = body.confirmForeign === true || body.confirmForeign === "true";
24538
24723
  if (!installationId || !repo) return wantsJson ? c.json({ error: "Missing installationId or repo." }, 400) : c.text("Missing installationId or repo.", 400);
24539
- const { parseRepoSlug, createGitHubClient } = await import("./github-api-BgSiE71w.js").then((n) => n.n);
24724
+ const { parseRepoSlug, createGitHubClient } = await import("./github-api-BNF35Lkx.js").then((n) => n.n);
24540
24725
  const parsed = parseRepoSlug(repo);
24541
24726
  if (!parsed) return wantsJson ? c.json({ error: "Invalid repository format." }, 400) : c.text("Invalid repository format.", 400);
24542
- const { classifyRepoForSync } = await import("./github-sync-ppWXN3jb.js");
24727
+ const { classifyRepoForSync } = await import("./github-sync-Bqg62IvV.js");
24543
24728
  const ghClient = createGitHubClient(() => getInstallationTokenFromApp(app, installationId));
24544
24729
  let classification;
24545
24730
  try {
@@ -24569,7 +24754,7 @@ function buildRepoPickerLabels(c) {
24569
24754
  await c.var.services.settings.set("GITHUB_SYNC_REPO", repo);
24570
24755
  await c.var.services.settings.set("GITHUB_SYNC_TOKEN", "");
24571
24756
  await c.var.services.settings.set("GITHUB_SYNC_ENABLED", "true");
24572
- const { createGitHubSyncService } = await import("./github-sync-ppWXN3jb.js");
24757
+ const { createGitHubSyncService } = await import("./github-sync-Bqg62IvV.js");
24573
24758
  const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
24574
24759
  storage: c.var.storage,
24575
24760
  githubApp: app
@@ -24608,7 +24793,7 @@ function requireGitHubApp(c) {
24608
24793
  * build a client for classification without adding the helper to the
24609
24794
  * top-level imports (the module already lazy-imports github-api).
24610
24795
  */ async function getInstallationTokenFromApp(app, installationId) {
24611
- const { getInstallationToken } = await import("./github-app-BbklkFmU.js").then((n) => n.i);
24796
+ const { getInstallationToken } = await import("./github-app-DdiD-bC4.js").then((n) => n.i);
24612
24797
  return getInstallationToken(app, installationId);
24613
24798
  }
24614
24799
  /** List GitHub App installations authorized for this site. */ settingsRoutes.get("/github-sync/app/installations", async (c) => {
@@ -24682,10 +24867,10 @@ function requireGitHubApp(c) {
24682
24867
  const installationId = String(body.installationId ?? "").trim();
24683
24868
  const repo = String(body.repo ?? "").trim();
24684
24869
  if (!installationId || !repo) return c.json({ error: "Missing installationId or repo." }, 400);
24685
- const { parseRepoSlug, createGitHubClient } = await import("./github-api-BgSiE71w.js").then((n) => n.n);
24870
+ const { parseRepoSlug, createGitHubClient } = await import("./github-api-BNF35Lkx.js").then((n) => n.n);
24686
24871
  const parsed = parseRepoSlug(repo);
24687
24872
  if (!parsed) return c.json({ error: "Invalid repository format." }, 400);
24688
- const { classifyRepoForSync } = await import("./github-sync-ppWXN3jb.js");
24873
+ const { classifyRepoForSync } = await import("./github-sync-Bqg62IvV.js");
24689
24874
  const client = createGitHubClient(() => getInstallationTokenFromApp(app, installationId));
24690
24875
  try {
24691
24876
  const classification = await classifyRepoForSync(client, parsed.owner, parsed.repo, c.var.currentSite.id);
@@ -25421,18 +25606,22 @@ postsApiRoutes.get("/:id/content", requireAuthApi(), async (c) => {
25421
25606
  });
25422
25607
  postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
25423
25608
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
25424
- const [post, mediaList, threadCollections] = await Promise.all([
25609
+ const [post, mediaList, threadCollections, threadPosition] = await Promise.all([
25425
25610
  c.var.services.posts.getById(id),
25426
25611
  c.var.services.media.getByPostId(id),
25427
- c.var.services.collections.getCollectionsByPostId(id)
25612
+ c.var.services.collections.getCollectionsByPostId(id),
25613
+ c.var.services.posts.getThreadPosition(id)
25428
25614
  ]);
25429
25615
  const foundPost = assertFound(post, "Post");
25430
25616
  const { r2PublicUrl, imageTransformUrl, s3PublicUrl, localPublicUrl, sitePathPrefix } = c.var.appConfig;
25431
25617
  const collectionIds = threadCollections.map((col) => col.id);
25432
- return c.json(toApiPost(foundPost, {
25433
- collectionIds,
25434
- attachments: mediaList.map((m) => toApiAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl, localPublicUrl, sitePathPrefix))
25435
- }));
25618
+ return c.json({
25619
+ ...toApiPost(foundPost, {
25620
+ collectionIds,
25621
+ attachments: mediaList.map((m) => toApiAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl, localPublicUrl, sitePathPrefix))
25622
+ }),
25623
+ threadPosition
25624
+ });
25436
25625
  });
25437
25626
  postsApiRoutes.post("/", requireAuthApi(), async (c) => {
25438
25627
  const body = parseValidated(CreatePostApiSchema, await c.req.json());
@@ -28087,6 +28276,7 @@ function createPathService(db, siteId, databaseSchema = sqliteSchemaBundle) {
28087
28276
  const postRows = await db.select({
28088
28277
  title: posts.title,
28089
28278
  format: posts.format,
28279
+ status: posts.status,
28090
28280
  path: pathRegistry.path
28091
28281
  }).from(pathRegistry).innerJoin(posts, and(eq(posts.id, pathRegistry.postId), eq(posts.siteId, siteId))).where(and(eq(pathRegistry.siteId, siteId), eq(pathRegistry.kind, "slug"), isNotNull(pathRegistry.postId), isNotNull(posts.title), eq(posts.format, "note")));
28092
28282
  const collectionRows = await db.select({
@@ -28100,7 +28290,8 @@ function createPathService(db, siteId, databaseSchema = sqliteSchemaBundle) {
28100
28290
  title: row.title,
28101
28291
  path: row.path,
28102
28292
  type: "post",
28103
- format: row.format
28293
+ format: row.format,
28294
+ status: row.status
28104
28295
  });
28105
28296
  }
28106
28297
  for (const row of collectionRows) items.push({
@@ -28269,11 +28460,20 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28269
28460
  const { navItems, pathRegistry, posts, sites, threadCollections } = databaseSchema;
28270
28461
  const databaseDialect = config.databaseDialect ?? "sqlite";
28271
28462
  const usesBatchWrites = !supportsDrizzleTransaction(db, databaseDialect);
28272
- function buildPublishedYearMonthExpr() {
28273
- return databaseDialect === "pg" ? sql`to_char(timezone('UTC', to_timestamp(${posts.publishedAt})), 'YYYY-MM')` : sql`strftime('%Y-%m', ${posts.publishedAt}, 'unixepoch')`;
28463
+ /**
28464
+ * Column that carries the time axis selected by `PostFilters.sortBy`.
28465
+ * Sorting, year/month bucketing, and date-range filters all read it through
28466
+ * here so they can never drift onto different columns.
28467
+ */ function timeAxisColumn(filters) {
28468
+ if (filters.sortBy === "activity") return posts.lastActivityAt;
28469
+ if (filters.sortBy === "thread_updated") return posts.threadUpdatedAt;
28470
+ return posts.publishedAt;
28274
28471
  }
28275
- function buildPublishedYearExpr() {
28276
- return databaseDialect === "pg" ? sql`to_char(timezone('UTC', to_timestamp(${posts.publishedAt})), 'YYYY')` : sql`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`;
28472
+ function buildYearMonthExpr(column) {
28473
+ return databaseDialect === "pg" ? sql`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY-MM')` : sql`strftime('%Y-%m', ${column}, 'unixepoch')`;
28474
+ }
28475
+ function buildYearExpr(column) {
28476
+ return databaseDialect === "pg" ? sql`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY')` : sql`strftime('%Y', ${column}, 'unixepoch')`;
28277
28477
  }
28278
28478
  const effectiveVisibilityExpr = sql`coalesce(
28279
28479
  ${posts.visibility},
@@ -28290,11 +28490,36 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28290
28490
  async function pathExists(path) {
28291
28491
  return (await db.select({ id: pathRegistry.id }).from(pathRegistry).where(and(eq(pathRegistry.siteId, siteId), eq(pathRegistry.path, normalizePath(path)))).limit(1)).length > 0;
28292
28492
  }
28293
- async function recalculateThreadLastActivity(rootId) {
28294
- const latestPublishedAt = (await db.select({ latestPublishedAt: sql`MAX(${posts.publishedAt})`.as("latest_published_at") }).from(posts).where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId))))[0]?.latestPublishedAt ?? null;
28295
- const root = await db.select({ updatedAt: posts.updatedAt }).from(posts).where(and(eq(posts.siteId, siteId), eq(posts.id, rootId))).limit(1);
28296
- const lastActivityAt = latestPublishedAt ?? root[0]?.updatedAt ?? now();
28297
- await db.update(posts).set({ lastActivityAt }).where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)));
28493
+ /**
28494
+ * Recompute both Thread activity timestamps on the root from the Thread's
28495
+ * rows.
28496
+ *
28497
+ * - `lastActivityAt` — newest published post excluding quiet replies. This
28498
+ * is "last announced" and drives Latest and the feeds.
28499
+ * - `threadUpdatedAt` — newest published post including quiet replies. This
28500
+ * is "last changed" and drives the archive's updated sort.
28501
+ *
28502
+ * Both are derived, never accumulated, so a quiet reply stays quiet no
28503
+ * matter what later edits or deletions trigger a recalculation.
28504
+ *
28505
+ * @param rootId - Thread root post ID
28506
+ */ async function recalculateThreadActivity(rootId) {
28507
+ const rootRows = await db.select({
28508
+ announcedAt: sql`MAX(
28509
+ CASE
28510
+ WHEN ${posts.quietReply} THEN NULL
28511
+ ELSE ${posts.publishedAt}
28512
+ END
28513
+ )`.as("announced_at"),
28514
+ updatedAt: sql`MAX(${posts.publishedAt})`.as("thread_updated_at")
28515
+ }).from(posts).where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId), eq(posts.status, "published")));
28516
+ const announcedAt = rootRows[0]?.announcedAt ?? null;
28517
+ const threadUpdatedAt = rootRows[0]?.updatedAt ?? null;
28518
+ const fallback = (await db.select({ updatedAt: posts.updatedAt }).from(posts).where(and(eq(posts.siteId, siteId), eq(posts.id, rootId))).limit(1))[0]?.updatedAt ?? now();
28519
+ await db.update(posts).set({
28520
+ lastActivityAt: announcedAt ?? fallback,
28521
+ threadUpdatedAt: threadUpdatedAt ?? fallback
28522
+ }).where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)));
28298
28523
  }
28299
28524
  function normalizeCollectionIds(collectionIds) {
28300
28525
  return [...new Set(collectionIds)];
@@ -28373,6 +28598,11 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28373
28598
  if (filters.excludeReplies) conditions.push(isNull(posts.replyToId));
28374
28599
  if (filters.publishedAfter !== void 0) conditions.push(sql`${posts.publishedAt} >= ${filters.publishedAfter}`);
28375
28600
  if (filters.publishedBefore !== void 0) conditions.push(sql`${posts.publishedAt} < ${filters.publishedBefore}`);
28601
+ if (filters.axisAfter !== void 0 || filters.axisBefore !== void 0) {
28602
+ const axis = timeAxisColumn(filters);
28603
+ if (filters.axisAfter !== void 0) conditions.push(sql`${axis} >= ${filters.axisAfter}`);
28604
+ if (filters.axisBefore !== void 0) conditions.push(sql`${axis} < ${filters.axisBefore}`);
28605
+ }
28376
28606
  if (filters.mediaKinds && filters.mediaKinds.length > 0) {
28377
28607
  const placeholders = filters.mediaKinds.map((k) => sql`${k}`);
28378
28608
  conditions.push(sql`${posts.id} IN (
@@ -28382,12 +28612,15 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28382
28612
  AND media_kind IN (${sql.join(placeholders, sql`, `)})
28383
28613
  )`);
28384
28614
  }
28385
- if (filters.hasMedia !== void 0) if (filters.hasMedia) conditions.push(sql`${posts.id} IN (
28386
- SELECT post_id FROM media WHERE site_id = ${siteId}
28387
- )`);
28388
- else conditions.push(sql`${posts.id} NOT IN (
28389
- SELECT post_id FROM media WHERE site_id = ${siteId}
28390
- )`);
28615
+ if (filters.hasMedia !== void 0) {
28616
+ const mediaExists = sql`EXISTS (
28617
+ SELECT 1
28618
+ FROM media
28619
+ WHERE site_id = ${siteId}
28620
+ AND post_id = ${posts.id}
28621
+ )`;
28622
+ conditions.push(filters.hasMedia ? mediaExists : sql`NOT ${mediaExists}`);
28623
+ }
28391
28624
  if (filters.hasTitle !== void 0) if (filters.hasTitle) conditions.push(sql`${posts.title} IS NOT NULL AND ${posts.title} != ''`);
28392
28625
  else conditions.push(sql`(${posts.title} IS NULL OR ${posts.title} = '')`);
28393
28626
  if (filters.hasRating !== void 0) conditions.push(filters.hasRating ? isNotNull(posts.rating) : isNull(posts.rating));
@@ -28404,13 +28637,26 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28404
28637
  }
28405
28638
  return conditions;
28406
28639
  }
28407
- async function getLastLivePostIdInThread(threadId) {
28408
- return (await db.select({ id: posts.id }).from(posts).where(and(eq(posts.siteId, siteId), eq(posts.threadId, threadId))).orderBy(desc(posts.createdAt), desc(posts.id)).limit(1))[0]?.id ?? null;
28409
- }
28410
28640
  function getCursorSortTimestamp(row, filters) {
28411
28641
  if (filters.sortBy === "published") return row.publishedAt ?? row.createdAt;
28642
+ if (filters.sortBy === "thread_updated") return row.threadUpdatedAt ?? -1;
28412
28643
  return row.status === "draft" ? row.updatedAt : row.lastActivityAt ?? -1;
28413
28644
  }
28645
+ /**
28646
+ * Chronological sort key for `list()`.
28647
+ *
28648
+ * Shared by the ORDER BY and the keyset cursor comparison — they must read
28649
+ * the same expression or pagination silently skips or repeats rows.
28650
+ */ function buildSortTimestampExpr(filters) {
28651
+ if (filters.sortBy === "published") return sql`coalesce(${posts.publishedAt}, ${posts.createdAt})`;
28652
+ if (filters.sortBy === "thread_updated") return posts.threadUpdatedAt;
28653
+ if (filters.status === "draft") return posts.updatedAt;
28654
+ if (filters.status === "published") return posts.lastActivityAt;
28655
+ return sql`CASE
28656
+ WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
28657
+ ELSE ${posts.lastActivityAt}
28658
+ END`;
28659
+ }
28414
28660
  function buildLexicographicCursorCondition(keys) {
28415
28661
  const [first, ...rest] = keys;
28416
28662
  const comparison = first.direction === "desc" ? sql`${first.expr} < ${first.value}` : sql`${first.expr} > ${first.value}`;
@@ -28424,10 +28670,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28424
28670
  if (!filters.cursor) return null;
28425
28671
  const cursorPost = (await db.select().from(posts).where(and(eq(posts.siteId, siteId), eq(posts.id, filters.cursor))).limit(1))[0];
28426
28672
  if (!cursorPost) return null;
28427
- const sortTimestampExpr = filters.sortBy === "published" ? sql`coalesce(${posts.publishedAt}, ${posts.createdAt})` : filters.status === "draft" ? posts.updatedAt : filters.status === "published" ? posts.lastActivityAt : sql`CASE
28428
- WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
28429
- ELSE ${posts.lastActivityAt}
28430
- END`;
28673
+ const sortTimestampExpr = buildSortTimestampExpr(filters);
28431
28674
  const pinnedSortExpr = sql`coalesce(${posts.pinnedAt}, -1)`;
28432
28675
  const featuredPublishedSortExpr = sql`coalesce(
28433
28676
  ${posts.publishedAt}, ${posts.createdAt}, -1
@@ -28550,8 +28793,10 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28550
28793
  previewProvider: row.previewProvider,
28551
28794
  replyToId: row.replyToId,
28552
28795
  threadId: row.threadId,
28796
+ quietReply: row.quietReply,
28553
28797
  publishedAt: row.publishedAt,
28554
28798
  lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
28799
+ threadUpdatedAt: row.threadUpdatedAt ?? row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
28555
28800
  createdAt: row.createdAt,
28556
28801
  updatedAt: row.updatedAt
28557
28802
  };
@@ -28616,16 +28861,18 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28616
28861
  if (options?.hasRating) conditions.push(isNotNull(posts.rating));
28617
28862
  return conditions;
28618
28863
  }
28619
- function buildCollectionThreadActivityExpr(alias) {
28864
+ /**
28865
+ * Thread activity timestamp used to order collection threads.
28866
+ *
28867
+ * Rows here are Thread *members* grouped by thread_id, so the definition is
28868
+ * reached through a subquery to the root. The outer MAX only collapses the
28869
+ * group — the subquery returns one value per thread. The outer COALESCE
28870
+ * covers a member whose root row is missing.
28871
+ */ function buildCollectionThreadActivityExpr(alias) {
28620
28872
  return sql`MAX(
28621
28873
  COALESCE(
28622
28874
  (
28623
- SELECT CASE
28624
- WHEN root.updated_at > root.created_at
28625
- AND root.updated_at > COALESCE(root.last_activity_at, -1)
28626
- THEN root.updated_at
28627
- ELSE COALESCE(root.last_activity_at, root.updated_at)
28628
- END
28875
+ SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
28629
28876
  FROM post AS root
28630
28877
  WHERE root.site_id = ${siteId}
28631
28878
  AND root.id = ${posts.threadId}
@@ -28770,10 +29017,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28770
29017
  const conditions = buildFilterConditions(filters);
28771
29018
  const cursorCondition = await buildListCursorCondition(filters);
28772
29019
  if (filters.cursor && !cursorCondition) return [];
28773
- const sortTimestamp = filters.sortBy === "published" ? sql`coalesce(${posts.publishedAt}, ${posts.createdAt})` : filters.status === "draft" ? posts.updatedAt : filters.status === "published" ? posts.lastActivityAt : sql`CASE
28774
- WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
28775
- ELSE ${posts.lastActivityAt}
28776
- END`;
29020
+ const sortTimestamp = buildSortTimestampExpr(filters);
28777
29021
  if (cursorCondition) conditions.push(cursorCondition);
28778
29022
  const ratingPresence = sql`CASE
28779
29023
  WHEN ${posts.rating} IS NULL THEN 0
@@ -28797,9 +29041,19 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28797
29041
  excludeReplies: true
28798
29042
  });
28799
29043
  if (afterId !== void 0) conditions.push(sql`${posts.id} > ${afterId}`);
29044
+ const threadModifiedAt = sql`COALESCE(
29045
+ (
29046
+ SELECT MAX(member.updated_at)
29047
+ FROM post AS member
29048
+ WHERE member.site_id = ${siteId}
29049
+ AND member.thread_id = "post"."id"
29050
+ AND member.status = 'published'
29051
+ ),
29052
+ "post"."updated_at"
29053
+ )`;
28800
29054
  const rows = await db.select({
28801
29055
  id: posts.id,
28802
- updatedAt: posts.updatedAt,
29056
+ updatedAt: threadModifiedAt,
28803
29057
  featuredAt: posts.featuredAt
28804
29058
  }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(asc(posts.id)).limit(limit);
28805
29059
  if (rows.length === 0) return [];
@@ -28846,8 +29100,9 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28846
29100
  return (await db.select({ id: posts.id }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0).limit(normalizedLimit)).length;
28847
29101
  },
28848
29102
  async countByYearMonth(filters = {}) {
28849
- const conditions = [...buildFilterConditions(filters), isNotNull(posts.publishedAt)];
28850
- const publishedYearMonthExpr = buildPublishedYearMonthExpr();
29103
+ const axis = timeAxisColumn(filters);
29104
+ const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
29105
+ const publishedYearMonthExpr = buildYearMonthExpr(axis);
28851
29106
  return db.select({
28852
29107
  yearMonth: publishedYearMonthExpr.as("year_month"),
28853
29108
  count: sql`CAST(count(*) AS INTEGER)`.as("count")
@@ -28860,6 +29115,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28860
29115
  const requestedStatus = data.status !== void 0 ? ensurePostStatus(data.status) : void 0;
28861
29116
  const requestedVisibility = data.visibility !== void 0 ? ensurePostVisibility(data.visibility) : void 0;
28862
29117
  const rating = ensurePostRating(data.rating);
29118
+ const isQuietReply = Boolean(data.replyToId) && data.quietReply === true;
28863
29119
  const rawBody = data.bodyMarkdown ? markdownToTiptapJson(data.bodyMarkdown) : data.body ?? null;
28864
29120
  const trimmedBody = rawBody ? trimTiptapBody(rawBody) : null;
28865
29121
  const preparedBody = trimmedBody ? tryPreparePostBodyHtml(id, trimmedBody) : null;
@@ -28888,16 +29144,15 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28888
29144
  const parent = await this.getById(data.replyToId);
28889
29145
  if (!parent) throw new NotFoundError("Parent post");
28890
29146
  if (resolvedPinnedAt !== null) throw new ConflictError("Cannot pin a thread reply. Pin the root post instead.");
28891
- const lastLivePostId = await getLastLivePostIdInThread(parent.threadId);
28892
- if (lastLivePostId && lastLivePostId !== parent.id) throw new ConflictError("This post is no longer the end of the thread. Reply to the latest post instead.");
29147
+ const tailId = (await this.getThreadTailIds([parent.threadId], { includeDrafts: true })).get(parent.threadId);
29148
+ if (tailId && tailId !== parent.id) throw new ConflictError((await this.getById(tailId))?.status === "draft" ? "This thread ends with an unpublished draft. Finish that draft or discard it, then reply." : "This post is no longer the end of the thread. Reply to the latest post instead.");
28893
29149
  threadId = parent.threadId;
28894
29150
  const root = parent.threadId === parent.id ? parent : await this.getById(parent.threadId);
28895
- if (root) {
28896
- if (data.status !== "draft") status = root.status;
28897
- }
29151
+ if (root && data.status !== "draft") status = root.status;
28898
29152
  visibility = null;
28899
29153
  if ((data.collectionIds?.length ?? 0) > 0 || (data.collectionEntries?.length ?? 0) > 0) throw new ConflictError("Cannot set Collections while creating a Thread reply. Set them on the Thread root instead.");
28900
29154
  }
29155
+ if (status === "draft" && resolvedFeaturedAt !== null) throw new ConflictError("Publish this post before featuring it.");
28901
29156
  assertDraftPublishedAt(status, data.publishedAt);
28902
29157
  const publishedAt = status === "published" ? data.publishedAt ?? timestamp : null;
28903
29158
  let slug;
@@ -28975,8 +29230,10 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28975
29230
  rating,
28976
29231
  replyToId: data.replyToId ?? null,
28977
29232
  threadId,
29233
+ quietReply: isQuietReply,
28978
29234
  publishedAt,
28979
29235
  lastActivityAt: publishedAt ?? timestamp,
29236
+ threadUpdatedAt: publishedAt ?? timestamp,
28980
29237
  createdAt: timestamp,
28981
29238
  updatedAt: timestamp
28982
29239
  }));
@@ -29026,8 +29283,10 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
29026
29283
  rating,
29027
29284
  replyToId: data.replyToId ?? null,
29028
29285
  threadId,
29286
+ quietReply: isQuietReply,
29029
29287
  publishedAt,
29030
29288
  lastActivityAt: publishedAt ?? timestamp,
29289
+ threadUpdatedAt: publishedAt ?? timestamp,
29031
29290
  createdAt: timestamp,
29032
29291
  updatedAt: timestamp
29033
29292
  });
@@ -29068,7 +29327,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
29068
29327
  }
29069
29328
  const post = await this.getById(id);
29070
29329
  if (!post) throw new ConflictError(`Slug "${slug}" could not be resolved`);
29071
- if (data.replyToId && status === "published" && !data.quietReply) await recalculateThreadLastActivity(threadId);
29330
+ if (data.replyToId && status === "published") await recalculateThreadActivity(threadId);
29072
29331
  return post;
29073
29332
  },
29074
29333
  async createWithAttachments(data, attachments, deps, summaryConfig) {
@@ -29107,10 +29366,12 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
29107
29366
  if (!item) continue;
29108
29367
  const { data, attachments } = item;
29109
29368
  const prevPost = created[i - 1];
29369
+ const rootPublishedAt = created[0]?.publishedAt ?? void 0;
29110
29370
  const postData = {
29111
29371
  ...data,
29112
29372
  replyToId: i === 0 ? data.replyToId : prevPost?.id,
29113
- quietReply: extendsExistingThreadQuietly ? true : data.quietReply
29373
+ quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
29374
+ publishedAt: i === 0 ? data.publishedAt : data.publishedAt ?? rootPublishedAt
29114
29375
  };
29115
29376
  try {
29116
29377
  const post = await this.createWithAttachments(postData, attachments, deps, summaryConfig);
@@ -29165,6 +29426,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
29165
29426
  else if (data.pinned !== void 0) updates.pinnedAt = data.pinned ? timestamp : null;
29166
29427
  if (data.featuredAt !== void 0) updates.featuredAt = data.featuredAt;
29167
29428
  else if (data.featured !== void 0) updates.featuredAt = data.featured ? timestamp : null;
29429
+ if (nextStatus === "draft" && updates.featuredAt) throw new ConflictError("Publish this post before featuring it.");
29168
29430
  let updatedBody;
29169
29431
  if (data.body !== void 0 || data.bodyMarkdown !== void 0) {
29170
29432
  const rawBody = data.bodyMarkdown ? markdownToTiptapJson(data.bodyMarkdown) : data.body ?? null;
@@ -29206,7 +29468,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
29206
29468
  if (!(needsCascade || needsReplyVisibilityCleanup || needsCollectionSync || needsPageNavDelete || needsPageNavUrlUpdate)) {
29207
29469
  const result = await db.update(posts).set(updates).where(and(eq(posts.siteId, siteId), eq(posts.id, id))).returning();
29208
29470
  if (needsThreadActivityRecalc) {
29209
- await recalculateThreadLastActivity(existing.threadId);
29471
+ await recalculateThreadActivity(existing.threadId);
29210
29472
  return this.getById(id);
29211
29473
  }
29212
29474
  return hydratePost(result[0]);
@@ -29291,7 +29553,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
29291
29553
  }
29292
29554
  });
29293
29555
  if (needsThreadActivityRecalc) {
29294
- await recalculateThreadLastActivity(existing.threadId);
29556
+ await recalculateThreadActivity(existing.threadId);
29295
29557
  return this.getById(id);
29296
29558
  }
29297
29559
  return hydratePost(updateResult?.[0]);
@@ -29374,7 +29636,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
29374
29636
  else {
29375
29637
  await db.update(posts).set({ replyToId: existing.replyToId }).where(and(eq(posts.siteId, siteId), eq(posts.replyToId, id)));
29376
29638
  await db.delete(posts).where(and(eq(posts.siteId, siteId), eq(posts.id, id)));
29377
- await recalculateThreadLastActivity(existing.threadId);
29639
+ await recalculateThreadActivity(existing.threadId);
29378
29640
  }
29379
29641
  return true;
29380
29642
  },
@@ -29384,6 +29646,28 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
29384
29646
  async getThread(rootId) {
29385
29647
  return hydratePosts(await db.select().from(posts).where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId))).orderBy(posts.createdAt, posts.id));
29386
29648
  },
29649
+ async getThreadPosition(postId) {
29650
+ const target = (await db.select({
29651
+ replyToId: posts.replyToId,
29652
+ threadId: posts.threadId
29653
+ }).from(posts).where(and(eq(posts.siteId, siteId), eq(posts.id, postId))).limit(1))[0];
29654
+ if (!target) return 0;
29655
+ if (!target.replyToId) return 1;
29656
+ const threadRows = await db.select({
29657
+ id: posts.id,
29658
+ replyToId: posts.replyToId
29659
+ }).from(posts).where(and(eq(posts.siteId, siteId), eq(posts.threadId, target.threadId)));
29660
+ const parentOf = new Map(threadRows.map((row) => [row.id, row.replyToId]));
29661
+ let position = 1;
29662
+ let cursor = target.replyToId;
29663
+ const visited = new Set([postId]);
29664
+ while (cursor && !visited.has(cursor)) {
29665
+ visited.add(cursor);
29666
+ position += 1;
29667
+ cursor = parentOf.get(cursor) ?? null;
29668
+ }
29669
+ return position;
29670
+ },
29387
29671
  async updateThreadStatusAndVisibility(rootId, status, visibility) {
29388
29672
  const nextStatus = ensurePostStatus(status);
29389
29673
  const nextVisibility = ensurePostVisibility(visibility);
@@ -29417,7 +29701,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
29417
29701
  updatedAt: timestamp
29418
29702
  }).where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId), isNotNull(posts.replyToId)));
29419
29703
  });
29420
- await recalculateThreadLastActivity(rootId);
29704
+ await recalculateThreadActivity(rootId);
29421
29705
  },
29422
29706
  async getReplyCounts(postIds) {
29423
29707
  if (postIds.length === 0) return /* @__PURE__ */ new Map();
@@ -29698,20 +29982,21 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
29698
29982
  }
29699
29983
  return result;
29700
29984
  },
29701
- async getLastPostIdsByThread(threadIds) {
29985
+ async getThreadTailIds(threadIds, options) {
29702
29986
  const result = /* @__PURE__ */ new Map();
29703
29987
  if (threadIds.length === 0) return result;
29704
29988
  const unique = [...new Set(threadIds)];
29705
29989
  const rows = await db.select({
29706
29990
  threadId: posts.threadId,
29707
29991
  id: posts.id
29708
- }).from(posts).where(and(eq(posts.siteId, siteId), inArray(posts.threadId, unique), eq(posts.status, "published"))).orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
29992
+ }).from(posts).where(and(eq(posts.siteId, siteId), inArray(posts.threadId, unique), ...options?.includeDrafts ? [] : [eq(posts.status, "published")])).orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
29709
29993
  for (const row of rows) if (!result.has(row.threadId)) result.set(row.threadId, row.id);
29710
29994
  return result;
29711
29995
  },
29712
29996
  async getDistinctYears(filters = {}) {
29713
- const conditions = [...buildFilterConditions(filters), isNotNull(posts.publishedAt)];
29714
- const publishedYearExpr = buildPublishedYearExpr();
29997
+ const axis = timeAxisColumn(filters);
29998
+ const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
29999
+ const publishedYearExpr = buildYearExpr(axis);
29715
30000
  return (await db.select({ year: publishedYearExpr.as("year") }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0).groupBy(publishedYearExpr).orderBy(desc(publishedYearExpr))).map((r) => parseInt(r.year, 10));
29716
30001
  },
29717
30002
  async reindexBodyText(options = {}) {
@@ -31073,10 +31358,12 @@ function toPublicPost(post, mediaList, threadCollections, appConfig, options) {
31073
31358
  previewImageUrl,
31074
31359
  replyToId: post.replyToId,
31075
31360
  threadId: post.threadId,
31361
+ quietReply: post.quietReply,
31076
31362
  pinnedAt: post.pinnedAt,
31077
31363
  featuredAt: post.featuredAt,
31078
31364
  publishedAt: post.publishedAt,
31079
31365
  lastActivityAt: post.lastActivityAt,
31366
+ threadUpdatedAt: post.threadUpdatedAt,
31080
31367
  createdAt: post.createdAt,
31081
31368
  updatedAt: post.updatedAt,
31082
31369
  attachments: mediaList.map((media) => toApiAttachment(media, r2PublicUrl, imageTransformUrl, s3PublicUrl, localPublicUrl, sitePathPrefix)),
@@ -31366,7 +31653,7 @@ composeRoutes.post("/thread", async (c) => {
31366
31653
  const root = (await c.var.services.posts.createThreadWithAttachments(postSchemas.map((data, index) => ({
31367
31654
  data: {
31368
31655
  format: data.format,
31369
- slug: index === 0 ? data.slug || void 0 : void 0,
31656
+ slug: data.slug || void 0,
31370
31657
  title: data.format === "quote" ? data.sourceName || void 0 : data.title || void 0,
31371
31658
  body: data.body || void 0,
31372
31659
  bodyMarkdown: data.bodyMarkdown || void 0,
@@ -31378,7 +31665,7 @@ composeRoutes.post("/thread", async (c) => {
31378
31665
  collectionIds: index === 0 ? data.collectionIds : void 0,
31379
31666
  replyToId: index === 0 ? data.replyToId : void 0,
31380
31667
  quietReply: data.quietReply,
31381
- publishedAt: index === 0 ? data.publishedAt : void 0
31668
+ publishedAt: data.publishedAt
31382
31669
  },
31383
31670
  attachments: data.attachments
31384
31671
  })), storageOpts, summaryConfig))[0];
@@ -33397,16 +33684,11 @@ function createCollectionService(db, siteId, paths, databaseSchema = sqliteSchem
33397
33684
  }).filter((row) => row !== null);
33398
33685
  }
33399
33686
  async function listDirectoryCollections() {
33400
- const threadActivityAt = sql`CASE
33401
- WHEN ${posts.updatedAt} > ${posts.createdAt}
33402
- AND ${posts.updatedAt} > COALESCE(${posts.lastActivityAt}, -1)
33403
- THEN ${posts.updatedAt}
33404
- ELSE COALESCE(
33405
- ${posts.lastActivityAt},
33406
- ${posts.publishedAt},
33407
- ${posts.updatedAt}
33408
- )
33409
- END`;
33687
+ const threadActivityAt = buildRootActivityExpr({
33688
+ lastActivityAt: posts.lastActivityAt,
33689
+ publishedAt: posts.publishedAt,
33690
+ updatedAt: posts.updatedAt
33691
+ });
33410
33692
  const threadCount = sql`
33411
33693
  CAST(COUNT(
33412
33694
  CASE
@@ -33918,8 +34200,10 @@ function createCollectionService(db, siteId, paths, databaseSchema = sqliteSchem
33918
34200
  previewProvider: null,
33919
34201
  replyToId: row.reply_to_id,
33920
34202
  threadId: row.thread_id,
34203
+ quietReply: Boolean(row.quiet_reply),
33921
34204
  publishedAt: row.published_at,
33922
34205
  lastActivityAt: row.last_activity_at ?? row.published_at ?? row.updated_at,
34206
+ threadUpdatedAt: row.thread_updated_at ?? row.last_activity_at ?? row.published_at ?? row.updated_at,
33923
34207
  createdAt: row.created_at,
33924
34208
  updatedAt: row.updated_at
33925
34209
  },
@@ -34965,7 +35249,7 @@ function createSiteAdminService(db, databaseSchema = sqliteSchemaBundle, databas
34965
35249
  const themeCss = buildThemeStyle(activeTheme, appConfig.themeMode, fontOverrides);
34966
35250
  const navItemList = await navItems.list();
34967
35251
  const appleTouchKey = allSettings[SETTINGS_KEYS.SITE_FAVICON_APPLE_TOUCH];
34968
- const { createExportService } = await import("./export-CsFx6F_M.js").then((n) => n.n);
35252
+ const { createExportService } = await import("./export-Dl5KCiEs.js").then((n) => n.n);
34969
35253
  const exportService = createExportService({
34970
35254
  collections,
34971
35255
  media: mediaService,