diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# --- START OF FILE app.py --- from flask import Flask, render_template_string, request, redirect, url_for import json import os @@ -28,64 +28,34 @@ def load_data(): data = json.load(file) logging.info("Данные успешно загружены из JSON") if not isinstance(data, dict) or 'products' not in data or 'categories' not in data: - logging.warning("JSON структура некорректна, инициализация пустой структурой.") - # Handle cases where data is just a list (old format?) or missing keys - if isinstance(data, list): - return {'products': data, 'categories': []} - else: - return {'products': [], 'categories': []} - # Ensure categories is a list - if not isinstance(data.get('categories'), list): - data['categories'] = [] - # Ensure products is a list - if not isinstance(data.get('products'), list): - data['products'] = [] + return {'products': [], 'categories': [] if not isinstance(data, list) else data} return data except FileNotFoundError: - logging.warning("Локальный файл базы данных не найден после попытки скачивания. Используется пустая база.") + logging.warning("Локальный файл базы данных не найден после скачивания.") return {'products': [], 'categories': []} except json.JSONDecodeError: - logging.error("Ошибка: Невозможно декодировать JSON файл. Используется пустая база.") + logging.error("Ошибка: Невозможно декодировать JSON файл.") return {'products': [], 'categories': []} except RepositoryNotFoundError: - logging.error("Репозиторий Hugging Face не найден. Создание локальной базы данных.") - # Create an empty local file if repo not found and local file doesn't exist - if not os.path.exists(DATA_FILE): - save_data({'products': [], 'categories': []}) # Save immediately to create the file structure + logging.error("Репозиторий не найден. Создание локальной базы данных.") return {'products': [], 'categories': []} except Exception as e: - logging.error(f"Произошла непредвиденная ошибка при загрузке данных: {e}. Используется пустая база.") + logging.error(f"Произошла ошибка при загрузке данных: {e}") return {'products': [], 'categories': []} def save_data(data): - # Ensure data integrity before saving - if not isinstance(data, dict): - logging.error("Попытка сохранить данные не в формате словаря. Операция отменена.") - return - if 'products' not in data or not isinstance(data['products'], list): - logging.warning("Ключ 'products' отсутствует или не является списком. Инициализация пустым списком.") - data['products'] = [] - if 'categories' not in data or not isinstance(data['categories'], list): - logging.warning("Ключ 'categories' отсутствует или не является списком. Инициализация пустым списком.") - data['categories'] = [] - try: with open(DATA_FILE, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) logging.info("Данные успешно сохранены в JSON") - # Attempt upload only after successful local save upload_db_to_hf() except Exception as e: - logging.error(f"Ошибка при сохранении данных локально или при загрузке на HF: {e}") - # Consider if you want to re-raise or just log the error - # raise # Re-raising might stop the application flow depending on where save_data is called + logging.error(f"Ошибка при сохранении данных: {e}") + raise def upload_db_to_hf(): if not HF_TOKEN_WRITE: - logging.warning("HF_TOKEN (WRITE) не установлен. Загрузка на Hugging Face пропущена.") - return - if not os.path.exists(DATA_FILE): - logging.warning(f"Файл {DATA_FILE} не найден для загрузки на Hugging Face.") + logging.warning("HF_TOKEN_WRITE не установлен. Пропуск загрузки на Hugging Face.") return try: api = HfApi() @@ -99,20 +69,17 @@ def upload_db_to_hf(): ) logging.info("Резервная копия JSON базы успешно загружена на Hugging Face.") except Exception as e: - logging.error(f"Ошибка при загрузке резервной копии на Hugging Face: {e}") + logging.error(f"Ошибка при загрузке резервной копии: {e}") def download_db_from_hf(): if not HF_TOKEN_READ: - logging.warning("HF_TOKEN_READ не установлен. Скачивание из Hugging Face пропущено.") - # If download is skipped, we should rely on the existing local file or start fresh - if os.path.exists(DATA_FILE): - logging.info("Используется существующий локальный файл data.json.") + logging.warning("HF_TOKEN_READ не установлен. Пропуск скачивания с Hugging Face.") + # If read token is missing, try to use write token if available + if HF_TOKEN_WRITE: + HF_TOKEN_READ = HF_TOKEN_WRITE + logging.info("Используется HF_TOKEN_WRITE для скачивания.") else: - logging.warning("Локальный файл data.json не найден, будет создана пустая структура.") - # Ensure an empty structure is created if the file doesn't exist and download fails/is skipped - save_data({'products': [], 'categories': []}) - return # Don't raise an error here, allow the app to continue - + raise Exception("Нет доступных токенов для чтения.") try: hf_hub_download( repo_id=REPO_ID, @@ -120,32 +87,21 @@ def download_db_from_hf(): repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", - local_dir_use_symlinks=False, - force_download=True # Ensure we get the latest version + local_dir_use_symlinks=False ) logging.info("JSON база успешно скачана из Hugging Face.") except RepositoryNotFoundError as e: - logging.error(f"Репозиторий Hugging Face не найден: {e}") - # Don't raise here, load_data handles this to start with an empty DB + logging.error(f"Репозиторий не найден: {e}") + raise except Exception as e: - # Catch specific hf download errors if possible, otherwise generic Exception - logging.error(f"Ошибка при скачивании JSON базы из Hugging Face: {e}") - # Don't raise here, allow load_data to handle fallback + logging.error(f"Ошибка при скачивании JSON базы: {e}") + raise def periodic_backup(): while True: - time.sleep(800) # Sleep first to avoid immediate backup on start - logging.info("Запуск периодического резервного копирования...") - # Ensure data is loaded before attempting backup? Or rely on save_data's upload call? - # Let's rely on save_data triggering uploads after changes. - # This thread can be simplified or removed if uploads only happen after saves. - # Keeping it for now as a periodic *check* and potential upload if needed. - # Maybe load and save here to force consistency? - try: - current_data = load_data() # Reload to ensure we have the latest state potentially - upload_db_to_hf() # Explicitly call upload here - except Exception as e: - logging.error(f"Ошибка во время периодического резервного копирования: {e}") + time.sleep(800) + logging.info("Запуск периодического резервного копирования.") + upload_db_to_hf() @app.route('/') @@ -160,7 +116,7 @@ def catalog(): - TeenAger - детская одежда оптом + TeenAger - детская одежда оптом @@ -176,89 +132,438 @@ def catalog(): --hover-primary-color: #FB8C00; --hover-secondary-color: #FFA000; } - * { margin: 0; padding: 0; box-sizing: border-box; } - body { font-family: 'Poppins', sans-serif; background-color: var(--bg-color); color: var(--text-color); line-height: 1.6; transition: background-color 0.3s, color 0.3s; } - .container { max-width: 1300px; margin: 0 auto; padding: 20px; } - .header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid var(--primary-color); } - .header-logo { width: 60px; height: 60px; border-radius: 50%; object-fit: cover; box-shadow: 0 4px 15px var(--shadow-color); transition: transform 0.3s ease, box-shadow 0.3s ease; } - .header-logo:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); } - .header h1 { font-size: 1.5rem; font-weight: 600; margin-left: 15px; color: var(--primary-color); } - .filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; } - .search-container { margin: 20px 0; text-align: center; } - #search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid var(--secondary-color); border-radius: 8px; outline: none; box-shadow: 0 2px 5px var(--shadow-color); transition: all 0.3s ease; } - #search-input:focus { border-color: var(--primary-color); box-shadow: 0 4px 15px rgba(255, 167, 38, 0.3); } - .category-filter { padding: 8px 16px; border: 1px solid var(--secondary-color); border-radius: 8px; background-color: var(--light-text); color: var(--text-color); cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); font-size: 0.9rem; font-weight: 400; } - .category-filter.active, .category-filter:hover { background-color: var(--primary-color); color: var(--light-text); border-color: var(--primary-color); box-shadow: 0 2px 10px rgba(255, 167, 38, 0.4); } - .products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; padding: 10px; } - .product { background: var(--light-text); border-radius: 15px; padding: 15px; box-shadow: 0 4px 15px var(--shadow-color); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; overflow: hidden; display: flex; flex-direction: column; } - .product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); } - .product-image { width: 100%; aspect-ratio: 1 / 1; background-color: #fff; border-radius: 10px; overflow: hidden; display: flex; justify-content: center; align-items: center; margin-bottom: 10px; } - .product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; display: block; } - .product-image img:hover { transform: scale(1.1); } - .product-content { flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between; } - .product h2 { font-size: 1rem; font-weight: 600; margin: 10px 0 5px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .product-price { font-size: 1.1rem; color: var(--accent-color); font-weight: 700; text-align: center; margin: 5px 0; } - .product-description { font-size: 0.8rem; color: #718096; text-align: center; margin-bottom: 15px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; min-height: 2.4em; /* Approx 2 lines */ } - .product-buttons { margin-top: auto; } - .product-button { display: block; width: 100%; padding: 8px; border: none; border-radius: 8px; background-color: var(--primary-color); color: var(--light-text); font-size: 0.8rem; font-weight: 500; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); margin: 5px 0; text-align: center; text-decoration: none; } - .product-button:hover { background-color: var(--hover-primary-color); box-shadow: 0 4px 15px rgba(251, 140, 0, 0.4); transform: translateY(-2px); } - .add-to-cart { background-color: var(--secondary-color); } - .add-to-cart:hover { background-color: var(--hover-secondary-color); box-shadow: 0 4px 15px rgba(255, 160, 0, 0.4); } - #cart-button { position: fixed; bottom: 20px; right: 20px; background-color: var(--primary-color); color: var(--light-text); border: none; border-radius: 50%; width: 50px; height: 50px; font-size: 1.2rem; cursor: pointer; display: none; align-items: center; justify-content: center; box-shadow: 0 4px 15px rgba(255, 167, 38, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; } - #cart-button:hover { background-color: var(--hover-primary-color); } - .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); backdrop-filter: blur(5px); } - .modal-content { background: var(--light-text); margin: 5% auto; padding: 25px; border-radius: 15px; width: 90%; max-width: 700px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); animation: slideIn 0.3s ease-out; position: relative; max-height: 85vh; /* Added max-height */ overflow-y: auto; /* Added overflow-y */ } - #cartContent { max-height: 50vh; /* Limit cart item list height specifically */ overflow-y: auto; /* Scroll for cart items */ margin-bottom: 15px; /* Space before total/buttons */ padding-right: 10px; /* Space for scrollbar */} - @keyframes slideIn { from { transform: translateY(-50px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } - .close { position: absolute; top: 15px; right: 20px; font-size: 1.8rem; color: #aaa; cursor: pointer; transition: color 0.3s; font-weight: bold; line-height: 1; } - .close:hover { color: var(--primary-color); } - .cart-item { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid var(--secondary-color); } - .cart-item:last-child { border-bottom: none; } - .cart-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; margin-right: 15px; } - .cart-item-details { flex-grow: 1; } - .cart-item-details strong { display: block; margin-bottom: 5px; } - .cart-item-details p { font-size: 0.9em; color: #555; margin: 0; } - .cart-item-total { font-weight: bold; margin-left: 15px; white-space: nowrap; } - .quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid var(--secondary-color); border-radius: 8px; font-size: 1rem; margin: 10px 0; display: block; } - .modal-buttons { margin-top: 20px; text-align: right; } - .modal-buttons .product-button { display: inline-block; width: auto; margin-left: 10px; } - .clear-cart { background-color: #ef4444; } - .clear-cart:hover { background-color: #dc2626; box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4); } - .order-button { background-color: var(--secondary-color); } - .order-button:hover { background-color: var(--hover-secondary-color); box-shadow: 0 4px 15px rgba(255, 160, 0, 0.4); } + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + body { + font-family: 'Poppins', sans-serif; + background-color: var(--bg-color); + color: var(--text-color); + line-height: 1.6; + transition: background-color 0.3s, color 0.3s; + } + .container { + max-width: 1300px; + margin: 0 auto; + padding: 20px; + } + .header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 0; + border-bottom: 1px solid var(--primary-color); + } + .header-logo { + width: 60px; + height: 60px; + border-radius: 50%; + object-fit: cover; + box-shadow: 0 4px 15px var(--shadow-color); + transition: transform 0.3s ease, box-shadow 0.3s ease; + } + .header-logo:hover { + transform: scale(1.1); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); + } + .header h1 { + font-size: 1.5rem; + font-weight: 600; + margin-left: 15px; + color: var(--primary-color); + } + .filters-container { + margin: 20px 0; + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + } + .search-container { + margin: 20px 0; + text-align: center; + } + #search-input { + width: 90%; + max-width: 600px; + padding: 12px 18px; + font-size: 1rem; + border: 1px solid var(--secondary-color); + border-radius: 8px; + outline: none; + box-shadow: 0 2px 5px var(--shadow-color); + transition: all 0.3s ease; + } + #search-input:focus { + border-color: var(--primary-color); + box-shadow: 0 4px 15px rgba(255, 167, 38, 0.3); + } + .category-filter { + padding: 8px 16px; + border: 1px solid var(--secondary-color); + border-radius: 8px; + background-color: var(--light-text); + color: var(--text-color); + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + font-size: 0.9rem; + font-weight: 400; + } + .category-filter.active, .category-filter:hover { + background-color: var(--primary-color); + color: var(--light-text); + border-color: var(--primary-color); + box-shadow: 0 2px 10px rgba(255, 167, 38, 0.4); + } + .products-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 15px; + padding: 10px; + } + @media (min-width: 768px) { + .products-grid { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } + } + @media (min-width: 1024px) { + .products-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + } + } + .product { + background: var(--light-text); + border-radius: 15px; + padding: 15px; + box-shadow: 0 4px 15px var(--shadow-color); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; + overflow: hidden; + display: flex; + flex-direction: column; + } + .product:hover { + transform: translateY(-5px) scale(1.02); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); + } + .product-image { + width: 100%; + aspect-ratio: 1 / 1; + background-color: #fff; + border-radius: 10px; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 10px; + } + .product-image img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + transition: transform 0.3s ease; + } + .product-image img:hover { + transform: scale(1.1); + } + .product h2 { + font-size: 1rem; + font-weight: 600; + margin: 10px 0 5px 0; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-grow: 0; /* Prevent title from growing */ + } + .product-price { + font-size: 1.1rem; + color: var(--accent-color); + font-weight: 700; + text-align: center; + margin: 5px 0; + flex-grow: 0; + } + .product-description { + font-size: 0.8rem; + color: #718096; + text-align: center; + margin-bottom: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-grow: 1; /* Allow description to take space if needed */ + min-height: 1.2em; /* Ensure minimum height */ + } + .product-buttons { + margin-top: auto; /* Push buttons to bottom */ + display: flex; + flex-direction: column; + gap: 5px; + } + .product-button { + display: block; + width: 100%; + padding: 8px; + border: none; + border-radius: 8px; + background-color: var(--primary-color); + color: var(--light-text); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + text-align: center; + text-decoration: none; + } + .product-button:hover { + background-color: var(--hover-primary-color); + box-shadow: 0 4px 15px rgba(251, 140, 0, 0.4); + transform: translateY(-2px); + } + .add-to-cart { + background-color: var(--secondary-color); + } + .add-to-cart:hover { + background-color: var(--hover-secondary-color); + box-shadow: 0 4px 15px rgba(255, 160, 0, 0.4); + } + #cart-button { + position: fixed; + bottom: 20px; + right: 20px; + background-color: var(--primary-color); + color: var(--light-text); + border: none; + border-radius: 50%; + width: 50px; + height: 50px; + font-size: 1.2rem; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + box-shadow: 0 4px 15px rgba(255, 167, 38, 0.4); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1000; + } + #cart-button:hover { + background-color: var(--hover-primary-color); + } + .modal { + display: none; + position: fixed; + z-index: 1001; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + backdrop-filter: blur(5px); + overflow-y: auto; /* Allow modal itself to scroll if content is very tall */ + } + .modal-content { + background: var(--light-text); + margin: 5% auto; + padding: 20px; + border-radius: 15px; + width: 90%; + max-width: 700px; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); + animation: slideIn 0.3s ease-out; + position: relative; /* Needed for close button positioning */ + max-height: 85vh; /* Limit modal height */ + overflow-y: auto; /* Enable scrolling WITHIN the modal content area */ + } + @keyframes slideIn { + from { transform: translateY(-50px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + .close { + position: absolute; /* Position relative to modal-content */ + top: 10px; + right: 15px; + font-size: 1.8rem; /* Make close button slightly larger */ + color: #aaa; + cursor: pointer; + transition: color 0.3s; + line-height: 1; /* Ensure consistent vertical alignment */ + } + .close:hover { + color: var(--primary-color); + } + .cart-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 0; + border-bottom: 1px solid var(--secondary-color); + gap: 10px; /* Add gap */ + } + .cart-item-details { + display: flex; + align-items: center; + gap: 15px; + flex-grow: 1; /* Allow details to take space */ + min-width: 0; /* Prevent overflow issues */ + } + .cart-item img { + width: 50px; + height: 50px; + object-fit: contain; + border-radius: 8px; + flex-shrink: 0; /* Prevent image shrinking */ + } + .cart-item-info { + min-width: 0; /* Prevent overflow */ + } + .cart-item-info strong { + display: block; /* Ensure name is on its own line */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .cart-item-info p { + font-size: 0.9em; + color: #555; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .cart-item-price { + font-weight: bold; + white-space: nowrap; /* Prevent price breaking */ + flex-shrink: 0; /* Prevent price shrinking */ + } + .cart-actions { + margin-top: 20px; + text-align: right; + display: flex; + flex-wrap: wrap; /* Allow buttons to wrap on small screens */ + justify-content: flex-end; /* Align buttons right */ + gap: 10px; /* Space between buttons */ + } + .cart-actions strong { + width: 100%; /* Make total take full width */ + margin-bottom: 10px; /* Space below total */ + font-size: 1.1rem; + } + .quantity-input, .color-select { + width: 100%; + max-width: 150px; + padding: 8px; + border: 1px solid var(--secondary-color); + border-radius: 8px; + font-size: 1rem; + margin: 5px 0; + } + .clear-cart { + background-color: #ef4444; + } + .clear-cart:hover { + background-color: #dc2626; + box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4); + } + .order-button { + background-color: var(--secondary-color); + } + .order-button:hover { + background-color: var(--hover-secondary-color); + box-shadow: 0 4px 15px rgba(255, 160, 0, 0.4); + } @media (max-width: 768px) { - .products-grid { grid-template-columns: repeat(2, minmax(150px, 1fr)); gap: 15px; } - .header h1 { font-size: 1.3rem; } - #search-input { padding: 10px 15px; } - .category-filter { padding: 6px 12px; font-size: 0.8rem; } - .product h2 { font-size: 0.9rem; } - .product-price { font-size: 1rem; } - .product-description { font-size: 0.75rem; min-height: 2.25em; } - .product-button { padding: 7px; font-size: 0.75rem; } - #cart-button { width: 45px; height: 45px; font-size: 1.1rem; } - .modal-content { padding: 20px; max-height: 90vh; } - .close { top: 10px; right: 15px; font-size: 1.6rem; } - .cart-item img { width: 50px; height: 50px; margin-right: 10px; } - } - @media (max-width: 480px) { - .products-grid { grid-template-columns: 1fr; gap: 15px; } - .header { flex-direction: column; align-items: center; text-align: center; } - .header h1 { margin-left: 0; margin-top: 10px; } - .filters-container { justify-content: center; gap: 8px; } - .category-filter { padding: 6px 10px; } - .modal-content { width: 95%; margin: 3% auto; padding: 15px; } - .cart-item { flex-direction: column; align-items: flex-start; } - .cart-item-details { margin-bottom: 10px; } - .cart-item-total { margin-left: 0; margin-top: 5px; align-self: flex-end;} - .modal-buttons .product-button { width: 100%; margin: 5px 0; } + .products-grid { + grid-template-columns: repeat(2, minmax(150px, 1fr)); + } + .header h1 { + font-size: 1.2rem; + } + #search-input { + padding: 10px 15px; + } + .category-filter { + padding: 6px 12px; + font-size: 0.8rem; + } + .product h2 { + font-size: 0.9rem; + } + .product-price { + font-size: 1rem; + } + .product-description { + font-size: 0.75rem; + } + .product-button { + padding: 6px; + font-size: 0.75rem; + } + #cart-button { + width: 45px; + height: 45px; + font-size: 1.1rem; + } + .modal-content { + padding: 15px; + padding-top: 40px; /* Add padding top for absolute close button */ + max-height: 90vh; /* Allow slightly more height on mobile */ + } + .close { + top: 5px; + right: 10px; + font-size: 1.6rem; + } + } + @media (max-width: 480px) { + .products-grid { + grid-template-columns: 1fr; + gap: 10px; + } + .header { + flex-direction: column; + align-items: center; + text-align: center; + } + .header h1 { + margin-left: 0; + margin-top: 10px; + } + .filters-container { + justify-content: flex-start; + gap: 5px; + } + .category-filter { + padding: 5px 10px; + } + .cart-item { + flex-direction: column; /* Stack cart items vertically */ + align-items: flex-start; /* Align left */ + } + .cart-item-details { + width: 100%; /* Take full width */ + } + .cart-item-price { + align-self: flex-end; /* Move price to the right */ + margin-top: 5px; + } + .cart-actions { + justify-content: center; /* Center buttons on small screens */ + } + .cart-actions button { + width: 100%; /* Make buttons full width */ + } + } + .product-hidden { + display: none !important; /* Use !important to override grid display */ }
- -

