diff --git a/app/api/api_v1/mixins.py b/app/api/api_v1/mixins.py index 6820889..dcb011a 100644 --- a/app/api/api_v1/mixins.py +++ b/app/api/api_v1/mixins.py @@ -22,4 +22,9 @@ def map_recipe_to_response(recipe: Recipe) -> RecipeResponse: total_calories=recipe.total_calories, total_quantity=recipe.total_quantity, is_saved=getattr(recipe, "is_saved", False), + username=( + getattr(recipe.user, "username", None) + if hasattr(recipe, "user") and recipe.user + else None + ), ) diff --git a/app/api/api_v1/recipes.py b/app/api/api_v1/recipes.py index d3a0a2a..dcd55c4 100644 --- a/app/api/api_v1/recipes.py +++ b/app/api/api_v1/recipes.py @@ -31,6 +31,25 @@ async def get_recipes_with_products( return [map_recipe_to_response(recipe) for recipe in recipe_list] +@router.get("/", response_model=List[RecipeResponse]) +async def get_public_recipes( + session: AsyncSession = Depends(db_helper.session_getter), + current_user: User = Depends(current_active_user_bearer), +): + """Возвращает список рецептов с продуктами""" + recipe_list = await recipes.get_recipes_with_products(session=session) + + if current_user: + saved_recipe_ids = await saved_recipes.get_saved_recipe_ids( + session, + current_user.id, + ) + for recipe in recipe_list: + recipe.is_saved = recipe.id in saved_recipe_ids + + return [map_recipe_to_response(recipe) for recipe in recipe_list] + + @router.post( "/", response_model=RecipeResponse, diff --git a/app/api/api_v1/saved_recipes.py b/app/api/api_v1/saved_recipes.py index b8ed7db..d5318d7 100644 --- a/app/api/api_v1/saved_recipes.py +++ b/app/api/api_v1/saved_recipes.py @@ -22,15 +22,18 @@ async def get_my_saved_recipes( ): """Возвращает сохраненные рецепты текущего пользователя""" - saved_recipes_list = await saved_recipes.get_saved_recipes( - session=session, - user_id=current_user.id, - ) + try: + saved_recipes_list = await saved_recipes.get_saved_recipes( + session=session, + user_id=current_user.id, + ) + + recipes = [saved_recipe.recipe for saved_recipe in saved_recipes_list] + return [map_recipe_to_response(recipe) for recipe in recipes] - return [ - map_recipe_to_response(saved_recipe.recipe) - for saved_recipe in saved_recipes_list - ] + except Exception as e: + print(f"Error in get_my_saved_recipes: {e}") + raise HTTPException(status_code=500, detail="Internal server error") @router.post( @@ -99,3 +102,8 @@ async def check_recipe_saved( ) return {"is_saved": is_saved} + + +@router.get("/test") +async def test_endpoint(): + return {"message": "Saved recipes router is working!"} diff --git a/app/core/config.py b/app/core/config.py index 43f08d6..874bfb7 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -21,7 +21,7 @@ class ApiV1Prefix(BaseModel): messages: str = "/messages" products: str = "/products" recipes: str = "/recipes" - saved_recipes: str = "/saved_recipes" + saved_recipes: str = "/saved-recipes" class ApiPrefix(BaseModel): diff --git a/app/core/schemas/recipe.py b/app/core/schemas/recipe.py index ea0ed11..80a836b 100644 --- a/app/core/schemas/recipe.py +++ b/app/core/schemas/recipe.py @@ -33,6 +33,7 @@ class RecipeResponse(BaseModel): total_calories: int total_quantity: int is_saved: bool = False + username: Optional[str] = None class RecipeUpdateRequest(BaseModel): diff --git a/app/crud/recipes.py b/app/crud/recipes.py index 904ecbe..0250421 100644 --- a/app/crud/recipes.py +++ b/app/crud/recipes.py @@ -18,7 +18,10 @@ async def get_recipes_with_products( stmt = ( select(Recipe) - .options(selectinload(Recipe.product_associations)) + .options( + selectinload(Recipe.product_associations), + selectinload(Recipe.user), + ) .order_by(Recipe.id) ) result: Result = await session.execute(stmt) diff --git a/app/static/css/templatemo-style.css b/app/static/css/templatemo-style.css index b0e1e85..67f4c8a 100644 --- a/app/static/css/templatemo-style.css +++ b/app/static/css/templatemo-style.css @@ -1112,4 +1112,148 @@ address { background: white; border: 1px solid #dee2e6; border-radius: 4px; +} + +/* Стили для кнопок сохранения */ +.save-recipe-btn { + background: #6c757d; + color: white; + border: none; + padding: 8px 15px; + border-radius: 5px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; + width: 100%; +} + +.save-recipe-btn.saved { + background: #28a745; +} + +.save-recipe-btn:hover:not(:disabled) { + opacity: 0.9; + transform: translateY(-1px); +} + +.save-recipe-btn:disabled { + background: #ccc; + cursor: not-allowed; +} + +.recipe-actions { + display: flex; + gap: 10px; + margin-top: 10px; + justify-content: center; + flex-direction: column; +} + +.recipe-author { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #eee; + font-size: 0.8rem; + color: #666; +} + +.recipe-products { + margin-top: 10px; + font-size: 0.9rem; +} + +.recipe-products ul { + margin: 5px 0; + padding-left: 20px; +} + +.recipe-products li { + margin-bottom: 3px; + font-size: 0.8rem; +} + +/* Стили для планировщика */ +.recipe-actions { + display: flex; + flex-direction: column; /* Вертикальное расположение */ + align-items: center; + gap: 8px; + margin-top: 10px; +} + +.action-btn { + border: none; + padding: 10px 12px; /* Уменьшили padding */ + border-radius: 5px; + cursor: pointer; + font-size: 0.85rem; /* Уменьшили размер шрифта */ + transition: all 0.3s ease; + width: 180px; /* Увеличили ширину */ + text-align: center; + white-space: nowrap; /* Запрет переноса текста */ + overflow: hidden; +} + +.add-to-grocery-btn { + background: #28a745; + color: white; +} + +.add-to-grocery-btn:hover { + background: #218838; + transform: translateY(-1px); +} + +.unsave-recipe-btn { + background: #dc3545; + color: white; +} + +.unsave-recipe-btn:hover { + background: #c82333; + transform: translateY(-1px); +} + +/* Стили для главной страницы */ +.save-recipe-btn { + background: #6c757d; + color: white; + border: none; + padding: 8px 15px; + border-radius: 5px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; + width: 100%; + margin-top: 10px; +} + +.save-recipe-btn.saved { + background: #28a745; +} + +.save-recipe-btn:hover:not(:disabled) { + opacity: 0.9; + transform: translateY(-1px); +} + +/* Статистика */ +.tm-stat-container { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + box-shadow: 0 4px 15px rgba(0,0,0,0.1); +} + +.tm-stat-number { + font-size: 2.5rem; + margin-bottom: 0.5rem; + font-weight: bold; + text-shadow: 2px 2px 4px rgba(0,0,0,0.3); +} + +.tm-stat-label { + font-size: 0.9rem; + opacity: 0.9; + font-weight: 500; + color: #ffffff !important; } \ No newline at end of file diff --git a/app/static/js/planner.js b/app/static/js/planner.js index ce98dbe..9d67227 100644 --- a/app/static/js/planner.js +++ b/app/static/js/planner.js @@ -1,239 +1,153 @@ -document.addEventListener('DOMContentLoaded', () => { - const gallery = document.getElementById('saved-recipes-gallery'); - const searchInput = document.getElementById('search-input'); - const sortSelect = document.getElementById('sort-select'); - const clearFilters = document.getElementById('clear-filters'); - const selectedRecipesContainer = document.getElementById('selected-recipes'); - const groceryListContainer = document.getElementById('grocery-list'); - const groceryItems = document.getElementById('grocery-items'); - const clearGroceryList = document.getElementById('clear-grocery-list'); - - let savedRecipes = []; - let selectedRecipes = new Set(); - - // Загрузка сохраненных рецептов - async function loadSavedRecipes() { - try { - const token = localStorage.getItem('access_token'); - if (!token) { - gallery.innerHTML = '

Пожалуйста, войдите в систему

'; - return; - } - - const response = await fetch('/api/v1/saved-recipes/', { - headers: { - 'Authorization': `Bearer ${token}` - } - }); +document.addEventListener('DOMContentLoaded', function() { + console.log('✅ Planner loaded'); + loadSavedRecipes(); +}); - if (!response.ok) { - throw new Error('Ошибка загрузки сохраненных рецептов'); - } +let selectedRecipes = new Set(); +let productsCache = {}; - savedRecipes = await response.json(); - renderRecipes(savedRecipes); - updateStatistics(savedRecipes); - } catch (error) { - console.error('Ошибка загрузки рецептов:', error); - gallery.innerHTML = '

Не удалось загрузить сохраненные рецепты

'; - } +async function loadSavedRecipes() { + const gallery = document.getElementById('saved-recipes-gallery'); + + if (!gallery) { + console.error('❌ Gallery element not found'); + return; } - // Рендеринг рецептов - function renderRecipes(recipes) { - if (!Array.isArray(recipes) || recipes.length === 0) { - gallery.innerHTML = '

У вас пока нет сохраненных рецептов

'; - return; - } - - gallery.innerHTML = ''; - - recipes.forEach(recipe => { - const article = document.createElement('article'); - article.className = 'recipe-article'; - article.dataset.recipeId = recipe.id; - - const imageUrl = recipe.image_url || '/static/img/products.jpg'; - const isSelected = selectedRecipes.has(recipe.id); - - article.innerHTML = ` -
-
- ${recipe.title} -

${recipe.total_calories} ккал / ${recipe.total_quantity} г.

-
- - -
-
-
-

${recipe.title}

-

${recipe.body}

-
- Продукты: -
    - ${recipe.products.map(product => - `
  • ${product.quantity}g - Product ID: ${product.product_id}
  • ` - ).join('')} -
-
-
-
- `; - - gallery.appendChild(article); - }); + const token = localStorage.getItem('access_token'); + if (!token) { + gallery.innerHTML = '

Пожалуйста, войдите в систему

'; + return; } - // Обновление статистики - function updateStatistics(recipes) { - const totalRecipes = document.getElementById('total-recipes'); - const totalCalories = document.getElementById('total-calories'); - const totalWeight = document.getElementById('total-weight'); - - totalRecipes.textContent = recipes.length; + try { + gallery.innerHTML = '

🔄 Загрузка рецептов...

'; - const totalCal = recipes.reduce((sum, recipe) => sum + recipe.total_calories, 0); - const totalWgt = recipes.reduce((sum, recipe) => sum + recipe.total_quantity, 0); - - totalCalories.textContent = totalCal.toLocaleString(); - totalWeight.textContent = totalWgt.toLocaleString(); - } - - // Фильтрация и сортировка - function filterAndSortRecipes() { - let filtered = [...savedRecipes]; - - // Поиск - const searchTerm = searchInput.value.toLowerCase(); - if (searchTerm) { - filtered = filtered.filter(recipe => - recipe.title.toLowerCase().includes(searchTerm) || - recipe.body.toLowerCase().includes(searchTerm) - ); - } + await loadProducts(); + + const response = await fetch('/api/v1/saved-recipes/', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); - // Сортировка - const sortBy = sortSelect.value; - switch (sortBy) { - case 'title': - filtered.sort((a, b) => a.title.localeCompare(b.title, 'ru')); - break; - case 'calories': - filtered.sort((a, b) => b.total_calories - a.total_calories); - break; - case 'weight': - filtered.sort((a, b) => b.total_quantity - a.total_quantity); - break; - case 'newest': - // По умолчанию уже новые сначала - break; + if (!response.ok) { + throw new Error(`Ошибка загрузки: ${response.status}`); } - renderRecipes(filtered); + const recipes = await response.json(); + renderRecipes(recipes); + + } catch (error) { + console.error('❌ Error:', error); + gallery.innerHTML = ` +
+

Ошибка загрузки

+

${error.message}

+
+ `; } - - // Управление выбором рецептов для списка покупок - window.toggleRecipeSelection = function(recipeId) { - if (selectedRecipes.has(recipeId)) { - selectedRecipes.delete(recipeId); - } else { - selectedRecipes.add(recipeId); +} + +async function loadProducts() { + try { + const response = await fetch('/api/v1/products/'); + if (response.ok) { + const products = await response.json(); + productsCache = {}; + products.forEach(product => { + productsCache[product.id] = product.name; + }); } - - updateSelectedRecipesDisplay(); - updateGroceryList(); - loadSavedRecipes(); // Перерисовываем для обновления стилей кнопок + } catch (error) { + console.error('❌ Error loading products:', error); } +} - function updateSelectedRecipesDisplay() { - if (selectedRecipes.size === 0) { - selectedRecipesContainer.innerHTML = '

Выберите рецепты для формирования списка покупок

'; - return; - } - - const selectedNames = Array.from(selectedRecipes).map(id => { - const recipe = savedRecipes.find(r => r.id === id); - return recipe ? recipe.title : 'Неизвестный рецепт'; - }); +function getProductName(productId) { + return productsCache[productId] || `Продукт #${productId}`; +} - selectedRecipesContainer.innerHTML = ` -
Выбранные рецепты (${selectedRecipes.size}):
- +function renderRecipes(recipes) { + const gallery = document.getElementById('saved-recipes-gallery'); + + if (!Array.isArray(recipes) || recipes.length === 0) { + gallery.innerHTML = ` +
+

У вас пока нет сохраненных рецептов

+

Перейдите на главную страницу и сохраните несколько рецептов

+
`; + return; } - function updateGroceryList() { - if (selectedRecipes.size === 0) { - groceryListContainer.style.display = 'none'; - return; - } + gallery.innerHTML = ''; - // Здесь можно добавить логику для агрегации продуктов из выбранных рецептов - // Пока просто показываем контейнер - groceryListContainer.style.display = 'block'; - groceryItems.innerHTML = ` -
  • Функциональность агрегации продуктов будет добавлена позже
  • -
  • Выбрано рецептов: ${selectedRecipes.size}
  • + recipes.forEach(recipe => { + const article = document.createElement('article'); + article.className = 'recipe-article'; + + const imageUrl = recipe.image_url || '/static/img/products.jpg'; + + article.innerHTML = ` +
    +
    + ${recipe.title} +

    ${recipe.total_calories} ккал / ${recipe.total_quantity} г.

    +
    + + +
    +
    +
    +

    ${recipe.title}

    +

    ${recipe.body}

    +
    + Ингредиенты: +
      + ${(recipe.products || []).map(product => + `
    • ${product.quantity}г - ${getProductName(product.product_id)}
    • ` + ).join('')} +
    +
    +
    +
    `; - } - // Удаление рецепта из сохраненных - window.unsaveRecipe = async function(recipeId) { - if (!confirm('Удалить этот рецепт из сохраненных?')) { - return; - } + gallery.appendChild(article); + }); +} - try { - const token = localStorage.getItem('access_token'); - const response = await fetch(`/api/v1/saved-recipes/${recipeId}/`, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${token}` - } - }); +window.addToGroceryList = function(recipeId) { + alert(`Рецепт #${recipeId} добавлен в список покупок\n(функциональность будет реализована позже)`); +}; + +window.unsaveRecipe = async function(recipeId) { + if (!confirm('Удалить этот рецепт из сохраненных?')) { + return; + } - if (response.ok) { - // Удаляем из выбранных если был выбран - selectedRecipes.delete(recipeId); - // Перезагружаем список - loadSavedRecipes(); - updateSelectedRecipesDisplay(); - updateGroceryList(); - } else { - alert('Ошибка при удалении рецепта'); + try { + const token = localStorage.getItem('access_token'); + const response = await fetch(`/api/v1/saved-recipes/${recipeId}/`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` } - } catch (error) { - console.error('Ошибка удаления рецепта:', error); + }); + + if (response.ok) { + loadSavedRecipes(); + } else { alert('Ошибка при удалении рецепта'); } + } catch (error) { + console.error('Ошибка удаления рецепта:', error); + alert('Ошибка при удалении рецепта'); } - - // Обработчики событий - searchInput.addEventListener('input', filterAndSortRecipes); - sortSelect.addEventListener('change', filterAndSortRecipes); - - clearFilters.addEventListener('click', () => { - searchInput.value = ''; - sortSelect.value = 'title'; - filterAndSortRecipes(); - }); - - clearGroceryList.addEventListener('click', () => { - selectedRecipes.clear(); - updateSelectedRecipesDisplay(); - updateGroceryList(); - loadSavedRecipes(); - }); - - // Загружаем рецепты при загрузке страницы - loadSavedRecipes(); -}); \ No newline at end of file +} \ No newline at end of file diff --git a/app/static/js/recipes-gallery.js b/app/static/js/recipes-gallery.js index a99adfc..935e91e 100644 --- a/app/static/js/recipes-gallery.js +++ b/app/static/js/recipes-gallery.js @@ -1,43 +1,183 @@ document.addEventListener('DOMContentLoaded', () => { const gallery = document.getElementById('recipe-gallery'); + const token = localStorage.getItem('access_token'); + const isAuthenticated = !!token; fetch('/api/v1/recipes/') - .then(response => { - if (!response.ok) throw new Error("Ошибка загрузки данных"); - return response.json(); - }) - .then(data => { - if (!Array.isArray(data) || data.length === 0) { - gallery.innerHTML = '

    Рецепты не найдены

    '; - return; - } - - data.forEach(recipe => { - const article = document.createElement('article'); - article.className = 'recipe-article'; - - // Используем картинку рецепта или заглушку - const imageUrl = recipe.image_url || '/static/img/products.jpg'; - - article.innerHTML = ` -
    -
    - ${recipe.title} -

    ${recipe.total_calories} ккал / ${recipe.total_quantity} г.

    -
    -
    -

    ${recipe.title}

    -

    ${recipe.body}

    -
    -
    - `; - - gallery.appendChild(article); + .then(response => { + if (!response.ok) throw new Error("Ошибка загрузки данных"); + return response.json(); + }) + .then(data => { + if (!Array.isArray(data) || data.length === 0) { + gallery.innerHTML = '

    Рецепты не найдены

    '; + return; + } + + data.forEach(recipe => { + const article = document.createElement('article'); + article.className = 'recipe-article'; + article.dataset.recipeId = recipe.id; + + const imageUrl = recipe.image_url || '/static/img/products.jpg'; + + // Показываем кнопку сохранения только для авторизованных пользователей + const saveButton = isAuthenticated ? ` +
    + +
    + ` : ''; + + // Определяем имя автора + const authorName = recipe.username || `пользователь #${recipe.user_id}`; + + article.innerHTML = ` +
    +
    + ${recipe.title} +

    ${recipe.total_calories} ккал / ${recipe.total_quantity} г.

    + ${saveButton} +
    +
    +

    ${recipe.title}

    +

    ${recipe.body}

    +
    + Автор: ${authorName} +
    +
    +
    + `; + + gallery.appendChild(article); + }); + }) + .catch(err => { + console.error('Ошибка загрузки рецептов:', err); + gallery.innerHTML = '

    Не удалось загрузить рецепты

    '; }); - }) - .catch(err => { - console.error('Ошибка загрузки рецептов:', err); - gallery.innerHTML = '

    Не удалось загрузить рецепты

    '; - }); -}); \ No newline at end of file +}); + +// Функция сохранения/удаления рецепта +window.toggleSaveRecipe = async function(recipeId, button) { + const token = localStorage.getItem('access_token'); + if (!token) { + alert('Пожалуйста, войдите в систему чтобы сохранять рецепты'); + return; + } + + const isCurrentlySaved = button.classList.contains('saved'); + + try { + let response; + if (isCurrentlySaved) { + // Удаляем из сохраненных + response = await fetch(`/api/v1/saved-recipes/${recipeId}/`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + } else { + // Сохраняем рецепт + response = await fetch('/api/v1/saved-recipes/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ recipe_id: recipeId }) + }); + } + + if (response.ok) { + // Обновляем состояние кнопки + button.classList.toggle('saved'); + button.textContent = isCurrentlySaved ? 'Сохранить' : '✓ Сохранено'; + + // Показываем уведомление + showNotification(isCurrentlySaved ? 'Рецепт удален из сохраненных' : 'Рецепт сохранен!'); + } else { + const error = await response.json(); + alert(error.detail || 'Произошла ошибка'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Произошла ошибка при сохранении рецепта'); + } +} + +// Функция показа уведомления +function showNotification(message) { + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #28a745; + color: white; + padding: 15px 20px; + border-radius: 5px; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + animation: slideIn 0.3s ease; + `; + notification.textContent = message; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => { + if (notification.parentNode) { + document.body.removeChild(notification); + } + }, 300); + }, 3000); +} + +// Добавляем CSS анимации +if (!document.querySelector('style[data-recipes-gallery]')) { + const style = document.createElement('style'); + style.setAttribute('data-recipes-gallery', 'true'); + style.textContent = ` + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + @keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } + } + + .save-recipe-btn { + background: #6c757d; + color: white; + border: none; + padding: 8px 15px; + border-radius: 5px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; + width: 100%; + margin-top: 10px; + } + + .save-recipe-btn.saved { + background: #28a745; + } + + .save-recipe-btn:hover { + opacity: 0.9; + transform: translateY(-1px); + } + + .recipe-actions { + margin-top: 10px; + } + `; + document.head.appendChild(style); +} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index 4fc2e29..111227e 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -13,5 +13,7 @@

    Добро пожаловать - {% block extra_js %}{% endblock %} + {% block extra_js %} + + {% endblock %} {% endblock %} \ No newline at end of file diff --git a/app/templates/planner.html b/app/templates/planner.html index c509471..2dd2ee7 100644 --- a/app/templates/planner.html +++ b/app/templates/planner.html @@ -12,48 +12,6 @@

    Мой планировщикЗдесь собраны все ваши сохраненные рецепты для планирования покупок

    - -
    -
    -
    -

    0

    -

    Всего рецептов

    -
    -
    -

    0

    -

    Общая калорийность

    -
    -
    -

    0

    -

    Общий вес (г)

    -
    -
    -
    - - -
    -
    -
    - -
    -
    - -
    -
    - -
    -
    -
    -