feat: improve recipe list UI with square cards and unified search
Some checks failed
Basil CI/CD Pipeline / Code Linting (push) Successful in 1m3s
Basil CI/CD Pipeline / Web Tests (push) Failing after 1m19s
Basil CI/CD Pipeline / API Tests (push) Successful in 1m25s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 59s
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m7s
Basil CI/CD Pipeline / Build All Packages (push) Has been skipped
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been skipped
Basil CI/CD Pipeline / Trigger Deployment (push) Has been skipped

Major UI improvements to the recipe list view:

Frontend Changes:
- Convert recipe cards to square layout (1:1 aspect ratio)
- Cards now resize properly with column count (3, 5, 7, 9)
- Optimize text sizing for compact square format
- Remove size slider control for simplicity
- Unify search functionality to search both titles AND tags simultaneously
- Remove title/tag search toggle buttons
- Add tag autocomplete to unified search field
- Improve button text visibility with explicit color and font-weight

Backend Changes:
- Update recipe search API to search across title, description, AND tags
- Single search parameter now handles all search types

Visual Improvements:
- Recipe cards maintain square shape at all column counts
- Text scales appropriately for small card sizes
- Cleaner, simpler toolbar with fewer controls
- Better readability on unselected control buttons

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Peter Kartchner
2026-01-16 12:54:07 -07:00
parent c71b77f54e
commit d4ce3ff81b
3 changed files with 44 additions and 91 deletions

View File

@@ -112,6 +112,15 @@ router.get('/', async (req, res) => {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ description: { contains: search as string, mode: 'insensitive' } },
{
tags: {
some: {
tag: {
name: { contains: search as string, mode: 'insensitive' }
}
}
}
},
];
}
if (cuisine) where.cuisine = cuisine;

View File

