@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.
- package/CHANGELOG.md +94 -0
- package/CLAUDE.md +3 -2
- package/GEMINI.md +13 -6
- package/README.md +1 -1
- package/TODO.md +21 -76
- package/bun.lock +18 -3
- package/content/about.mdx +1 -0
- package/content/about.zh.mdx +21 -0
- package/content/flows/2026/02/20.md +16 -0
- package/content/links.mdx +42 -0
- package/content/links.zh.mdx +41 -0
- package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
- package/content/posts/multimedia-showcase/index.mdx +261 -0
- package/content/privacy.mdx +32 -0
- package/content/privacy.zh.mdx +32 -0
- package/docs/ARCHITECTURE.md +11 -2
- package/docs/CONTRIBUTING.md +4 -2
- package/docs/deployment.md +9 -1
- package/eslint.config.mjs +2 -0
- package/package.json +5 -4
- package/public/next-image-export-optimizer-hashes.json +0 -3
- package/scripts/copy-assets.ts +1 -1
- package/site.config.ts +126 -44
- package/src/app/[slug]/page.tsx +0 -10
- package/src/app/archive/page.tsx +38 -10
- package/src/app/books/[slug]/page.tsx +18 -0
- package/src/app/flows/[year]/[month]/[day]/page.tsx +21 -4
- package/src/app/layout.tsx +48 -21
- package/src/app/page.tsx +135 -72
- package/src/app/posts/[slug]/page.tsx +6 -12
- package/src/app/search.json/route.ts +4 -0
- package/src/app/series/[slug]/page.tsx +18 -0
- package/src/app/subscribe/page.tsx +17 -0
- package/src/app/tags/[tag]/page.tsx +9 -26
- package/src/app/tags/page.tsx +3 -8
- package/src/components/AuthorCard.tsx +43 -0
- package/src/components/Comments.tsx +20 -4
- package/src/components/ExternalLinks.tsx +6 -2
- package/src/components/Footer.tsx +35 -26
- package/src/components/LanguageProvider.tsx +0 -5
- package/src/components/LanguageSwitch.tsx +117 -6
- package/src/components/LocaleSwitch.tsx +33 -0
- package/src/components/Navbar.tsx +31 -8
- package/src/components/PostNavigation.tsx +55 -0
- package/src/components/PostSidebar.tsx +172 -126
- package/src/components/ReadingProgressBar.tsx +6 -21
- package/src/components/RelatedPosts.tsx +1 -1
- package/src/components/Search.tsx +420 -70
- package/src/components/SelectedBooksSection.tsx +12 -6
- package/src/components/ShareBar.tsx +115 -0
- package/src/components/SimpleLayoutHeader.tsx +5 -14
- package/src/components/SubscribePage.tsx +298 -0
- package/src/components/TagContentTabs.tsx +103 -0
- package/src/components/TagPageHeader.tsx +7 -13
- package/src/components/TagSidebar.tsx +142 -0
- package/src/components/TagsIndexClient.tsx +156 -0
- package/src/hooks/useScrollY.ts +41 -0
- package/src/i18n/translations.ts +110 -2
- package/src/layouts/PostLayout.tsx +34 -7
- package/src/layouts/SimpleLayout.tsx +53 -15
- package/src/lib/markdown.ts +71 -15
- package/src/lib/search-utils.test.ts +163 -0
- package/src/lib/search-utils.ts +39 -0
- package/src/types/pagefind.d.ts +42 -0
- 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
|
|
13
|
+
interface DisplayResult {
|
|
14
|
+
url: string;
|
|
9
15
|
title: string;
|
|
10
|
-
|
|
16
|
+
excerpt: string; // contains <mark> tags from Pagefind
|
|
11
17
|
date: string;
|
|
12
|
-
|
|
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 [
|
|
21
|
-
const [
|
|
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
|
-
//
|
|
134
|
+
// Pre-load Pagefind when the modal first opens
|
|
27
135
|
useEffect(() => {
|
|
28
|
-
if (isOpen
|
|
29
|
-
|
|
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
|
|
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
|
-
//
|
|
148
|
+
// Run Pagefind search on debounced query
|
|
37
149
|
useEffect(() => {
|
|
38
|
-
if (!
|
|
150
|
+
if (!debouncedQuery) {
|
|
151
|
+
setAllResults([]);
|
|
152
|
+
setActiveIndex(-1);
|
|
153
|
+
setActiveType('All');
|
|
39
154
|
return;
|
|
40
155
|
}
|
|
41
156
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
threshold: 0.3,
|
|
45
|
-
});
|
|
157
|
+
let cancelled = false;
|
|
158
|
+
setIsFetching(true);
|
|
46
159
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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 = (
|
|
81
|
-
if (searchRef.current && !searchRef.current.contains(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
118
|
-
if (!e.target.value) setResults([]);
|
|
119
|
-
}}
|
|
334
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
335
|
+
onKeyDown={handleInputKeyDown}
|
|
120
336
|
/>
|
|
121
|
-
|
|
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
|
-
{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 (
|
|
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
|
-
<
|
|
35
|
-
{
|
|
36
|
-
|
|
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
|
-
|
|
82
|
+
</div>
|
|
83
|
+
</HorizontalScroll>
|
|
78
84
|
</section>
|
|
79
85
|
);
|
|
80
86
|
}
|