Skip to content

Commit fb2b94a

Browse files
authored
Merge pull request #752 from zackproser/codex/add-typeahead-search-to-blog
Add blog typeahead search
2 parents beb1b40 + 54f289f commit fb2b94a

File tree

5 files changed

+223
-48
lines changed

5 files changed

+223
-48
lines changed

src/actions/tool-actions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,29 @@ export async function getAllTools(): Promise<Tool[]> {
6363
console.error("Error fetching tools:", error)
6464
return []; // Return empty array on error
6565
}
66+
}
67+
68+
export async function getToolBySlug(slug: string): Promise<Tool | null> {
69+
console.log(`Fetching tool by slug: ${slug} (using Prisma)`)
70+
try {
71+
// Convert slug back to a readable name for searching
72+
const searchName = slug
73+
.split('-')
74+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
75+
.join(' ');
76+
77+
const tool = await prisma.tool.findFirst({
78+
where: {
79+
OR: [
80+
{ name: { equals: searchName, mode: 'insensitive' } },
81+
{ name: { contains: slug, mode: 'insensitive' } },
82+
{ name: { contains: searchName, mode: 'insensitive' } }
83+
]
84+
}
85+
});
86+
return tool;
87+
} catch (error) {
88+
console.error("Error fetching tool by slug:", error)
89+
return null;
90+
}
6691
}

src/app/blog/blog-client.tsx

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"use client"
2+
3+
import { useState, useEffect } from "react";
4+
import { Search } from "lucide-react";
5+
import { Input } from "@/components/ui/input";
6+
import { Button } from "@/components/ui/button";
7+
import {
8+
Select,
9+
SelectContent,
10+
SelectItem,
11+
SelectTrigger,
12+
SelectValue,
13+
} from "@/components/ui/select";
14+
import { ContentCard } from "@/components/ContentCard";
15+
import { track } from "@vercel/analytics";
16+
import debounce from "lodash.debounce";
17+
import type { ArticleWithSlug } from "@/types";
18+
19+
interface BlogClientProps {
20+
articles: ArticleWithSlug[];
21+
years: string[];
22+
}
23+
24+
export default function BlogClient({ articles, years }: BlogClientProps) {
25+
const [searchTerm, setSearchTerm] = useState("");
26+
const [selectedYear, setSelectedYear] = useState("");
27+
const [filteredArticles, setFilteredArticles] = useState<ArticleWithSlug[]>(articles);
28+
29+
// Debounced tracking function for search analytics
30+
const debouncedTrack = debounce((query: string) => {
31+
if (query.trim()) {
32+
track('blog-search', { term: query });
33+
}
34+
}, 1200);
35+
36+
useEffect(() => {
37+
let filtered = [...articles];
38+
39+
// Sort articles by date in chronological order (newest first)
40+
filtered.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
41+
42+
if (searchTerm) {
43+
const term = searchTerm.toLowerCase();
44+
filtered = filtered.filter(
45+
(article) =>
46+
article.title.toLowerCase().includes(term) ||
47+
article.description.toLowerCase().includes(term)
48+
);
49+
}
50+
51+
if (selectedYear && selectedYear !== "All Years") {
52+
filtered = filtered.filter((article) => article.date.startsWith(selectedYear));
53+
}
54+
55+
setFilteredArticles(filtered);
56+
}, [searchTerm, selectedYear, articles]);
57+
58+
const handleSearchChange = (value: string) => {
59+
setSearchTerm(value);
60+
debouncedTrack(value);
61+
};
62+
63+
const resetFilters = () => {
64+
setSearchTerm("");
65+
setSelectedYear("");
66+
};
67+
68+
return (
69+
<div className="w-full">
70+
<div className="container mx-auto px-4 py-16">
71+
<div className="text-center mb-12">
72+
<h1 className="text-5xl font-bold text-slate-900 dark:text-white mb-4">
73+
I write to learn, and publish to share
74+
</h1>
75+
<p className="text-xl text-slate-600 dark:text-slate-400 max-w-3xl mx-auto">
76+
All of my technical tutorials, musings and developer rants
77+
</p>
78+
</div>
79+
80+
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 p-4 mb-8 max-w-4xl mx-auto">
81+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
82+
<div className="relative">
83+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" size={18} />
84+
<Input
85+
placeholder="Search articles..."
86+
className="pl-10"
87+
value={searchTerm}
88+
onChange={(e) => handleSearchChange(e.target.value)}
89+
/>
90+
</div>
91+
92+
<Select value={selectedYear} onValueChange={setSelectedYear}>
93+
<SelectTrigger className="bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
94+
<SelectValue placeholder="Year" />
95+
</SelectTrigger>
96+
<SelectContent className="bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
97+
<SelectItem value="All Years">All Years</SelectItem>
98+
{years.map((year) => (
99+
<SelectItem key={year} value={year}>
100+
{year}
101+
</SelectItem>
102+
))}
103+
</SelectContent>
104+
</Select>
105+
</div>
106+
107+
<div className="flex justify-between items-center mt-4">
108+
<Button variant="outline" size="sm" onClick={resetFilters} className="text-slate-600 dark:text-slate-400">
109+
Reset Filters
110+
</Button>
111+
<span className="text-sm text-slate-500 dark:text-slate-400">
112+
{filteredArticles.length} articles found
113+
</span>
114+
</div>
115+
</div>
116+
117+
<div className="space-y-8">
118+
<section>
119+
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white mb-6 text-center">All Articles</h2>
120+
121+
<div className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
122+
<div className="mx-auto grid max-w-2xl grid-cols-1 gap-x-8 gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-3">
123+
{filteredArticles.map((article) => (
124+
<ContentCard key={article.slug} article={article} />
125+
))}
126+
</div>
127+
</div>
128+
</section>
129+
</div>
130+
</div>
131+
</div>
132+
);
133+
}
134+

