Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | 14x 14x 14x 14x 14x 7x 7x 7x 7x 7x 7x 7x 14x 14x 14x 14x 2x 3x 5x | import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { BookOpen, Search } from 'lucide-react'
import { toast } from 'sonner'
import { api, type Recipe } from '../api/client'
import NavHeader from '../components/NavHeader'
import RecipeCard from '../components/RecipeCard'
import RecipeSearchFilter from '../components/RecipeSearchFilter'
import { RecipeGridSkeleton } from '../components/Skeletons'
export default function AllRecipes() {
const navigate = useNavigate()
const [recipes, setRecipes] = useState<Recipe[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await api.recipes.list(1000)
Eif (!cancelled) setRecipes(data)
} catch (error) {
if (!cancelled) {
console.error('Failed to load recipes:', error)
toast.error('Failed to load recipes')
}
} finally {
Eif (!cancelled) setLoading(false)
}
})()
return () => { cancelled = true }
}, [])
const handleRecipeClick = async (recipeId: number) => {
try {
await api.history.record(recipeId)
} catch (error) {
console.error('Failed to record history:', error)
}
navigate(`/recipe/${recipeId}`)
}
const filteredRecipes = useMemo(() => {
Eif (!searchQuery.trim()) return recipes
const query = searchQuery.toLowerCase()
return recipes.filter(
(recipe) =>
recipe.title.toLowerCase().includes(query) ||
recipe.host.toLowerCase().includes(query)
)
}, [recipes, searchQuery])
return (
<div className="min-h-screen bg-background">
<NavHeader />
<main className="px-4 py-6">
<div className="mx-auto max-w-4xl">
<h2 className="mb-4 text-lg font-medium text-foreground">My Recipes</h2>
{loading ? (
<RecipeGridSkeleton count={8} />
) : recipes.length > 0 ? (
<RecipeList
recipes={recipes}
filteredRecipes={filteredRecipes}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onRecipeClick={handleRecipeClick}
/>
) : (
<EmptyRecipes onImport={() => navigate('/home')} />
)}
</div>
</main>
</div>
)
}
function RecipeList({
recipes,
filteredRecipes,
searchQuery,
onSearchChange,
onRecipeClick,
}: {
recipes: Recipe[]
filteredRecipes: Recipe[]
searchQuery: string
onSearchChange: (q: string) => void
onRecipeClick: (id: number) => void
}) {
return (
<>
<RecipeSearchFilter
searchQuery={searchQuery}
onSearchChange={onSearchChange}
totalCount={recipes.length}
filteredCount={filteredRecipes.length}
/>
{filteredRecipes.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{filteredRecipes.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
onClick={() => onRecipeClick(recipe.id)}
/>
))}
</div>
) : (
<div className="py-8 text-center text-muted-foreground">
No recipes match "{searchQuery}"
</div>
)}
</>
)
}
function EmptyRecipes({ onImport }: { onImport: () => void }) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-border py-12">
<div className="mb-4 rounded-full bg-muted p-4">
<BookOpen className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="mb-2 text-lg font-medium text-foreground">No recipes yet</h3>
<p className="mb-4 text-center text-muted-foreground">
Import recipes from the web to see them here
</p>
<button
onClick={onImport}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90"
>
<Search className="h-4 w-4" />
Import Recipes
</button>
</div>
)
}
|