@hutusi/amytis 1.5.6 → 1.7.0

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 (65) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/CLAUDE.md +3 -2
  3. package/GEMINI.md +13 -6
  4. package/README.md +1 -1
  5. package/TODO.md +21 -76
  6. package/bun.lock +18 -3
  7. package/content/about.mdx +1 -0
  8. package/content/about.zh.mdx +21 -0
  9. package/content/flows/2026/02/20.md +16 -0
  10. package/content/links.mdx +42 -0
  11. package/content/links.zh.mdx +41 -0
  12. package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
  13. package/content/posts/multimedia-showcase/index.mdx +261 -0
  14. package/content/privacy.mdx +32 -0
  15. package/content/privacy.zh.mdx +32 -0
  16. package/docs/ARCHITECTURE.md +11 -2
  17. package/docs/CONTRIBUTING.md +4 -2
  18. package/docs/deployment.md +9 -1
  19. package/eslint.config.mjs +2 -0
  20. package/package.json +5 -4
  21. package/public/next-image-export-optimizer-hashes.json +0 -3
  22. package/scripts/copy-assets.ts +1 -1
  23. package/site.config.ts +126 -44
  24. package/src/app/[slug]/page.tsx +0 -10
  25. package/src/app/archive/page.tsx +38 -10
  26. package/src/app/books/[slug]/page.tsx +18 -0
  27. package/src/app/flows/[year]/[month]/[day]/page.tsx +21 -4
  28. package/src/app/layout.tsx +48 -21
  29. package/src/app/page.tsx +135 -72
  30. package/src/app/posts/[slug]/page.tsx +6 -12
  31. package/src/app/search.json/route.ts +4 -0
  32. package/src/app/series/[slug]/page.tsx +18 -0
  33. package/src/app/subscribe/page.tsx +17 -0
  34. package/src/app/tags/[tag]/page.tsx +9 -26
  35. package/src/app/tags/page.tsx +3 -8
  36. package/src/components/AuthorCard.tsx +43 -0
  37. package/src/components/Comments.tsx +20 -4
  38. package/src/components/ExternalLinks.tsx +6 -2
  39. package/src/components/Footer.tsx +35 -26
  40. package/src/components/LanguageProvider.tsx +0 -5
  41. package/src/components/LanguageSwitch.tsx +117 -6
  42. package/src/components/LocaleSwitch.tsx +33 -0
  43. package/src/components/Navbar.tsx +31 -8
  44. package/src/components/PostNavigation.tsx +55 -0
  45. package/src/components/PostSidebar.tsx +172 -126
  46. package/src/components/ReadingProgressBar.tsx +6 -21
  47. package/src/components/RelatedPosts.tsx +1 -1
  48. package/src/components/Search.tsx +420 -70
  49. package/src/components/SelectedBooksSection.tsx +12 -6
  50. package/src/components/ShareBar.tsx +115 -0
  51. package/src/components/SimpleLayoutHeader.tsx +5 -14
  52. package/src/components/SubscribePage.tsx +298 -0
  53. package/src/components/TagContentTabs.tsx +103 -0
  54. package/src/components/TagPageHeader.tsx +7 -13
  55. package/src/components/TagSidebar.tsx +142 -0
  56. package/src/components/TagsIndexClient.tsx +156 -0
  57. package/src/hooks/useScrollY.ts +41 -0
  58. package/src/i18n/translations.ts +110 -2
  59. package/src/layouts/PostLayout.tsx +34 -7
  60. package/src/layouts/SimpleLayout.tsx +53 -15
  61. package/src/lib/markdown.ts +71 -15
  62. package/src/lib/search-utils.test.ts +163 -0
  63. package/src/lib/search-utils.ts +39 -0
  64. package/src/types/pagefind.d.ts +42 -0
  65. package/src/components/TableOfContents.tsx +0 -158
@@ -1,94 +1,295 @@
1
1
  "use client";
2
2
 