Каталог товаров

+ +

Каталог

@@ -267,7 +572,7 @@ def catalog(): {% endfor %}
- +
{% for product in products %} @@ -277,31 +582,24 @@ def catalog(): data-category="{{ product.get('category', 'Без категории') }}"> {% if product.get('photos') and product['photos']|length > 0 %}
- {{ product['name'] }}
{% else %}
- Нет изображения + Нет фото
{% endif %} -
-
-

{{ product['name'] }}

-
{{ product['price'] }} с
-

{{ product['description'] }}

-
-
- - -
+

{{ product['name'] }}

+
{{ product['price'] }} с
+

{{ product['description'][:50] }}{% if product['description']|length > 50 %}...{% endif %}

+
+ +
{% endfor %} - {% if not products %} -

Товары не найдены или еще не добавлены.

- {% endif %}
@@ -316,11 +614,9 @@ def catalog(): @@ -328,11 +624,11 @@ def catalog(): @@ -342,22 +638,17 @@ def catalog(): ''' - return render_template_string(catalog_html, products=products, categories=categories, repo_id=REPO_ID, logo_url=LOGO_URL) + return render_template_string(catalog_html, products=products, categories=categories, repo_id=REPO_ID) @app.route('/product/') def product_detail(index): @@ -611,47 +854,40 @@ def product_detail(index): products = data.get('products', []) if not 0 <= index < len(products): return "Продукт не найден", 404 - product = products[index] detail_html = '''

