UI polish: Introduce visual theme tokens, refreshed RecipeList page & cards, improved empty/loading states

This commit is contained in:
Paul Huliganga 2026-03-25 18:25:37 -04:00
parent 2ffb1da919
commit b7e7e9955e
5 changed files with 191 additions and 152 deletions

View File

@ -1,18 +1,12 @@
/**
* RecipeCard - Displays a single recipe in the list view
*/
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { Recipe, Tag } from '../types/recipe'; import type { Recipe, Tag } from '../types/recipe';
import { colors, radius, shadows } from '../theme';
interface RecipeCardProps { interface RecipeCardProps {
recipe: Recipe; recipe: Recipe;
tags?: Tag[]; tags?: Tag[];
} }
/**
* Format time in minutes to readable string
*/
function formatTime(minutes?: number): string { function formatTime(minutes?: number): string {
if (!minutes) return ''; if (!minutes) return '';
if (minutes < 60) return `${minutes}m`; if (minutes < 60) return `${minutes}m`;
@ -21,9 +15,6 @@ function formatTime(minutes?: number): string {
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
} }
/**
* Format date timestamp to readable string
*/
function formatDate(timestamp?: number): string { function formatDate(timestamp?: number): string {
if (!timestamp) return ''; if (!timestamp) return '';
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
@ -36,29 +27,23 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
return ( return (
<Link <Link
to={`/recipe/${recipe.id}`} to={`/recipe/${recipe.id}`}
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200" 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]"
style={{ boxShadow: shadows.card, borderRadius: radius.lg }}
> >
<div className="p-5"> <div className="p-5 flex flex-col h-full">
{/* Title */} {/* Title */}
<h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-2"> <h3 className="text-lg font-bold text-gray-900 mb-1 line-clamp-2 group-hover:text-blue-700 transition-colors">{recipe.title}</h3>
{recipe.title}
</h3>
{/* Description */} {/* Description */}
{recipe.description && ( {recipe.description && <p className="text-xs text-gray-600 mb-2 line-clamp-2">{recipe.description}</p>}
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{recipe.description}
</p>
)}
{/* Tags */} {/* Tags */}
{tags.length > 0 && ( {tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3"> <div className="flex flex-wrap gap-1 mb-2">
{tags.map(tag => ( {tags.map(tag => (
<span <span
key={tag.id} key={tag.id}
className="px-2 py-0.5 rounded-full text-xs font-medium text-white" className="px-2 py-0.5 rounded-full text-xs font-semibold text-white shadow"
style={{ backgroundColor: tag.color || '#3B82F6' }} style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }}
> >
{tag.name} {tag.name}
</span> </span>
@ -67,21 +52,19 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
)} )}
{/* Meta information */} {/* Meta information */}
<div className="flex flex-wrap gap-3 text-xs text-gray-500"> <div className="flex flex-wrap gap-3 text-xs text-gray-500 mb-2">
{recipe.servings && ( {recipe.servings && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span>🍽</span> <span>🍽</span>
<span>{recipe.servings} servings</span> <span>{recipe.servings} servings</span>
</div> </div>
)} )}
{totalTime > 0 && ( {totalTime > 0 && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span></span> <span></span>
<span>{formatTime(totalTime)}</span> <span>{formatTime(totalTime)}</span>
</div> </div>
)} )}
{recipe.last_cooked_at && ( {recipe.last_cooked_at && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span>👨🍳</span> <span>👨🍳</span>
@ -90,12 +73,9 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
)} )}
</div> </div>
{/* Footer with ingredient count */} <div className="mt-auto pt-3 border-t border-gray-100 flex justify-between items-center text-xs text-gray-500">
<div className="mt-4 pt-3 border-t border-gray-100"> <span>{recipe.ingredients.length} ingredients</span>
<div className="flex justify-between items-center text-xs text-gray-500"> <span className="text-blue-600 font-medium group-hover:underline">View Recipe </span>
<span>{recipe.ingredients.length} ingredients</span>
<span className="text-blue-600 font-medium">View Recipe </span>
</div>
</div> </div>
</div> </div>
</Link> </Link>

View File

@ -3,17 +3,16 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
--text: #6b6375; --text: #374151;
--text-h: #08060d; --text-h: #1e293b;
--bg: #fff; --bg: #fff;
--border: #e5e4e7; --bg-alt: #f9fafb;
--border: #e5e7eb;
--code-bg: #f4f3ec; --code-bg: #f4f3ec;
--accent: #aa3bff; --accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1); --accent-bg: rgba(170, 59, 255, 0.08);
--accent-border: rgba(170, 59, 255, 0.5); --accent-border: rgba(170, 59, 255, 0.35);
--social-bg: rgba(244, 243, 236, 0.5); --card-shadow: 0 2px 8px 0 rgba(28,30,34,0.08);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
@ -27,17 +26,16 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--text: #9ca3af; --text: #d1d5db;
--text-h: #f3f4f6; --text-h: #f3f4f6;
--bg: #16171d; --bg: #16171d;
--bg-alt: #1a1b20;
--border: #2e303a; --border: #2e303a;
--code-bg: #1f2028; --code-bg: #1f2028;
--accent: #c084fc; --accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15); --accent-bg: rgba(192, 132, 252, 0.11);
--accent-border: rgba(192, 132, 252, 0.5); --accent-border: rgba(192, 132, 252, 0.33);
--social-bg: rgba(47, 48, 58, 0.5); --card-shadow: 0 3px 14px 0 rgba(32,34,40,0.21);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
} }
} }
@ -45,25 +43,27 @@ body {
margin: 0; margin: 0;
font-family: var(--sans); font-family: var(--sans);
color: var(--text); color: var(--text);
background: var(--bg); background: var(--bg-alt);
} }
#root { #root {
min-height: 100vh; min-height: 100vh;
background: var(--bg-alt);
}
input, button, textarea, select {
font-family: inherit;
}
.shadow-card {
box-shadow: var(--card-shadow) !important;
} }
/* Toast animation */ /* Toast animation */
@keyframes slide-in { @keyframes slide-in {
from { from { transform: translateX(100%); opacity: 0; }
transform: translateX(100%); to { transform: translateX(0); opacity: 1; }
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
} }
.animate-slide-in { .animate-slide-in {
animation: slide-in 0.3s ease-out; animation: slide-in 0.3s ease-out;
} }

View File

@ -1,13 +1,17 @@
/**
* RecipeListPage - Displays a list of all recipes with search and filtering
*/
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useRecipes } from '../hooks/useRecipes'; import { useRecipes } from '../hooks/useRecipes';
import { useTags } from '../hooks/useTags'; import { useTags } from '../hooks/useTags';
import { RecipeCard } from '../components/RecipeCard'; import { RecipeCard } from '../components/RecipeCard';
import { MissionControlPanel } from '../components/MissionControlPanel'; import { MissionControlPanel } from '../components/MissionControlPanel';
import type { HarnessStatus } from '../types/recipe';
import { radius } from '../theme';
const emptyStatus: HarnessStatus = {
running: false,
version: '-',
uptime: 0,
};
export function RecipeListPage() { export function RecipeListPage() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@ -42,166 +46,143 @@ export function RecipeListPage() {
const hasActiveFilters = searchQuery || selectedTagId !== null; const hasActiveFilters = searchQuery || selectedTagId !== null;
return ( return (
<div> <div className="max-w-6xl mx-auto pb-8">
<MissionControlPanel /> <MissionControlPanel status={emptyStatus} />
{/* Header */}
<div className="mb-6"> <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 justify-between items-center mb-4"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div> <div>
<h2 className="text-2xl font-bold text-gray-900">My Recipes</h2> <h2 className="text-2xl font-extrabold text-gray-900 mb-0">My Recipes</h2>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">Browse and search your recipe collection</p>
Browse and search your recipe collection
</p>
</div> </div>
<Link <Link
to="/recipe/new" to="/recipe/new"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors" 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"
style={{borderRadius: radius.md}}
> >
+ New Recipe + New Recipe
</Link> </Link>
</div> </div>
{/* Search Bar */}
<form onSubmit={handleSearch} className="flex gap-2"> {/* 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"> <div className="flex-1 relative">
<input <input
type="text" type="text"
placeholder="Search recipes by title, ingredients, or tags..." placeholder="Search recipes by title, ingredients, or tags..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} 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" 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"
style={{borderRadius: radius.md}}
/> />
{searchQuery && ( {!!searchQuery && (
<button <button
type="button" type="button"
onClick={handleClearSearch} onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search" aria-label="Clear search"
> ></button>
</button>
)} )}
</div> </div>
<button <button
type="submit" type="submit"
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors" 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"
style={{borderRadius: radius.md}}
> >
Search Search
</button> </button>
</form> </form>
{/* Tag Filter */}
{!tagsLoading && tags.length > 0 && ( {!tagsLoading && tags.length > 0 && (
<div className="mt-4"> <div className="flex flex-wrap gap-2 items-center mt-0 md:mt-2">
<label className="block text-sm font-medium text-gray-700 mb-2"> <span className="text-sm text-gray-700 font-medium mr-1">Filter by tag:</span>
Filter by tag: <button
</label> onClick={() => setSelectedTagId(null)}
<div className="flex flex-wrap gap-2"> 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'}
style={{borderRadius: radius.full}}
>
All Recipes
</button>
{tags.map(tag => (
<button <button
onClick={() => setSelectedTagId(null)} key={tag.id}
className={ onClick={() => setSelectedTagId(tag.id)}
selectedTagId === null className={selectedTagId === tag.id
? 'bg-blue-600 text-white px-3 py-1.5 rounded-full text-sm font-medium transition-colors' ? 'text-white bg-blue-600 px-3 py-1.5 rounded-full text-sm font-semibold shadow outline-none'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 px-3 py-1.5 rounded-full text-sm font-medium transition-colors' : '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'}
} style={{backgroundColor: selectedTagId === tag.id ? tag.color : '', borderRadius: radius.full}}
> >
All Recipes {tag.name}
</button> </button>
{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-medium transition-colors'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 px-3 py-1.5 rounded-full text-sm font-medium transition-colors'
}
>
{tag.name}
</button>
))}
</div>
</div> </div>
)} )}
{/* Active Filters */}
{hasActiveFilters && ( {hasActiveFilters && (
<div className="mt-3 flex items-center gap-3 text-sm"> <div className="mt-2 flex items-center gap-3 text-sm">
<span className="text-gray-600">Active filters:</span> <span className="text-gray-600">Active filters:</span>
{searchQuery && ( {searchQuery && <span className="px-2 py-1 bg-blue-50 text-blue-700 rounded">Search: "{searchQuery}"</span>}
<span className="px-2 py-1 bg-blue-50 text-blue-700 rounded">
Search: "{searchQuery}"
</span>
)}
{selectedTagId !== null && ( {selectedTagId !== null && (
<span className="px-2 py-1 bg-blue-50 text-blue-700 rounded"> <span className="px-2 py-1 bg-blue-50 text-blue-700 rounded">Tag: {tags.find(t => t.id === selectedTagId)?.name}</span>
Tag: {tags.find(t => t.id === selectedTagId)?.name}
</span>
)} )}
<button <button onClick={handleClearFilters} className="text-blue-600 hover:text-blue-700 font-medium">Clear all filters</button>
onClick={handleClearFilters}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Clear all filters
</button>
</div> </div>
)} )}
</div> </div>
{/* Error State */} {/* Error State */}
{error && ( {error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6"> <div className="bg-red-50 border border-red-200 rounded-lg p-4 my-6 text-center">
<p className="text-red-800"> <p className="text-red-800"><strong>Error:</strong> {error}</p>
<strong>Error:</strong> {error}
</p>
</div> </div>
)} )}
{/* Loading State */} {/* Loading State */}
{loading && recipes.length === 0 && ( {loading && recipes.length === 0 && (
<div className="text-center py-12"> <div className="text-center py-16">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <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-600">Loading recipes...</p> <p className="mt-4 text-gray-700 text-lg font-medium">Loading recipes...</p>
</div> </div>
)} )}
{/* Empty State */} {/* Empty State */}
{!loading && !error && filteredRecipes.length === 0 && ( {!loading && !error && filteredRecipes.length === 0 && (
<div className="bg-white rounded-lg shadow p-12 text-center"> <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-4">🍳</div> <div className="text-6xl mb-2">🍳</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"> <h3 className="text-xl font-bold text-gray-800 mb-2">{searchQuery ? 'No recipes found' : 'No recipes yet'}</h3>
{searchQuery ? 'No recipes found' : 'No recipes yet'} <p className="text-gray-600 mb-4">{searchQuery
</h3> ? 'Try a different search term'
<p className="text-gray-600 mb-6"> : 'Get started by adding your first recipe.'}</p>
{searchQuery
? 'Try a different search term'
: 'Get started by adding your first recipe'}
</p>
{!searchQuery && ( {!searchQuery && (
<Link <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>
to="/recipe/new"
className="inline-block bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium transition-colors"
>
Add Your First Recipe
</Link>
)} )}
</div> </div>
)} )}
{/* Recipe Grid */} {/* Recipe Grid */}
{filteredRecipes.length > 0 && ( {filteredRecipes.length > 0 && (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
{filteredRecipes.map((recipe) => ( {filteredRecipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} /> <RecipeCard key={recipe.id} recipe={recipe} tags={recipe.tags} />
))} ))}
</div> </div>
{/* Load More Button */}
{hasMore && ( {hasMore && (
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<button <button
onClick={loadMore} onClick={loadMore}
disabled={loading} 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" 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"
style={{borderRadius: radius.md}}
> >
{loading ? 'Loading...' : 'Load More'} {loading ? 'Loading...' : 'Load More'}
</button> </button>
</div> </div>
)} )}
{/* Results summary */} <div className="mt-7 text-center text-sm text-gray-500">
<div className="mt-6 text-center text-sm text-gray-500">
Showing {filteredRecipes.length} recipe{filteredRecipes.length !== 1 ? 's' : ''} Showing {filteredRecipes.length} recipe{filteredRecipes.length !== 1 ? 's' : ''}
</div> </div>
</> </>

54
frontend/src/theme.ts Normal file
View File

@ -0,0 +1,54 @@
/** theme.ts - Defines visual theme tokens and utility styles across the Recipe Manager frontend */
export const colors = {
primary: '#2563eb', // Tailwind blue-600
primaryDark: '#1d4ed8',
primaryLight: '#eff6ff',
accent: '#aa3bff',
success: '#16a34a',
warning: '#eab308',
error: '#dc2626',
bg: '#fff',
bgAlt: '#f9fafb', // Tailwind gray-50
surface: '#fcfcff',
border: '#e5e7eb', // Tailwind gray-200
text: '#374151', // Tailwind gray-700
textDim: '#6b7280',
textHeading: '#1e293b',
cardShadow: '0 2px 8px 0 rgba(28,30,34,0.08)',
};
export const radius = {
xs: '4px',
sm: '6px',
md: '10px',
lg: '16px',
full: '999px',
};
export const spacing = {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '40px',
};
export const shadows = {
card: '0 2px 8px 0 rgba(28,30,34,0.08)',
hover: '0 4px 20px 0 rgba(28,30,34,0.16)',
};
export const typography = {
fontFamily: {
sans: 'system-ui, Segoe UI, Roboto, sans-serif',
heading: 'system-ui, Segoe UI, Roboto, sans-serif',
mono: 'ui-monospace, Consolas, monospace',
},
fontWeight: {
regular: 400,
medium: 500,
bold: 700,
},
};

View File

@ -5,7 +5,31 @@ export default {
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",
], ],
theme: { theme: {
extend: {}, extend: {
borderRadius: {
xs: '4px',
sm: '6px',
md: '10px',
lg: '16px',
full: '999px',
},
boxShadow: {
card: '0 2px 8px 0 rgba(28,30,34,0.08)',
hover: '0 4px 20px 0 rgba(28,30,34,0.16)',
},
colors: {
primary: '#2563eb',
accent: '#aa3bff',
success: '#16a34a',
warning: '#eab308',
error: '#dc2626',
},
fontFamily: {
sans: ['system-ui', 'Segoe UI', 'Roboto', 'sans-serif'],
heading: ['system-ui', 'Segoe UI', 'Roboto', 'sans-serif'],
mono: ['ui-monospace', 'Consolas', 'monospace'],
},
},
}, },
plugins: [], plugins: [],
} }