Compare commits
20 Commits
v2026.01.0
...
v2026.01.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e941db4e6 | ||
|
|
8d6ddd7e8f | ||
|
|
05cf8d7c00 | ||
| 7a02017c69 | |||
|
|
0e611c379e | ||
|
|
a20dfd848c | ||
|
|
f1e790bb35 | ||
|
|
33a857c456 | ||
|
|
766307050c | ||
|
|
822dd036d4 | ||
|
|
41789fee80 | ||
|
|
4633f7c0cc | ||
|
|
4ce62d5d3e | ||
|
|
70c9f8b751 | ||
|
|
be98d20713 | ||
|
|
8dbc24f335 | ||
|
|
2953bb9f04 | ||
|
|
beff2d1b4b | ||
|
|
1ec5e5f189 | ||
|
|
d87210f8d3 |
23
.env.dev
Normal file
23
.env.dev
Normal file
@@ -0,0 +1,23 @@
|
||||
# Development Environment Variables
|
||||
IMAGE_TAG=dev
|
||||
DOCKER_REGISTRY=localhost
|
||||
DOCKER_USERNAME=basil
|
||||
|
||||
# Database - uses local postgres from docker-compose
|
||||
DATABASE_URL=postgresql://basil:basil@postgres:5432/basil?schema=public
|
||||
|
||||
# CORS for local development
|
||||
CORS_ORIGIN=http://localhost
|
||||
|
||||
# JWT Secrets (dev only - not secure)
|
||||
JWT_SECRET=dev-secret-change-this-in-production-min-32-chars
|
||||
JWT_REFRESH_SECRET=dev-refresh-secret-change-this-in-prod-min-32
|
||||
|
||||
# Google OAuth (optional for dev)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CALLBACK_URL=http://localhost/api/auth/google/callback
|
||||
|
||||
# Application URLs
|
||||
APP_URL=http://localhost
|
||||
API_URL=http://localhost/api
|
||||
@@ -398,7 +398,8 @@ jobs:
|
||||
name: Trigger Deployment
|
||||
runs-on: ubuntu-latest
|
||||
needs: docker-build-and-push
|
||||
if: success()
|
||||
# Skip deployment if commit message contains [skip-deploy] or [dev]
|
||||
if: success() && !contains(github.event.head_commit.message, '[skip-deploy]') && !contains(github.event.head_commit.message, '[dev]')
|
||||
steps:
|
||||
- name: Trigger webhook
|
||||
run: |
|
||||
|
||||
97
CLAUDE.md
97
CLAUDE.md
@@ -324,3 +324,100 @@ The current version is displayed in:
|
||||
- API: `GET /api/version` endpoint returns `{ version: '2026.01.002' }`
|
||||
- Web: Footer or about section shows current version
|
||||
- Both packages export `APP_VERSION` constant for internal use
|
||||
|
||||
## UI Design System - Thumbnail Cards
|
||||
|
||||
### Responsive Column Layout System
|
||||
|
||||
All recipe and cookbook thumbnail displays support a responsive column system (3, 5, 7, or 9 columns) with column-specific styling for optimal readability at different densities.
|
||||
|
||||
**Column-Responsive Font Sizes:**
|
||||
- **Column 3** (Largest cards): Title 0.95rem, Description 0.8rem (2 lines), Meta 0.75rem
|
||||
- **Column 5** (Medium cards): Title 0.85rem, Description 0.75rem (2 lines), Meta 0.7rem
|
||||
- **Column 7** (Compact): Title 0.75rem, Description hidden, Meta 0.6rem
|
||||
- **Column 9** (Most compact): Title 0.75rem, Description hidden, Meta 0.6rem
|
||||
|
||||
**Implementation Pattern:**
|
||||
1. Add `gridClassName = \`recipes-grid columns-${columnCount}\`` or `\`cookbooks-grid columns-${columnCount}\``
|
||||
2. Apply className to grid container: `<div className={gridClassName} style={gridStyle}>`
|
||||
3. Use column-specific CSS selectors: `.columns-3 .recipe-info h3 { font-size: 0.95rem; }`
|
||||
|
||||
### Recipe Thumbnail Display Locations
|
||||
|
||||
All locations use square aspect ratio (1:1) cards with 60% image height.
|
||||
|
||||
1. **Recipe List Page** (`packages/web/src/pages/RecipeList.tsx`)
|
||||
- Class: `recipe-grid-enhanced columns-{3|5|7|9}`
|
||||
- CSS: `packages/web/src/styles/RecipeList.css`
|
||||
- Features: Main recipe browsing with pagination, search, filtering
|
||||
- Displays: Image, title, description, time, rating
|
||||
- Status: ✅ Responsive column styling applied
|
||||
|
||||
2. **Cookbooks Page - Recent Recipes** (`packages/web/src/pages/Cookbooks.tsx`)
|
||||
- Class: `recipes-grid columns-{3|5|7|9}`
|
||||
- CSS: `packages/web/src/styles/Cookbooks.css`
|
||||
- Features: Shows 6 most recent recipes below cookbook list
|
||||
- Displays: Image, title, description, time, rating
|
||||
- Status: ✅ Responsive column styling applied
|
||||
|
||||
3. **Cookbook Detail - Recipes Section** (`packages/web/src/pages/CookbookDetail.tsx`)
|
||||
- Class: `recipes-grid columns-{3|5|7|9}`
|
||||
- CSS: `packages/web/src/styles/CookbookDetail.css`
|
||||
- Features: Paginated recipes within a cookbook, with remove button
|
||||
- Displays: Image, title, description, time, rating, remove button
|
||||
- Status: ✅ Responsive column styling applied
|
||||
|
||||
4. **Add Meal Modal - Recipe Selection** (`packages/web/src/components/meal-planner/AddMealModal.tsx`)
|
||||
- Class: `recipe-list` with `recipe-item`
|
||||
- CSS: `packages/web/src/styles/AddMealModal.css`
|
||||
- Features: Selectable recipe list for adding to meal plan
|
||||
- Displays: Small thumbnail, title, description
|
||||
- Status: ⚠️ Needs responsive column styling review
|
||||
|
||||
5. **Meal Card Component** (`packages/web/src/components/meal-planner/MealCard.tsx`)
|
||||
- Class: `meal-card` with `meal-card-image`
|
||||
- CSS: `packages/web/src/styles/MealCard.css`
|
||||
- Features: Recipe thumbnail in meal planner (compact & full views)
|
||||
- Displays: Recipe image as part of meal display
|
||||
- Status: ⚠️ Different use case - calendar/list view, not grid-based
|
||||
|
||||
### Cookbook Thumbnail Display Locations
|
||||
|
||||
All locations use square aspect ratio (1:1) cards with 50% image height.
|
||||
|
||||
1. **Cookbooks Page - Main Grid** (`packages/web/src/pages/Cookbooks.tsx`)
|
||||
- Class: `cookbooks-grid`
|
||||
- CSS: `packages/web/src/styles/Cookbooks.css`
|
||||
- Features: Main cookbook browsing with pagination
|
||||
- Displays: Cover image, name, recipe count, cookbook count
|
||||
- Status: ✅ Already has compact styling (description/tags hidden)
|
||||
- Note: Could benefit from column-responsive font sizes
|
||||
|
||||
2. **Cookbook Detail - Nested Cookbooks** (`packages/web/src/pages/CookbookDetail.tsx`)
|
||||
- Class: `cookbooks-grid` with `cookbook-card nested`
|
||||
- CSS: `packages/web/src/styles/CookbookDetail.css`
|
||||
- Features: Child cookbooks within parent cookbook
|
||||
- Displays: Cover image, name, recipe count, cookbook count
|
||||
- Status: ✅ Already has compact styling (description/tags hidden)
|
||||
- Note: Could benefit from column-responsive font sizes
|
||||
|
||||
### Key CSS Classes
|
||||
|
||||
- `recipe-card` - Individual recipe card
|
||||
- `recipe-grid-enhanced` or `recipes-grid` - Recipe grid container
|
||||
- `cookbook-card` - Individual cookbook card
|
||||
- `cookbooks-grid` - Cookbook grid container
|
||||
- `columns-{3|5|7|9}` - Dynamic column count modifier class
|
||||
|
||||
### Styling Consistency Rules
|
||||
|
||||
1. **Image Heights**: Recipes 60%, Cookbooks 50%
|
||||
2. **Aspect Ratio**: All cards are square (1:1)
|
||||
3. **Border**: 1px solid #e0e0e0 (not box-shadow)
|
||||
4. **Border Radius**: 8px
|
||||
5. **Hover Effect**: `translateY(-2px)` with `box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1)`
|
||||
6. **Description Display**:
|
||||
- Columns 3 & 5: Show 2 lines
|
||||
- Columns 7 & 9: Hide completely
|
||||
7. **Font Scaling**: Larger fonts for fewer columns, smaller for more columns
|
||||
8. **Text Truncation**: Use `-webkit-line-clamp` with `text-overflow: ellipsis`
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
* Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3)
|
||||
* Month and patch are zero-padded. Patch increments with each deployment in a month.
|
||||
*/
|
||||
export const APP_VERSION = '2026.01.003';
|
||||
export const APP_VERSION = '2026.01.006';
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { CookbookWithRecipes, Recipe } from '@basil/shared';
|
||||
import { cookbooksApi } from '../services/api';
|
||||
import '../styles/CookbookDetail.css';
|
||||
|
||||
const ITEMS_PER_PAGE_OPTIONS = [12, 24, 48, -1]; // -1 = All
|
||||
|
||||
// LocalStorage keys
|
||||
const LS_ITEMS_PER_PAGE = 'basil_cookbook_itemsPerPage';
|
||||
const LS_COLUMN_COUNT = 'basil_cookbook_columnCount';
|
||||
|
||||
// Helper function to extract tag name from string or RecipeTag object
|
||||
const getTagName = (tag: string | { tag: { name: string } }): string => {
|
||||
return typeof tag === 'string' ? tag : tag.tag.name;
|
||||
@@ -12,10 +18,33 @@ const getTagName = (tag: string | { tag: { name: string } }): string => {
|
||||
function CookbookDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [cookbook, setCookbook] = useState<CookbookWithRecipes | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const page = searchParams.get('page');
|
||||
return page ? parseInt(page) : 1;
|
||||
});
|
||||
const [itemsPerPage, setItemsPerPage] = useState(() => {
|
||||
const saved = localStorage.getItem(LS_ITEMS_PER_PAGE);
|
||||
if (saved) return parseInt(saved);
|
||||
const param = searchParams.get('limit');
|
||||
return param ? parseInt(param) : 24;
|
||||
});
|
||||
|
||||
// Display controls state
|
||||
const [columnCount, setColumnCount] = useState<3 | 5 | 7 | 9>(() => {
|
||||
const saved = localStorage.getItem(LS_COLUMN_COUNT);
|
||||
if (saved) {
|
||||
const val = parseInt(saved);
|
||||
if (val === 3 || val === 5 || val === 7 || val === 9) return val;
|
||||
}
|
||||
return 5;
|
||||
});
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
@@ -27,6 +56,28 @@ function CookbookDetail() {
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// Save preferences to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LS_ITEMS_PER_PAGE, itemsPerPage.toString());
|
||||
}, [itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LS_COLUMN_COUNT, columnCount.toString());
|
||||
}, [columnCount]);
|
||||
|
||||
// Update URL params
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (currentPage > 1) params.set('page', currentPage.toString());
|
||||
if (itemsPerPage !== 24) params.set('limit', itemsPerPage.toString());
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [currentPage, itemsPerPage, setSearchParams]);
|
||||
|
||||
// Reset page when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, selectedTags, selectedCuisine]);
|
||||
|
||||
const loadCookbook = async (cookbookId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -129,6 +180,24 @@ function CookbookDetail() {
|
||||
setSelectedCuisine('');
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (value: number) => {
|
||||
setItemsPerPage(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Apply pagination to filtered recipes
|
||||
const getPaginatedRecipes = (filteredRecipes: Recipe[]): Recipe[] => {
|
||||
if (itemsPerPage === -1) return filteredRecipes;
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
return filteredRecipes.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="cookbook-detail-page">
|
||||
@@ -147,9 +216,19 @@ function CookbookDetail() {
|
||||
}
|
||||
|
||||
const filteredRecipes = getFilteredRecipes();
|
||||
const paginatedRecipes = getPaginatedRecipes(filteredRecipes);
|
||||
const allTags = getAllTags();
|
||||
const allCuisines = getAllCuisines();
|
||||
const hasActiveFilters = searchQuery || selectedTags.length > 0 || selectedCuisine;
|
||||
const totalPages = itemsPerPage === -1 ? 1 : Math.ceil(filteredRecipes.length / itemsPerPage);
|
||||
|
||||
// Grid style with CSS variables
|
||||
const gridStyle: React.CSSProperties = {
|
||||
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
|
||||
};
|
||||
|
||||
const recipesGridClassName = `recipes-grid columns-${columnCount}`;
|
||||
const cookbooksGridClassName = `cookbooks-grid columns-${columnCount}`;
|
||||
|
||||
return (
|
||||
<div className="cookbook-detail-page">
|
||||
@@ -227,11 +306,66 @@ function CookbookDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display and Pagination Controls */}
|
||||
<div className="cookbook-toolbar">
|
||||
<div className="display-controls">
|
||||
<div className="control-group">
|
||||
<label>Columns:</label>
|
||||
<div className="column-buttons">
|
||||
{([3, 5, 7, 9] as const).map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
className={columnCount === count ? 'active' : ''}
|
||||
onClick={() => setColumnCount(count)}
|
||||
>
|
||||
{count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pagination-controls">
|
||||
<div className="control-group">
|
||||
<label>Per page:</label>
|
||||
<div className="items-per-page">
|
||||
{ITEMS_PER_PAGE_OPTIONS.map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
className={itemsPerPage === count ? 'active' : ''}
|
||||
onClick={() => handleItemsPerPageChange(count)}
|
||||
>
|
||||
{count === -1 ? 'All' : count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-navigation">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="page-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Included Cookbooks */}
|
||||
{cookbook.cookbooks && cookbook.cookbooks.length > 0 && (
|
||||
<section className="included-cookbooks-section">
|
||||
<h2>Included Cookbooks ({cookbook.cookbooks.length})</h2>
|
||||
<div className="cookbooks-grid">
|
||||
<div className={cookbooksGridClassName} style={gridStyle}>
|
||||
{cookbook.cookbooks.map((childCookbook) => (
|
||||
<div
|
||||
key={childCookbook.id}
|
||||
@@ -272,7 +406,12 @@ function CookbookDetail() {
|
||||
<div className="results-section">
|
||||
<h2>Recipes</h2>
|
||||
<p className="results-count">
|
||||
Showing {filteredRecipes.length} of {cookbook.recipes.length} recipes
|
||||
{itemsPerPage === -1 ? (
|
||||
`Showing all ${filteredRecipes.length} recipes`
|
||||
) : (
|
||||
`Showing ${(currentPage - 1) * itemsPerPage + 1}-${Math.min(currentPage * itemsPerPage, filteredRecipes.length)} of ${filteredRecipes.length} recipes`
|
||||
)}
|
||||
{filteredRecipes.length < cookbook.recipes.length && ` (filtered from ${cookbook.recipes.length} total)`}
|
||||
</p>
|
||||
|
||||
{filteredRecipes.length === 0 ? (
|
||||
@@ -284,8 +423,8 @@ function CookbookDetail() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="recipes-grid">
|
||||
{filteredRecipes.map(recipe => (
|
||||
<div className={recipesGridClassName} style={gridStyle}>
|
||||
{paginatedRecipes.map(recipe => (
|
||||
<div key={recipe.id} className="recipe-card">
|
||||
<div onClick={() => navigate(`/recipes/${recipe.id}`)}>
|
||||
{recipe.imageUrl ? (
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Cookbook, Recipe, Tag } from '@basil/shared';
|
||||
import { cookbooksApi, recipesApi, tagsApi } from '../services/api';
|
||||
import '../styles/Cookbooks.css';
|
||||
|
||||
const ITEMS_PER_PAGE_OPTIONS = [12, 24, 48, -1]; // -1 = All
|
||||
|
||||
// LocalStorage keys
|
||||
const LS_ITEMS_PER_PAGE = 'basil_cookbooks_itemsPerPage';
|
||||
const LS_COLUMN_COUNT = 'basil_cookbooks_columnCount';
|
||||
|
||||
function Cookbooks() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [cookbooks, setCookbooks] = useState<Cookbook[]>([]);
|
||||
const [recentRecipes, setRecentRecipes] = useState<Recipe[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -22,10 +29,49 @@ function Cookbooks() {
|
||||
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
|
||||
const [autoAddCollapsed, setAutoAddCollapsed] = useState(true);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const page = searchParams.get('page');
|
||||
return page ? parseInt(page) : 1;
|
||||
});
|
||||
const [itemsPerPage, setItemsPerPage] = useState(() => {
|
||||
const saved = localStorage.getItem(LS_ITEMS_PER_PAGE);
|
||||
if (saved) return parseInt(saved);
|
||||
const param = searchParams.get('limit');
|
||||
return param ? parseInt(param) : 24;
|
||||
});
|
||||
|
||||
// Display controls state
|
||||
const [columnCount, setColumnCount] = useState<3 | 5 | 7 | 9>(() => {
|
||||
const saved = localStorage.getItem(LS_COLUMN_COUNT);
|
||||
if (saved) {
|
||||
const val = parseInt(saved);
|
||||
if (val === 3 || val === 5 || val === 7 || val === 9) return val;
|
||||
}
|
||||
return 5;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Save preferences to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LS_ITEMS_PER_PAGE, itemsPerPage.toString());
|
||||
}, [itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LS_COLUMN_COUNT, columnCount.toString());
|
||||
}, [columnCount]);
|
||||
|
||||
// Update URL params
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (currentPage > 1) params.set('page', currentPage.toString());
|
||||
if (itemsPerPage !== 24) params.set('limit', itemsPerPage.toString());
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [currentPage, itemsPerPage, setSearchParams]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -117,6 +163,35 @@ function Cookbooks() {
|
||||
setAutoFilterCookbookTags(autoFilterCookbookTags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (value: number) => {
|
||||
setItemsPerPage(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Apply pagination to cookbooks
|
||||
const getPaginatedCookbooks = (): Cookbook[] => {
|
||||
if (itemsPerPage === -1) return cookbooks;
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
return cookbooks.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const paginatedCookbooks = getPaginatedCookbooks();
|
||||
const totalPages = itemsPerPage === -1 ? 1 : Math.ceil(cookbooks.length / itemsPerPage);
|
||||
|
||||
// Grid style with CSS variables
|
||||
const gridStyle: React.CSSProperties = {
|
||||
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
|
||||
};
|
||||
|
||||
const recipesGridClassName = `recipes-grid columns-${columnCount}`;
|
||||
const cookbooksGridClassName = `cookbooks-grid columns-${columnCount}`;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="cookbooks-page">
|
||||
@@ -150,9 +225,30 @@ function Cookbooks() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page-level Controls */}
|
||||
<div className="page-toolbar">
|
||||
<div className="display-controls">
|
||||
<div className="control-group">
|
||||
<label>Columns:</label>
|
||||
<div className="column-buttons">
|
||||
{([3, 5, 7, 9] as const).map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
className={columnCount === count ? 'active' : ''}
|
||||
onClick={() => setColumnCount(count)}
|
||||
>
|
||||
{count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cookbooks Grid */}
|
||||
<section className="cookbooks-section">
|
||||
<h2>Cookbooks</h2>
|
||||
|
||||
{cookbooks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No cookbooks yet. Create your first cookbook to organize your recipes!</p>
|
||||
@@ -161,8 +257,56 @@ function Cookbooks() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cookbooks-grid">
|
||||
{cookbooks.map((cookbook) => (
|
||||
<>
|
||||
{/* Pagination Controls */}
|
||||
<div className="pagination-toolbar">
|
||||
<div className="pagination-controls">
|
||||
<div className="control-group">
|
||||
<label>Per page:</label>
|
||||
<div className="items-per-page">
|
||||
{ITEMS_PER_PAGE_OPTIONS.map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
className={itemsPerPage === count ? 'active' : ''}
|
||||
onClick={() => handleItemsPerPageChange(count)}
|
||||
>
|
||||
{count === -1 ? 'All' : count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-navigation">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="page-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<p className="results-count">
|
||||
{itemsPerPage === -1 ? (
|
||||
`Showing all ${cookbooks.length} cookbooks`
|
||||
) : (
|
||||
`Showing ${(currentPage - 1) * itemsPerPage + 1}-${Math.min(currentPage * itemsPerPage, cookbooks.length)} of ${cookbooks.length} cookbooks`
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className={cookbooksGridClassName} style={gridStyle}>
|
||||
{paginatedCookbooks.map((cookbook) => (
|
||||
<div
|
||||
key={cookbook.id}
|
||||
className="cookbook-card"
|
||||
@@ -195,12 +339,13 @@ function Cookbooks() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent Recipes */}
|
||||
<section className="recent-recipes-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title-row">
|
||||
<h2>Recent Recipes</h2>
|
||||
<button onClick={() => navigate('/recipes')} className="btn-link">
|
||||
View all →
|
||||
@@ -209,7 +354,7 @@ function Cookbooks() {
|
||||
{recentRecipes.length === 0 ? (
|
||||
<p className="empty-state">No recipes yet.</p>
|
||||
) : (
|
||||
<div className="recipes-grid">
|
||||
<div className={recipesGridClassName} style={gridStyle}>
|
||||
{recentRecipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.id}
|
||||
|
||||
@@ -141,36 +141,62 @@ function RecipeDetail() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistically update the UI immediately
|
||||
const optimisticTag = { tag: { id: 'temp', name: trimmedTag } };
|
||||
setRecipe({
|
||||
...recipe,
|
||||
tags: [...(recipe.tags || []), optimisticTag]
|
||||
});
|
||||
setTagInput('');
|
||||
// Keep focus in input field
|
||||
setTimeout(() => tagInputRef.current?.focus(), 0);
|
||||
|
||||
try {
|
||||
setSavingTags(true);
|
||||
// Send array of tag names (strings) to API
|
||||
const updatedTags = [...existingTagNames, trimmedTag];
|
||||
await recipesApi.update(id, { tags: updatedTags });
|
||||
|
||||
// Reload the recipe to get the updated tag structure from API
|
||||
await loadRecipe(id);
|
||||
setTagInput('');
|
||||
|
||||
// Reload available tags to include newly created ones
|
||||
loadTags();
|
||||
|
||||
// Keep focus in input field
|
||||
setTimeout(() => tagInputRef.current?.focus(), 0);
|
||||
// Fetch the updated recipe to get the proper tag IDs, but don't reload the whole page
|
||||
const response = await recipesApi.getById(id);
|
||||
if (response.data) {
|
||||
setRecipe(response.data);
|
||||
// Restore focus after state update
|
||||
setTimeout(() => tagInputRef.current?.focus(), 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add tag:', err);
|
||||
alert('Failed to add tag');
|
||||
// Revert optimistic update on error
|
||||
await loadRecipe(id);
|
||||
} finally {
|
||||
setSavingTags(false);
|
||||
// Ensure focus is maintained
|
||||
setTimeout(() => tagInputRef.current?.focus(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = async (tagToRemove: string) => {
|
||||
if (!id || !recipe) return;
|
||||
|
||||
// Optimistically update the UI immediately
|
||||
const previousTags = recipe.tags;
|
||||
const updatedTagsOptimistic = (recipe.tags || []).filter(tagItem => {
|
||||
const tagName = typeof tagItem === 'string' ? tagItem : tagItem.tag?.name || tagItem.name;
|
||||
return tagName !== tagToRemove;
|
||||
});
|
||||
setRecipe({
|
||||
...recipe,
|
||||
tags: updatedTagsOptimistic
|
||||
});
|
||||
|
||||
try {
|
||||
setSavingTags(true);
|
||||
// Convert existing tags to string array and filter out the removed tag
|
||||
const existingTagNames = (recipe.tags || [])
|
||||
const existingTagNames = (previousTags || [])
|
||||
.map(tagItem =>
|
||||
typeof tagItem === 'string' ? tagItem : tagItem.tag?.name || tagItem.name
|
||||
)
|
||||
@@ -178,11 +204,16 @@ function RecipeDetail() {
|
||||
const updatedTags = existingTagNames.filter(tag => tag !== tagToRemove);
|
||||
await recipesApi.update(id, { tags: updatedTags });
|
||||
|
||||
// Reload the recipe to get the updated tag structure from API
|
||||
await loadRecipe(id);
|
||||
// Fetch the updated recipe to get the proper tag structure
|
||||
const response = await recipesApi.getById(id);
|
||||
if (response.data) {
|
||||
setRecipe(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to remove tag:', err);
|
||||
alert('Failed to remove tag');
|
||||
// Revert optimistic update on error
|
||||
await loadRecipe(id);
|
||||
} finally {
|
||||
setSavingTags(false);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,8 @@ function RecipeList() {
|
||||
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
|
||||
};
|
||||
|
||||
const gridClassName = `recipe-grid-enhanced columns-${columnCount}`;
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
@@ -243,7 +245,7 @@ function RecipeList() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="recipe-grid-enhanced" style={gridStyle}>
|
||||
<div className={gridClassName} style={gridStyle}>
|
||||
{recipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.id}
|
||||
|
||||
@@ -261,6 +261,118 @@
|
||||
background-color: #616161;
|
||||
}
|
||||
|
||||
/* Toolbar and Pagination Controls */
|
||||
|
||||
.cookbook-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.display-controls,
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.column-buttons,
|
||||
.items-per-page {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column-buttons button,
|
||||
.items-per-page button {
|
||||
min-width: 2rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: none;
|
||||
background: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.column-buttons button:not(:last-child),
|
||||
.items-per-page button:not(:last-child) {
|
||||
border-right: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
.column-buttons button:hover,
|
||||
.items-per-page button:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.column-buttons button.active,
|
||||
.items-per-page button.active {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-navigation {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-navigation button {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: white;
|
||||
color: #555;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.page-navigation button:hover:not(:disabled) {
|
||||
background-color: #f5f5f5;
|
||||
border-color: #2e7d32;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.page-navigation button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* Results Section */
|
||||
|
||||
.results-section {
|
||||
@@ -275,82 +387,131 @@
|
||||
|
||||
.recipes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
background: white;
|
||||
position: relative;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.recipe-card > div:first-child {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
.recipe-card img.recipe-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
height: 60%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-image-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
height: 60%;
|
||||
background: linear-gradient(135deg, #ffb74d 0%, #ff9800 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
font-size: 3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-info {
|
||||
padding: 1.25rem;
|
||||
padding: 0.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.recipe-info h3 {
|
||||
font-size: 1.2rem;
|
||||
color: #212121;
|
||||
margin: 0 0 0.5rem 0;
|
||||
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-info .description {
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
font-size: 0.65rem;
|
||||
color: #666;
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.recipe-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #757575;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.6rem;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.recipe-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recipe-tags .tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-radius: 12px;
|
||||
/* Column-specific styles for recipes */
|
||||
.columns-3 .recipe-info h3 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.columns-3 .recipe-info .description {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.columns-3 .recipe-meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.columns-5 .recipe-info h3 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.columns-5 .recipe-info .description {
|
||||
font-size: 0.75rem;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.columns-5 .recipe-meta {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.columns-7 .recipe-info .description,
|
||||
.columns-9 .recipe-info .description {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.remove-recipe-btn {
|
||||
@@ -427,9 +588,26 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cookbook-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.display-controls,
|
||||
.pagination-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.included-cookbooks-section .cookbooks-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Included Cookbooks Section */
|
||||
@@ -446,11 +624,19 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.included-cookbooks-section .cookbooks-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.cookbook-card.nested {
|
||||
border: 2px solid #e0e0e0;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cookbook-card.nested:hover {
|
||||
@@ -458,3 +644,75 @@
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cookbook-card.nested .cookbook-cover,
|
||||
.cookbook-card.nested .cookbook-cover-placeholder {
|
||||
height: 50%;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.cookbook-card.nested .cookbook-info {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cookbook-card.nested .cookbook-info h3 {
|
||||
font-size: 0.75rem;
|
||||
color: #212121;
|
||||
margin: 0 0 0.25rem 0;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cookbook-card.nested .cookbook-info .description {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cookbook-card.nested .cookbook-stats {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.cookbook-card.nested .recipe-count,
|
||||
.cookbook-card.nested .cookbook-count {
|
||||
font-size: 0.6rem;
|
||||
color: #2e7d32;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cookbook-card.nested .cookbook-tags {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Column-specific styles for nested cookbooks */
|
||||
.cookbooks-grid.columns-3 .cookbook-card.nested .cookbook-info h3 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.cookbooks-grid.columns-3 .cookbook-card.nested .recipe-count,
|
||||
.cookbooks-grid.columns-3 .cookbook-card.nested .cookbook-count {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.cookbooks-grid.columns-5 .cookbook-card.nested .cookbook-info h3 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cookbooks-grid.columns-5 .cookbook-card.nested .recipe-count,
|
||||
.cookbooks-grid.columns-5 .cookbook-card.nested .cookbook-count {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,18 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Page-level Controls */
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
background: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
/* Cookbooks Section */
|
||||
.cookbooks-section {
|
||||
margin-bottom: 3rem;
|
||||
@@ -37,9 +49,124 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Pagination Controls */
|
||||
.pagination-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
background: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.display-controls,
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.column-buttons,
|
||||
.items-per-page {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column-buttons button,
|
||||
.items-per-page button {
|
||||
min-width: 2rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: none;
|
||||
background: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.column-buttons button:not(:last-child),
|
||||
.items-per-page button:not(:last-child) {
|
||||
border-right: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
.column-buttons button:hover,
|
||||
.items-per-page button:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.column-buttons button.active,
|
||||
.items-per-page button.active {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-navigation {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-navigation button {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: white;
|
||||
color: #555;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.page-navigation button:hover:not(:disabled) {
|
||||
background-color: #f5f5f5;
|
||||
border-color: #2e7d32;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.page-navigation button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 0.95rem;
|
||||
color: #757575;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.cookbooks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -50,6 +177,9 @@
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.cookbook-card:hover {
|
||||
@@ -59,42 +189,86 @@
|
||||
|
||||
.cookbook-cover {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
height: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cookbook-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
height: 50%;
|
||||
background: linear-gradient(135deg, #81c784 0%, #4caf50 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
font-size: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cookbook-info {
|
||||
padding: 1.25rem;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cookbook-info h3 {
|
||||
font-size: 1.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: #212121;
|
||||
margin: 0 0 0.5rem 0;
|
||||
margin: 0 0 0.25rem 0;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cookbook-info .description {
|
||||
font-size: 0.95rem;
|
||||
color: #666;
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cookbook-info .recipe-count {
|
||||
font-size: 0.9rem;
|
||||
.cookbook-info .cookbook-stats {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.cookbook-info .recipe-count,
|
||||
.cookbook-info .cookbook-count {
|
||||
font-size: 0.6rem;
|
||||
color: #2e7d32;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cookbook-info .cookbook-tags {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Column-specific styles for Cookbooks */
|
||||
.cookbooks-grid.columns-3 .cookbook-info h3 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.cookbooks-grid.columns-3 .recipe-count,
|
||||
.cookbooks-grid.columns-3 .cookbook-count {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.cookbooks-grid.columns-5 .cookbook-info h3 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cookbooks-grid.columns-5 .recipe-count,
|
||||
.cookbooks-grid.columns-5 .cookbook-count {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Recent Recipes Section */
|
||||
@@ -102,77 +276,133 @@
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
.recent-recipes-section h2 {
|
||||
font-size: 1.8rem;
|
||||
color: #1b5e20;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.8rem;
|
||||
color: #1b5e20;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
.recent-recipes-section .recipe-card {
|
||||
cursor: pointer;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
.recent-recipes-section .recipe-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
.recent-recipes-section .recipe-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
height: 60%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-image-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
height: 60%;
|
||||
background: linear-gradient(135deg, #ffb74d 0%, #ff9800 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
font-size: 3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-info {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.recipe-info h3 {
|
||||
font-size: 1.2rem;
|
||||
color: #212121;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.recipe-info .description {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recipe-meta {
|
||||
.recent-recipes-section .recipe-info {
|
||||
padding: 0.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.recent-recipes-section .recipe-info h3 {
|
||||
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;
|
||||
}
|
||||
|
||||
.recent-recipes-section .recipe-info .description {
|
||||
margin: 0;
|
||||
font-size: 0.65rem;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.recent-recipes-section .recipe-meta {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.6rem;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Column-specific styles for Recent Recipes */
|
||||
.recent-recipes-section .columns-3 .recipe-info h3 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.recent-recipes-section .columns-3 .recipe-info .description {
|
||||
font-size: 0.8rem;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.recent-recipes-section .columns-3 .recipe-meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.recent-recipes-section .columns-5 .recipe-info h3 {
|
||||
font-size: 0.85rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.recent-recipes-section .columns-5 .recipe-info .description {
|
||||
font-size: 0.75rem;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.recent-recipes-section .columns-5 .recipe-meta {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.recent-recipes-section .columns-7 .recipe-info .description,
|
||||
.recent-recipes-section .columns-9 .recipe-info .description {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
@@ -521,6 +751,20 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-toolbar,
|
||||
.pagination-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.display-controls,
|
||||
.pagination-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cookbooks-grid,
|
||||
.recipes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -534,11 +778,6 @@
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.cookbook-count {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Cookbook tags */
|
||||
.cookbook-tags {
|
||||
display: flex;
|
||||
|
||||
@@ -266,9 +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;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.recipe-grid-enhanced .recipe-card:hover {
|
||||
@@ -278,7 +278,7 @@
|
||||
|
||||
.recipe-grid-enhanced .recipe-card img {
|
||||
width: 100%;
|
||||
height: 65%;
|
||||
height: 60%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
@@ -327,6 +327,38 @@
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Column-specific styles for recipe grid */
|
||||
.recipe-grid-enhanced.columns-3 .recipe-card-content h3 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.recipe-grid-enhanced.columns-3 .recipe-card-content p {
|
||||
font-size: 0.8rem;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.recipe-grid-enhanced.columns-3 .recipe-meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.recipe-grid-enhanced.columns-5 .recipe-card-content h3 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.recipe-grid-enhanced.columns-5 .recipe-card-content p {
|
||||
font-size: 0.75rem;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.recipe-grid-enhanced.columns-5 .recipe-meta {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.recipe-grid-enhanced.columns-7 .recipe-card-content p,
|
||||
.recipe-grid-enhanced.columns-9 .recipe-card-content p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
* Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3)
|
||||
* Month and patch are zero-padded. Patch increments with each deployment in a month.
|
||||
*/
|
||||
export const APP_VERSION = '2026.01.003';
|
||||
export const APP_VERSION = '2026.01.006';
|
||||
|
||||
14
traefik-local/dynamic-dev.yml
Normal file
14
traefik-local/dynamic-dev.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
http:
|
||||
routers:
|
||||
basil-dev:
|
||||
rule: "Host(`localhost`) || Host(`127.0.0.1`)"
|
||||
entryPoints:
|
||||
- http
|
||||
service: basil-dev-service
|
||||
priority: 1000 # Higher priority than Docker labels (default is 0)
|
||||
|
||||
services:
|
||||
basil-dev-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://basil-web:80"
|
||||
20
traefik-local/traefik.yml
Normal file
20
traefik-local/traefik.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Static Traefik configuration for local development
|
||||
entryPoints:
|
||||
http:
|
||||
address: ":80"
|
||||
|
||||
providers:
|
||||
docker:
|
||||
endpoint: "unix:///var/run/docker.sock"
|
||||
exposedByDefault: false
|
||||
network: traefik
|
||||
file:
|
||||
filename: /dynamic-dev.yml
|
||||
watch: true
|
||||
|
||||
api:
|
||||
insecure: true
|
||||
dashboard: true
|
||||
|
||||
log:
|
||||
level: INFO
|
||||
Reference in New Issue
Block a user