Skip to content

Commit 99b28e1

Browse files
authored
Merge pull request #938 from hirosystems/develop
Release 2.5.1
2 parents 81c98ce + ba6e6ca commit 99b28e1

40 files changed

+540
-439
lines changed

.gitignore

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
node_modules
2-
.env
3-
.env.local
4-
.next
51
bun.lockb
2+
node_modules
63
openapi
74
.DS_Store
85
**/.DS_Store
9-
tmp
10-
.cursorrules
6+
.env
7+
.env.local
8+
.cache
9+
.cursorrules
10+
.next
11+
tmp

app/cookbook/[id]/page.tsx

+9-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Code } from "@/components/docskit/code";
2-
import { loadRecipes } from "@/utils/loader";
2+
import { loadRecipe, loadRecipes } from "@/utils/loader";
33
import { Badge } from "@/components/ui/badge";
44
import { HoverProvider } from "@/context/hover";
55
import { HoverLink } from "@/components/docskit/annotations/hover";
@@ -29,20 +29,18 @@ export default async function Page({
2929
params: Param;
3030
}): Promise<JSX.Element> {
3131
const { id } = params;
32+
3233
const recipes = await loadRecipes();
3334
const recipe = recipes.find((r) => r.id === id);
3435

3536
if (!recipe) {
3637
return <div>Recipe not found</div>;
3738
}
3839

39-
// Dynamically import MDX content based on recipe id
40-
const Content = await import(`@/content/_recipes/guides/${id}.mdx`).catch(
41-
() => {
42-
console.error(`Failed to load MDX content for recipe: ${id}`);
43-
return { default: () => <div>Content not found</div> };
44-
}
45-
);
40+
const Content = await import(`@/content/_recipes/${id}.mdx`).catch(() => {
41+
console.error(`Failed to load MDX content for recipe: ${id}`);
42+
return { default: () => <div>Content not found</div> };
43+
});
4644

4745
return (
4846
<>
@@ -100,7 +98,7 @@ export default async function Page({
10098
</Badge>
10199
))}
102100
</div>
103-
<div className="prose max-w-none">
101+
<div className="content prose max-w-none">
104102
<Content.default
105103
components={{
106104
HoverLink,
@@ -139,8 +137,8 @@ export default async function Page({
139137
</div>
140138
</div>
141139

142-
<div className="mt-0 md:mt-16">
143-
<RecipeCarousel currentRecipeId={id} data={recipes} />
140+
<div className="mt-16">
141+
<RecipeCarousel currentRecipeId={id} data={recipes.slice(0, 6)} />
144142
</div>
145143
</div>
146144
</HoverProvider>

app/cookbook/components/cookbook-ui.tsx

+85-41
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import { Badge } from "@/components/ui/badge";
99
import { Button } from "@/components/ui/button";
1010
import { LayoutGrid, List, Search } from "lucide-react";
1111
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
12-
import { FilterPopover } from "@/components/filter-popover";
12+
import * as timeago from "timeago.js";
13+
// import { FilterPopover } from "@/components/filter-popover";
1314

14-
// Internal components
1515
function ViewToggle({
1616
view,
1717
onChange,
@@ -52,8 +52,8 @@ function ViewToggle({
5252
function RecipeFilters({
5353
search,
5454
onSearchChange,
55-
selectedCategories,
56-
onCategoriesChange,
55+
// selectedCategories,
56+
// onCategoriesChange,
5757
}: {
5858
search: string;
5959
onSearchChange: (value: string) => void;
@@ -62,20 +62,20 @@ function RecipeFilters({
6262
}) {
6363
return (
6464
<div className="flex flex-row gap-2 flex-wrap items-start justify-between">
65-
<div className="relative w-1/3">
65+
<div className="relative w-2/3">
6666
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
6767
<Input
6868
type="search"
69-
placeholder="Search by keywords..."
69+
placeholder="Search by title, description, or keywords..."
7070
className="font-aeonikFono text-md pl-8"
7171
value={search}
7272
onChange={(e) => onSearchChange(e.target.value)}
7373
/>
7474
</div>
75-
<FilterPopover
75+
{/* <FilterPopover
7676
selectedCategories={selectedCategories}
7777
onCategoriesChange={onCategoriesChange}
78-
/>
78+
/> */}
7979
</div>
8080
);
8181
}
@@ -89,8 +89,8 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) {
8989
const router = useRouter();
9090
const searchParams = useSearchParams();
9191

92-
const ITEMS_PER_PAGE = 10;
93-
const [currentPage, _] = useState(1);
92+
const ITEMS_PER_PAGE = 9;
93+
const [currentPage, setCurrentPage] = useState(1);
9494

9595
const [view, setView] = useState<"grid" | "list">(() => {
9696
return (searchParams.get("view") as "grid" | "list") || "list";
@@ -125,15 +125,16 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) {
125125
};
126126

127127
const handleCategoriesChange = (categories: string[]) => {
128+
setCurrentPage(1);
128129
setSelectedCategories(categories);
129130
updateURL(view, categories);
130131
};
131132

132133
const handleSearchChange = (value: string) => {
134+
setCurrentPage(1);
133135
setSearch(value);
134136
};
135137

136-
// Create a map of recipe IDs to their corresponding rendered cards
137138
const recipeCardMap = useMemo(() => {
138139
return initialRecipes.reduce(
139140
(map, recipe, index) => {
@@ -145,50 +146,75 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) {
145146
}, [initialRecipes, recipeCards]);
146147

147148
const filteredRecipeCards = useMemo(() => {
148-
// First sort by date
149149
const sortedRecipes = [...initialRecipes].sort((a, b) => {
150150
return new Date(b.date).getTime() - new Date(a.date).getTime();
151151
});
152152

153-
// Then apply filters
153+
// Filter all recipes without pagination
154154
const filteredRecipes = sortedRecipes.filter((recipe) => {
155-
const searchText = search.toLowerCase();
155+
const searchText = search.toLowerCase().trim();
156+
157+
if (!searchText) {
158+
return true;
159+
}
160+
161+
const titleMatch = recipe.title.toLowerCase().includes(searchText);
162+
const descriptionMatch = recipe.description
163+
.toLowerCase()
164+
.includes(searchText);
165+
const categoryMatch = recipe.categories.some((category) =>
166+
category.toLowerCase().includes(searchText)
167+
);
168+
const tagMatch = recipe.tags.some((tag) =>
169+
tag.toLowerCase().includes(searchText)
170+
);
171+
156172
const matchesSearch =
157-
recipe.title.toLowerCase().includes(searchText) ||
158-
recipe.description.toLowerCase().includes(searchText) ||
159-
recipe.categories.some((category) =>
160-
category.toLowerCase().includes(searchText)
161-
) ||
162-
recipe.tags.some((tag) => tag.toLowerCase().includes(searchText));
173+
titleMatch || descriptionMatch || categoryMatch || tagMatch;
163174

164175
const matchesCategories =
165176
selectedCategories.length === 0 ||
166177
recipe.categories.some((category) =>
167178
selectedCategories.includes(category.toLowerCase())
168179
);
169180

170-
return matchesSearch && matchesCategories;
181+
const shouldInclude = matchesSearch && matchesCategories;
182+
183+
return shouldInclude;
171184
});
172185

173186
const startIndex = 0;
174187
const endIndex = currentPage * ITEMS_PER_PAGE;
175188

176-
return filteredRecipes
177-
.slice(startIndex, endIndex)
178-
.map((recipe) => recipeCardMap[recipe.id]);
189+
const displayedRecipes = filteredRecipes.slice(startIndex, endIndex);
190+
191+
return displayedRecipes.map((recipe) => ({
192+
recipe,
193+
card: recipeCardMap[recipe.id],
194+
}));
179195
}, [search, selectedCategories, initialRecipes, recipeCardMap, currentPage]);
180196

181-
// Add total pages calculation
182197
const totalPages = useMemo(() => {
183198
const filteredLength = initialRecipes.filter((recipe) => {
184-
const searchText = search.toLowerCase();
199+
const searchText = search.toLowerCase().trim();
200+
201+
if (!searchText) {
202+
return true;
203+
}
204+
205+
const titleMatch = recipe.title.toLowerCase().includes(searchText);
206+
const descriptionMatch = recipe.description
207+
.toLowerCase()
208+
.includes(searchText);
209+
const categoryMatch = recipe.categories.some((category) =>
210+
category.toLowerCase().includes(searchText)
211+
);
212+
const tagMatch = recipe.tags.some((tag) =>
213+
tag.toLowerCase().includes(searchText)
214+
);
215+
185216
const matchesSearch =
186-
recipe.title.toLowerCase().includes(searchText) ||
187-
recipe.description.toLowerCase().includes(searchText) ||
188-
recipe.categories.some((category) =>
189-
category.toLowerCase().includes(searchText)
190-
) ||
191-
recipe.tags.some((tag) => tag.toLowerCase().includes(searchText));
217+
titleMatch || descriptionMatch || categoryMatch || tagMatch;
192218

193219
const matchesCategories =
194220
selectedCategories.length === 0 ||
@@ -206,14 +232,30 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) {
206232

207233
const lastItemRef = useRef<HTMLTableRowElement>(null);
208234

235+
const [hasScrolled, setHasScrolled] = useState(false);
236+
237+
useEffect(() => {
238+
const handleScroll = () => {
239+
if (!hasScrolled) {
240+
setHasScrolled(true);
241+
}
242+
};
243+
244+
window.addEventListener("scroll", handleScroll);
245+
return () => window.removeEventListener("scroll", handleScroll);
246+
}, [hasScrolled]);
247+
209248
useEffect(() => {
210249
const observer = new IntersectionObserver(
211250
(entries) => {
212251
const lastEntry = entries[0];
213-
if (lastEntry.isIntersecting && !isLoading) {
214-
// Check if we have more pages to load
252+
if (lastEntry.isIntersecting && !isLoading && hasScrolled) {
215253
if (currentPage < totalPages) {
216254
setIsLoading(true);
255+
setTimeout(() => {
256+
setCurrentPage((prev) => prev + 1);
257+
setIsLoading(false);
258+
}, 500);
217259
}
218260
}
219261
},
@@ -230,7 +272,7 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) {
230272
observer.unobserve(currentRef);
231273
}
232274
};
233-
}, [currentPage, totalPages, isLoading]);
275+
}, [currentPage, totalPages, isLoading, hasScrolled]);
234276

235277
return (
236278
<div className="max-w-5xl mx-auto">
@@ -239,8 +281,8 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) {
239281
<div className="space-y-1">
240282
<h1 className="text-4xl font-semibold">Cookbook</h1>
241283
<p className="text-lg text-muted-foreground w-full">
242-
Explore ready-to-use code recipes for building applications on
243-
Stacks.
284+
Explore common blockchain recipes with ready-to-copy examples for
285+
building applications on Stacks and Bitcoin.
244286
</p>
245287
</div>
246288
<ViewToggle view={view} onChange={handleViewChange} />
@@ -257,8 +299,7 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) {
257299
{view === "list" ? (
258300
<Table>
259301
<TableBody>
260-
{filteredRecipeCards.map((recipeCard, index) => {
261-
const recipe = initialRecipes[index];
302+
{filteredRecipeCards.map(({ recipe, card }, index) => {
262303
const isLastItem = index === filteredRecipeCards.length - 1;
263304

264305
return (
@@ -282,16 +323,19 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) {
282323
))}
283324
</div>
284325
</TableCell>
326+
<TableCell className="text-muted-foreground font-aeonikFono">
327+
{timeago.format(new Date(recipe.date))}
328+
</TableCell>
285329
</TableRow>
286330
);
287331
})}
288332
</TableBody>
289333
</Table>
290334
) : (
291335
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
292-
{filteredRecipeCards.map((card, index) => (
336+
{filteredRecipeCards.map(({ recipe, card }, index) => (
293337
<div
294-
key={index}
338+
key={recipe.id}
295339
ref={
296340
index === filteredRecipeCards.length - 1
297341
? lastItemRef

app/layout.tsx

+5-5
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ export default function RootLayout({
4040
<body className="flex min-h-screen flex-col">
4141
<Provider>
4242
<Banner
43-
id="stacks-js-v7"
43+
id="hiro-hacks"
4444
cta="Learn more here"
45-
url="https://www.hiro.so/blog/announcing-stacks-js-v7"
46-
startDate="2024-11-11"
47-
endDate="2024-12-15"
45+
url="/stacks/hacks"
46+
startDate="2025-01-19T17:00:00.000Z"
47+
endDate="2025-06-24T23:59:59.999Z"
4848
>
49-
Announcing Stacks.js v7!
49+
Hiro Hacks is live! Get started with this months theme: AI x Stacks
5050
</Banner>
5151
{children}
5252
<Footer />

0 commit comments

Comments
 (0)