{{ product['name'] }}

-
+
- {% set photos = product.get('photos', []) %} - {% if photos %} - {% for photo in photos %} -
-
- {{ product['name'] }} - Фото {{ loop.index }} -
+ {% if product.get('photos') and product['photos']|length > 0 %} + {% for photo in product['photos'] %} +
+
+ {{ product['name'] }}
- {% endfor %} +
+ {% endfor %} {% else %} -
- Нет изображения -
+
+ Нет изображения +
{% endif %}
- {% if photos and photos|length > 1 %} -
-
-
+ {% if product.get('photos') and product['photos']|length > 1 %} +
+
+
{% endif %}
-
-

Категория: {{ product.get('category', 'Без категории') }}

-

Цена: {{ product['price'] }} с

-

Описание:
{{ product['description'] | replace('\n', '
') | safe }}

- {% set colors = product.get('colors', []) %} - {% if colors %} -

Доступные цвета: {{ colors|join(', ') }}

- {% else %} -

Доступные цвета: Стандартный

- {% endif %} +
+

Категория: {{ product.get('category', 'Без категории') }}

+

Цена: {{ product['price'] }} с

+

Описание:
{{ product['description'] | replace('\n', '
') | safe }}

+

Доступные цвета: {{ product.get('colors', ['Нет цветов'])|join(', ') if product.get('colors') else 'Нет цветов' }}

''' @@ -666,167 +902,191 @@ def admin(): if request.method == 'POST': action = request.form.get('action') - data_changed = False # Flag to check if save is needed if action == 'add_category': category_name = request.form.get('category_name', '').strip() if category_name and category_name not in categories: categories.append(category_name) - data_changed = True - elif not category_name: - logging.warning("Попытка добавить пустую категорию.") - # Optionally add flash message here + save_data({'products': products, 'categories': categories}) + logging.info(f"Добавлена категория: {category_name}") + return redirect(url_for('admin')) + elif category_name in categories: + return "Ошибка: Категория уже существует", 400 else: - logging.warning(f"Категория '{category_name}' уже существует.") - # Optionally add flash message here + return "Ошибка: Не указано название категории", 400 elif action == 'delete_category': - try: - category_index = int(request.form.get('category_index')) + category_index_str = request.form.get('category_index') + try: + category_index = int(category_index_str) if 0 <= category_index < len(categories): deleted_category = categories.pop(category_index) - logging.info(f"Удалена категория: {deleted_category}") - # Update products using this category for product in products: if product.get('category') == deleted_category: product['category'] = 'Без категории' - data_changed = True + save_data({'products': products, 'categories': categories}) + logging.info(f"Удалена категория: {deleted_category}") + return redirect(url_for('admin')) else: - logging.error("Неверный индекс категории для удаления.") - except (ValueError, TypeError): - logging.error("Некорректный индекс категории для удаления.") + return "Ошибка: Неверный индекс категории", 400 + except (ValueError, TypeError): + return "Ошибка: Неверный индекс категории", 400 + + + elif action == 'add': + name = request.form.get('name', '').strip() + price_str = request.form.get('price', '').strip() + description = request.form.get('description', '').strip() + category = request.form.get('category') + photos_files = request.files.getlist('photos') + colors = [c.strip() for c in request.form.getlist('colors') if c.strip()] + photos_list = [] + + if not name or not price_str or not description: + return "Ошибка: Заполните все обязательные поля (Название, Цена, Описание)", 400 - elif action == 'add' or action == 'edit': try: - index = -1 # Default for 'add' - if action == 'edit': - index = int(request.form.get('index')) - if not 0 <= index < len(products): - raise ValueError("Неверный индекс товара для редактирования") - - name = request.form.get('name', '').strip() - price_str = request.form.get('price', '').replace(',', '.') - description = request.form.get('description', '').strip() - category = request.form.get('category', 'Без категории') - photos_files = request.files.getlist('photos') - colors = sorted(list(set(c.strip() for c in request.form.getlist('colors') if c.strip()))) # Unique, sorted, stripped colors - - if not name or not price_str or not description: - raise ValueError("Не заполнены обязательные поля: Название, Цена, Описание") - - try: - price_float = float(price_str) - if price_float < 0: - raise ValueError("Цена не может быть отрицательной") - except ValueError: - raise ValueError("Неверный формат цены") - - # Handle photo uploads - current_photos = products[index]['photos'] if action == 'edit' and index != -1 and 'photos' in products[index] else [] - newly_uploaded_photos = [] - - if photos_files and any(f.filename for f in photos_files): - uploads_dir = 'uploads' - os.makedirs(uploads_dir, exist_ok=True) - api = HfApi() if HF_TOKEN_WRITE else None - - for photo in photos_files[:10]: # Limit uploads - if photo and photo.filename: - original_filename = secure_filename(photo.filename) - timestamp = int(time.time() * 1000) # Milliseconds for higher uniqueness - unique_filename = f"{timestamp}_{original_filename}" - temp_path = os.path.join(uploads_dir, unique_filename) - - try: - photo.save(temp_path) - if api: - logging.info(f"Загрузка фото {unique_filename} на Hugging Face...") - api.upload_file( - path_or_fileobj=temp_path, - path_in_repo=f"photos/{unique_filename}", - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"{'Добавлено' if action == 'add' else 'Обновлено'} фото для товара {name}" - ) - newly_uploaded_photos.append(unique_filename) - logging.info(f"Фото {unique_filename} успешно загружено.") - else: - logging.warning("HF_TOKEN_WRITE не установлен, фото не загружено на Hugging Face.") - # Decide if you want to keep photos locally if HF upload fails/is skipped - # For now, we only add to list if uploaded - # newly_uploaded_photos.append(unique_filename) # Uncomment if local storage is backup - - except Exception as e: - logging.error(f"Ошибка при загрузке фото {unique_filename}: {e}") - finally: - if os.path.exists(temp_path): - try: - os.remove(temp_path) - except OSError as e: - logging.error(f"Ошибка при удалении временного файла {temp_path}: {e}") - - - product_data = { - 'name': name, - 'price': price_float, - 'description': description, - 'category': category if category in categories else 'Без категории', - 'colors': colors, - # Photo logic: If editing and new photos uploaded, replace. If adding, use new. Otherwise keep existing. - 'photos': newly_uploaded_photos if newly_uploaded_photos else (current_photos if action == 'edit' else []), - } + price = float(price_str.replace(',', '.')) + if price < 0: raise ValueError("Цена не может быть отрицательной") + except ValueError: + return "Ошибка: Неверный формат цены", 400 + + uploads_dir = 'uploads' + os.makedirs(uploads_dir, exist_ok=True) + + if photos_files and HF_TOKEN_WRITE: + for photo in photos_files[:10]: + if photo and photo.filename: + photo_filename = secure_filename(f"{int(time.time())}_{photo.filename}") + temp_path = os.path.join(uploads_dir, photo_filename) + try: + photo.save(temp_path) + api = HfApi() + api.upload_file( + path_or_fileobj=temp_path, + path_in_repo=f"photos/{photo_filename}", + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Добавлено фото для товара {name}" + ) + photos_list.append(photo_filename) + logging.info(f"Загружено фото {photo_filename} для товара {name}") + except Exception as e: + logging.error(f"Ошибка при загрузке фото {photo_filename}: {e}") + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError as e: + logging.error(f"Ошибка при удалении временного файла {temp_path}: {e}") + elif photos_files and not HF_TOKEN_WRITE: + logging.warning("Пропуск загрузки фото: HF_TOKEN_WRITE не установлен.") + + new_product = { + 'name': name, + 'price': price, + 'description': description, + 'category': category if category in categories else 'Без категории', + 'photos': photos_list, + 'colors': colors, + 'added_at': datetime.now().isoformat() + } + products.append(new_product) + save_data({'products': products, 'categories': categories}) + logging.info(f"Добавлен товар: {name}") + return redirect(url_for('admin')) - if action == 'add': - product_data['added_at'] = datetime.now().isoformat() - products.append(product_data) - logging.info(f"Добавлен новый товар: {name}") - else: # action == 'edit' - # Preserve original added_at timestamp - product_data['added_at'] = products[index].get('added_at', datetime.now().isoformat()) - products[index].update(product_data) - logging.info(f"Обновлен товар: {name} (индекс {index})") - - data_changed = True - - except ValueError as e: - logging.error(f"Ошибка валидации при {action}: {e}") - # Add flash message for user feedback - return f"Ошибка: {e}", 400 - except Exception as e: - logging.error(f"Непредвиденная ошибка при {action} товара: {e}") - return "Внутренняя ошибка сервера", 500 + elif action == 'edit': + index_str = request.form.get('index') + try: + index = int(index_str) + if not 0 <= index < len(products): + return "Ошибка: Неверный индекс товара для редактирования", 400 + except (ValueError, TypeError): + return "Ошибка: Неверный индекс товара для редактирования", 400 + name = request.form.get('name', '').strip() + price_str = request.form.get('price', '').strip() + description = request.form.get('description', '').strip() + category = request.form.get('category') + photos_files = request.files.getlist('photos') + colors = [c.strip() for c in request.form.getlist('colors') if c.strip()] + + if not name or not price_str or not description: + return "Ошибка: Заполните все обязательные поля (Название, Цена, Описание)", 400 + + try: + price_float = float(price_str.replace(',', '.')) + if price_float < 0: raise ValueError("Цена не может быть отрицательной") + except ValueError: + return "Ошибка: Неверный формат цены", 400 + + new_photos_list = [] + uploads_dir = 'uploads' + os.makedirs(uploads_dir, exist_ok=True) + + if photos_files and any(photo.filename for photo in photos_files) and HF_TOKEN_WRITE: + for photo in photos_files[:10]: + if photo and photo.filename: + photo_filename = secure_filename(f"{int(time.time())}_{photo.filename}") + temp_path = os.path.join(uploads_dir, photo_filename) + try: + photo.save(temp_path) + api = HfApi() + api.upload_file( + path_or_fileobj=temp_path, + path_in_repo=f"photos/{photo_filename}", + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Обновлено фото для товара {name}" + ) + new_photos_list.append(photo_filename) + logging.info(f"Загружено новое фото {photo_filename} для товара {name}") + except Exception as e: + logging.error(f"Ошибка при загрузке фото {photo_filename} при редактировании: {e}") + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError as e: + logging.error(f"Ошибка при удалении временного файла {temp_path} при редактировании: {e}") + # TODO: Consider deleting old photos from HF if replaced. This needs tracking old filenames. + products[index]['photos'] = new_photos_list + elif photos_files and any(photo.filename for photo in photos_files) and not HF_TOKEN_WRITE: + logging.warning("Пропуск загрузки новых фото при редактировании: HF_TOKEN_WRITE не установлен.") + + + products[index]['name'] = name + products[index]['price'] = price_float + products[index]['description'] = description + products[index]['category'] = category if category in categories else 'Без категории' + products[index]['colors'] = colors + + save_data({'products': products, 'categories': categories}) + logging.info(f"Отредактирован товар: {name} (индекс {index})") + return redirect(url_for('admin')) elif action == 'delete': + index_str = request.form.get('index') try: - index = int(request.form.get('index')) - if 0 <= index < len(products): - deleted_product = products.pop(index) - logging.info(f"Удален товар: {deleted_product.get('name', 'N/A')} (бывший индекс {index})") - # Optionally: Add logic to delete photos from Hugging Face here - # This requires iterating deleted_product['photos'] and calling api.delete_file - data_changed = True - else: - logging.error("Неверный индекс товара для удаления.") + index = int(index_str) + if not 0 <= index < len(products): + return "Ошибка: Неверный индекс товара для удаления", 400 + + deleted_product_name = products[index]['name'] + # TODO: Consider deleting photos from HF associated with this product. + del products[index] + save_data({'products': products, 'categories': categories}) + logging.info(f"Удален товар: {deleted_product_name} (индекс {index})") except (ValueError, TypeError): - logging.error("Некорректный индекс товара для удаления.") + return "Ошибка: Неверный индекс товара для удаления", 400 except Exception as e: logging.error(f"Ошибка при удалении товара: {e}") return "Ошибка при удалении товара", 500 - - - if data_changed: - try: - save_data(data) - except Exception as e: - # Error already logged in save_data - return "Ошибка при сохранении данных", 500 - return redirect(url_for('admin')) # Redirect after successful POST - - # If no action caused a change or redirect, fall through to GET rendering - + return redirect(url_for('admin')) admin_html = ''' @@ -838,86 +1098,351 @@ def admin():
- +

Админ-панель

- Перейти в каталог -

Добавить новый товар

-
+ - + @@ -930,14 +1455,14 @@ def admin(): {% endfor %} - - + +
- +
@@ -950,65 +1475,56 @@ def admin(): - +

Список категорий

- {% if categories %}
{% for category in categories %}

{{ category }}

-
+
+ {% else %} +

Нет добавленных категорий.

{% endfor %}
- {% else %} -

Нет добавленных категорий.

- {% endif %} -

Управление базой данных (Hugging Face)

+

Управление базой данных

-
- + +
-
- + +
-

Список товаров ({{ products|length }})

+

Список товаров

{% for product in products %}

{{ product['name'] }}

Категория: {{ product.get('category', 'Без категории') }}

Цена: {{ product['price'] }} с

-

Описание:
{{ product['description'] | replace('\n', '
') | safe }}

-

Цвета: {{ (product.get('colors') | join(', ')) if product.get('colors') else 'Стандартный' }}

-

Добавлено: {{ product.get('added_at', 'N/A') }}

- {% set photos = product.get('photos', []) %} - {% if photos %} -

Фото:

+

Описание: {{ product['description'] | replace('\n', '
') | safe }}

+

Цвета: {{ product.get('colors', [])|join(', ') if product.get('colors') else 'Нет цветов' }}

+

Добавлено: {{ product.get('added_at', 'Неизвестно')[:10] }}

{# Display only date #} + {% if product.get('photos') and product['photos']|length > 0 %}
- {% for photo in photos %} - - Фото {{ product['name'] }} - + {% for photo in product['photos'] %} + Фото {{ product['name'] }} {% endfor %}
- {% else %} -

Фото: Нет

{% endif %} -
-
+
Редактировать
@@ -1016,7 +1532,7 @@ def admin(): - + @@ -1026,22 +1542,22 @@ def admin(): {% endfor %} - - + +
- {% set current_colors = product.get('colors', []) %} - {% if current_colors %} - {% for color in current_colors %} + {% set colors = product.get('colors', []) %} + {% if colors %} + {% for color in colors %}
- +
{% endfor %} {% else %}
- +
{% endif %}
@@ -1049,98 +1565,77 @@ def admin():
-
+ - +
+ {% else %} +

В базе пока нет товаров.

{% endfor %} - {% if not products %} -

В базе пока нет товаров.

- {% endif %}
''' - # Pass logo_url to the admin template as well - return render_template_string(admin_html, products=products, categories=categories, repo_id=REPO_ID, logo_url=LOGO_URL) + # Sort products by added_at date descending for display in admin panel + products_sorted = sorted(products, key=lambda x: x.get('added_at', ''), reverse=True) + return render_template_string(admin_html, products=products_sorted, categories=categories, repo_id=REPO_ID) @app.route('/backup', methods=['POST']) def backup(): - logging.info("Запрос на ручное резервное копирование...") try: - # Optionally load latest data before backup? Or just upload current file? - # Let's just upload the current local file. - if not os.path.exists(DATA_FILE): - return "Ошибка: Локальный файл базы данных отсутствует.", 404 upload_db_to_hf() - # Add user feedback, e.g., flash message or simple response - return "Резервная копия успешно инициирована для загрузки на Hugging Face.", 200 + return "Резервная копия успешно создана и загружена на Hugging Face.", 200 except Exception as e: logging.error(f"Ошибка при ручном создании резервной копии: {e}") return f"Ошибка при создании резервной копии: {e}", 500 @@ -1148,14 +1643,10 @@ def backup(): @app.route('/download', methods=['GET']) def download(): - logging.info("Запрос на ручное скачивание базы данных...") try: download_db_from_hf() - # Reload data in the app state after download? Depends on workflow. - # For now, just confirm download. User might need to refresh admin page. - return "Актуальная база данных успешно скачана из Hugging Face. Обновите страницу, чтобы увидеть изменения.", 200 + return "Актуальная база данных успешно скачана из Hugging Face.", 200 except RepositoryNotFoundError: - logging.error("Репозиторий Hugging Face не найден при попытке ручного скачивания.") return "Ошибка: Репозиторий Hugging Face не найден.", 404 except Exception as e: logging.error(f"Ошибка при ручном скачивании базы данных: {e}") @@ -1165,23 +1656,20 @@ def download(): if __name__ == '__main__': os.makedirs('uploads', exist_ok=True) - # Attempt initial data load/download before starting backup thread or app - logging.info("Первоначальная загрузка/скачивание данных...") - load_data() # Call load_data to attempt download and initialize DATA_FILE if needed - - # Start background backup thread only if HF token is available - if HF_TOKEN_WRITE: - logging.info("Запуск потока периодического резервного копирования...") + if HF_TOKEN_WRITE or HF_TOKEN_READ: backup_thread = threading.Thread(target=periodic_backup, daemon=True) backup_thread.start() else: - logging.warning("HF_TOKEN_WRITE не установлен, периодическое резервное копирование отключено.") + logging.warning("Токены Hugging Face не установлены. Периодическое резервное копирование отключено.") + + + try: + load_data() + except Exception as e: + logging.warning(f"Не удалось первоначально загрузить/скачать базу данных: {e}. Приложение запустится с пустыми данными или существующим локальным файлом, если он есть.") port = int(os.environ.get("PORT", 7860)) - logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}") - # Use Waitress or Gunicorn for production instead of app.run(debug=True) - # For development: app.run(debug=True, host='0.0.0.0', port=port) - # For simple production: - from waitress import serve - serve(app, host='0.0.0.0', port=port) - # Or run with Gunicorn: gunicorn --bind 0.0.0.0:7860 app:app \ No newline at end of file + logging.info(f"Запуск приложения на порту {port}") + app.run(host='0.0.0.0', port=port) + +# --- END OF FILE app.py --- \ No newline at end of file