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 type { Recipe, Tag } from '../types/recipe';
import { colors, radius, shadows } from '../theme';
interface RecipeCardProps {
recipe: Recipe;
tags?: Tag[];
}
/**
* Format time in minutes to readable string
*/
function formatTime(minutes?: number): string {
if (!minutes) return '';
if (minutes < 60) return `${minutes}m`;
@ -21,9 +15,6 @@ function formatTime(minutes?: number): string {
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
/**
* Format date timestamp to readable string
*/
function formatDate(timestamp?: number): string {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
@ -36,29 +27,23 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
return (
<Link
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 */}
<h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-2">
{recipe.title}
</h3>
<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-sm text-gray-600 mb-3 line-clamp-2">
{recipe.description}
</p>
)}
{recipe.description && <p className="text-xs text-gray-600 mb-2 line-clamp-2">{recipe.description}</p>}
{/* Tags */}
{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 => (
<span
key={tag.id}
className="px-2 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: tag.color || '#3B82F6' }}
className="px-2 py-0.5 rounded-full text-xs font-semibold text-white shadow"
style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }}
>
{tag.name}
</span>
@ -67,21 +52,19 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
)}
{/* 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 && (
<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>
@ -90,12 +73,9 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
)}
</div>
{/* Footer with ingredient count */}
<div className="mt-4 pt-3 border-t border-gray-100">
<div className="flex justify-between items-center text-xs text-gray-500">
<span>{recipe.ingredients.length} ingredients</span>
<span className="text-blue-600 font-medium">View Recipe </span>
</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>
</div>
</Link>

View File

@ -3,17 +3,16 @@
@tailwind utilities;
:root {
--text: #6b6375;
--text-h: #08060d;
--text: #374151;
--text-h: #1e293b;
--bg: #fff;
--border: #e5e4e7;
--bg-alt: #f9fafb;
--border: #e5e7eb;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--accent-bg: rgba(170, 59, 255, 0.08);
--accent-border: rgba(170, 59, 255, 0.35);
--card-shadow: 0 2px 8px 0 rgba(28,30,34,0.08);
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
@ -27,17 +26,16 @@
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text: #d1d5db;
--text-h: #f3f4f6;
--bg: #16171d;
--bg-alt: #1a1b20;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
--accent-bg: rgba(192, 132, 252, 0.11);
--accent-border: rgba(192, 132, 252, 0.33);
--card-shadow: 0 3px 14px 0 rgba(32,34,40,0.21);
}
}
@ -45,25 +43,27 @@ body {
margin: 0;
font-family: var(--sans);
color: var(--text);
background: var(--bg);
background: var(--bg-alt);
}
#root {
min-height: 100vh;
background: var(--bg-alt);
}
input, button, textarea, select {
font-family: inherit;
}
.shadow-card {
box-shadow: var(--card-shadow) !important;
}
/* Toast animation */
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.animate-slide-in {
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 { Link } from 'react-router-dom';
import { useRecipes } from '../hooks/useRecipes';
import { useTags } from '../hooks/useTags';
import { RecipeCard } from '../components/RecipeCard';
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() {
const [searchTerm, setSearchTerm] = useState('');
@ -42,166 +46,143 @@ export function RecipeListPage() {
const hasActiveFilters = searchQuery || selectedTagId !== null;
return (
<div>
<MissionControlPanel />
{/* Header */}
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<div className="max-w-6xl mx-auto 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">
<div>
<h2 className="text-2xl font-bold text-gray-900">My Recipes</h2>
<p className="mt-1 text-sm text-gray-500">
Browse and search your recipe collection
</p>
<h2 className="text-2xl font-extrabold text-gray-900 mb-0">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-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
</Link>
</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">
<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"
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"
style={{borderRadius: radius.md}}
/>
{searchQuery && (
{!!searchQuery && (
<button
type="button"
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-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
</button>
</form>
{/* Tag Filter */}
{!tagsLoading && tags.length > 0 && (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by tag:
</label>
<div className="flex flex-wrap gap-2">
<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>
<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'}
style={{borderRadius: radius.full}}
>
All Recipes
</button>
{tags.map(tag => (
<button
onClick={() => setSelectedTagId(null)}
className={
selectedTagId === null
? 'bg-blue-600 text-white 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'
}
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'}
style={{backgroundColor: selectedTagId === tag.id ? tag.color : '', borderRadius: radius.full}}
>
All Recipes
{tag.name}
</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>
)}
{/* Active Filters */}
{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>
{searchQuery && (
<span className="px-2 py-1 bg-blue-50 text-blue-700 rounded">
Search: "{searchQuery}"
</span>
)}
{searchQuery && <span className="px-2 py-1 bg-blue-50 text-blue-700 rounded">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="px-2 py-1 bg-blue-50 text-blue-700 rounded">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="text-blue-600 hover:text-blue-700 font-medium">Clear all filters</button>
</div>
)}
</div>
{/* Error State */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800">
<strong>Error:</strong> {error}
</p>
<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>
)}
{/* Loading State */}
{loading && recipes.length === 0 && (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading recipes...</p>
<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>
)}
{/* Empty State */}
{!loading && !error && filteredRecipes.length === 0 && (
<div className="bg-white rounded-lg shadow p-12 text-center">
<div className="text-6xl mb-4">🍳</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
{searchQuery ? 'No recipes found' : 'No recipes yet'}
</h3>
<p className="text-gray-600 mb-6">
{searchQuery
? 'Try a different search term'
: 'Get started by adding your first recipe'}
</p>
<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>
{!searchQuery && (
<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>
<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>
)}
</div>
)}
{/* Recipe Grid */}
{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) => (
<RecipeCard key={recipe.id} recipe={recipe} />
<RecipeCard key={recipe.id} recipe={recipe} tags={recipe.tags} />
))}
</div>
{/* Load More Button */}
{hasMore && (
<div className="mt-8 text-center">
<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"
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'}
</button>
</div>
)}
{/* Results summary */}
<div className="mt-6 text-center text-sm text-gray-500">
<div className="mt-7 text-center text-sm text-gray-500">
Showing {filteredRecipes.length} recipe{filteredRecipes.length !== 1 ? 's' : ''}
</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}",
],
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: [],
}