3
- import React, { useState, useEffect, useRef } from 'react';
3
+ import React, { useState, useEffect, useRef, useMemo } from 'react';
4
+ import { useRouter } from 'next/navigation';
4
5
  import Link from 'next/link';
5
- import Fuse from 'fuse.js';
6
6
  import { useLanguage } from '@/components/LanguageProvider';
7
+ import { type ContentType, getResultType, getDateFromUrl, cleanTitle } from '@/lib/search-utils';
8
+ import type { TranslationKey } from '@/i18n/translations';
9
+ import { siteConfig } from '../../site.config';
10
+ import { resolveLocaleValue } from '@/lib/i18n';
11
+ // ─── Types ───────────────────────────────────────────────────────────────────
7
12
 
8
- interface SearchResult {
13
+ interface DisplayResult {
14
+ url: string;
9
15
  title: string;
10
- slug: string;
16
+ excerpt: string; // contains <mark> tags from Pagefind
11
17
  date: string;
12
- excerpt: string;
13
- category: string;
14
- tags: string[];
18
+ type: Exclude<ContentType, 'All'>;
15
19
  }
16
20
 
21
+ // ─── Constants ────────────────────────────────────────────────────────────────
22
+
23
+ const CONTENT_TYPES: ContentType[] = [
24
+ 'All',
25
+ ...(siteConfig.features?.posts?.enabled !== false ? ['Post' as ContentType] : []),
26
+ ...(siteConfig.features?.flows?.enabled !== false ? ['Flow' as ContentType] : []),
27
+ ...(siteConfig.features?.books?.enabled !== false ? ['Book' as ContentType] : []),
28
+ ];
29
+
30
+ const CONTENT_TYPE_FEATURE: Record<Exclude<ContentType, 'All'>, keyof typeof siteConfig.features> = {
31
+ Post: 'posts',
32
+ Flow: 'flows',
33
+ Book: 'books',
34
+ };
35
+ const RECENT_KEY = 'amytis-recent-searches';
36
+ const MAX_RECENT = 5;
37
+ const MAX_RESULTS = 8;
38
+ const FETCH_RESULTS = 24; // fetch more so type filter always has enough
39
+ const DEBOUNCE_MS = 150;
40
+
41
+ const TYPE_LABEL_KEYS: Record<Exclude<ContentType, 'All'>, TranslationKey> = {
42
+ Post: 'search_type_post',
43
+ Flow: 'search_type_flow',
44
+ Book: 'search_type_book',
45
+ };
46
+
47
+ const TYPE_STYLES: Record<string, string> = {
48
+ Flow: 'border-accent/30 text-accent',
49
+ Book: 'border-foreground/30 text-foreground/60',
50
+ Post: 'border-muted/30 text-muted',
51
+ };
52
+
53
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
54
+
55
+ function loadRecentSearches(): string[] {
56
+ try { return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); }
57
+ catch { return []; }
58
+ }
59
+
60
+ function persistRecentSearch(query: string, current: string[]): string[] {
61
+ const updated = [query, ...current.filter((s) => s !== query)].slice(0, MAX_RECENT);
62
+ try { localStorage.setItem(RECENT_KEY, JSON.stringify(updated)); } catch { /* ignore */ }
63
+ return updated;
64
+ }
65
+
66
+ // ─── Pagefind loader ──────────────────────────────────────────────────────────
67
+ //
68
+ // We use `new Function` to create a runtime-only dynamic import so that
69
+ // neither webpack nor Turbopack tries to bundle /pagefind/pagefind.js at
70
+ // compile time (the file only exists after `pagefind --site out` runs).
71
+
72
+ interface PagefindFragment {
73
+ url: string;
74
+ excerpt: string; // contains <mark> tags
75
+ meta: { title?: string; image?: string; date?: string; [key: string]: string | undefined };
76
+ word_count: number;
77
+ }
78
+
79
+ interface PagefindAPI {
80
+ init: () => Promise<void>;
81
+ search: (q: string) => Promise<{ results: Array<{ data: () => Promise<PagefindFragment> }> }>;
82
+ }
83
+
84
+ let pagefindCache: PagefindAPI | null = null;
85
+ let pagefindUnavailable = false;
86
+
87
+ async function loadPagefind(): Promise<PagefindAPI | null> {
88
+ if (pagefindCache) return pagefindCache;
89
+ if (pagefindUnavailable) return null;
90
+ try {
91
+ const load = new Function('path', 'return import(path)') as (p: string) => Promise<PagefindAPI>;
92
+ const pf = await load('/pagefind/pagefind.js');
93
+ await pf.init();
94
+ pagefindCache = pf;
95
+ return pf;
96
+ } catch {
97
+ pagefindUnavailable = true;
98
+ return null;
99
+ }
100
+ }
101
+
102
+ // ─── Component ────────────────────────────────────────────────────────────────
103
+
17
104
  export default function Search() {
105
+ const router = useRouter();
18
106
  const [isOpen, setIsOpen] = useState(false);
19
107
  const [query, setQuery] = useState('');
20
- const [results, setResults] = useState<SearchResult[]>([]);
21
- const [posts, setPosts] = useState<SearchResult[]>([]);
108
+ const [debouncedQuery, setDebouncedQuery] = useState('');
109
+ const [allResults, setAllResults] = useState<DisplayResult[]>([]);
110
+ const [activeIndex, setActiveIndex] = useState(-1);
111
+ const [activeType, setActiveType] = useState<ContentType>('All');
112
+ const [recentSearches, setRecentSearches] = useState<string[]>([]);
113
+ const [isFetching, setIsFetching] = useState(false);
114
+ const [isUnavailable, setIsUnavailable] = useState(false);
22
115
  const searchRef = useRef<HTMLDivElement>(null);
23
116
  const inputRef = useRef<HTMLInputElement>(null);
24
- const { t } = useLanguage();
117
+ const { t, tWith, language } = useLanguage();
118
+
119
+ const getTypeLabel = (type: Exclude<ContentType, 'All'>): string => {
120
+ const featureKey = CONTENT_TYPE_FEATURE[type];
121
+ const featureName = siteConfig.features?.[featureKey]?.name;
122
+ if (featureName) return resolveLocaleValue(featureName, language);
123
+ return t(TYPE_LABEL_KEYS[type]);
124
+ };
125
+
126
+ // True while debounce is pending — suppress "no results" flash
127
+ const isTyping = query.length > 0 && query !== debouncedQuery;
128
+
129
+ // Load recent searches on mount
130
+ useEffect(() => {
131
+ setRecentSearches(loadRecentSearches());
132
+ }, []);
25
133
 
26
- // Load search index
134
+ // Pre-load Pagefind when the modal first opens
27
135
  useEffect(() => {
28
- if (isOpen && posts.length === 0) {
29
- fetch('/search.json')
30
- .then((res) => res.json())
31
- .then((data) => setPosts(data))
32
- .catch((err) => console.error("Failed to load search index", err));
136
+ if (isOpen) {
137
+ loadPagefind().then((pf) => { if (!pf) setIsUnavailable(true); });
33
138
  }
34
- }, [isOpen, posts.length]);
139
+ }, [isOpen]);
140
+
141
+ // Debounce query
142
+ useEffect(() => {
143
+ if (!query) { setDebouncedQuery(''); return; }
144
+ const timer = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS);
145
+ return () => clearTimeout(timer);
146
+ }, [query]);
35
147
 
