@jant/core 0.3.35 → 0.3.37

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 (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -1,14 +1,62 @@
1
1
  /**
2
2
  * Archive Page
3
3
  *
4
- * Posts grouped by year-month with format filter and cursor pagination.
4
+ * Tumblr-style grid/list with compact chip filter bar,
5
+ * month-based grouping, and page-based pagination.
5
6
  */
6
7
 
7
8
  import type { FC } from "hono/jsx";
8
9
  import { useLingui } from "@lingui/react/macro";
9
- import type { ArchivePageProps } from "../../types.js";
10
- import { FORMATS } from "../../types.js";
11
- import { Pagination } from "../shared/Pagination.js";
10
+ import type {
11
+ ArchivePageProps,
12
+ ArchiveFilters,
13
+ ArchiveView,
14
+ ArchiveVisibility,
15
+ MediaKind,
16
+ } from "../../types.js";
17
+ import type { PostView } from "../../types/views.js";
18
+ import { FORMATS, MEDIA_KINDS } from "../../types.js";
19
+ import { getIconSvg, renderCollectionIcon } from "../../lib/icons.js";
20
+ import { toMediaKind } from "../../lib/upload.js";
21
+ import { PagePagination } from "../shared/Pagination.js";
22
+ import { TimelineItemFromPost } from "../feed/TimelineItem.js";
23
+
24
+ // =============================================================================
25
+ // URL Builder
26
+ // =============================================================================
27
+
28
+ /** Build an archive URL preserving existing filter params, overriding with updates. */
29
+ function buildFilterUrl(
30
+ current: ArchiveFilters,
31
+ updates: Partial<ArchiveFilters & { clear?: boolean }>,
32
+ ): string {
33
+ if (updates.clear) return "/archive";
34
+
35
+ const merged = { ...current, ...updates };
36
+ const params = new URLSearchParams();
37
+
38
+ if (merged.year) params.set("year", String(merged.year));
39
+ if (merged.collectionSlug) params.set("collection", merged.collectionSlug);
40
+ if (merged.format) params.set("format", merged.format);
41
+ if (merged.mediaKinds && merged.mediaKinds.length > 0) {
42
+ params.set("media", merged.mediaKinds.join(","));
43
+ }
44
+ if (merged.hasMedia !== undefined) {
45
+ params.set("hasMedia", merged.hasMedia ? "1" : "0");
46
+ }
47
+ if (merged.hasTitle !== undefined) {
48
+ params.set("hasTitle", merged.hasTitle ? "1" : "0");
49
+ }
50
+ if (merged.visibility) params.set("visibility", merged.visibility);
51
+ if (merged.view && merged.view !== "grid") params.set("view", merged.view);
52
+
53
+ const qs = params.toString();
54
+ return qs ? `/archive?${qs}` : "/archive";
55
+ }
56
+
57
+ // =============================================================================
58
+ // Format Labels
59
+ // =============================================================================
12
60
 
13
61
  function getFormatLabel(format: string): string {
14
62
  const { t } = useLingui();
@@ -42,120 +90,900 @@ function getFormatLabelPlural(format: string): string {
42
90
  return labels[format] ?? format + "s";
43
91
  }
44
92
 
45
- export const ArchivePage: FC<ArchivePageProps> = ({
46
- groups,
47
- hasMore,
48
- nextCursor,
49
- format,
50
- featured,
51
- }) => {
93
+ /** Icon name mapping for post formats. */
94
+ const FORMAT_ICONS: Record<string, string> = {
95
+ note: "notepad-text",
96
+ link: "external-link",
97
+ quote: "text-quote",
98
+ };
99
+
100
+ /** Icon name mapping for media kinds. */
101
+ const MEDIA_KIND_ICONS: Record<MediaKind, string> = {
102
+ image: "image",
103
+ video: "video",
104
+ audio: "music",
105
+ text: "file-text",
106
+ document: "file",
107
+ };
108
+
109
+ function getMediaKindLabel(kind: MediaKind): string {
52
110
  const { t } = useLingui();
53
- const title = format
54
- ? getFormatLabelPlural(format)
55
- : t({ message: "Archive", comment: "@context: Archive page title" });
111
+ const labels: Record<MediaKind, string> = {
112
+ image: t({
113
+ message: "Images",
114
+ comment: "@context: Archive media filter - images",
115
+ }),
116
+ video: t({
117
+ message: "Video",
118
+ comment: "@context: Archive media filter - video",
119
+ }),
120
+ audio: t({
121
+ message: "Audio",
122
+ comment: "@context: Archive media filter - audio",
123
+ }),
124
+ text: t({
125
+ message: "Text attachment",
126
+ comment: "@context: Archive media filter - text file attachments",
127
+ }),
128
+ document: t({
129
+ message: "Files",
130
+ comment: "@context: Archive media filter - files/documents",
131
+ }),
132
+ };
133
+ return labels[kind] ?? kind;
134
+ }
135
+
136
+ // =============================================================================
137
+ // Shared Icon Helpers
138
+ // =============================================================================
56
139
 
140
+ /** Inline SVG icon with specified size class. */
141
+ const Icon: FC<{ name: string; class?: string }> = ({
142
+ name,
143
+ class: cls = "[&>svg]:size-4",
144
+ }) => {
145
+ const svg = getIconSvg(name);
146
+ if (!svg) return null;
57
147
  return (
58
- <div class="py-6" data-page="archive">
59
- <header class="mb-8">
60
- <h1 class="text-2xl font-semibold">{title}</h1>
148
+ <span
149
+ class={`shrink-0 inline-flex ${cls}`}
150
+ dangerouslySetInnerHTML={{ __html: svg }}
151
+ />
152
+ );
153
+ };
154
+
155
+ /** Chevron indicator for chip triggers. */
156
+ const ChipChevron: FC = () => (
157
+ <Icon name="chevron-down" class="[&>svg]:size-3 opacity-40" />
158
+ );
159
+
160
+ // =============================================================================
161
+ // Chip Select Components
162
+ // =============================================================================
163
+
164
+ interface ChipSelectOption {
165
+ label: string;
166
+ value: string;
167
+ icon?: string;
168
+ iconHtml?: string;
169
+ indent?: boolean;
170
+ }
61
171
 
62
- {/* Format filter */}
63
- <nav class="flex flex-wrap gap-2 mt-4">
172
+ /**
173
+ * Compact chip-style dropdown.
174
+ *
175
+ * Default state: icon + chevron (no text).
176
+ * Active state (iconOnly): active icon + ✕ clear button (no text).
177
+ * Active state (with label): icon + selected label + ✕ clear button.
178
+ */
179
+ const ChipSelect: FC<{
180
+ id: string;
181
+ icon: string;
182
+ options: ChipSelectOption[];
183
+ currentValue: string;
184
+ clearUrl: string;
185
+ activeLabel?: string;
186
+ activeIconHtml?: string;
187
+ activeIcon?: string;
188
+ iconOnly?: boolean;
189
+ }> = ({
190
+ id,
191
+ icon,
192
+ options,
193
+ currentValue,
194
+ clearUrl,
195
+ activeLabel,
196
+ activeIconHtml,
197
+ activeIcon,
198
+ iconOnly,
199
+ }) => {
200
+ const isActive = !!activeLabel;
201
+
202
+ return (
203
+ <div
204
+ id={id}
205
+ class="archive-chip-select archive-chip-dropdown select"
206
+ data-select-initialized
207
+ >
208
+ <button
209
+ type="button"
210
+ class={`archive-chip${isActive ? " archive-chip-active" : ""}`}
211
+ id={`${id}-trigger`}
212
+ aria-haspopup="listbox"
213
+ aria-expanded="false"
214
+ aria-controls={`${id}-listbox`}
215
+ >
216
+ {isActive && activeIconHtml ? (
217
+ <span
218
+ class="shrink-0 inline-flex [&>svg]:size-4"
219
+ dangerouslySetInnerHTML={{ __html: activeIconHtml }}
220
+ />
221
+ ) : isActive && activeIcon ? (
222
+ <Icon name={activeIcon} class="[&>svg]:size-4" />
223
+ ) : (
224
+ <Icon name={icon} class="[&>svg]:size-4 text-muted-foreground" />
225
+ )}
226
+ {isActive && !iconOnly && (
227
+ <span class="archive-chip-label">{activeLabel}</span>
228
+ )}
229
+ {isActive ? (
64
230
  <a
65
- href="/archive"
66
- class={
67
- "badge " +
68
- (!format && !featured ? "badge-primary" : "badge-outline")
69
- }
231
+ href={clearUrl}
232
+ class="archive-chip-clear"
233
+ aria-label="Clear filter"
70
234
  >
71
- {t({
72
- message: "All",
73
- comment: "@context: Archive filter - all formats",
74
- })}
235
+ <Icon name="x" class="[&>svg]:size-3" />
75
236
  </a>
76
- {FORMATS.map((formatKey) => (
77
- <a
78
- key={formatKey}
79
- href={"/archive?format=" + formatKey}
80
- class={
81
- "badge " +
82
- (format === formatKey ? "badge-primary" : "badge-outline")
83
- }
237
+ ) : (
238
+ <ChipChevron />
239
+ )}
240
+ </button>
241
+ <div id={`${id}-popover`} data-popover aria-hidden="true">
242
+ <div
243
+ role="listbox"
244
+ id={`${id}-listbox`}
245
+ aria-orientation="vertical"
246
+ aria-labelledby={`${id}-trigger`}
247
+ >
248
+ {options.map((opt) => (
249
+ <div
250
+ key={opt.value}
251
+ role="option"
252
+ data-value={opt.value}
253
+ aria-selected={opt.value === currentValue ? "true" : undefined}
254
+ class={opt.indent ? "pl-4" : undefined}
84
255
  >
85
- {getFormatLabelPlural(formatKey)}
86
- </a>
256
+ {opt.iconHtml ? (
257
+ <span class="flex items-center gap-2">
258
+ <span
259
+ class="shrink-0 inline-flex [&>svg]:size-4"
260
+ dangerouslySetInnerHTML={{ __html: opt.iconHtml }}
261
+ />
262
+ {opt.label}
263
+ </span>
264
+ ) : opt.icon ? (
265
+ <span class="flex items-center gap-2">
266
+ <Icon
267
+ name={opt.icon}
268
+ class="[&>svg]:size-4 text-muted-foreground"
269
+ />
270
+ {opt.label}
271
+ </span>
272
+ ) : (
273
+ opt.label
274
+ )}
275
+ </div>
87
276
  ))}
277
+ </div>
278
+ </div>
279
+ </div>
280
+ );
281
+ };
282
+
283
+ /**
284
+ * Chip-style multi-select for media kinds.
285
+ *
286
+ * "Text only" at the top navigates immediately (mutually exclusive with kinds).
287
+ * Media kind options are multi-toggle; navigation happens on popover close.
288
+ * Shows count when multiple kinds are selected.
289
+ */
290
+ const ChipMediaSelect: FC<{
291
+ id: string;
292
+ icon: string;
293
+ filters: ArchiveFilters;
294
+ activeLabel?: string;
295
+ clearUrl: string;
296
+ }> = ({ id, icon, filters: f, activeLabel, clearUrl }) => {
297
+ const { t } = useLingui();
298
+ const isActive = !!activeLabel;
299
+ const activeKinds = f.mediaKinds ?? [];
300
+
301
+ const singleKind = activeKinds.length === 1 ? activeKinds[0] : undefined;
302
+ const activeMediaIcon = isActive
303
+ ? f.hasMedia === false
304
+ ? "text"
305
+ : singleKind
306
+ ? MEDIA_KIND_ICONS[singleKind]
307
+ : icon
308
+ : undefined;
309
+
310
+ const textOnlyUrl = buildFilterUrl(
311
+ { ...f, mediaKinds: undefined, hasMedia: undefined },
312
+ { hasMedia: false, mediaKinds: undefined },
313
+ );
314
+
315
+ return (
316
+ <div
317
+ id={id}
318
+ class="archive-chip-select archive-chip-dropdown archive-chip-media select"
319
+ data-select-initialized
320
+ data-filter-key="media"
321
+ >
322
+ <button
323
+ type="button"
324
+ class={`archive-chip${isActive ? " archive-chip-active" : ""}`}
325
+ id={`${id}-trigger`}
326
+ aria-haspopup="listbox"
327
+ aria-expanded="false"
328
+ aria-controls={`${id}-listbox`}
329
+ >
330
+ {isActive && activeMediaIcon ? (
331
+ <Icon name={activeMediaIcon} class="[&>svg]:size-4" />
332
+ ) : (
333
+ <Icon name={icon} class="[&>svg]:size-4 text-muted-foreground" />
334
+ )}
335
+ {isActive && activeKinds.length > 1 && (
336
+ <span class="archive-chip-label">{activeLabel}</span>
337
+ )}
338
+ {isActive ? (
88
339
  <a
89
- href="/archive?featured=true"
90
- class={"badge " + (featured ? "badge-primary" : "badge-outline")}
340
+ href={clearUrl}
341
+ class="archive-chip-clear"
342
+ aria-label="Clear filter"
91
343
  >
92
- {t({
93
- message: "Featured",
94
- comment: "@context: Archive filter - featured posts",
95
- })}
344
+ <Icon name="x" class="[&>svg]:size-3" />
96
345
  </a>
97
- </nav>
346
+ ) : (
347
+ <ChipChevron />
348
+ )}
349
+ </button>
350
+ <div id={`${id}-popover`} data-popover aria-hidden="true">
351
+ <div
352
+ role="listbox"
353
+ id={`${id}-listbox`}
354
+ aria-orientation="vertical"
355
+ aria-labelledby={`${id}-trigger`}
356
+ aria-multiselectable="true"
357
+ >
358
+ <div
359
+ role="option"
360
+ data-value={textOnlyUrl}
361
+ data-navigate="true"
362
+ aria-selected={f.hasMedia === false ? "true" : undefined}
363
+ >
364
+ <span class="flex items-center gap-2">
365
+ <Icon name="text" class="[&>svg]:size-4 text-muted-foreground" />
366
+ {t({
367
+ message: "Text",
368
+ comment:
369
+ "@context: Archive media filter - posts without any media attachments",
370
+ })}
371
+ </span>
372
+ </div>
373
+ {MEDIA_KINDS.map((kind) => {
374
+ const label = getMediaKindLabel(kind);
375
+ const kindIcon = MEDIA_KIND_ICONS[kind];
376
+ return (
377
+ <div
378
+ key={kind}
379
+ role="option"
380
+ data-value={kind}
381
+ data-label={label}
382
+ aria-selected={activeKinds.includes(kind) ? "true" : undefined}
383
+ >
384
+ <span class="flex items-center gap-2">
385
+ <Icon
386
+ name={kindIcon}
387
+ class="[&>svg]:size-4 text-muted-foreground"
388
+ />
389
+ {label}
390
+ </span>
391
+ </div>
392
+ );
393
+ })}
394
+ </div>
395
+ </div>
396
+ </div>
397
+ );
398
+ };
399
+
400
+ // =============================================================================
401
+ // View Toggle
402
+ // =============================================================================
403
+
404
+ const ViewToggle: FC<{ filters: ArchiveFilters }> = ({ filters }) => {
405
+ const currentView: ArchiveView = filters.view ?? "grid";
406
+ const gridUrl = buildFilterUrl(filters, { view: undefined });
407
+ const listUrl = buildFilterUrl(filters, { view: "list" });
408
+
409
+ return (
410
+ <div class="archive-view-toggle" role="radiogroup" aria-label="View mode">
411
+ <a
412
+ href={gridUrl}
413
+ class={`archive-view-btn${currentView === "grid" ? " archive-view-btn-active" : ""}`}
414
+ role="radio"
415
+ aria-checked={currentView === "grid" ? "true" : "false"}
416
+ aria-label="Grid view"
417
+ >
418
+ <Icon name="layout-grid" class="[&>svg]:size-4" />
419
+ </a>
420
+ <a
421
+ href={listUrl}
422
+ class={`archive-view-btn${currentView === "list" ? " archive-view-btn-active" : ""}`}
423
+ role="radio"
424
+ aria-checked={currentView === "list" ? "true" : "false"}
425
+ aria-label="List view"
426
+ >
427
+ <Icon name="list" class="[&>svg]:size-4" />
428
+ </a>
429
+ </div>
430
+ );
431
+ };
432
+
433
+ // =============================================================================
434
+ // Filter Bar
435
+ // =============================================================================
436
+
437
+ const ARCHIVE_VISIBILITIES: ArchiveVisibility[] = [
438
+ "public",
439
+ "unlisted",
440
+ "private",
441
+ "featured",
442
+ ];
443
+
444
+ function getVisibilityLabel(v: ArchiveVisibility): string {
445
+ const { t } = useLingui();
446
+ const labels: Record<ArchiveVisibility, string> = {
447
+ public: t({
448
+ message: "Public",
449
+ comment: "@context: Archive visibility filter - public posts",
450
+ }),
451
+ unlisted: t({
452
+ message: "Unlisted",
453
+ comment: "@context: Archive visibility filter - unlisted posts",
454
+ }),
455
+ private: t({
456
+ message: "Private",
457
+ comment: "@context: Archive visibility filter - private posts",
458
+ }),
459
+ featured: t({
460
+ message: "Featured",
461
+ comment: "@context: Archive visibility filter - featured posts",
462
+ }),
463
+ };
464
+ return labels[v];
465
+ }
466
+
467
+ const VISIBILITY_ICONS: Record<ArchiveVisibility, string> = {
468
+ public: "globe",
469
+ unlisted: "eye-off",
470
+ private: "lock",
471
+ featured: "star",
472
+ };
473
+
474
+ /** Chip icon for each filter dimension. */
475
+ const FILTER_ICONS = {
476
+ year: "calendar",
477
+ collection: "monitor",
478
+ format: "shapes",
479
+ media: "video",
480
+ visibility: "scan-eye",
481
+ } as const;
482
+
483
+ const FilterBar: FC<{
484
+ filters: ArchiveFilters;
485
+ availableYears: number[];
486
+ availableCollections: { slug: string; title: string; icon: string | null }[];
487
+ isAuthenticated: boolean;
488
+ }> = ({ filters, availableYears, availableCollections, isAuthenticated }) => {
489
+ const { t } = useLingui();
490
+ const currentUrl = buildFilterUrl(filters, {});
491
+
492
+ // --- Year options ---------------------------------------------------------
493
+
494
+ const yearOptions: ChipSelectOption[] = [
495
+ {
496
+ label: t({
497
+ message: "All years",
498
+ comment: "@context: Archive filter - year dropdown default",
499
+ }),
500
+ icon: FILTER_ICONS.year,
501
+ value: buildFilterUrl(
502
+ { ...filters, year: undefined },
503
+ { year: undefined },
504
+ ),
505
+ },
506
+ ...availableYears.map((year) => ({
507
+ label: String(year),
508
+ value: buildFilterUrl(filters, { year }),
509
+ })),
510
+ ];
511
+
512
+ // --- Collection options ---------------------------------------------------
513
+
514
+ const collectionOptions: ChipSelectOption[] = [
515
+ {
516
+ label: t({
517
+ message: "All collections",
518
+ comment: "@context: Archive filter - collection dropdown default",
519
+ }),
520
+ icon: FILTER_ICONS.collection,
521
+ value: buildFilterUrl(
522
+ {
523
+ ...filters,
524
+ collectionSlug: undefined,
525
+ collectionTitle: undefined,
526
+ },
527
+ { collectionSlug: undefined, collectionTitle: undefined },
528
+ ),
529
+ },
530
+ ...availableCollections.map((col) => ({
531
+ label: col.title,
532
+ iconHtml:
533
+ renderCollectionIcon(col.icon, { size: 16, fallback: true }) ||
534
+ undefined,
535
+ value: buildFilterUrl(filters, { collectionSlug: col.slug }),
536
+ })),
537
+ ];
538
+
539
+ // --- Format options (Notes split into All / Titled / Untitled) -----------
540
+
541
+ const formatActiveLabel = filters.format
542
+ ? filters.hasTitle === true
543
+ ? t({
544
+ message: "Titled",
545
+ comment: "@context: Archive filter - notes that have a title",
546
+ })
547
+ : filters.hasTitle === false
548
+ ? t({
549
+ message: "Untitled",
550
+ comment: "@context: Archive filter - notes without a title",
551
+ })
552
+ : getFormatLabelPlural(filters.format)
553
+ : undefined;
554
+
555
+ const formatActiveIcon = filters.format
556
+ ? filters.hasTitle === true
557
+ ? "type"
558
+ : filters.hasTitle === false
559
+ ? "text"
560
+ : FORMAT_ICONS[filters.format]
561
+ : undefined;
562
+
563
+ const formatOptions: ChipSelectOption[] = [
564
+ {
565
+ label: t({
566
+ message: "All formats",
567
+ comment: "@context: Archive filter - all formats select option",
568
+ }),
569
+ icon: FILTER_ICONS.format,
570
+ value: buildFilterUrl(
571
+ { ...filters, format: undefined, hasTitle: undefined },
572
+ { format: undefined, hasTitle: undefined },
573
+ ),
574
+ },
575
+ {
576
+ label: getFormatLabelPlural("note"),
577
+ icon: FORMAT_ICONS.note,
578
+ value: buildFilterUrl(filters, {
579
+ format: "note",
580
+ hasTitle: undefined,
581
+ }),
582
+ },
583
+ {
584
+ label: t({
585
+ message: "Titled",
586
+ comment: "@context: Archive filter - notes that have a title",
587
+ }),
588
+ icon: "type",
589
+ indent: true,
590
+ value: buildFilterUrl(filters, {
591
+ format: "note",
592
+ hasTitle: true,
593
+ }),
594
+ },
595
+ {
596
+ label: t({
597
+ message: "Untitled",
598
+ comment: "@context: Archive filter - notes without a title",
599
+ }),
600
+ icon: "text",
601
+ indent: true,
602
+ value: buildFilterUrl(filters, {
603
+ format: "note",
604
+ hasTitle: false,
605
+ }),
606
+ },
607
+ ...FORMATS.filter((f) => f !== "note").map((f) => ({
608
+ label: getFormatLabelPlural(f),
609
+ icon: FORMAT_ICONS[f],
610
+ value: buildFilterUrl(filters, { format: f, hasTitle: undefined }),
611
+ })),
612
+ ];
613
+
614
+ // --- Visibility options (authenticated only) --------------------------------
615
+
616
+ // "All visibility" needs the explicit ?visibility=all param so the route
617
+ // doesn't default back to "public". Build its URL by appending to the
618
+ // base URL (which has no visibility param since we merge undefined).
619
+ const allVisibilityBaseUrl = buildFilterUrl(
620
+ { ...filters, visibility: undefined },
621
+ { visibility: undefined },
622
+ );
623
+ const allVisibilityUrl = allVisibilityBaseUrl.includes("?")
624
+ ? `${allVisibilityBaseUrl}&visibility=all`
625
+ : `${allVisibilityBaseUrl}?visibility=all`;
626
+
627
+ const visibilityOptions: ChipSelectOption[] = [
628
+ {
629
+ label: t({
630
+ message: "All visibility",
631
+ comment: "@context: Archive filter - all visibility select option",
632
+ }),
633
+ icon: FILTER_ICONS.visibility,
634
+ value: allVisibilityUrl,
635
+ },
636
+ ...ARCHIVE_VISIBILITIES.map((v) => ({
637
+ label: getVisibilityLabel(v),
638
+ icon: VISIBILITY_ICONS[v],
639
+ value: buildFilterUrl(filters, { visibility: v }),
640
+ })),
641
+ ];
642
+
643
+ const activeKinds = filters.mediaKinds ?? [];
644
+ const mediaActiveLabel =
645
+ filters.hasMedia === false
646
+ ? t({
647
+ message: "Text",
648
+ comment:
649
+ "@context: Archive media filter - posts without any media attachments",
650
+ })
651
+ : activeKinds.length === 1
652
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length check guarantees element exists
653
+ getMediaKindLabel(activeKinds[0]!)
654
+ : activeKinds.length > 1
655
+ ? String(activeKinds.length)
656
+ : undefined;
657
+ const mediaClearUrl = buildFilterUrl(
658
+ { ...filters, mediaKinds: undefined, hasMedia: undefined },
659
+ { mediaKinds: undefined, hasMedia: undefined },
660
+ );
661
+
662
+ return (
663
+ <div class="archive-filters">
664
+ <div class="archive-filters-chips">
665
+ {availableYears.length > 0 && (
666
+ <ChipSelect
667
+ id="af-year"
668
+ icon={FILTER_ICONS.year}
669
+ options={yearOptions}
670
+ currentValue={currentUrl}
671
+ clearUrl={buildFilterUrl(
672
+ { ...filters, year: undefined },
673
+ { year: undefined },
674
+ )}
675
+ activeLabel={filters.year ? String(filters.year) : undefined}
676
+ />
677
+ )}
678
+ {availableCollections.length > 0 && (
679
+ <ChipSelect
680
+ id="af-collection"
681
+ icon={FILTER_ICONS.collection}
682
+ options={collectionOptions}
683
+ currentValue={currentUrl}
684
+ clearUrl={buildFilterUrl(
685
+ {
686
+ ...filters,
687
+ collectionSlug: undefined,
688
+ collectionTitle: undefined,
689
+ },
690
+ { collectionSlug: undefined, collectionTitle: undefined },
691
+ )}
692
+ activeLabel={filters.collectionTitle}
693
+ activeIconHtml={
694
+ renderCollectionIcon(filters.collectionIcon ?? null, {
695
+ size: 16,
696
+ fallback: true,
697
+ }) || undefined
698
+ }
699
+ iconOnly
700
+ />
701
+ )}
702
+ <ChipSelect
703
+ id="af-format"
704
+ icon={FILTER_ICONS.format}
705
+ options={formatOptions}
706
+ currentValue={currentUrl}
707
+ clearUrl={buildFilterUrl(
708
+ { ...filters, format: undefined, hasTitle: undefined },
709
+ { format: undefined, hasTitle: undefined },
710
+ )}
711
+ activeLabel={formatActiveLabel}
712
+ activeIcon={formatActiveIcon}
713
+ iconOnly
714
+ />
715
+
716
+ <ChipMediaSelect
717
+ id="af-media"
718
+ icon={FILTER_ICONS.media}
719
+ filters={filters}
720
+ activeLabel={mediaActiveLabel}
721
+ clearUrl={mediaClearUrl}
722
+ />
723
+
724
+ {isAuthenticated && (
725
+ <ChipSelect
726
+ id="af-visibility"
727
+ icon={FILTER_ICONS.visibility}
728
+ options={visibilityOptions}
729
+ currentValue={currentUrl}
730
+ clearUrl={allVisibilityUrl}
731
+ activeLabel={
732
+ filters.visibility
733
+ ? getVisibilityLabel(filters.visibility)
734
+ : undefined
735
+ }
736
+ activeIcon={
737
+ filters.visibility
738
+ ? VISIBILITY_ICONS[filters.visibility]
739
+ : undefined
740
+ }
741
+ iconOnly
742
+ />
743
+ )}
744
+ </div>
745
+
746
+ <ViewToggle filters={filters} />
747
+ </div>
748
+ );
749
+ };
750
+
751
+ // =============================================================================
752
+ // Archive Tile (Grid View)
753
+ // =============================================================================
754
+
755
+ /**
756
+ * Determine tile variant based on post content and media.
757
+ */
758
+ function getTileVariant(post: PostView): "text" | "image" | "mixed" | "quote" {
759
+ const firstMedia = post.media[0];
760
+ const firstKind = firstMedia ? toMediaKind(firstMedia.mimeType) : undefined;
761
+ const hasImage = post.media.some((m) => m.mimeType.startsWith("image/"));
762
+
763
+ const hasVisualBg =
764
+ firstKind === "video" && firstMedia
765
+ ? !!(firstMedia.posterUrl || firstMedia.thumbnailUrl)
766
+ : hasImage;
767
+
768
+ if (post.format === "quote") {
769
+ return hasVisualBg ? "mixed" : "quote";
770
+ }
771
+ if (hasVisualBg && (post.title || post.excerpt)) return "mixed";
772
+ if (hasVisualBg) return "image";
773
+ return "text";
774
+ }
775
+
776
+ /**
777
+ * Resolve the background image URL for a tile.
778
+ */
779
+ function getTileBgImage(
780
+ post: PostView,
781
+ ): { url: string; alt: string } | undefined {
782
+ const firstMedia = post.media[0];
783
+ if (firstMedia) {
784
+ const firstKind = toMediaKind(firstMedia.mimeType);
785
+ if (firstKind === "video") {
786
+ const src = firstMedia.posterUrl ?? firstMedia.thumbnailUrl;
787
+ if (src) return { url: src, alt: firstMedia.altText ?? "" };
788
+ }
789
+ }
790
+ const firstImage = post.media.find((m) => m.mimeType.startsWith("image/"));
791
+ if (firstImage)
792
+ return { url: firstImage.thumbnailUrl, alt: firstImage.altText ?? "" };
793
+ return undefined;
794
+ }
795
+
796
+ /**
797
+ * Resolve a media-kind badge icon for the tile corner.
798
+ */
799
+ function getTileBadge(
800
+ post: PostView,
801
+ ): { icon: string; position: "center" | "corner" } | undefined {
802
+ const firstMedia = post.media[0];
803
+ if (!firstMedia) return undefined;
804
+ const kind = toMediaKind(firstMedia.mimeType);
805
+
806
+ if (kind === "video") return { icon: "circle-play", position: "center" };
807
+ if (kind === "audio")
808
+ return { icon: MEDIA_KIND_ICONS.audio, position: "corner" };
809
+ if (kind === "text")
810
+ return { icon: MEDIA_KIND_ICONS.text, position: "corner" };
811
+ if (kind === "document")
812
+ return { icon: MEDIA_KIND_ICONS.document, position: "corner" };
813
+ return undefined;
814
+ }
815
+
816
+ /** Strip HTML tags to get plain text for tile previews. */
817
+ function stripHtml(html: string): string {
818
+ return html.replace(/<[^>]*>/g, "").trim();
819
+ }
820
+
821
+ function getTileText(post: PostView): { title?: string; summary: string } {
822
+ if (post.title) {
823
+ const summary = post.bodyHtml
824
+ ? stripHtml(post.bodyHtml).slice(0, 200)
825
+ : (post.url ?? "");
826
+ return { title: post.title, summary };
827
+ }
828
+ if (post.format === "quote" && post.quoteText)
829
+ return { summary: post.quoteText };
830
+ if (post.bodyHtml) return { summary: stripHtml(post.bodyHtml).slice(0, 200) };
831
+ if (post.url) return { summary: post.url };
832
+ return { summary: getFormatLabel(post.format) };
833
+ }
834
+
835
+ const ArchiveTile: FC<{ post: PostView }> = ({ post }) => {
836
+ const variant = getTileVariant(post);
837
+ const bgImage = getTileBgImage(post);
838
+ const badge = getTileBadge(post);
839
+ const { title, summary } = getTileText(post);
840
+ const hasBg = variant === "image" || variant === "mixed";
841
+ const cornerBadge = badge?.position === "corner" ? badge : undefined;
842
+ const hasContent = variant !== "image" || cornerBadge;
843
+
844
+ return (
845
+ <a
846
+ href={post.permalink}
847
+ target="_blank"
848
+ rel="noopener"
849
+ class={`archive-tile archive-tile-${variant}`}
850
+ data-post
851
+ data-format={post.format}
852
+ >
853
+ {bgImage && hasBg && (
854
+ <img
855
+ class="archive-tile-bg"
856
+ src={bgImage.url}
857
+ alt={bgImage.alt}
858
+ loading="lazy"
859
+ />
860
+ )}
861
+
862
+ {hasContent && (
863
+ <div class="archive-tile-content">
864
+ {variant !== "image" && title && (
865
+ <span class="archive-tile-title">
866
+ {post.format === "link" && (
867
+ <span
868
+ class="archive-tile-link-indicator"
869
+ dangerouslySetInnerHTML={{
870
+ __html: getIconSvg("external-link") ?? "",
871
+ }}
872
+ />
873
+ )}
874
+ {title}
875
+ </span>
876
+ )}
877
+ {variant !== "image" && !title && summary && (
878
+ <span class="archive-tile-summary">{summary}</span>
879
+ )}
880
+ {cornerBadge && (
881
+ <span
882
+ class="archive-tile-badge-row"
883
+ dangerouslySetInnerHTML={{
884
+ __html: getIconSvg(cornerBadge.icon) ?? "",
885
+ }}
886
+ />
887
+ )}
888
+ </div>
889
+ )}
890
+
891
+ {badge?.position === "center" && (
892
+ <span
893
+ class="archive-tile-badge archive-tile-badge-center"
894
+ dangerouslySetInnerHTML={{ __html: getIconSvg(badge.icon) ?? "" }}
895
+ />
896
+ )}
897
+
898
+ <span class="archive-tile-date">{post.publishedAtFormatted}</span>
899
+ </a>
900
+ );
901
+ };
902
+
903
+ // =============================================================================
904
+ // Main Component
905
+ // =============================================================================
906
+
907
+ export const ArchivePage: FC<ArchivePageProps> = ({
908
+ groups,
909
+ currentPage,
910
+ totalPages,
911
+ filters,
912
+ availableYears,
913
+ availableCollections,
914
+ isAuthenticated,
915
+ }) => {
916
+ const { t } = useLingui();
917
+ const currentView: ArchiveView = filters.view ?? "grid";
918
+ const paginationBaseUrl = buildFilterUrl(filters, {});
919
+
920
+ return (
921
+ <div class="py-6" data-page="archive">
922
+ <header class="mb-6">
923
+ <h1 class="text-2xl font-semibold mb-4">
924
+ {t({ message: "Archive", comment: "@context: Archive page title" })}
925
+ </h1>
926
+
927
+ <FilterBar
928
+ filters={filters}
929
+ availableYears={availableYears}
930
+ availableCollections={availableCollections}
931
+ isAuthenticated={isAuthenticated}
932
+ />
98
933
  </header>
99
934
 
100
935
  <main>
101
936
  {groups.length === 0 ? (
102
- <p class="text-muted-foreground">
937
+ <p class="text-muted-foreground py-8 text-center">
103
938
  {t({
104
- message: "No posts found.",
105
- comment: "@context: Archive empty state",
939
+ message:
940
+ "No posts match these filters. Try adjusting your selection or clear all filters.",
941
+ comment: "@context: Archive empty state with filters",
106
942
  })}
107
943
  </p>
108
- ) : (
109
- groups.map((group) => (
110
- <section key={group.year + "-" + group.month} class="mb-8">
111
- <h2 class="text-lg font-medium mb-4 text-muted-foreground">
112
- {group.label}
113
- </h2>
114
- <div class="divide-y divide-border">
115
- {group.posts.map((post) => (
116
- <article
117
- key={post.id}
118
- class="flex items-baseline gap-4 py-2.5"
119
- data-post
120
- data-format={post.format}
944
+ ) : currentView === "grid" ? (
945
+ <div class="archive-grid-wrapper">
946
+ <div class="archive-grid">
947
+ {groups.map((group) => (
948
+ <>
949
+ <div
950
+ key={`header-${group.year}-${group.month}`}
951
+ class="archive-month-header"
121
952
  >
122
- <time
123
- class="text-sm text-muted-foreground w-12 shrink-0"
124
- datetime={post.publishedAt}
125
- >
126
- {new Date(post.publishedAt).getUTCDate()}
127
- </time>
128
- <div class="flex-1 min-w-0">
129
- <a href={post.permalink} class="hover:underline">
130
- {post.title ||
131
- post.excerpt?.slice(0, 80) ||
132
- "Post #" + post.id}
133
- </a>
134
- {!format && (
135
- <span class="ml-2 badge-outline text-xs">
136
- {getFormatLabel(post.format)}
137
- </span>
138
- )}
139
- </div>
140
- </article>
141
- ))}
142
- </div>
143
- </section>
144
- ))
953
+ {group.label}
954
+ </div>
955
+ {group.posts.map((post) => (
956
+ <ArchiveTile key={post.id} post={post} />
957
+ ))}
958
+ </>
959
+ ))}
960
+ </div>
961
+ </div>
962
+ ) : (
963
+ <div data-feed>
964
+ <div class="archive-list-groups">
965
+ {groups.map((group) => (
966
+ <div key={`list-${group.year}-${group.month}`}>
967
+ <div class="archive-list-month-header">{group.label}</div>
968
+ <div class="flex flex-col">
969
+ {group.posts.map((post, pi) => (
970
+ <div key={post.id}>
971
+ {pi > 0 && <hr class="feed-divider" />}
972
+ <TimelineItemFromPost post={post} />
973
+ </div>
974
+ ))}
975
+ </div>
976
+ </div>
977
+ ))}
978
+ </div>
979
+ </div>
145
980
  )}
146
981
  </main>
147
982
 
148
- {/* Pagination */}
149
- <Pagination
150
- baseUrl={
151
- format
152
- ? "/archive?format=" + format
153
- : featured
154
- ? "/archive?featured=true"
155
- : "/archive"
156
- }
157
- hasMore={hasMore}
158
- nextCursor={nextCursor}
983
+ <PagePagination
984
+ baseUrl={paginationBaseUrl}
985
+ currentPage={currentPage}
986
+ totalPages={totalPages}
159
987
  />
160
988
  </div>
161
989
  );