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
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:
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user