36
- // Perform search
148
+ // Run Pagefind search on debounced query
37
149
  useEffect(() => {
38
- if (!query) {
150
+ if (!debouncedQuery) {
151
+ setAllResults([]);
152
+ setActiveIndex(-1);
153
+ setActiveType('All');
39
154
  return;
40
155
  }
41
156
 
42
- const fuse = new Fuse(posts, {
43
- keys: ['title', 'excerpt', 'tags', 'category'],
44
- threshold: 0.3,
45
- });
157
+ let cancelled = false;
158
+ setIsFetching(true);
46
159
 
47
- const searchResults = fuse.search(query).map((result) => result.item);
48
- // Wrap in requestAnimationFrame to avoid cascading render lint error
49
- const rafId = requestAnimationFrame(() => {
50
- setResults(searchResults.slice(0, 5)); // Limit to 5 results
160
+ loadPagefind().then(async (pf) => {
161
+ if (!pf || cancelled) { setIsFetching(false); return; }
162
+ try {
163
+ const search = await pf.search(debouncedQuery);
164
+ const fragments = await Promise.all(
165
+ search.results.slice(0, FETCH_RESULTS).map((r) => r.data())
166
+ );
167
+ if (cancelled) return;
168
+ setAllResults(
169
+ fragments.map((f: PagefindFragment) => ({
170
+ url: f.url,
171
+ title: cleanTitle(f.meta.title ?? ''),
172
+ excerpt: f.excerpt,
173
+ date: f.meta.date ?? getDateFromUrl(f.url),
174
+ type: getResultType(f.url),
175
+ }))
176
+ );
177
+ setActiveIndex(-1);
178
+ setActiveType('All');
179
+ } finally {
180
+ if (!cancelled) setIsFetching(false);
181
+ }
51
182
  });
52
- return () => cancelAnimationFrame(rafId);
53
- }, [query, posts]);
54
183
 
55
- // Handle keyboard shortcuts
184
+ return () => { cancelled = true; };
185
+ }, [debouncedQuery]);
186
+
187
+ // Filtered results for the active type tab
188
+ const { displayedResults, totalFilteredCount } = useMemo(() => {
189
+ const filtered = activeType === 'All' ? allResults : allResults.filter((r) => r.type === activeType);
190
+ return { displayedResults: filtered.slice(0, MAX_RESULTS), totalFilteredCount: filtered.length };
191
+ }, [allResults, activeType]);
192
+
193
+ // Count per type for tab badges
194
+ const typeCounts = useMemo(() => {
195
+ const counts: Record<ContentType, number> = { All: allResults.length, Post: 0, Flow: 0, Book: 0 };
196
+ for (const r of allResults) counts[r.type]++;
197
+ return counts;
198
+ }, [allResults]);
199
+
200
+ // Global Cmd/Ctrl+K + Escape shortcut
56
201
  useEffect(() => {
57
202
  const handleKeyDown = (e: KeyboardEvent) => {
58
203
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
59
204
  e.preventDefault();
60
205
  setIsOpen((prev) => !prev);
61
206
  }
62
- if (e.key === 'Escape') {
63
- setIsOpen(false);
64
- }
207
+ if (e.key === 'Escape') setIsOpen(false);
65
208
  };
66
-
67
209
  window.addEventListener('keydown', handleKeyDown);
68
210
  return () => window.removeEventListener('keydown', handleKeyDown);
69
211
  }, []);
