@jant/core 0.3.36 → 0.3.38

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 (271) 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/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -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,123 +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
- visibility,
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
+ }
56
135
 
136
+ // =============================================================================
137
+ // Shared Icon Helpers
138
+ // =============================================================================
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
+ }
171
+
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;
61
201
 
62
- {/* Format filter */}
63
- <nav class="flex flex-wrap gap-2 mt-4">
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 && !visibility ? "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?visibility=featured"
90
- class={
91
- "badge " +
92
- (visibility === "featured" ? "badge-primary" : "badge-outline")
93
- }
340
+ href={clearUrl}
341
+ class="archive-chip-clear"
342
+ aria-label="Clear filter"
94
343
  >
95
- {t({
96
- message: "Featured",
97
- comment: "@context: Archive filter - featured posts",
98
- })}
344
+ <Icon name="x" class="[&>svg]:size-3" />
99
345
  </a>
100
- </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
+ />
101
933
  </header>
102
934
 
103
935
  <main>
104
936
  {groups.length === 0 ? (
105
- <p class="text-muted-foreground">
937
+ <p class="text-muted-foreground py-8 text-center">
106
938
  {t({
107
- message: "No posts match this filter.",
108
- 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",
109
942
  })}
110
943
  </p>
111
- ) : (
112
- groups.map((group) => (
113
- <section key={group.year + "-" + group.month} class="mb-8">
114
- <h2 class="text-lg font-medium mb-4 text-muted-foreground">
115
- {group.label}
116
- </h2>
117
- <div class="divide-y divide-border">
118
- {group.posts.map((post) => (
119
- <article
120
- key={post.id}
121
- class="flex items-baseline gap-4 py-2.5"
122
- data-post
123
- 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"
124
952
  >
125
- <time
126
- class="text-sm text-muted-foreground w-12 shrink-0"
127
- datetime={post.publishedAt}
128
- >
129
- {new Date(post.publishedAt).getUTCDate()}
130
- </time>
131
- <div class="flex-1 min-w-0">
132
- <a href={post.permalink} class="hover:underline">
133
- {post.title ||
134
- post.excerpt?.slice(0, 80) ||
135
- "Post #" + post.id}
136
- </a>
137
- {!format && (
138
- <span class="ml-2 badge-outline text-xs">
139
- {getFormatLabel(post.format)}
140
- </span>
141
- )}
142
- </div>
143
- </article>
144
- ))}
145
- </div>
146
- </section>
147
- ))
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>
148
980
  )}
149
981
  </main>
150
982
 
151
- {/* Pagination */}
152
- <Pagination
153
- baseUrl={
154
- format
155
- ? "/archive?format=" + format
156
- : visibility
157
- ? "/archive?visibility=" + visibility
158
- : "/archive"
159
- }
160
- hasMore={hasMore}
161
- nextCursor={nextCursor}
983
+ <PagePagination
984
+ baseUrl={paginationBaseUrl}
985
+ currentPage={currentPage}
986
+ totalPages={totalPages}
162
987
  />
163
988
  </div>
164
989
  );