@@ -4,23 +4,11 @@ import { Recipe, Tag } from '@basil/shared';
import { recipesApi, tagsApi } from '../services/api';
import '../styles/RecipeList.css';
// Size presets for card display
const SIZE_PRESETS: Record<number, { name: string; minWidth: number; imageHeight: number }> = {
0: { name: 'XS', minWidth: 150, imageHeight: 100 },
1: { name: 'S', minWidth: 200, imageHeight: 133 },
2: { name: 'M', minWidth: 250, imageHeight: 167 },
3: { name: 'Default', minWidth: 300, imageHeight: 200 },
4: { name: 'L', minWidth: 350, imageHeight: 233 },
5: { name: 'XL', minWidth: 400, imageHeight: 267 },
6: { name: 'XXL', minWidth: 500, imageHeight: 333 },
};
const ITEMS_PER_PAGE_OPTIONS = [12, 24, 48, -1]; // -1 = All
// LocalStorage keys
const LS_ITEMS_PER_PAGE = 'basil_recipes_itemsPerPage';
const LS_COLUMN_COUNT = 'basil_recipes_columnCount';
const LS_CARD_SIZE = 'basil_recipes_cardSize';
function RecipeList() {
const [recipes, setRecipes] = useState<Recipe[]>([]);
@@ -51,22 +39,10 @@ function RecipeList() {
}
return 5;
});
const [cardSize, setCardSize] = useState(() => {
const saved = localStorage.getItem(LS_CARD_SIZE);
if (saved) {
const val = parseInt(saved);
if (val >= 0 && val <= 6) return val;
}
return 3;
});
// Search state
const [searchInput, setSearchInput] = useState(() => searchParams.get('search') || '');
const [debouncedSearch, setDebouncedSearch] = useState(searchInput);
const [searchType, setSearchType] = useState<'title' | 'tag'>(() => {
const type = searchParams.get('type');
return type === 'tag' ? 'tag' : 'title';
});
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
// Load tags for autocomplete
@@ -93,7 +69,7 @@ function RecipeList() {
// Reset page when search changes
useEffect(() => {
setCurrentPage(1);
}, [debouncedSearch, searchType]);
}, [debouncedSearch]);
// Save preferences to localStorage
useEffect(() => {
@@ -104,10 +80,6 @@ function RecipeList() {
localStorage.setItem(LS_COLUMN_COUNT, columnCount.toString());
}, [columnCount]);
useEffect(() => {
localStorage.setItem(LS_CARD_SIZE, cardSize.toString());
}, [cardSize]);
// Update URL params
useEffect(() => {
const params = new URLSearchParams();
@@ -115,10 +87,9 @@ function RecipeList() {
if (itemsPerPage !== 24) params.set('limit', itemsPerPage.toString());
if (debouncedSearch) {
params.set('search', debouncedSearch);
if (searchType === 'tag') params.set('type', 'tag');
}
setSearchParams(params, { replace: true });
}, [currentPage, itemsPerPage, debouncedSearch, searchType, setSearchParams]);
}, [currentPage, itemsPerPage, debouncedSearch, setSearchParams]);
// Load recipes
const loadRecipes = useCallback(async () => {
@@ -128,18 +99,13 @@ function RecipeList() {
page: number;
limit: number;
search?: string;
tag?: string;
} = {
page: currentPage,
limit: itemsPerPage === -1 ? 10000 : itemsPerPage,
};
if (debouncedSearch) {
if (searchType === 'title') {
params.search = debouncedSearch;
} else {
params.tag = debouncedSearch;
}
params.search = debouncedSearch;
}
const response = await recipesApi.getAll(params);
@@ -152,7 +118,7 @@ function RecipeList() {
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage, debouncedSearch, searchType]);
}, [currentPage, itemsPerPage, debouncedSearch]);
useEffect(() => {
loadRecipes();
@@ -176,11 +142,6 @@ function RecipeList() {
setCurrentPage(1);
};
const handleSearchTypeChange = (type: 'title' | 'tag') => {
setSearchType(type);
setSearchInput('');
};
if (loading && recipes.length === 0) {
return <div className="loading">Loading recipes...</div>;
}
@@ -201,32 +162,16 @@ function RecipeList() {
<div className="search-input-wrapper">
<input
type="text"
placeholder={searchType === 'title' ? 'Search by title...' : 'Search by tag...'}
placeholder="Search recipes by title or tag..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
list={searchType === 'tag' ? 'tag-suggestions' : undefined}
list="tag-suggestions"
/>
{searchType === 'tag' && (
<datalist id="tag-suggestions">
{availableTags.map((tag) => (
<option key={tag.id} value={tag.name} />
))}
</datalist>
)}
</div>
<div className="search-type-toggle">
<button
className={searchType === 'title' ? 'active' : ''}
onClick={() => handleSearchTypeChange('title')}
>
Title
</button>
<button
className={searchType === 'tag' ? 'active' : ''}
onClick={() => handleSearchTypeChange('tag')}
>
Tag
</button>
<datalist id="tag-suggestions">
{availableTags.map((tag) => (
<option key={tag.id} value={tag.name} />
))}
</datalist>
</div>
</div>
</div>
@@ -248,21 +193,6 @@ function RecipeList() {
))}
</div>
</div>
<div className="control-group">
<label>Size:</label>
<div className="size-slider-wrapper">
<input
type="range"
min="0"
max="6"
value={cardSize}
onChange={(e) => setCardSize(parseInt(e.target.value))}
className="size-slider"
/>
<span className="size-label">{SIZE_PRESETS[cardSize].name}</span>
</div>
</div>
</div>
{/* Pagination Controls */}

View File

@@ -266,6 +266,9 @@
overflow: hidden;
background: var(--bg-primary, #ffffff);
transition: transform 0.2s, box-shadow 0.2s;
aspect-ratio: 1 / 1;
display: flex;
flex-direction: column;
}
.recipe-grid-enhanced .recipe-card:hover {
@@ -275,42 +278,53 @@
.recipe-grid-enhanced .recipe-card img {
width: 100%;
aspect-ratio: 1 / 1;
height: 65%;
object-fit: cover;
display: block;
flex-shrink: 0;
}
.recipe-grid-enhanced .recipe-card-content {
padding: 0.75rem;
padding: 0.5rem;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
min-height: 0;
}
.recipe-grid-enhanced .recipe-card-content h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
line-height: 1.3;
margin: 0 0 0.25rem 0;
font-size: 0.75rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
flex-shrink: 0;
}
.recipe-grid-enhanced .recipe-card-content p {
margin: 0 0 0.5rem 0;
font-size: 0.85rem;
margin: 0;
font-size: 0.65rem;
color: var(--text-secondary, #666);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
flex-shrink: 1;
}
.recipe-grid-enhanced .recipe-meta {
display: flex;
gap: 0.75rem;
font-size: 0.8rem;
gap: 0.4rem;
font-size: 0.6rem;
color: var(--text-secondary, #888);
flex-shrink: 0;
margin-top: auto;
}
/* Empty state */