70
212
 
71
- // Focus input on open
213
+ // Focus on open; full reset on close
72
214
  useEffect(() => {
73
215
  if (isOpen) {
74
216
  setTimeout(() => inputRef.current?.focus(), 100);
217
+ } else {
218
+ setQuery('');
219
+ setDebouncedQuery('');
220
+ setAllResults([]);
221
+ setActiveIndex(-1);
222
+ setActiveType('All');
223
+ setIsFetching(false);
75
224
  }
76
225
  }, [isOpen]);
77
226
 
78
- // Click outside to close
227
+ // Body scroll lock while modal is open
228
+ useEffect(() => {
229
+ if (isOpen) document.body.classList.add('overflow-hidden');
230
+ else document.body.classList.remove('overflow-hidden');
231
+ return () => document.body.classList.remove('overflow-hidden');
232
+ }, [isOpen]);
233
+
234
+ // Click outside to close (desktop — modal is full-screen on mobile)
79
235
  useEffect(() => {
80
- const handleClickOutside = (event: MouseEvent) => {
81
- if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
236
+ const handleClickOutside = (e: MouseEvent) => {
237
+ if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
82
238
  setIsOpen(false);
83
239
  }
84
240
  };
85
-
86
- if (isOpen) {
87
- document.addEventListener('mousedown', handleClickOutside);
88
- }
241
+ if (isOpen) document.addEventListener('mousedown', handleClickOutside);
89
242
  return () => document.removeEventListener('mousedown', handleClickOutside);
90
243
  }, [isOpen]);
