feat(frontend): refresh homepage and recipe list visuals
This commit is contained in:
parent
f42bd53cff
commit
ca11d9d878
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
Loading…
Reference in New Issue