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 = [
|
where.OR = [
|
||||||
{ title: { contains: search as string, mode: 'insensitive' } },
|
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||||
{ description: { 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;
|
if (cuisine) where.cuisine = cuisine;
|
||||||
|
|||||||
@@ -4,23 +4,11 @@ import { Recipe, Tag } from '@basil/shared';
|
|||||||
import { recipesApi, tagsApi } from '../services/api';
|
import { recipesApi, tagsApi } from '../services/api';
|
||||||
import '../styles/RecipeList.css';
|
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
|
const ITEMS_PER_PAGE_OPTIONS = [12, 24, 48, -1]; // -1 = All
|
||||||
|
|
||||||
// LocalStorage keys
|
// LocalStorage keys
|
||||||
const LS_ITEMS_PER_PAGE = 'basil_recipes_itemsPerPage';
|
const LS_ITEMS_PER_PAGE = 'basil_recipes_itemsPerPage';
|
||||||
const LS_COLUMN_COUNT = 'basil_recipes_columnCount';
|
const LS_COLUMN_COUNT = 'basil_recipes_columnCount';
|
||||||
const LS_CARD_SIZE = 'basil_recipes_cardSize';
|
|
||||||
|
|
||||||
function RecipeList() {
|
function RecipeList() {
|
||||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||||
@@ -51,22 +39,10 @@ function RecipeList() {
|
|||||||
}
|
}
|
||||||
return 5;
|
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
|
// Search state
|
||||||
const [searchInput, setSearchInput] = useState(() => searchParams.get('search') || '');
|
const [searchInput, setSearchInput] = useState(() => searchParams.get('search') || '');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState(searchInput);
|
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[]>([]);
|
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
|
||||||
|
|
||||||
// Load tags for autocomplete
|
// Load tags for autocomplete
|
||||||
@@ -93,7 +69,7 @@ function RecipeList() {
|
|||||||
// Reset page when search changes
|
// Reset page when search changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [debouncedSearch, searchType]);
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
// Save preferences to localStorage
|
// Save preferences to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,10 +80,6 @@ function RecipeList() {
|
|||||||
localStorage.setItem(LS_COLUMN_COUNT, columnCount.toString());
|
localStorage.setItem(LS_COLUMN_COUNT, columnCount.toString());
|
||||||
}, [columnCount]);
|
}, [columnCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(LS_CARD_SIZE, cardSize.toString());
|
|
||||||
}, [cardSize]);
|
|
||||||
|
|
||||||
// Update URL params
|
// Update URL params
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -115,10 +87,9 @@ function RecipeList() {
|
|||||||
if (itemsPerPage !== 24) params.set('limit', itemsPerPage.toString());
|
if (itemsPerPage !== 24) params.set('limit', itemsPerPage.toString());
|
||||||
if (debouncedSearch) {
|
if (debouncedSearch) {
|
||||||
params.set('search', debouncedSearch);
|
params.set('search', debouncedSearch);
|
||||||
if (searchType === 'tag') params.set('type', 'tag');
|
|
||||||
}
|
}
|
||||||
setSearchParams(params, { replace: true });
|
setSearchParams(params, { replace: true });
|
||||||
}, [currentPage, itemsPerPage, debouncedSearch, searchType, setSearchParams]);
|
}, [currentPage, itemsPerPage, debouncedSearch, setSearchParams]);
|
||||||
|
|
||||||
// Load recipes
|
// Load recipes
|
||||||
const loadRecipes = useCallback(async () => {
|
const loadRecipes = useCallback(async () => {
|
||||||
@@ -128,18 +99,13 @@ function RecipeList() {
|
|||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
tag?: string;
|
|
||||||
} = {
|
} = {
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
limit: itemsPerPage === -1 ? 10000 : itemsPerPage,
|
limit: itemsPerPage === -1 ? 10000 : itemsPerPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (debouncedSearch) {
|
if (debouncedSearch) {
|
||||||
if (searchType === 'title') {
|
params.search = debouncedSearch;
|
||||||
params.search = debouncedSearch;
|
|
||||||
} else {
|
|
||||||
params.tag = debouncedSearch;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await recipesApi.getAll(params);
|
const response = await recipesApi.getAll(params);
|
||||||
@@ -152,7 +118,7 @@ function RecipeList() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentPage, itemsPerPage, debouncedSearch, searchType]);
|
}, [currentPage, itemsPerPage, debouncedSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRecipes();
|
loadRecipes();
|
||||||
@@ -176,11 +142,6 @@ function RecipeList() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchTypeChange = (type: 'title' | 'tag') => {
|
|
||||||
setSearchType(type);
|
|
||||||
setSearchInput('');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading && recipes.length === 0) {
|
if (loading && recipes.length === 0) {
|
||||||
return <div className="loading">Loading recipes...</div>;
|
return <div className="loading">Loading recipes...</div>;
|
||||||
}
|
}
|
||||||
@@ -201,32 +162,16 @@ function RecipeList() {
|
|||||||
<div className="search-input-wrapper">
|
<div className="search-input-wrapper">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={searchType === 'title' ? 'Search by title...' : 'Search by tag...'}
|
placeholder="Search recipes by title or tag..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
list={searchType === 'tag' ? 'tag-suggestions' : undefined}
|
list="tag-suggestions"
|
||||||
/>
|
/>
|
||||||
{searchType === 'tag' && (
|
<datalist id="tag-suggestions">
|
||||||
<datalist id="tag-suggestions">
|
{availableTags.map((tag) => (
|
||||||
{availableTags.map((tag) => (
|
<option key={tag.id} value={tag.name} />
|
||||||
<option key={tag.id} value={tag.name} />
|
))}
|
||||||
))}
|
</datalist>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,21 +193,6 @@ function RecipeList() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* Pagination Controls */}
|
||||||
|
|||||||
@@ -266,6 +266,9 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-primary, #ffffff);
|
background: var(--bg-primary, #ffffff);
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-grid-enhanced .recipe-card:hover {
|
.recipe-grid-enhanced .recipe-card:hover {
|
||||||
@@ -275,42 +278,53 @@
|
|||||||
|
|
||||||
.recipe-grid-enhanced .recipe-card img {
|
.recipe-grid-enhanced .recipe-card img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
height: 65%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-grid-enhanced .recipe-card-content {
|
.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 {
|
.recipe-grid-enhanced .recipe-card-content h3 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
font-size: 1rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.3;
|
line-height: 1.2;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-grid-enhanced .recipe-card-content p {
|
.recipe-grid-enhanced .recipe-card-content p {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.65rem;
|
||||||
color: var(--text-secondary, #666);
|
color: var(--text-secondary, #666);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 1;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-grid-enhanced .recipe-meta {
|
.recipe-grid-enhanced .recipe-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.6rem;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-secondary, #888);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
|
|||||||
Reference in New Issue
Block a user