91
244
 
245
+ function handleNavigate(q: string) {
246
+ if (q.trim()) setRecentSearches((prev) => persistRecentSearch(q.trim(), prev));
247
+ setIsOpen(false);
248
+ }
249
+
250
+ function handleInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
251
+ // Number keys 1–4 switch type tabs when results are visible
252
+ if (allResults.length > 0 && e.altKey && ['1', '2', '3', '4'].includes(e.key)) {
253
+ const visibleTypes = CONTENT_TYPES.filter((ct) => ct === 'All' || typeCounts[ct] > 0);
254
+ const target = visibleTypes[parseInt(e.key, 10) - 1];
255
+ if (target) { e.preventDefault(); setActiveType(target); setActiveIndex(-1); return; }
256
+ }
257
+ if (displayedResults.length === 0) return;
258
+ if (e.key === 'ArrowDown') {
259
+ e.preventDefault();
260
+ setActiveIndex((i) => Math.min(i + 1, displayedResults.length - 1));
261
+ } else if (e.key === 'ArrowUp') {
262
+ e.preventDefault();
263
+ setActiveIndex((i) => Math.max(i - 1, -1));
264
+ } else if (e.key === 'Enter' && activeIndex >= 0) {
265
+ e.preventDefault();
266
+ router.push(displayedResults[activeIndex].url);
267
+ handleNavigate(query);
268
+ }
269
+ }
270
+
271
+ function handleModalKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
272
+ if (e.key !== 'Tab') return;
273
+ const focusable = searchRef.current?.querySelectorAll<HTMLElement>(
274
+ 'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])'
275
+ );
276
+ if (!focusable || focusable.length === 0) return;
277
+ const first = focusable[0];
278
+ const last = focusable[focusable.length - 1];
279
+ if (e.shiftKey) {
280
+ if (document.activeElement === first) { e.preventDefault(); last.focus(); }
281
+ } else {
282
+ if (document.activeElement === last) { e.preventDefault(); first.focus(); }
283
+ }
284
+ }
285
+
286
+ function clearRecentSearches() {
287
+ setRecentSearches([]);
288
+ try { localStorage.removeItem(RECENT_KEY); } catch { /* ignore */ }
289
+ }
290
+
291
+ const showNoResults = !isTyping && !isFetching && debouncedQuery && displayedResults.length === 0;
292
+
92
293
  return (
93
294
  <>
94
295
  <button
@@ -100,49 +301,198 @@ export default function Search() {
100
301
  </button>
101
302
 
102
303
  {isOpen && (
103
- <div className="fixed inset-0 z-50 flex items-start justify-center pt-24 px-4 bg-background/80 backdrop-blur-sm transition-opacity">
304
+ // Overlay: full-column on mobile, centered on desktop
305
+ <div className="fixed inset-0 z-50 flex flex-col sm:flex-row sm:items-start sm:justify-center sm:pt-24 sm:px-4 bg-background/80 backdrop-blur-sm">
306
+ {/* Modal: full-height on mobile, auto-height on desktop */}
104
307
  <div
105
308
  ref={searchRef}
106
- className="w-full max-w-xl bg-background border border-muted/20 rounded-lg shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200"
309
+ role="dialog"
310
+ aria-modal="true"
311
+ aria-label="Search"
312
+ onKeyDown={handleModalKeyDown}
313
+ className="flex flex-col flex-1 sm:flex-initial min-h-0 w-full sm:max-w-xl bg-background border-b sm:border border-muted/20 rounded-none sm:rounded-lg shadow-none sm:shadow-2xl overflow-hidden sm:animate-in sm:fade-in sm:zoom-in-95 sm:duration-200"
107
314
  >
108
- <div className="flex items-center px-4 py-3 border-b border-muted/10">
109
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted mr-3"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
315
+ {/* Screen-reader live region for result counts */}
316
+ <div aria-live="polite" aria-atomic="true" className="sr-only">
317
+ {debouncedQuery && !isFetching && (
318
+ displayedResults.length > 0
319
+ ? tWith('search_results_found', { total: totalFilteredCount, query: debouncedQuery })
320
+ : tWith('search_no_results_for', { query: debouncedQuery })
321
+ )}
322
+ </div>
323
+ {/* Input row */}
324
+ <div className="flex items-center px-4 py-3 border-b border-muted/10 shrink-0">
325
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted mr-3 shrink-0"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
110
326
  <input
111
327
  ref={inputRef}
112
328
  type="text"
113
329
  placeholder={t('search_placeholder')}
330
+ aria-label="Search"
331
+ aria-autocomplete="list"
114
332
  className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted"
115
333
  value={query}
116
- onChange={(e) => {
117
- setQuery(e.target.value);
118
- if (!e.target.value) setResults([]);
119
- }}
334
+ onChange={(e) => setQuery(e.target.value)}
335
+ onKeyDown={handleInputKeyDown}
120
336
  />
121
- <div className="text-xs text-muted border border-muted/20 px-1.5 py-0.5 rounded">ESC</div>
337
+ {/* Spinner visible while fetching */}
338
+ {isFetching && (
339
+ <svg className="animate-spin shrink-0 ml-2 text-muted" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24">
340
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
341
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
342
+ </svg>
343
+ )}
344
+ {/* ESC hint — desktop only, hidden while fetching */}
345
+ {!isFetching && <div className="hidden sm:block text-xs text-muted border border-muted/20 px-1.5 py-0.5 rounded ml-2">ESC</div>}
346
+ {/* Close button — mobile only */}
347
+ <button
348
+ onClick={() => setIsOpen(false)}
349
+ className="sm:hidden ml-2 p-1 text-muted hover:text-foreground transition-colors"
350
+ aria-label="Close search"
351
+ >
352
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
353
+ </button>
122
354
  </div>
123
355
 
124
- {results.length > 0 && (
125
- <ul className="py-2 max-h-[60vh] overflow-y-auto">
126
- {results.map((post) => (
127
- <li key={post.slug}>
128
- <Link
129
- href={post.slug.startsWith('books/') || post.slug.startsWith('flows/') ? `/${post.slug}` : `/posts/${post.slug}`}
130
- onClick={() => setIsOpen(false)}
131
- className="block px-4 py-3 hover:bg-muted/5 transition-colors"
132
- >
133
- <div className="text-sm font-serif font-bold text-heading">{post.title}</div>
134
- <div className="text-xs text-muted mt-1 line-clamp-1">{post.excerpt}</div>
135
- </Link>
136
- </li>
356
+ {/* Type filter tabs — visible when results exist */}
357
+ {allResults.length > 0 && (
358
+ <div className="flex items-center gap-1 px-4 pt-2 pb-1 border-b border-muted/10 shrink-0" role="tablist" aria-label="Filter by type">
359
+ {CONTENT_TYPES.filter((type) => type === 'All' || typeCounts[type] > 0).map((type, i) => (
360
+ <button
361
+ key={type}
362
+ role="tab"
363
+ aria-selected={activeType === type}
364
+ onClick={() => { setActiveType(type); setActiveIndex(-1); }}
365
+ className={`text-xs px-2 py-0.5 rounded-md transition-colors ${
366
+ activeType === type
367
+ ? 'bg-accent/10 text-accent font-medium'
368
+ : 'text-muted hover:text-foreground hover:bg-muted/5'
369
+ }`}
370
+ >
371
+ {type === 'All' ? t('search_all') : getTypeLabel(type)}
372
+ <span className="ml-1 text-[10px] opacity-60">{typeCounts[type]}</span>
373
+ <span className="hidden sm:inline ml-1 text-[9px] opacity-30">⌥{i + 1}</span>
374
+ </button>
137
375
  ))}
138
- </ul>
139
- )}
140
-
141
- {query && results.length === 0 && (
142
- <div className="p-8 text-center text-muted text-sm">
143
- {t('no_results')}
144
376
  </div>
145
377
  )}
378
+
379
+ {/* Scrollable body: flex-1 on mobile, capped at 60vh on desktop */}
380
+ <div className="flex-1 sm:flex-none overflow-y-auto min-h-0 sm:max-h-[60vh]">
381
+
382
+ {/* Results */}
383
+ {displayedResults.length > 0 && (
384
+ <ul className="py-2">
385
+ {displayedResults.map((result, index) => (
386
+ <li key={result.url}>
387
+ <Link
388
+ href={result.url}
389
+ onClick={() => handleNavigate(query)}
390
+ onMouseEnter={() => setActiveIndex(index)}
391
+ className={`block px-4 py-3 transition-colors ${index === activeIndex ? 'bg-muted/10' : 'hover:bg-muted/5'}`}
392
+ >
393
+ <div className="flex items-baseline justify-between gap-2">
394
+ <div className="text-sm font-serif font-bold text-heading truncate">
395
+ {result.title}
396
+ </div>
397
+ <div className="flex items-center gap-2 shrink-0">
398
+ {result.date && (
399
+ <span className="text-[10px] font-mono text-muted/60">{result.date}</span>
400
+ )}
401
+ <span className={`text-[10px] px-1.5 py-0.5 rounded-full border font-medium ${TYPE_STYLES[result.type]}`}>
402
+ {getTypeLabel(result.type)}
403
+ </span>
404
+ </div>
405
+ </div>
406
+ {/* Pagefind excerpts already include <mark> highlight tags */}
407
+ <div
408
+ className="text-xs text-muted mt-1 line-clamp-2 [&_mark]:bg-transparent [&_mark]:text-accent [&_mark]:font-semibold [&_mark]:not-italic"
409
+ dangerouslySetInnerHTML={{ __html: result.excerpt }}
410
+ />
411
+ </Link>
412
+ </li>
413
+ ))}
414
+ </ul>
415
+ )}
416
+
417
+ {/* Result count when capped */}
418
+ {displayedResults.length > 0 && totalFilteredCount > MAX_RESULTS && (
419
+ <div className="px-4 py-2 text-[11px] text-muted/60 border-t border-muted/10 text-center">
420
+ {tWith('search_showing', { shown: displayedResults.length, total: totalFilteredCount })}
421
+ </div>
422
+ )}
423
+
424
+ {/* No results */}
425
+ {showNoResults && (
426
+ <div className="p-8 text-center text-muted text-sm">{t('no_results')}</div>
427
+ )}
428
+
429
+ {/* Pagefind not yet built (dev without running build:dev) */}
430
+ {isUnavailable && !query && (
431
+ <div className="p-8 text-center text-muted text-sm space-y-1">
432
+ <p>Search index not found.</p>
433
+ <p>
434
+ Run{' '}
435
+ <code className="text-xs bg-muted/10 px-1 py-0.5 rounded">
436
+ bun run build:dev
437
+ </code>{' '}
438
+ to generate it.
439
+ </p>
440
+ </div>
441
+ )}
442
+
443
+ {/* Recent searches — shown when input is empty and pagefind is available */}
444
+ {!query && !isUnavailable && recentSearches.length > 0 && (
445
+ <div className="py-2">
446
+ <div className="flex items-center justify-between px-4 py-1">
447
+ <span className="text-[10px] font-medium text-muted uppercase tracking-wider">
448
+ {t('recent_searches')}
449
+ </span>
450
+ <button
451
+ onClick={clearRecentSearches}
452
+ className="text-[10px] text-muted hover:text-accent transition-colors"
453
+ >
454
+ {t('clear')}
455
+ </button>
456
+ </div>
457
+ <ul>
458
+ {recentSearches.map((s) => (
459
+ <li key={s}>
460
+ <button
461
+ onClick={() => setQuery(s)}
462
+ className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left text-muted hover:text-foreground hover:bg-muted/5 transition-colors"
463
+ >
464
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
465
+ <circle cx="11" cy="11" r="8" />
466
+ <path d="M11 8v4l2 2" />
467
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
468
+ </svg>
469
+ {s}
470
+ </button>
471
+ </li>
472
+ ))}
473
+ </ul>
474
+ </div>
475
+ )}
476
+
477
+ {/* Search tips — shown when input is empty and search is available */}
478
+ {!query && !isUnavailable && (
479
+ <div className="px-4 py-3 border-t border-muted/10">
480
+ <p className="text-[10px] font-medium text-muted/50 uppercase tracking-wider mb-2">{t('search_tips')}</p>
481
+ <div className="flex flex-col gap-1.5">
482
+ {([
483
+ ['"exact phrase"', t('search_tip_phrase')],
484
+ ['word1 word2', t('search_tip_and')],
485
+ ['-word', t('search_tip_exclude')],
486
+ ] as [string, string][]).map(([syntax, desc]) => (
487
+ <div key={syntax} className="flex items-center gap-2 text-[11px]">
488
+ <code className="font-mono text-accent/70 bg-accent/5 px-1.5 py-0.5 rounded text-[10px] shrink-0">{syntax}</code>
489
+ <span className="text-muted/50">{desc}</span>
490
+ </div>
491
+ ))}
492
+ </div>
493
+ </div>
494
+ )}
495
+ </div>
146
496
  </div>
147
497
  </div>
148
498
  )}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import Link from 'next/link';
4
4
  import CoverImage from './CoverImage';
5
+ import HorizontalScroll from './HorizontalScroll';
5
6
  import { useLanguage } from './LanguageProvider';
6
7
 
7
8
  export interface BookItem {
@@ -16,12 +17,15 @@ export interface BookItem {
16
17
 
17
18
  interface SelectedBooksSectionProps {
18
19
  books: BookItem[];
20
+ maxItems?: number;
21
+ scrollThreshold?: number;
19
22
  }
20
23
 
21
- export default function SelectedBooksSection({ books }: SelectedBooksSectionProps) {
24
+ export default function SelectedBooksSection({ books, maxItems = 4, scrollThreshold = 2 }: SelectedBooksSectionProps) {
22
25
  const { t } = useLanguage();
26
+ const displayed = books.slice(0, maxItems);
23
27
 
24
- if (books.length === 0) return null;
28
+ if (displayed.length === 0) return null;
25
29
 
26
30
  return (
27
31
  <section className="mb-24">
@@ -31,9 +35,10 @@ export default function SelectedBooksSection({ books }: SelectedBooksSectionProp
31
35
  {t('all_books')} →
32
36
  </Link>
33
37
  </div>
34
- <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
35
- {books.map(book => (
36
- <Link key={book.slug} href={`/books/${book.slug}`} className="group block no-underline">
38
+ <HorizontalScroll itemCount={displayed.length} scrollThreshold={scrollThreshold}>
39
+ <div className={`flex gap-8 ${displayed.length > scrollThreshold ? 'pb-4' : ''}`}>
40
+ {displayed.map(book => (
41
+ <Link key={book.slug} href={`/books/${book.slug}`} className={`group block no-underline ${displayed.length > scrollThreshold ? 'w-[85vw] md:w-[calc(50%-1rem)] flex-shrink-0 snap-start' : 'flex-1'}`}>
37
42
  <div className="card-base h-full group flex flex-col p-0 overflow-hidden">
38
43
  <div className="relative h-48 w-full overflow-hidden bg-muted/10">
39
44
  <CoverImage
@@ -74,7 +79,8 @@ export default function SelectedBooksSection({ books }: SelectedBooksSectionProp
74
79
  </div>
75
80
  </Link>
76
81
  ))}
77
- </div>
82
+ </div>
83
+ </HorizontalScroll>
78
84
  </section>
79
85
  );
80
86
  }