src/app/blog/page.tsx

Lines changed: 5 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import { Metadata } from 'next'
2-
import { SimpleLayout } from '@/components/SimpleLayout'
3-
import { ContentCard } from '@/components/ContentCard'
2+
import BlogClient from './blog-client'
43
import { createMetadata } from '@/utils/createMetadata'
5-
import { Suspense } from 'react'
64
import { getAllContent } from '@/lib/content-handlers'
7-
import { ExtendedMetadata } from '@/types'
8-
import ArticleSearch from '@/components/ArticleSearch'
95

106
// Base metadata using createMetadata
117
const baseMetadata = createMetadata({
@@ -19,51 +15,12 @@ export const metadata: Metadata = {
1915
metadataBase: new URL('https://zackproser.com'),
2016
}
2117

22-
function ArticleGrid({ articles }: { articles: ExtendedMetadata[] }) {
23-
return (
24-
<div className="mx-auto mt-16 grid max-w-none grid-cols-1 gap-x-8 gap-y-16 lg:grid-cols-3">
25-
{articles.map((article, index) => {
26-
// Simply use the index as the key
27-
return (
28-
<ContentCard
29-
key={index}
30-
article={article}
31-
/>
32-
);
33-
})}
34-
</div>
35-
)
36-
}
3718

3819
export default async function ArticlesIndex() {
39-
// Use our helper function to get all blog post metadata
4020
const articles = await getAllContent('blog');
41-
42-
// Add debugging to log the articles being loaded
43-
// console.log(`Loaded ${articles.length} blog articles`); // Commented out verbose log
44-
45-
// Log the first few articles for debugging
46-
/* // Commented out verbose log
47-
articles.slice(0, 3).forEach((article, index) => {
48-
console.log(`Article ${index + 1}:`, {
49-
title: article.title,
50-
slug: article.slug,
51-
type: article.type
52-
});
53-
});
54-
*/
55-
56-
return (
57-
<SimpleLayout
58-
title="I write to learn, and publish to share"
59-
intro="All of my technical tutorials, musings and developer rants"
60-
>
61-
<div className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
62-
<Suspense fallback={<div>Loading articles...</div>}>
63-
<ArticleGrid articles={articles} />
64-
</Suspense>
65-
</div>
66-
</SimpleLayout>
67-
)
21+
22+
const years = [...new Set(articles.map((a) => new Date(a.date).getFullYear().toString()))].sort((a, b) => parseInt(b) - parseInt(a));
23+
24+
return <BlogClient articles={articles} years={years} />;
6825
}
6926

src/components/ui/skeleton.jsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { cn } from "@/lib/utils"
2+
3+
function Skeleton({
4+
className,
5+
...props
6+
}) {
7+
return (
8+
<div
9+
className={cn("animate-pulse rounded-md bg-muted", className)}
10+
{...props}
11+
/>
12+
)
13+
}
14+
15+
export { Skeleton }

src/utils/comparison-helpers.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Utility functions for tool comparison pages
3+
*/
4+
5+
/**
6+
* Convert a slug back to a readable tool name
7+
* @param {string} slug - The slugified tool name
8+
* @returns {string} - The readable tool name
9+
*/
10+
export function deslugifyToolName(slug) {
11+
return slug
12+
.split('-')
13+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
14+
.join(' ');
15+
}
16+
17+
/**
18+
* Generate comparison prose for two tools
19+
* @param {Object} tool1 - First tool object
20+
* @param {Object} tool2 - Second tool object
21+
* @returns {Array} - Array of prose paragraphs
22+
*/
23+
export function generateComparisonProse(tool1, tool2) {
24+
const paragraphs = [];
25+
26+
// Introduction paragraph
27+
paragraphs.push(
28+
`When comparing ${tool1.name} and ${tool2.name}, developers need to consider several key factors including ease of use, feature set, pricing, and integration capabilities. Both tools serve the developer community but with different approaches and strengths.`
29+
);
30+
31+
// Feature comparison
32+
if (tool1.description && tool2.description) {
33+
paragraphs.push(
34+
`${tool1.name} focuses on ${tool1.description.toLowerCase()}, while ${tool2.name} emphasizes ${tool2.description.toLowerCase()}. This fundamental difference in approach affects how each tool fits into different development workflows.`
35+
);
36+
}
37+
38+
// Use case paragraph
39+
paragraphs.push(
40+
`The choice between ${tool1.name} and ${tool2.name} often depends on your specific use case, team size, and technical requirements. Consider factors like learning curve, community support, and long-term maintenance when making your decision.`
41+
);
42+
43+
return paragraphs;
44+
}

0 commit comments

Comments
 (0)