Skip to content

Commit 25fc1da

Browse files
committed
feature(blog): adding search, fix deploy issue
1 parent 32c2bc1 commit 25fc1da

6 files changed

Lines changed: 283 additions & 7 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import groq from 'groq';
2+
import { NextResponse } from 'next/server';
3+
import { client } from '@/client';
4+
5+
const searchQuery = groq`
6+
*[_type == "post" && defined(slug.current)] | order(publishedAt desc) {
7+
_id,
8+
"title": coalesce(title.en, title.uk, title.pl, title),
9+
"body": coalesce(body.en, body.uk, body.pl, body)[]{
10+
...,
11+
children[]{ text }
12+
},
13+
slug
14+
}
15+
`;
16+
17+
export async function GET() {
18+
const items = await client.withConfig({ useCdn: false }).fetch(searchQuery);
19+
return NextResponse.json(items || []);
20+
}

frontend/app/globals.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@import url(https://fonts.googleapis.com/css?family=Lato:100,300,400,700);
2+
@import url(https://raw.github.com/FortAwesome/Font-Awesome/master/docs/assets/css/font-awesome.min.css);
13
@import 'tailwindcss';
24

35
@custom-variant dark (&:is(.dark *));

frontend/components/blog/BlogFilters.tsx

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client';
22

3-
import { useEffect, useMemo, useState } from 'react';
3+
import { useEffect, useMemo, useRef, useState } from 'react';
44
import Image from 'next/image';
55
import { useLocale, useTranslations } from 'next-intl';
66
import { useSearchParams } from 'next/navigation';
7+
import { usePathname, useRouter } from '@/i18n/routing';
78
import BlogGrid from '@/components/blog/BlogGrid';
89
import { Link } from '@/i18n/routing';
910

@@ -76,12 +77,16 @@ function plainTextFromPortableText(value?: PortableText): string {
7677
return value
7778
.filter(block => block?._type === 'block')
7879
.map(block =>
79-
(block.children || []).map(child => child.text || '').join('')
80+
(block.children || []).map(child => child.text || '').join(' ')
8081
)
81-
.join('\n')
82+
.join(' ')
8283
.trim();
8384
}
8485

86+
function normalizeSearchText(value: string) {
87+
return value.toLowerCase().replace(/\s+/g, ' ').trim();
88+
}
89+
8590
function plainTextExcerpt(value?: PortableText): string {
8691
return plainTextFromPortableText(value);
8792
}
@@ -103,6 +108,8 @@ export default function BlogFilters({
103108
const tNav = useTranslations('navigation');
104109
const locale = useLocale();
105110
const searchParams = useSearchParams();
111+
const router = useRouter();
112+
const pathname = usePathname();
106113
const [selectedAuthor, setSelectedAuthor] = useState<{
107114
name: string;
108115
norm: string;
@@ -154,6 +161,28 @@ export default function BlogFilters({
154161
const categoryParam = useMemo(() => {
155162
return searchParams?.get('category') || '';
156163
}, [searchParams]);
164+
const searchQuery = useMemo(() => {
165+
return (searchParams?.get('search') || '').trim();
166+
}, [searchParams]);
167+
const searchQueryLower = searchQuery.toLowerCase();
168+
const didClearSearchRef = useRef(false);
169+
170+
useEffect(() => {
171+
if (didClearSearchRef.current) return;
172+
if (!searchQuery) {
173+
didClearSearchRef.current = true;
174+
return;
175+
}
176+
if (typeof performance === 'undefined') return;
177+
const [navEntry] = performance.getEntriesByType('navigation');
178+
const navType = (navEntry as PerformanceNavigationTiming | undefined)?.type;
179+
if (navType !== 'reload') return;
180+
const params = new URLSearchParams(searchParams?.toString() || '');
181+
params.delete('search');
182+
const nextPath = params.toString() ? `${pathname}?${params}` : pathname;
183+
router.replace(nextPath);
184+
didClearSearchRef.current = true;
185+
}, [pathname, router, searchParams, searchQuery]);
157186

158187
useEffect(() => {
159188
const normParam = normalizeTag(categoryParam);
@@ -185,9 +214,22 @@ export default function BlogFilters({
185214
if (!postCategories.includes(selectedCategory.norm)) return false;
186215
}
187216

217+
if (searchQueryLower) {
218+
const titleText = normalizeSearchText(post.title);
219+
const bodyText = normalizeSearchText(
220+
plainTextFromPortableText(post.body)
221+
);
222+
if (
223+
!titleText.includes(searchQueryLower) &&
224+
!bodyText.includes(searchQueryLower)
225+
) {
226+
return false;
227+
}
228+
}
229+
188230
return true;
189231
});
190-
}, [posts, selectedAuthor, selectedCategory]);
232+
}, [posts, selectedAuthor, selectedCategory, searchQueryLower]);
191233

192234
const selectedAuthorData = selectedAuthor?.data || null;
193235
const authorBioText = useMemo(() => {
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
'use client';
2+
3+
import { useEffect, useMemo, useRef, useState } from 'react';
4+
import { Search } from 'lucide-react';
5+
import { useRouter } from '@/i18n/routing';
6+
7+
type PostSearchItem = {
8+
_id: string;
9+
title?: string;
10+
body?: Array<{ _type: string; children?: Array<{ text?: string }> }>;
11+
slug?: { current?: string };
12+
};
13+
14+
type SearchResult = PostSearchItem & { snippet?: string };
15+
16+
function extractSnippet(body: PostSearchItem['body'], query: string) {
17+
const text = (body || [])
18+
.filter(block => block?._type === 'block')
19+
.map(block =>
20+
(block.children || []).map(child => child.text || '').join(' ')
21+
)
22+
.join(' ')
23+
.replace(/\s+/g, ' ')
24+
.trim();
25+
if (!text) return '';
26+
const lower = text.toLowerCase();
27+
const idx = lower.indexOf(query.toLowerCase());
28+
if (idx === -1) return text.slice(0, 90);
29+
const start = Math.max(0, idx - 36);
30+
const end = Math.min(text.length, idx + 54);
31+
const prefix = start > 0 ? '...' : '';
32+
const suffix = end < text.length ? '...' : '';
33+
return `${prefix}${text.slice(start, end)}${suffix}`;
34+
}
35+
36+
const SEARCH_ENDPOINT = '/api/blog-search';
37+
38+
export function BlogHeaderSearch() {
39+
const [open, setOpen] = useState(false);
40+
const [value, setValue] = useState('');
41+
const [items, setItems] = useState<PostSearchItem[]>([]);
42+
const [isLoading, setIsLoading] = useState(false);
43+
const inputRef = useRef<HTMLInputElement>(null);
44+
const router = useRouter();
45+
const debounceRef = useRef<number | null>(null);
46+
47+
useEffect(() => {
48+
if (!open) return;
49+
inputRef.current?.focus();
50+
}, [open]);
51+
52+
useEffect(() => {
53+
if (!open || items.length || isLoading) return;
54+
let active = true;
55+
setIsLoading(true);
56+
fetch(SEARCH_ENDPOINT, { cache: 'no-store' })
57+
.then(response => (response.ok ? response.json() : []))
58+
.then((result: PostSearchItem[]) => {
59+
if (!active) return;
60+
setItems(Array.isArray(result) ? result : []);
61+
})
62+
.catch(() => {
63+
if (!active) return;
64+
setItems([]);
65+
})
66+
.finally(() => {
67+
if (!active) return;
68+
setIsLoading(false);
69+
});
70+
return () => {
71+
active = false;
72+
};
73+
}, [open, items.length, isLoading]);
74+
75+
useEffect(() => {
76+
if (!open) return;
77+
if (debounceRef.current) window.clearTimeout(debounceRef.current);
78+
debounceRef.current = window.setTimeout(() => {
79+
const query = value.trim();
80+
router.replace(query ? `/blog?search=${encodeURIComponent(query)}` : '/blog');
81+
}, 300);
82+
return () => {
83+
if (debounceRef.current) window.clearTimeout(debounceRef.current);
84+
};
85+
}, [open, router, value]);
86+
87+
const results = useMemo<SearchResult[]>(() => {
88+
const query = value.trim().toLowerCase();
89+
if (!query) return [];
90+
const words = query.split(/\s+/).filter(Boolean);
91+
return items
92+
.filter(item => {
93+
const title = (item.title || '').toLowerCase();
94+
const bodyText = (item.body || [])
95+
.filter(block => block?._type === 'block')
96+
.map(block =>
97+
(block.children || []).map(child => child.text || '').join(' ')
98+
)
99+
.join(' ')
100+
.toLowerCase();
101+
return words.every(
102+
word => title.includes(word) || bodyText.includes(word)
103+
);
104+
})
105+
.slice(0, 6)
106+
.map(item => ({
107+
...item,
108+
snippet: extractSnippet(item.body, query),
109+
}));
110+
}, [items, value]);
111+
112+
const submit = (event?: React.FormEvent) => {
113+
if (event) event.preventDefault();
114+
const query = value.trim();
115+
router.push(query ? `/blog?search=${encodeURIComponent(query)}` : '/blog');
116+
setOpen(false);
117+
};
118+
119+
const clear = () => {
120+
setValue('');
121+
setOpen(false);
122+
};
123+
124+
return (
125+
<div className="relative flex items-center">
126+
<button
127+
type="button"
128+
onClick={() => setOpen(prev => !prev)}
129+
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
130+
aria-label="Search blog"
131+
>
132+
<Search className="h-4 w-4" aria-hidden="true" />
133+
</button>
134+
135+
{open && (
136+
<div
137+
id="wrap"
138+
className="absolute right-0 top-12 z-50 w-72 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
139+
>
140+
<form
141+
action=""
142+
autoComplete="on"
143+
onSubmit={submit}
144+
className="flex items-center gap-2 px-3 py-2"
145+
>
146+
<input
147+
ref={inputRef}
148+
id="search"
149+
name="search"
150+
type="text"
151+
value={value}
152+
onChange={event => {
153+
setValue(event.target.value);
154+
if (!open) setOpen(true);
155+
}}
156+
onKeyDown={event => {
157+
if (event.key === 'Escape') setOpen(false);
158+
}}
159+
placeholder="What're we looking for ?"
160+
className="w-full bg-transparent text-sm text-foreground outline-none"
161+
style={{ fontFamily: 'Lato, system-ui, -apple-system, sans-serif' }}
162+
/>
163+
<input
164+
id="search_submit"
165+
value=""
166+
type="submit"
167+
className="text-sm font-medium text-muted-foreground hover:text-foreground"
168+
/>
169+
</form>
170+
{value && results.length > 0 && (
171+
<div className="max-h-56 overflow-auto border-t border-border py-2">
172+
{results.map(result => (
173+
<button
174+
key={result._id}
175+
type="button"
176+
onClick={() => {
177+
const slug = result.slug?.current;
178+
if (slug) {
179+
router.push(`/blog/${slug}`);
180+
} else {
181+
router.push(
182+
`/blog?search=${encodeURIComponent(result.title || '')}`
183+
);
184+
}
185+
setOpen(false);
186+
}}
187+
className="block w-full px-3 py-2 text-left text-sm text-muted-foreground hover:bg-secondary hover:text-foreground"
188+
>
189+
<div className="font-medium text-foreground">
190+
{result.title}
191+
</div>
192+
{result.snippet && (
193+
<div className="mt-1 text-xs text-muted-foreground">
194+
{result.snippet}
195+
</div>
196+
)}
197+
</button>
198+
))}
199+
</div>
200+
)}
201+
{value && !results.length && !isLoading && (
202+
<div className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
203+
No matches
204+
</div>
205+
)}
206+
</div>
207+
)}
208+
</div>
209+
);
210+
}

frontend/components/header/UnifiedHeader.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { LogoutButton } from '@/components/auth/logoutButton';
99
import { CartButton } from '@/components/shop/header/cart-button';
1010
import { NavLinks } from '@/components/shop/header/nav-links';
1111
import { BlogCategoryLinks } from '@/components/blog/BlogCategoryLinks';
12+
import { BlogHeaderSearch } from '@/components/blog/BlogHeaderSearch';
1213
import { AppMobileMenu } from '@/components/header/AppMobileMenu';
1314

1415
export type UnifiedHeaderVariant = 'platform' | 'shop' | 'blog';
@@ -98,6 +99,7 @@ export function UnifiedHeader({
9899
</Link>
99100
) : null}
100101

102+
{isBlog && <BlogHeaderSearch />}
101103
<LanguageSwitcher />
102104
{isShop && <CartButton />}
103105

@@ -114,6 +116,7 @@ export function UnifiedHeader({
114116
)}
115117
</div>
116118
<div className="flex items-center gap-1 md:hidden">
119+
{isBlog && <BlogHeaderSearch />}
117120
<LanguageSwitcher />
118121
{isShop && <CartButton />}
119122
<AppMobileMenu

frontend/drizzle.config.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { defineConfig } from 'drizzle-kit';
21
import * as dotenv from 'dotenv';
32

43
dotenv.config();
54

6-
export default defineConfig({
5+
export default {
76
out: './drizzle',
87
schema: './db/schema/index.ts',
98
dialect: 'postgresql',
109
dbCredentials: {
1110
url: process.env.DATABASE_URL!,
1211
},
13-
});
12+
};

0 commit comments

Comments
 (0)