feat(frontend): refresh homepage and recipe list visuals

This commit is contained in:
Paul Huliganga 2026-03-26 16:28:01 -04:00
parent f42bd53cff
commit ca11d9d878
2 changed files with 163 additions and 86 deletions

View File

@ -7,6 +7,8 @@ interface RecipeCardProps {
tags?: Tag[];
}
const foodEmojis = ['🥗', '🍲', '🍝', '🍜', '🍛', '🥘', '🍗', '🍤', '🍕', '🥪', '🍳', '🍱'];
function formatTime(minutes?: number): string {
if (!minutes) return '';
if (minutes < 60) return `${minutes}m`;
@ -21,61 +23,83 @@ function formatDate(timestamp?: number): string {
return date.toLocaleDateString();
}
function accentForRecipe(recipe: Recipe, tags: Tag[]) {
if (tags[0]?.color) return tags[0].color;
const palette = ['#f97316', '#ef4444', '#22c55e', '#06b6d4', '#3b82f6', '#a855f7'];
return palette[recipe.id % palette.length];
}
function emojiForRecipe(recipe: Recipe) {
return foodEmojis[recipe.id % foodEmojis.length];
}
function MetaChip({ label, value, emoji }: { label: string; value: string; emoji: string }) {
return (
<div className="inline-flex items-center gap-1.5 rounded-full border border-slate-200/80 bg-slate-50 px-2.5 py-1 text-xs text-slate-700">
<span aria-hidden="true">{emoji}</span>
<span className="font-medium">{value}</span>
<span className="text-slate-500">{label}</span>
</div>
);
}
export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0);
const accent = accentForRecipe(recipe, tags);
return (
<Link
to={`/recipe/${recipe.id}`}
className="block bg-white border border-gray-200 rounded-xl shadow-card hover:shadow-lg hover:border-blue-300 transition-shadow group outline-none focus-visible:ring-2 focus-visible:ring-blue-600 min-h-[200px]"
className="group block overflow-hidden border border-slate-200/80 bg-white/95 outline-none transition-all duration-200 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-blue-600"
style={{ boxShadow: shadows.card, borderRadius: radius.lg }}
>
<div className="p-5 flex flex-col h-full">
{/* Title */}
<h3 className="text-lg font-bold text-gray-900 mb-1 line-clamp-2 group-hover:text-blue-700 transition-colors">{recipe.title}</h3>
{/* Description */}
{recipe.description && <p className="text-xs text-gray-600 mb-2 line-clamp-2">{recipe.description}</p>}
<div
className="relative h-40 border-b border-slate-200/70"
style={{
background: `linear-gradient(135deg, color-mix(in srgb, ${accent} 30%, white) 0%, color-mix(in srgb, ${accent} 14%, #f8fafc) 55%, #ffffff 100%)`,
}}
>
<div className="absolute left-4 top-4 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 backdrop-blur-sm">
{tags[0]?.name ?? 'Homemade'}
</div>
<div className="absolute inset-0 flex items-center justify-center text-6xl opacity-90" aria-hidden="true">
{emojiForRecipe(recipe)}
</div>
</div>
<div className="flex h-[calc(100%-10rem)] min-h-[210px] flex-col p-5">
<h3 className="mb-1 line-clamp-2 text-lg font-bold text-gray-900 transition-colors group-hover:text-blue-700">{recipe.title}</h3>
{recipe.description ? (
<p className="mb-3 line-clamp-2 text-sm text-gray-600">{recipe.description}</p>
) : (
<p className="mb-3 text-sm italic text-gray-400">No description yet</p>
)}
<div className="mb-3 flex flex-wrap gap-2">
{recipe.servings ? <MetaChip emoji="🍽️" value={`${recipe.servings}`} label="servings" /> : null}
{totalTime > 0 ? <MetaChip emoji="⏱️" value={formatTime(totalTime)} label="total" /> : null}
<MetaChip emoji="🥄" value={`${recipe.ingredients.length}`} label="ingredients" />
</div>
{/* Tags */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{tags.map(tag => (
<div className="mb-3 flex flex-wrap gap-1.5">
{tags.slice(0, 4).map((tag) => (
<span
key={tag.id}
className="px-2 py-0.5 rounded-full text-xs font-semibold text-white shadow"
className="rounded-full px-2.5 py-1 text-xs font-semibold text-white shadow-sm"
style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }}
>
{tag.name}
</span>
))}
{tags.length > 4 ? <span className="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">+{tags.length - 4}</span> : null}
</div>
)}
{/* Meta information */}
<div className="flex flex-wrap gap-3 text-xs text-gray-500 mb-2">
{recipe.servings && (
<div className="flex items-center gap-1">
<span>🍽</span>
<span>{recipe.servings} servings</span>
</div>
)}
{totalTime > 0 && (
<div className="flex items-center gap-1">
<span></span>
<span>{formatTime(totalTime)}</span>
</div>
)}
{recipe.last_cooked_at && (
<div className="flex items-center gap-1">
<span>👨🍳</span>
<span>Last cooked {formatDate(recipe.last_cooked_at)}</span>
</div>
)}
</div>
<div className="mt-auto pt-3 border-t border-gray-100 flex justify-between items-center text-xs text-gray-500">
<span>{recipe.ingredients.length} ingredients</span>
<span className="text-blue-600 font-medium group-hover:underline">View Recipe </span>
<div className="mt-auto flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-gray-500">
<span>{recipe.last_cooked_at ? `Cooked ${formatDate(recipe.last_cooked_at)}` : 'Not cooked yet'}</span>
<span className="font-medium text-blue-600 group-hover:underline">View recipe </span>
</div>
</div>
</Link>

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import heroImage from '../assets/hero.png';
import { useRecipes } from '../hooks/useRecipes';
import { useTags } from '../hooks/useTags';
import { RecipeCard } from '../components/RecipeCard';
@ -43,36 +44,73 @@ export function RecipeListPage() {
};
const filteredRecipes = recipes;
const hasActiveFilters = searchQuery || selectedTagId !== null;
const hasActiveFilters = Boolean(searchQuery) || selectedTagId !== null;
return (
<div className="max-w-6xl mx-auto pb-8">
<div className="mx-auto max-w-6xl pb-8">
<MissionControlPanel status={emptyStatus} />
<div className="bg-white border rounded-xl shadow-card px-6 py-7 mt-8 mb-10 flex flex-col gap-4" style={{borderRadius: radius.lg, boxShadow: '0 2px 8px 0 rgba(28,30,34,0.07)'}}>
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<section
className="relative mt-8 overflow-hidden border border-slate-200/80 bg-white/85 p-0 shadow-card"
style={{ borderRadius: radius.lg }}
>
<div className="grid grid-cols-1 md:grid-cols-2">
<div className="flex flex-col justify-center gap-4 px-6 py-8 md:px-8 md:py-10">
<p className="text-sm font-semibold uppercase tracking-wider text-orange-500">Kitchen Companion</p>
<h1 className="text-3xl font-extrabold leading-tight text-slate-900 md:text-4xl">Cook smarter with your favorite recipes in one place</h1>
<p className="max-w-lg text-sm text-slate-600 md:text-base">
Build your personal cookbook, tag meals by mood or diet, and find what you need fast.
</p>
<div className="flex flex-wrap gap-3">
<Link
to="/recipe/new"
className="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow transition-colors hover:bg-blue-700"
style={{ borderRadius: radius.md }}
>
Add Recipe
</Link>
<a
href="#recipes-grid"
className="rounded-lg border border-slate-200 bg-white px-5 py-2.5 font-semibold text-slate-700 transition-colors hover:bg-slate-50"
style={{ borderRadius: radius.md }}
>
Browse Recipes
</a>
</div>
</div>
<div className="relative min-h-[220px] bg-slate-100 md:min-h-full">
<img src={heroImage} alt="Fresh ingredients and plated food" className="h-full w-full object-cover" />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-900/20 via-transparent to-transparent" />
</div>
</div>
</section>
<section
className="mt-8 mb-10 flex flex-col gap-4 rounded-xl border border-slate-200/80 bg-white/90 px-5 py-6 shadow-card md:px-6"
style={{ borderRadius: radius.lg, boxShadow: '0 2px 8px 0 rgba(28,30,34,0.07)' }}
>
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h2 className="text-2xl font-extrabold text-gray-900 mb-0">My Recipes</h2>
<h2 className="mb-0 text-2xl font-extrabold text-gray-900">My Recipes</h2>
<p className="mt-1 text-sm text-gray-500">Browse and search your recipe collection</p>
</div>
<Link
to="/recipe/new"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-semibold transition-colors shadow focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
className="rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white shadow transition-colors hover:bg-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
style={{ borderRadius: radius.md }}
>
+ New Recipe
</Link>
</div>
{/* Search/Tag Filter Row */}
<form onSubmit={handleSearch} className="flex flex-col md:flex-row items-stretch gap-3 mt-3 md:mt-0">
<div className="flex-1 relative">
<form onSubmit={handleSearch} className="mt-3 flex flex-col items-stretch gap-3 md:mt-0 md:flex-row">
<div className="relative flex-1">
<input
type="text"
placeholder="Search recipes by title, ingredients, or tags..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-base"
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-base focus:border-transparent focus:ring-2 focus:ring-blue-500"
style={{ borderRadius: radius.md }}
/>
{!!searchQuery && (
@ -81,12 +119,14 @@ export function RecipeListPage() {
onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search"
></button>
>
</button>
)}
</div>
<button
type="submit"
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-semibold transition-colors border border-gray-200"
className="rounded-lg border border-gray-200 bg-gray-100 px-6 py-2 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
style={{ borderRadius: radius.md }}
>
Search
@ -94,24 +134,28 @@ export function RecipeListPage() {
</form>
{!tagsLoading && tags.length > 0 && (
<div className="flex flex-wrap gap-2 items-center mt-0 md:mt-2">
<span className="text-sm text-gray-700 font-medium mr-1">Filter by tag:</span>
<div className="mt-0 flex flex-wrap items-center gap-2 md:mt-2">
<span className="mr-1 text-sm font-medium text-gray-700">Filter by tag:</span>
<button
onClick={() => setSelectedTagId(null)}
className={selectedTagId === null
? 'bg-blue-600 text-white px-3 py-1.5 rounded-full text-sm font-semibold shadow transition-colors outline-none'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 px-3 py-1.5 rounded-full text-sm font-medium transition-colors outline-none'}
className={
selectedTagId === null
? 'rounded-full bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white shadow outline-none transition-colors'
: 'rounded-full bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 outline-none transition-colors hover:bg-gray-200'
}
style={{ borderRadius: radius.full }}
>
All Recipes
</button>
{tags.map(tag => (
{tags.map((tag) => (
<button
key={tag.id}
onClick={() => setSelectedTagId(tag.id)}
className={selectedTagId === tag.id
? 'text-white bg-blue-600 px-3 py-1.5 rounded-full text-sm font-semibold shadow outline-none'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 px-3 py-1.5 rounded-full text-sm font-medium transition-colors outline-none'}
className={
selectedTagId === tag.id
? 'rounded-full px-3 py-1.5 text-sm font-semibold text-white shadow outline-none'
: 'rounded-full bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 outline-none transition-colors hover:bg-gray-200'
}
style={{ backgroundColor: selectedTagId === tag.id ? tag.color : '', borderRadius: radius.full }}
>
{tag.name}
@ -120,52 +164,61 @@ export function RecipeListPage() {
</div>
)}
{/* Active Filters */}
{hasActiveFilters && (
<div className="mt-2 flex items-center gap-3 text-sm">
<span className="text-gray-600">Active filters:</span>
{searchQuery && <span className="px-2 py-1 bg-blue-50 text-blue-700 rounded">Search: "{searchQuery}"</span>}
{searchQuery && <span className="rounded bg-blue-50 px-2 py-1 text-blue-700">Search: "{searchQuery}"</span>}
{selectedTagId !== null && (
<span className="px-2 py-1 bg-blue-50 text-blue-700 rounded">Tag: {tags.find(t => t.id === selectedTagId)?.name}</span>
<span className="rounded bg-blue-50 px-2 py-1 text-blue-700">Tag: {tags.find((t) => t.id === selectedTagId)?.name}</span>
)}
<button onClick={handleClearFilters} className="text-blue-600 hover:text-blue-700 font-medium">Clear all filters</button>
<button onClick={handleClearFilters} className="font-medium text-blue-600 hover:text-blue-700">
Clear all filters
</button>
</div>
)}
</div>
</section>
{/* Error State */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 my-6 text-center">
<p className="text-red-800"><strong>Error:</strong> {error}</p>
<div className="my-6 rounded-lg border border-red-200 bg-red-50 p-4 text-center">
<p className="text-red-800">
<strong>Error:</strong> {error}
</p>
</div>
)}
{/* Loading State */}
{loading && recipes.length === 0 && (
<div className="text-center py-16">
<div className="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-700 text-lg font-medium">Loading recipes...</p>
<div className="mx-auto my-8 max-w-4xl rounded-xl border border-slate-200/80 bg-white/80 p-8 text-center shadow-card" style={{ borderRadius: radius.lg }}>
<div className="mb-4 text-5xl" aria-hidden="true">🍽</div>
<p className="text-lg font-semibold text-slate-700">Warming up your recipe shelf...</p>
<div className="mx-auto mt-5 h-2 w-56 overflow-hidden rounded-full bg-slate-200">
<div className="h-full w-1/2 animate-pulse rounded-full bg-blue-500" />
</div>
</div>
)}
{/* Empty State */}
{!loading && !error && filteredRecipes.length === 0 && (
<div className="bg-gradient-to-br from-white to-blue-50 rounded-xl shadow-card p-14 text-center flex flex-col items-center gap-2 border border-dashed border-blue-200 mx-auto max-w-xl" style={{borderRadius: radius.lg}}>
<div className="text-6xl mb-2">🍳</div>
<h3 className="text-xl font-bold text-gray-800 mb-2">{searchQuery ? 'No recipes found' : 'No recipes yet'}</h3>
<p className="text-gray-600 mb-4">{searchQuery
? 'Try a different search term'
: 'Get started by adding your first recipe.'}</p>
<div
className="mx-auto flex max-w-xl flex-col items-center gap-2 rounded-xl border border-dashed border-orange-200 bg-gradient-to-br from-white to-orange-50 p-14 text-center shadow-card"
style={{ borderRadius: radius.lg }}
>
<div className="mb-2 text-6xl">🧑🍳</div>
<h3 className="mb-2 text-xl font-bold text-gray-800">{searchQuery ? 'No recipes found' : 'No recipes yet'}</h3>
<p className="mb-4 text-gray-600">{searchQuery ? 'Try another keyword or clear filters.' : 'Start your cookbook with a first delicious recipe.'}</p>
{!searchQuery && (
<Link to="/recipe/new" className="inline-block bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow transition-colors" style={{borderRadius: radius.md}}>Add Your First Recipe</Link>
<Link
to="/recipe/new"
className="inline-block rounded-lg bg-blue-600 px-6 py-3 font-semibold text-white shadow transition-colors hover:bg-blue-700"
style={{ borderRadius: radius.md }}
>
Add Your First Recipe
</Link>
)}
</div>
)}
{/* Recipe Grid */}
{filteredRecipes.length > 0 && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
<div id="recipes-grid" className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
{filteredRecipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} tags={recipe.tags} />
))}
@ -175,7 +228,7 @@ export function RecipeListPage() {
<button
onClick={loadMore}
disabled={loading}
className="px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border"
className="rounded-lg border bg-gray-100 px-6 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderRadius: radius.md }}
>
{loading ? 'Loading...' : 'Load More'}