Almaty / app.py
Kgshop's picture
Update app.py
5522788 verified
from flask import Flask, render_template_string, request, redirect, url_for, send_from_directory
import json
import os
import logging
import threading
import time
from datetime import datetime
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError
from werkzeug.utils import secure_filename
app = Flask(__name__)
DATA_FILE = 'data.json'
UPLOAD_FOLDER = 'uploads'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
# Настройки Hugging Face
REPO_ID = "Kgshop/glasman"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
# Ссылка на логотип
LOGO_URL = "https://huggingface.co/spaces/GlasmanGL/shop/resolve/main/gl_glasman-20250423-0001.jpg"
# Настройка логирования
logging.basicConfig(level=logging.INFO) # Changed level to INFO for production clarity
def load_data():
try:
# Ensure local file exists before attempting to load
if not os.path.exists(DATA_FILE):
logging.info(f"{DATA_FILE} not found locally, attempting download from HF.")
download_db_from_hf() # Download if it doesn't exist
# Check again if download succeeded
if not os.path.exists(DATA_FILE):
logging.warning("Local database file still not found after download attempt. Starting with empty data.")
return {'products': [], 'categories': []}
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
logging.info("Data successfully loaded from JSON")
if not isinstance(data, dict) or 'products' not in data or 'categories' not in data:
logging.warning("JSON structure is invalid or incomplete. Resetting to default structure.")
# Handle case where data is just a list (old format?) or missing keys
if isinstance(data, list): # Assuming list might be old product list
return {'products': data, 'categories': []}
else:
return {'products': [], 'categories': []}
# Ensure products and categories are lists
if not isinstance(data.get('products'), list):
data['products'] = []
if not isinstance(data.get('categories'), list):
data['categories'] = []
return data
except FileNotFoundError:
# This case should ideally be handled by the initial check and download
logging.warning("Local database file not found. Starting with empty data.")
return {'products': [], 'categories': []}
except json.JSONDecodeError:
logging.error("Error: Unable to decode JSON file. File might be corrupted. Starting with empty data.")
# Optionally, try to rename the corrupted file and download again
try:
os.rename(DATA_FILE, f"{DATA_FILE}.corrupted_{datetime.now().strftime('%Y%m%d%H%M%S')}")
logging.info("Renamed corrupted data file.")
# Try downloading again
download_db_from_hf()
# Recursive call - be careful with recursion depth
return load_data()
except Exception as e_rename:
logging.error(f"Could not rename corrupted file or re-download: {e_rename}")
return {'products': [], 'categories': []}
except RepositoryNotFoundError:
logging.error("Hugging Face repository not found. Cannot download initial database. Starting with empty local data.")
return {'products': [], 'categories': []}
except Exception as e:
logging.error(f"An unexpected error occurred during data loading: {e}")
return {'products': [], 'categories': []}
def save_data(data):
temp_file = f"{DATA_FILE}.tmp"
try:
# Write to a temporary file first
with open(temp_file, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
# Rename the temporary file to the actual data file (atomic operation on POSIX)
os.replace(temp_file, DATA_FILE)
logging.info("Data successfully saved to JSON")
# Schedule HF upload (consider doing it asynchronously or less frequently)
# Using threading here for simplicity, but a task queue (like Celery) is better for production
threading.Thread(target=upload_db_to_hf).start()
except Exception as e:
logging.error(f"Error saving data: {e}")
# Clean up temp file if it exists
if os.path.exists(temp_file):
try:
os.remove(temp_file)
except OSError as e_remove:
logging.error(f"Could not remove temporary save file {temp_file}: {e_remove}")
raise # Re-raise the exception to indicate failure
def upload_db_to_hf():
if not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN_WRITE not set. Skipping Hugging Face upload.")
return
if not os.path.exists(DATA_FILE):
logging.warning(f"Data file {DATA_FILE} not found. Skipping Hugging Face upload.")
return
try:
api = HfApi()
api.upload_file(
path_or_fileobj=DATA_FILE,
path_in_repo=DATA_FILE,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Automated database backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
logging.info("JSON database backup successfully uploaded to Hugging Face.")
except RepositoryNotFoundError:
logging.error(f"Failed to upload backup: Repository '{REPO_ID}' not found.")
except Exception as e:
logging.error(f"Error uploading backup: {e}")
def download_db_from_hf():
if not HF_TOKEN_READ:
logging.warning("HF_TOKEN_READ not set. Skipping Hugging Face download.")
# Raise an error if download is essential for startup without local file
# raise ValueError("HF Read Token not configured, cannot download database.")
return # Or just return if local fallback is acceptable
try:
hf_hub_download(
repo_id=REPO_ID,
filename=DATA_FILE,
repo_type="dataset",
token=HF_TOKEN_READ,
local_dir=".", # Download to current directory
local_dir_use_symlinks=False, # Recommended for safety
force_download=True # Overwrite local file if it exists
)
logging.info("JSON database successfully downloaded from Hugging Face.")
except RepositoryNotFoundError:
logging.error(f"Repository '{REPO_ID}' not found on Hugging Face.")
raise # Re-raise to be handled by load_data
except Exception as e: # Catch more specific exceptions if needed (e.g., hf_hub_utils.HFValidationError)
logging.error(f"Error downloading JSON database from Hugging Face: {e}")
raise # Re-raise to be handled by load_data
def periodic_backup():
if not HF_TOKEN_WRITE:
logging.info("Periodic backup disabled: HF_TOKEN_WRITE not set.")
return
logging.info("Starting periodic backup thread.")
while True:
time.sleep(800) # Sleep for 13 minutes and 20 seconds
logging.info("Initiating periodic backup...")
try:
# Load current data before backup to ensure consistency?
# Or just upload the last saved state. Uploading last saved is simpler.
upload_db_to_hf()
except Exception as e:
logging.error(f"Error during periodic backup: {e}")
# Add error handling/retry logic if needed
@app.route('/')
def catalog():
data = load_data()
products = data.get('products', []) # Use .get for safety
categories = data.get('categories', [])
catalog_html = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GL Glasman</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Poppins', sans-serif;
background: linear-gradient(135deg, #f0f2f5, #e9ecef);
color: #2d3748;
line-height: 1.6;
transition: background 0.3s, color 0.3s;
}
body.dark-mode {
background: linear-gradient(135deg, #1a202c, #2d3748);
color: #e2e8f0;
}
.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 #e2e8f0;
}
body.dark-mode .header {
border-bottom: 1px solid #4a5568;
}
.header-logo {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
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; /* Adjusted for potentially longer titles */
font-weight: 600;
margin-left: 15px;
flex-grow: 1; /* Allow title to take space */
text-align: center; /* Center title */
}
.theme-toggle {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #4a5568;
transition: color 0.3s ease;
}
body.dark-mode .theme-toggle {
color: #a0aec0;
}
.theme-toggle:hover {
color: #3b82f6; /* Blue accent */
}
body.dark-mode .theme-toggle:hover {
color: #63b3ed; /* Lighter blue in dark mode */
}
.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 #e2e8f0;
border-radius: 8px;
outline: none;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
transition: all 0.3s ease;
}
body.dark-mode #search-input {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
#search-input:focus {
border-color: #3b82f6;
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.2);
}
.category-filter {
padding: 8px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background-color: #fff;
color: #4a5568; /* Default text color */
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.9rem;
font-weight: 400;
}
body.dark-mode .category-filter {
background-color: #4a5568;
border-color: #718096;
color: #e2e8f0;
}
.category-filter.active, .category-filter:hover {
background-color: #3b82f6;
color: white;
border-color: #3b82f6;
box-shadow: 0 2px 10px rgba(59, 130, 246, 0.3);
}
body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover {
background-color: #63b3ed; /* Lighter blue for active/hover in dark */
border-color: #63b3ed;
color: #1a202c; /* Dark text on light blue */
box-shadow: 0 2px 10px rgba(99, 179, 237, 0.3);
}
.products-grid {
display: grid;
/* Responsive columns: 1 on small, 2 on medium, 3 on large */
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); /* Adjusted min width */
gap: 20px; /* Increased gap */
padding: 10px 0; /* Padding top/bottom */
}
/* Media queries for better responsiveness */
@media (min-width: 600px) {
.products-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 900px) {
.products-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1200px) {
.products-grid { grid-template-columns: repeat(4, 1fr); } /* Maybe 4 columns on very wide screens */
}
.product {
background: #fff;
border-radius: 15px;
/* Removed fixed padding, using flex column for layout */
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); /* Softer shadow */
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
overflow: hidden;
display: flex;
flex-direction: column; /* Stack elements vertically */
}
body.dark-mode .product {
background: #2d3748;
color: #e2e8f0; /* Ensure text is visible */
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); /* Darker shadow */
}
.product:hover {
transform: translateY(-5px) scale(1.02);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
body.dark-mode .product:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.product-image-wrapper { /* New wrapper for aspect ratio */
width: 100%;
aspect-ratio: 1 / 1; /* Keep square aspect ratio */
background-color: #fff; /* White background for image area */
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
border-bottom: 1px solid #e2e8f0; /* Separator line */
}
body.dark-mode .product-image-wrapper {
background-color: #4a5568; /* Darker background for image */
border-bottom: 1px solid #718096;
}
.product-image-wrapper img {
display: block; /* Remove extra space below image */
max-width: 100%;
max-height: 100%;
object-fit: contain; /* Changed to contain to see full image */
transition: transform 0.3s ease;
}
.product-image-wrapper img:hover {
transform: scale(1.08); /* Slightly larger zoom */
}
.product-info { /* Container for text and buttons */
padding: 15px;
display: flex;
flex-direction: column;
flex-grow: 1; /* Allow this section to grow */
justify-content: space-between; /* Push buttons to bottom */
}
.product h2 { /* Name */
font-size: 1rem;
font-weight: 600;
margin-bottom: 5px; /* Reduced margin */
text-align: center;
/* Allow wrapping */
/* white-space: nowrap; */
/* overflow: hidden; */
/* text-overflow: ellipsis; */
line-height: 1.3; /* Adjust line height */
min-height: 2.6em; /* Reserve space for 2 lines */
}
.product-price {
font-size: 1.1rem;
color: #ef4444; /* Red price */
font-weight: 700;
text-align: center;
margin: 5px 0;
}
body.dark-mode .product-price {
color: #fca5a5; /* Lighter red */
}
.product-description {
font-size: 0.8rem;
color: #718096;
text-align: center;
margin-bottom: 15px;
flex-grow: 1; /* Allow description to take available space */
/* Allow wrapping for description */
/* overflow: hidden; */
/* text-overflow: ellipsis; */
/* white-space: nowrap; */
line-height: 1.4;
}
body.dark-mode .product-description {
color: #a0aec0;
}
.product-buttons { /* Group buttons */
margin-top: auto; /* Push buttons to the bottom */
display: flex;
flex-direction: column; /* Stack buttons */
gap: 8px; /* Space between buttons */
}
.product-button {
display: block;
width: 100%;
padding: 10px; /* Slightly larger padding */
border: none;
border-radius: 8px;
background-color: #3b82f6; /* Blue */
color: white;
font-size: 0.85rem; /* Slightly larger font */
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: #2563eb; /* Darker blue */
box-shadow: 0 4px 15px rgba(37, 99, 235, 0.4);
transform: translateY(-2px);
}
.add-to-cart {
background-color: #10b981; /* Green */
}
.add-to-cart:hover {
background-color: #059669; /* Darker green */
box-shadow: 0 4px 15px rgba(5, 150, 105, 0.4);
}
#cart-button {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #ef4444; /* Red */
color: white;
border: none;
border-radius: 50%;
width: 55px; /* Slightly larger */
height: 55px;
font-size: 1.5rem; /* Larger icon */
cursor: pointer;
display: flex; /* Use flex to center icon */
justify-content: center;
align-items: center;
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
display: none; /* Initially hidden */
}
#cart-button .cart-count { /* Badge for item count */
position: absolute;
top: -5px;
right: -5px;
background-color: #3b82f6; /* Blue badge */
color: white;
border-radius: 50%;
padding: 2px 6px;
font-size: 0.7rem;
font-weight: bold;
}
.modal {
display: none;
position: fixed;
z-index: 1001;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto; /* Enable scroll if needed */
background-color: rgba(0,0,0,0.6); /* Darker overlay */
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px); /* For Safari */
}
.modal-content {
background: #fff;
margin: 5% auto; /* Vertically centered */
padding: 25px; /* More padding */
border-radius: 15px;
width: 90%;
max-width: 700px; /* Consistent max width */
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
animation: slideIn 0.4s ease-out;
position: relative; /* For close button positioning */
}
body.dark-mode .modal-content {
background: #2d3748;
color: #e2e8f0;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
@keyframes slideIn {
from { transform: translateY(-30px) scale(0.95); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
.close {
position: absolute; /* Position relative to modal-content */
top: 10px;
right: 15px;
font-size: 2rem; /* Larger close button */
font-weight: bold;
color: #aaa;
cursor: pointer;
transition: color 0.3s;
line-height: 1; /* Adjust line height for better positioning */
}
.close:hover, .close:focus {
color: #333; /* Darker hover */
text-decoration: none;
}
body.dark-mode .close {
color: #a0aec0;
}
body.dark-mode .close:hover, body.dark-mode .close:focus {
color: #fff;
}
.modal-content h2 {
margin-bottom: 20px;
text-align: center;
font-size: 1.5rem;
}
/* Cart specific styles */
#cartContent {
max-height: 40vh; /* Limit cart height */
overflow-y: auto; /* Enable scrolling for cart items */
margin-bottom: 20px;
padding-right: 10px; /* Space for scrollbar */
}
.cart-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #e2e8f0;
}
body.dark-mode .cart-item {
border-bottom: 1px solid #4a5568;
}
.cart-item:last-child {
border-bottom: none; /* No border for the last item */
}
.cart-item img {
width: 60px; /* Larger image in cart */
height: 60px;
object-fit: contain;
border-radius: 8px;
margin-right: 15px;
background-color: #fff; /* Ensure background for transparent images */
}
body.dark-mode .cart-item img {
background-color: #4a5568;
}
.cart-item-details {
flex-grow: 1; /* Allow details to take space */
font-size: 0.9rem;
}
.cart-item-details strong {
display: block; /* Name on its own line */
margin-bottom: 5px;
}
.cart-item-details span { /* Price and quantity line */
display: block;
color: #718096;
font-size: 0.8rem;
}
body.dark-mode .cart-item-details span {
color: #a0aec0;
}
.cart-item-total {
font-weight: bold;
font-size: 1rem;
min-width: 70px; /* Ensure alignment */
text-align: right;
}
.cart-item-remove { /* Button to remove item */
background: none;
border: none;
color: #ef4444;
font-size: 1.2rem;
cursor: pointer;
margin-left: 15px;
transition: color 0.3s;
}
.cart-item-remove:hover {
color: #dc2626;
}
body.dark-mode .cart-item-remove {
color: #fca5a5;
}
body.dark-mode .cart-item-remove:hover {
color: #f87171;
}
/* Quantity/Options Modal Styles */
#quantityModal label {
display: block;
margin: 15px 0 5px;
font-weight: 500;
}
.quantity-input, .color-select, .size-select, .pattern-select {
width: 100%;
padding: 10px; /* Consistent padding */
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
margin-bottom: 10px; /* Space below each input/select */
}
body.dark-mode .quantity-input,
body.dark-mode .color-select,
body.dark-mode .size-select,
body.dark-mode .pattern-select {
background-color: #4a5568;
border-color: #718096;
color: #e2e8f0;
}
#quantityModal .product-button { /* Button specific to this modal */
margin-top: 20px;
width: 100%; /* Full width button */
}
/* Cart Summary and Actions */
.cart-summary {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #e2e8f0;
text-align: right;
}
body.dark-mode .cart-summary {
border-top: 1px solid #4a5568;
}
.cart-summary strong {
font-size: 1.2rem;
margin-bottom: 15px; /* Space before buttons */
display: block; /* Total on its own line */
}
.cart-actions {
display: flex;
justify-content: flex-end; /* Align buttons right */
gap: 10px; /* Space between buttons */
margin-top: 15px;
}
.clear-cart {
background-color: #ef4444; /* Red */
}
.clear-cart:hover {
background-color: #dc2626; /* Darker red */
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4);
}
.order-button {
background-color: #10b981; /* Green */
}
.order-button:hover {
background-color: #059669; /* Darker green */
box-shadow: 0 4px 15px rgba(5, 150, 105, 0.4);
}
/* Product Detail Modal Specifics */
#productModal .swiper-container {
max-width: 450px; /* Larger image display */
margin-bottom: 25px;
}
#productModal .swiper-slide {
background-color: #f9f9f9; /* Light background for slides */
display: flex;
justify-content: center;
align-items: center;
height: 400px; /* Fixed height for swiper */
}
body.dark-mode #productModal .swiper-slide {
background-color: #4a5568;
}
#productModal .swiper-slide img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
#productModal .swiper-button-next,
#productModal .swiper-button-prev {
color: #3b82f6; /* Blue navigation arrows */
}
body.dark-mode #productModal .swiper-button-next,
body.dark-mode #productModal .swiper-button-prev {
color: #63b3ed; /* Lighter blue */
}
#productModal .swiper-pagination-bullet-active {
background: #3b82f6; /* Blue active pagination dot */
}
body.dark-mode #productModal .swiper-pagination-bullet-active {
background: #63b3ed;
}
#modalContent p {
margin-bottom: 10px; /* Spacing for details */
font-size: 1rem;
line-height: 1.5;
}
#modalContent p strong {
font-weight: 600;
margin-right: 8px; /* Space after label */
}
/* Ensure Swiper elements are styled */
.swiper-button-next::after, .swiper-button-prev::after {
font-size: 1.5rem !important; /* Adjust arrow size */
}
/* Loading indicator (optional) */
.loading {
text-align: center;
padding: 50px;
font-size: 1.2rem;
color: #718096;
}
body.dark-mode .loading {
color: #a0aec0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="''' + LOGO_URL + '''" alt="Logo" class="header-logo">
<h1>Каталог Товаров</h1>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme">
<i class="fas fa-moon"></i>
</button>
</div>
<div class="filters-container">
<button class="category-filter active" data-category="all" onclick="filterProductsByCategory('all', this)">Все категории</button>
{% for category in categories %}
<button class="category-filter" data-category="{{ category|e }}" onclick="filterProductsByCategory('{{ category|e }}', this)">{{ category|e }}</button>
{% endfor %}
</div>
<div class="search-container">
<input type="text" id="search-input" placeholder="Поиск товаров по названию или описанию..." oninput="filterProducts()">
</div>
<div class="products-grid" id="products-grid">
{% if products %}
{% for product in products %}
<div class="product"
data-id="{{ loop.index0 }}"
data-name="{{ product['name']|lower|e }}"
data-description="{{ product['description']|lower|e }}"
data-category="{{ product.get('category', 'Без категории')|e }}">
<div class="product-image-wrapper">
{% if product.get('photos') and product['photos']|length > 0 %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0]|e }}"
alt="{{ product['name']|e }}"
loading="lazy"
onerror="this.onerror=null; this.src='https://via.placeholder.com/200?text=No+Image';" >
{% else %}
<img src="https://via.placeholder.com/200?text=No+Image" alt="No Image Available" loading="lazy">
{% endif %}
</div>
<div class="product-info">
<h2>{{ product['name']|e }}</h2>
<div class="product-price">{{ product['price'] }} ₸</div>
<p class="product-description">{{ product['description'][:80]|e }}{% if product['description']|length > 80 %}...{% endif %}</p>
<div class="product-buttons">
<button class="product-button" onclick="openProductModal({{ loop.index0 }})">Подробнее</button>
<button class="product-button add-to-cart" onclick="openQuantityModal({{ loop.index0 }})">В корзину</button>
</div>
</div>
</div>
{% endfor %}
{% else %}
<p>Нет товаров для отображения.</p>
{% endif %}
</div>
</div>
<!-- Product Detail Modal -->
<div id="productModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('productModal')" aria-label="Close product details">&times;</span>
<h2>Детали Продукта</h2>
<div id="modalContent"><div class="loading">Загрузка...</div></div>
</div>
</div>
<!-- Quantity, Color, Size, Pattern Modal -->
<div id="quantityModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('quantityModal')" aria-label="Close options selector">&times;</span>
<h2>Выберите опции</h2>
<input type="hidden" id="selectedProductIndexInput">
<div>
<label for="quantityInput">Количество:</label>
<input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
</div>
<div>
<label for="colorSelect">Цвет:</label>
<select id="colorSelect" class="color-select"></select>
</div>
<div>
<label for="sizeSelect">Размер:</label>
<select id="sizeSelect" class="size-select"></select>
</div>
<div>
<label for="patternSelect">Узор:</label>
<select id="patternSelect" class="pattern-select"></select>
</div>
<button class="product-button add-to-cart" onclick="confirmAddToCart()">Добавить в корзину</button>
</div>
</div>
<!-- Cart Modal -->
<div id="cartModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('cartModal')" aria-label="Close cart">&times;</span>
<h2>Корзина</h2>
<div id="cartContent"><p>Ваша корзина пуста.</p></div>
<div class="cart-summary">
<strong>Итого: <span id="cartTotal">0</span> ₸</strong>
<div class="cart-actions">
<button class="product-button clear-cart" onclick="clearCart()">Очистить корзину</button>
<button class="product-button order-button" onclick="orderViaWhatsApp()">Заказать через WhatsApp</button>
</div>
</div>
</div>
</div>
<!-- Cart Floating Action Button -->
<button id="cart-button" onclick="openCartModal()" aria-label="Open cart">
<i class="fas fa-shopping-cart"></i>
<span class="cart-count" id="cart-count-badge">0</span>
</button>
<!-- Use CDN for libraries -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
<script>
// Use const/let instead of var
const products = {{ products|tojson|safe }};
const repoId = "{{ repo_id|e }}"; // Pass repo_id safely
let currentSwiper = null; // To manage Swiper instance
// --- Theme Handling ---
function toggleTheme() {
document.body.classList.toggle('dark-mode');
const isDarkMode = document.body.classList.contains('dark-mode');
const icon = document.querySelector('.theme-toggle i');
icon.classList.toggle('fa-moon', !isDarkMode);
icon.classList.toggle('fa-sun', isDarkMode);
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
}
// Apply theme on load
document.addEventListener('DOMContentLoaded', () => {
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark-mode');
const icon = document.querySelector('.theme-toggle i');
if (icon) { // Ensure icon exists
icon.classList.remove('fa-moon');
icon.classList.add('fa-sun');
}
}
updateCartButton(); // Update cart button on load
filterProducts(); // Initial display of products
});
// --- Modal Handling ---
function openModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = "block";
// Trap focus within the modal (basic example)
// Consider a more robust focus trapping library for accessibility
const focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
} else {
console.error(`Modal with ID ${modalId} not found.`);
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = "none";
if (modalId === 'productModal' && currentSwiper) {
// Destroy Swiper instance when product modal closes to prevent issues
currentSwiper.destroy(true, true);
currentSwiper = null;
}
} else {
console.error(`Modal with ID ${modalId} not found.`);
}
}
// Close modal if clicking outside the content area
window.onclick = function(event) {
if (event.target.classList.contains('modal')) {
closeModal(event.target.id);
}
}
// Close modal on Escape key press
document.addEventListener('keydown', function(event) {
if (event.key === "Escape") {
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
if (modal.style.display === "block") {
closeModal(modal.id);
}
});
}
});
// --- Product Detail Modal ---
function openProductModal(index) {
const modalContent = document.getElementById('modalContent');
modalContent.innerHTML = '<div class="loading">Загрузка...</div>'; // Show loading state
openModal('productModal');
// Fetch details from backend (keeps JS cleaner)
fetch(`/product/${index}`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
})
.then(html => {
modalContent.innerHTML = html;
initializeSwiper(); // Initialize Swiper *after* content is loaded
})
.catch(error => {
console.error('Error loading product details:', error);
modalContent.innerHTML = '<p>Не удалось загрузить детали продукта. Пожалуйста, попробуйте еще раз.</p>';
});
}
function initializeSwiper() {
// Ensure container exists before initializing
const swiperContainer = document.querySelector('#productModal .swiper-container');
if (swiperContainer) {
if (currentSwiper) { // Destroy previous instance if exists
currentSwiper.destroy(true, true);
}
currentSwiper = new Swiper(swiperContainer, {
slidesPerView: 1,
spaceBetween: 20,
loop: true, // Loop only if more than 1 slide? Check Swiper docs
grabCursor: true,
pagination: {
el: '.swiper-pagination',
clickable: true
},
navigation: {
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev'
},
zoom: {
maxRatio: 2.5 // Adjust zoom level
},
// Make loop conditional based on number of slides
on: {
init: function () {
if (this.slides.length <= 3) { // Swiper duplicates slides for loop, so check original count
this.params.loop = false;
this.update(); // Update swiper instance after changing params
// Hide nav buttons if not looping and only 1 slide
if (this.slides.length <=1){
const navNext = this.navigation.nextEl;
const navPrev = this.navigation.prevEl;
if(navNext) navNext.style.display = 'none';
if(navPrev) navPrev.style.display = 'none';
}
} else {
const navNext = this.navigation.nextEl;
const navPrev = this.navigation.prevEl;
if(navNext) navNext.style.display = 'block';
if(navPrev) navPrev.style.display = 'block';
}
}
}
});
} else {
console.warn("Swiper container not found in modal content.");
}
}
// --- Quantity/Options Modal ---
function openQuantityModal(index) {
document.getElementById('selectedProductIndexInput').value = index; // Store index
const product = products[index];
if (!product) {
console.error("Product not found for index:", index);
alert("Произошла ошибка при выборе товара.");
return;
}
populateSelectWithOptions('colorSelect', product.colors, 'Нет доступных цветов');
populateSelectWithOptions('sizeSelect', product.sizes, 'Нет доступных размеров');
populateSelectWithOptions('patternSelect', product.patterns, 'Нет доступных узоров');
document.getElementById('quantityInput').value = 1; // Reset quantity
openModal('quantityModal');
}
function populateSelectWithOptions(selectId, optionsArray, defaultText) {
const selectElement = document.getElementById(selectId);
selectElement.innerHTML = ''; // Clear previous options
if (optionsArray && optionsArray.length > 0) {
optionsArray.forEach(optionValue => {
const option = document.createElement('option');
option.value = optionValue;
option.textContent = optionValue; // Use textContent for safety
selectElement.appendChild(option);
});
selectElement.disabled = false; // Enable select if options exist
// Hide label if only one default/placeholder option? Or keep it visible?
// const label = document.querySelector(`label[for='${selectId}']`);
// if(label) label.style.display = 'block';
} else {
const option = document.createElement('option');
option.value = defaultText; // Use defaultText as value too? Or empty value?
option.textContent = defaultText;
selectElement.appendChild(option);
selectElement.disabled = true; // Disable select if no options
// Hide label if disabled?
// const label = document.querySelector(`label[for='${selectId}']`);
// if(label) label.style.display = 'none';
}
// Ensure parent div is visible/hidden based on disabled state if needed
const parentDiv = selectElement.closest('div');
if(parentDiv) parentDiv.style.display = 'block'; // Always show for now
}
// --- Cart Logic ---
function getCart() {
try {
const cartData = localStorage.getItem('cart');
return cartData ? JSON.parse(cartData) : [];
} catch (e) {
console.error("Error parsing cart from localStorage:", e);
return []; // Return empty cart on error
}
}
function saveCart(cart) {
try {
localStorage.setItem('cart', JSON.stringify(cart));
} catch (e) {
console.error("Error saving cart to localStorage:", e);
alert("Не удалось сохранить корзину. Возможно, хранилище переполнено.");
}
}
function confirmAddToCart() {
const selectedIndex = parseInt(document.getElementById('selectedProductIndexInput').value);
if (isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= products.length) {
alert("Ошибка: Неверный товар выбран.");
return;
}
const quantity = parseInt(document.getElementById('quantityInput').value) || 1;
const color = document.getElementById('colorSelect').value;
const size = document.getElementById('sizeSelect').value;
const pattern = document.getElementById('patternSelect').value;
if (quantity <= 0) {
alert("Укажите количество больше 0");
return;
}
let cart = getCart();
const product = products[selectedIndex];
// Create a unique ID based on product index and selected options
const cartItemId = `prod_${selectedIndex}_${color}_${size}_${pattern}`;
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
if (existingItemIndex > -1) {
// Update quantity of existing item
cart[existingItemIndex].quantity += quantity;
} else {
// Add new item to cart
cart.push({
id: cartItemId,
productIndex: selectedIndex, // Store index to retrieve details later
name: product.name,
price: product.price,
// Get the first photo, handle missing photos gracefully
photo: (product.photos && product.photos.length > 0) ? product.photos[0] : null,
quantity: quantity,
color: color,
size: size,
pattern: pattern
});
}
saveCart(cart);
closeModal('quantityModal');
updateCartButton();
// Optional: Show a confirmation message
// alert(`${product.name} добавлен в корзину!`);
openCartModal(); // Optionally open cart after adding
}
function updateCartButton() {
const cart = getCart();
const cartButton = document.getElementById('cart-button');
const cartCountBadge = document.getElementById('cart-count-badge');
const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0);
if (totalItems > 0) {
cartButton.style.display = 'flex'; // Show button
cartCountBadge.textContent = totalItems;
cartCountBadge.style.display = 'block'; // Show badge
} else {
cartButton.style.display = 'none'; // Hide button
cartCountBadge.style.display = 'none'; // Hide badge
}
}
function openCartModal() {
const cart = getCart();
const cartContent = document.getElementById('cartContent');
let total = 0;
if (cart.length === 0) {
cartContent.innerHTML = '<p>Ваша корзина пуста.</p>';
} else {
cartContent.innerHTML = cart.map((item, index) => {
const itemTotal = item.price * item.quantity;
total += itemTotal;
const photoUrl = item.photo ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}` : 'https://via.placeholder.com/60?text=N/A';
const photoAlt = item.photo ? item.name : "No image available";
// Escape details for display
const nameEsc = escapeHtml(item.name);
const colorEsc = escapeHtml(item.color);
const sizeEsc = escapeHtml(item.size);
const patternEsc = escapeHtml(item.pattern);
return `
<div class="cart-item" data-cart-item-id="${item.id}">
<img src="${photoUrl}" alt="${photoAlt}" onerror="this.onerror=null; this.src='https://via.placeholder.com/60?text=N/A';">
<div class="cart-item-details">
<strong>${nameEsc}</strong>
<span>${item.price} ₸ &times; ${item.quantity}</span>
<span>Цвет: ${colorEsc}, Размер: ${sizeEsc}, Узор: ${patternEsc}</span>
</div>
<span class="cart-item-total">${itemTotal.toFixed(2)} ₸</span>
<button class="cart-item-remove" onclick="removeFromCart('${item.id}')" aria-label="Remove ${nameEsc} from cart">
<i class="fas fa-trash-alt"></i>
</button>
</div>
`;
}).join('');
}
document.getElementById('cartTotal').textContent = total.toFixed(2); // Format total
openModal('cartModal');
}
function removeFromCart(cartItemId) {
let cart = getCart();
cart = cart.filter(item => item.id !== cartItemId);
saveCart(cart);
openCartModal(); // Refresh cart modal view
updateCartButton(); // Update floating button count
}
function clearCart() {
if (confirm("Вы уверены, что хотите очистить корзину?")) {
saveCart([]); // Save an empty array
closeModal('cartModal');
updateCartButton();
}
}
function orderViaWhatsApp() {
const cart = getCart();
if (cart.length === 0) {
alert("Корзина пуста! Добавьте товары перед заказом.");
return;
}
let total = 0;
let orderItemsText = "";
cart.forEach((item, index) => {
const itemTotal = item.price * item.quantity;
total += itemTotal;
// Sanitize item details for URL
const name = encodeURIComponent(item.name);
const color = encodeURIComponent(item.color);
const size = encodeURIComponent(item.size);
const pattern = encodeURIComponent(item.pattern);
const price = item.price;
const quantity = item.quantity;
orderItemsText += `${index + 1}. ${name} - ${price} ₸ × ${quantity}%0A`; // %0A is newline
// Add details only if they are not the default "Нет..." values (or similar)
if (color && !color.startsWith('Нет')) {
orderItemsText += ` Цвет: ${color}%0A`;
}
if (size && !size.startsWith('Нет')) {
orderItemsText += ` Размер: ${size}%0A`;
}
if (pattern && !pattern.startsWith('Нет')) {
orderItemsText += ` Узор: ${pattern}%0A`;
}
});
const totalText = encodeURIComponent(`Итого: ${total.toFixed(2)} ₸`);
const whatsappMessage = `Здравствуйте! Хочу сделать заказ:%0A%0A${orderItemsText}%0A${totalText}`;
// Replace with the actual WhatsApp number
const whatsappNumber = "77058158999"; // IMPORTANT: Use the correct number
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${whatsappMessage}`;
// Open WhatsApp link in a new tab
window.open(whatsappUrl, '_blank');
// Optional: Clear cart after sending order? Ask user?
// if (confirm("Заказ отправлен в WhatsApp. Очистить корзину?")) {
// clearCart();
// }
}
// --- Filtering and Searching ---
function filterProductsByCategory(category, buttonElement) {
// Update active state for buttons
document.querySelectorAll('.category-filter').forEach(f => f.classList.remove('active'));
buttonElement.classList.add('active');
filterProducts(); // Call the main filter function
}
function filterProducts() {
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
const activeCategoryButton = document.querySelector('.category-filter.active');
// Handle case where no category button is active (shouldn't happen with default 'all')
const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
const productElements = document.querySelectorAll('.product');
productElements.forEach(productElement => {
const name = productElement.getAttribute('data-name') || '';
const description = productElement.getAttribute('data-description') || '';
const category = productElement.getAttribute('data-category') || 'Без категории'; // Match default
const matchesSearch = searchTerm === '' || name.includes(searchTerm) || description.includes(searchTerm);
const matchesCategory = activeCategory === 'all' || category === activeCategory;
// Show or hide based on both conditions
if (matchesSearch && matchesCategory) {
productElement.style.display = 'flex'; // Use flex as per updated CSS
} else {
productElement.style.display = 'none';
}
});
}
// --- Utility Functions ---
function escapeHtml(unsafe) {
if (!unsafe) return ""; // Handle null or undefined
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
</script>
</body>
</html>
'''
return render_template_string(catalog_html, products=products, categories=categories, repo_id=REPO_ID)
@app.route('/product/<int:index>')
def product_detail(index):
# This endpoint now returns HTML partial for the modal
data = load_data()
products = data.get('products', [])
if index < 0 or index >= len(products):
return "<p>Продукт не найден.</p>", 404
product = products[index]
# Generate only the inner content for the modal
detail_html = '''
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 25px; border-radius: 10px; overflow: hidden;">
<div class="swiper-wrapper">
{% if product.get('photos') and product['photos']|length > 0 %}
{% for photo in product['photos'] %}
<div class="swiper-slide">
<div class="swiper-zoom-container">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo|e }}"
alt="{{ product['name']|e }} - фото {{ loop.index }}"
style="display: block; max-width: 100%; max-height: 400px; object-fit: contain; margin: auto;"
onerror="this.onerror=null; this.src='https://via.placeholder.com/400x400?text=Image+Error';" >
</div>
</div>
{% endfor %}
{% else %}
<div class="swiper-slide">
<img src="https://via.placeholder.com/400x400?text=No+Image+Available" alt="No Image Available" style="display: block; max-width: 100%; max-height: 400px; object-fit: contain; margin: auto;">
</div>
{% endif %}
</div>
<!-- Add Pagination -->
<div class="swiper-pagination"></div>
<!-- Add Navigation -->
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
</div>
<div>
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории')|e }}</p>
<p><strong>Цена:</strong> {{ product['price'] }} ₸</p>
<p><strong>Описание:</strong></p>
<p style="white-space: pre-wrap;">{{ product['description']|e }}</p> {# Use pre-wrap to respect newlines #}
{% set colors = product.get('colors') %}
{% if colors and colors|length > 0 %}
<p><strong>Доступные цвета:</strong> {{ colors|join(', ')|e }}</p>
{% else %}
<p><strong>Доступные цвета:</strong> Цвет не указан</p>
{% endif %}
{% set sizes = product.get('sizes') %}
{% if sizes and sizes|length > 0 %}
<p><strong>Доступные размеры:</strong> {{ sizes|join(', ')|e }}</p>
{% else %}
<p><strong>Доступные размеры:</strong> Размер не указан</p>
{% endif %}
{% set patterns = product.get('patterns') %}
{% if patterns and patterns|length > 0 %}
<p><strong>Доступные узоры:</strong> {{ patterns|join(', ')|e }}</p>
{% else %}
<p><strong>Доступные узоры:</strong> Узор не указан</p>
{% endif %}
</div>
'''
# Note: repo_id is needed here as well for the image URLs
return render_template_string(detail_html, product=product, repo_id=REPO_ID)
# Helper function to upload a single photo to Hugging Face
def upload_photo_to_hf(local_path, repo_filename, product_name):
if not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN_WRITE not set. Cannot upload photo.")
return False
try:
api = HfApi()
api.upload_file(
path_or_fileobj=local_path,
path_in_repo=f"photos/{repo_filename}", # Store in 'photos' directory
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Upload photo {repo_filename} for product {product_name}"
)
logging.info(f"Successfully uploaded photo {repo_filename} to HF.")
return True
except Exception as e:
logging.error(f"Failed to upload photo {repo_filename} to HF: {e}")
return False
finally:
# Clean up the temporary local file
if os.path.exists(local_path):
try:
os.remove(local_path)
except OSError as e_remove:
logging.error(f"Could not remove temporary photo file {local_path}: {e_remove}")
@app.route('/admin', methods=['GET', 'POST'])
def admin():
# Basic Password Protection (Replace with a proper auth system)
# IMPORTANT: This is NOT secure for production. Use Flask-Login, OAuth, etc.
# Example: Check for a specific header or query parameter
# admin_secret = os.getenv("ADMIN_SECRET", "default_secret") # Get secret from env
# provided_secret = request.headers.get("X-Admin-Secret") or request.args.get("secret")
#
# if provided_secret != admin_secret:
# return "Unauthorized", 401 # Or redirect to a login page
data = load_data()
# Ensure keys exist and are lists, provide defaults if not
products = data.setdefault('products', [])
categories = data.setdefault('categories', [])
if request.method == 'POST':
action = request.form.get('action')
logging.debug(f"Admin action received: {action}")
try:
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)
categories.sort() # Keep categories sorted
save_data(data)
logging.info(f"Category '{category_name}' added.")
return redirect(url_for('admin'))
elif not category_name:
return "Ошибка: Название категории не может быть пустым.", 400
else:
return "Ошибка: Категория с таким названием уже существует.", 400
elif action == 'delete_category':
category_to_delete = request.form.get('category_name')
if category_to_delete and category_to_delete in categories:
categories.remove(category_to_delete)
# Update products using this category
updated_products = []
for product in products:
if product.get('category') == category_to_delete:
product['category'] = 'Без категории' # Reassign to default
updated_products.append(product)
data['products'] = updated_products
save_data(data)
logging.info(f"Category '{category_to_delete}' deleted and products updated.")
return redirect(url_for('admin'))
elif not category_to_delete:
return "Ошибка: Не выбрана категория для удаления.", 400
else:
return "Ошибка: Категория не найдена.", 404
elif action == 'add' or action == 'edit':
# Common data extraction for add/edit
name = request.form.get('name', '').strip()
price_str = request.form.get('price', '0').replace(',', '.')
description = request.form.get('description', '').strip()
category = request.form.get('category', 'Без категории')
# Use getlist to handle multiple inputs with the same name
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
sizes = [s.strip() for s in request.form.getlist('sizes') if s.strip()]
patterns = [p.strip() for p in request.form.getlist('patterns') if p.strip()]
photos_files = request.files.getlist('photos')
if not name or not description: # Price can be 0
return "Ошибка: Название и описание товара обязательны.", 400
try:
price = float(price_str)
if price < 0:
return "Ошибка: Цена не может быть отрицательной.", 400
except ValueError:
return "Ошибка: Неверный формат цены.", 400
uploaded_photo_filenames = []
if photos_files:
for photo in photos_files:
if photo and photo.filename:
# Sanitize filename
original_filename = secure_filename(photo.filename)
# Create a unique filename to avoid collisions
timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
unique_filename = f"{timestamp}_{original_filename}"
temp_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
try:
photo.save(temp_path)
logging.debug(f"Photo saved locally to {temp_path}")
# Attempt upload to HF
if upload_photo_to_hf(temp_path, unique_filename, name):
uploaded_photo_filenames.append(unique_filename)
else:
# Decide error handling: stop, warn, or continue without photo
logging.warning(f"Failed to upload photo {unique_filename} to HF. It will not be added to the product.")
# Optionally: return "Error uploading photo", 500
except Exception as e_save:
logging.error(f"Error saving uploaded file {original_filename}: {e_save}")
# Clean up failed temp file if necessary
if os.path.exists(temp_path): os.remove(temp_path)
# Optionally: return "Error processing file upload", 500
if action == 'add':
new_product = {
'name': name,
'price': price,
'description': description,
'category': category if category in categories else 'Без категории',
'photos': uploaded_photo_filenames, # Add newly uploaded photos
'colors': list(set(colors)), # Remove duplicates
'sizes': list(set(sizes)),
'patterns': list(set(patterns))
}
products.append(new_product)
logging.info(f"Product '{name}' added.")
elif action == 'edit':
index_str = request.form.get('index')
if index_str is None:
return "Ошибка: Индекс товара не предоставлен для редактирования.", 400
try:
index = int(index_str)
if index < 0 or index >= len(products):
raise IndexError("Index out of bounds")
except (ValueError, IndexError):
return "Ошибка: Неверный индекс товара для редактирования.", 400
# Get existing photos unless new ones were uploaded
existing_photos = products[index].get('photos', [])
# Decide update strategy for photos: replace or append? Replacing is simpler.
products[index]['photos'] = uploaded_photo_filenames if uploaded_photo_filenames else existing_photos
# Option to remove specific photos would need more UI elements
products[index].update({
'name': name,
'price': price,
'description': description,
'category': category if category in categories else 'Без категории',
'colors': list(set(colors)), # Update with new list, ensuring uniqueness
'sizes': list(set(sizes)),
'patterns': list(set(patterns))
})
logging.info(f"Product at index {index} ('{name}') updated.")
save_data(data)
return redirect(url_for('admin'))
elif action == 'delete':
index_str = request.form.get('index')
if index_str is None:
return "Ошибка: Индекс товара не предоставлен для удаления.", 400
try:
index = int(index_str)
if index < 0 or index >= len(products):
raise IndexError("Index out of bounds")
except (ValueError, IndexError):
return "Ошибка: Неверный индекс товара для удаления.", 400
# Optional: Delete associated photos from HF (more complex, requires tracking)
# For now, just remove product data
deleted_product_name = products[index].get('name', 'Unknown')
del products[index]
save_data(data)
logging.info(f"Product at index {index} ('{deleted_product_name}') deleted.")
return redirect(url_for('admin'))
else:
logging.warning(f"Unknown admin action received: {action}")
return "Неизвестное действие.", 400
except Exception as e:
# Log the full error traceback for debugging
logging.exception(f"An error occurred in admin POST action '{action}': {e}")
# Provide a generic error message to the user
return f"Произошла внутренняя ошибка сервера при обработке действия '{action}'. Подробности см. в логах сервера.", 500
# --- Render Admin Page (GET request or after POST redirect) ---
admin_html = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
:root {
--primary-color: #3b82f6; /* Blue */
--primary-darker: #2563eb;
--secondary-color: #10b981; /* Green */
--secondary-darker: #059669;
--danger-color: #ef4444; /* Red */
--danger-darker: #dc2626;
--background-light: linear-gradient(135deg, #f0f2f5, #e9ecef);
--card-bg-light: #fff;
--text-light: #2d3748;
--border-light: #e2e8f0;
--input-bg-light: #fff;
--input-border-light: #e2e8f0;
}
body {
font-family: 'Poppins', sans-serif;
background: var(--background-light);
color: var(--text-light);
padding: 20px;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
padding-bottom: 15px; /* Add padding below header */
margin-bottom: 25px; /* Space below header */
border-bottom: 1px solid var(--border-light);
}
.header-logo {
width: 50px; /* Slightly smaller logo */
height: 50px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
margin-right: 15px;
}
h1, h2, h3 {
font-weight: 600;
margin: 0; /* Reset default margins for headings */
}
h1 { font-size: 1.8rem; margin-bottom: 20px; }
h2 { font-size: 1.5rem; margin-bottom: 15px; margin-top: 30px; } /* Add top margin to section titles */
h3 { font-size: 1.2rem; margin-bottom: 10px; }
.section {
background: var(--card-bg-light);
padding: 25px;
border-radius: 15px;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
margin-bottom: 30px;
}
form {
margin-bottom: 0; /* Remove default form margin if inside a section */
}
label {
font-weight: 500;
margin-top: 15px;
margin-bottom: 5px; /* Space below label */
display: block;
font-size: 0.95rem;
}
input[type="text"],
input[type="number"],
textarea,
select {
width: 100%;
padding: 10px 12px; /* Adjusted padding */
border: 1px solid var(--input-border-light);
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
background-color: var(--input-bg-light);
color: var(--text-light);
box-sizing: border-box; /* Include padding and border in width */
}
input:focus, textarea:focus, select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
outline: none;
}
textarea {
min-height: 80px; /* Minimum height for textarea */
resize: vertical; /* Allow vertical resizing */
}
button {
padding: 10px 18px; /* Adjusted padding */
border: none;
border-radius: 8px;
background-color: var(--primary-color);
color: white;
font-weight: 500;
font-size: 0.95rem; /* Slightly smaller font */
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-top: 15px;
margin-right: 10px; /* Space between inline buttons */
}
button:hover {
background-color: var(--primary-darker);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
.button-danger { background-color: var(--danger-color); }
.button-danger:hover { background-color: var(--danger-darker); }
.button-secondary { background-color: var(--secondary-color); }
.button-secondary:hover { background-color: var(--secondary-darker); }
.list-container { /* Grid for categories/products */
display: grid;
gap: 20px;
/* Responsive grid */
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.list-item {
background: var(--card-bg-light);
padding: 20px;
border-radius: 10px; /* Slightly smaller radius */
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
border: 1px solid var(--border-light); /* Subtle border */
display: flex;
flex-direction: column; /* Stack content vertically */
}
.list-item h3 { /* Name in list item */
margin-bottom: 15px;
word-break: break-word; /* Prevent long names from overflowing */
}
.list-item p {
font-size: 0.9rem;
margin-bottom: 8px;
color: #555;
}
body.dark-mode .list-item p { color: #a0aec0; }
.list-item p strong { font-weight: 500; color: var(--text-light); }
body.dark-mode .list-item p strong { color: #e2e8f0; }
.item-actions { /* Container for buttons at the bottom of item */
margin-top: auto; /* Push actions to the bottom */
padding-top: 15px; /* Space above actions */
border-top: 1px solid var(--border-light);
display: flex;
justify-content: flex-end; /* Align buttons right */
gap: 10px;
}
.item-actions form { /* Forms used for delete/edit buttons */
display: inline-block;
margin: 0;
}
.item-actions button {
margin: 0; /* Reset margin for buttons inside actions */
}
details { /* For edit form */
margin-top: 15px;
}
summary {
cursor: pointer;
font-weight: 500;
color: var(--primary-color);
padding: 8px 0;
display: inline-block; /* Prevent full width */
}
.edit-form {
margin-top: 10px;
padding: 20px;
background: #f7fafc; /* Light background for edit area */
border: 1px solid var(--border-light);
border-radius: 10px;
}
body.dark-mode .edit-form { background: #2d3748; }
.input-group { /* For adding multiple colors/sizes/patterns */
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.input-group input {
flex-grow: 1; /* Input takes available space */
margin: 0; /* Reset margin */
}
.input-group button {
padding: 5px 10px; /* Smaller remove button */
font-size: 0.8rem;
margin: 0; /* Reset margin */
flex-shrink: 0; /* Prevent button from shrinking */
}
.add-item-btn { /* Button to add a new input field */
margin-top: 5px; /* Space above add button */
margin-bottom: 15px; /* Space below add button */
display: inline-block; /* Don't take full width */
}
/* Image previews */
.image-previews {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
margin-bottom: 15px;
}
.image-preview {
position: relative;
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-light);
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-preview .remove-img-btn { /* Button to remove existing image (JS needed) */
position: absolute;
top: 2px;
right: 2px;
background: rgba(239, 68, 68, 0.8); /* Semi-transparent red */
color: white;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.image-preview .remove-img-btn:hover {
background: var(--danger-color);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="''' + LOGO_URL + '''" alt="Логотип" class="header-logo">
<h1>Админ-панель</h1>
<!-- Maybe add a link back to the main catalog -->
<a href="/" target="_blank" style="margin-left: auto; text-decoration: none;" title="Открыть каталог">
<button type="button"><i class="fas fa-store"></i> Каталог</button>
</a>
</div>
<!-- Add Product Section -->
<div class="section">
<h2>Добавить новый товар</h2>
<form method="POST" enctype="multipart/form-data" id="addProductForm">
<input type="hidden" name="action" value="add">
<div>
<label for="addName">Название товара:</label>
<input type="text" id="addName" name="name" required>
</div>
<div>
<label for="addPrice">Цена (₸):</label>
<input type="number" id="addPrice" name="price" step="0.01" min="0" required>
</div>
<div>
<label for="addCategory">Категория:</label>
<select id="addCategory" name="category">
<option value="Без категории">Без категории</option>
{% for category in categories %}
<option value="{{ category|e }}">{{ category|e }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="addDescription">Описание:</label>
<textarea id="addDescription" name="description" rows="4" required></textarea>
</div>
<div>
<label for="addPhotos">Фотографии (можно выбрать несколько, до 10):</label>
<input type="file" id="addPhotos" name="photos" accept="image/*" multiple>
</div>
<!-- Dynamic Inputs for Colors, Sizes, Patterns -->
<div id="add-options-container">
<label>Цвета:</label>
<div id="add-color-inputs">
<!-- JS will add inputs here -->
</div>
<button type="button" class="button-secondary add-item-btn" onclick="addOptionInput('add-color-inputs', 'colors', 'Цвет')">
<i class="fas fa-plus"></i> Добавить цвет
</button>
<label>Размеры:</label>
<div id="add-size-inputs"></div>
<button type="button" class="button-secondary add-item-btn" onclick="addOptionInput('add-size-inputs', 'sizes', 'Размер')">
<i class="fas fa-plus"></i> Добавить размер
</button>
<label>Узоры:</label>
<div id="add-pattern-inputs"></div>
<button type="button" class="button-secondary add-item-btn" onclick="addOptionInput('add-pattern-inputs', 'patterns', 'Узор')">
<i class="fas fa-plus"></i> Добавить узор
</button>
</div>
<button type="submit">Добавить товар</button>
</form>
</div>
<!-- Manage Categories Section -->
<div class="section">
<h2>Управление категориями</h2>
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<!-- Add Category Form -->
<div style="flex: 1; min-width: 250px;">
<h3>Добавить категорию</h3>
<form method="POST" id="addCategoryForm" style="display: flex; align-items: flex-end; gap: 10px;">
<input type="hidden" name="action" value="add_category">
<div style="flex-grow: 1;">
<label for="category_name" style="margin-top: 0;">Название новой категории:</label>
<input type="text" id="category_name" name="category_name" required>
</div>
<button type="submit" style="margin-top: 0; margin-bottom: 0;">Добавить</button>
</form>
</div>
<!-- List and Delete Categories -->
<div style="flex: 1; min-width: 250px;">
<h3>Существующие категории</h3>
{% if categories %}
<ul style="list-style: none; padding: 0; max-height: 200px; overflow-y: auto;">
{% for category in categories %}
<li style="display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border-light);">
<span>{{ category|e }}</span>
<form method="POST" style="display: inline; margin:0;">
<input type="hidden" name="action" value="delete_category">
<input type="hidden" name="category_name" value="{{ category|e }}">
<button type="submit" class="button-danger" style="padding: 4px 8px; font-size: 0.8rem; margin:0;" title="Удалить категорию {{ category|e }}">
<i class="fas fa-trash-alt"></i>
</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p>Нет созданных категорий.</p>
{% endif %}
</div>
</div>
</div>
<!-- Database Management Section -->
<div class="section">
<h2>Управление базой данных</h2>
<p>Резервное копирование JSON файла базы данных на Hugging Face.</p>
<form method="POST" action="{{ url_for('backup') }}" style="display: inline;">
<button type="submit"><i class="fas fa-cloud-upload-alt"></i> Загрузить резервную копию</button>
</form>
<form method="GET" action="{{ url_for('download') }}" style="display: inline;">
<button type="submit"><i class="fas fa-cloud-download-alt"></i> Скачать базу с HF</button>
</form>
<a href="/admin/download_local_db" style="display: inline;">
<button type="button"><i class="fas fa-download"></i> Скачать локальную базу</button>
</a>
</div>
<!-- Product List Section -->
<div class="section">
<h2>Список товаров</h2>
<div class="list-container" id="productList">
{% for product in products %}
<div class="list-item product-item">
<h3>{{ product['name']|e }}</h3>
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории')|e }}</p>
<p><strong>Цена:</strong> {{ product['price'] }} ₸</p>
<p><strong>Описание:</strong> {{ product['description'][:100]|e }}{% if product['description']|length > 100 %}...{% endif %}</p>
<p><strong>Цвета:</strong> {{ (product.get('colors')|join(', '))|e if product.get('colors') else 'Нет' }}</p>
<p><strong>Размеры:</strong> {{ (product.get('sizes')|join(', '))|e if product.get('sizes') else 'Нет' }}</p>
<p><strong>Узоры:</strong> {{ (product.get('patterns')|join(', '))|e if product.get('patterns') else 'Нет' }}</p>
{% if product.get('photos') %}
<div class="image-previews">
{% for photo in product['photos'] %}
<div class="image-preview">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo|e }}"
alt="Фото {{ product['name']|e }}"
onerror="this.style.display='none'; this.parentElement.style.border='1px dashed red';" >
<!-- Add remove button here if implementing individual photo removal -->
</div>
{% endfor %}
</div>
{% endif %}
<div class="item-actions">
<details>
<summary><i class="fas fa-edit"></i> Редактировать</summary>
<form method="POST" enctype="multipart/form-data" class="edit-form">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="index" value="{{ loop.index0 }}">
<div>
<label>Название:</label>
<input type="text" name="name" value="{{ product['name']|e }}" required>
</div>
<div>
<label>Цена (₸):</label>
<input type="number" name="price" step="0.01" min="0" value="{{ product['price'] }}" required>
</div>
<div>
<label>Категория:</label>
<select name="category">
<option value="Без категории" {% if product.get('category', 'Без категории') == 'Без категории' %}selected{% endif %}>Без категории</option>
{% for cat in categories %}
<option value="{{ cat|e }}" {% if product.get('category') == cat %}selected{% endif %}>{{ cat|e }}</option>
{% endfor %}
</select>
</div>
<div>
<label>Описание:</label>
<textarea name="description" rows="4" required>{{ product['description']|e }}</textarea>
</div>
<div>
<label>Фотографии (выберите новые, чтобы заменить старые):</label>
<input type="file" name="photos" accept="image/*" multiple>
<small>Если файлы не выбраны, старые фото останутся.</small>
<!-- Add display of current photos here if needed -->
</div>
<!-- Dynamic Edit Options -->
<div id="edit-options-container-{{ loop.index0 }}">
<label>Цвета:</label>
<div id="edit-color-inputs-{{ loop.index0 }}">
{% for color in product.get('colors', []) %}
<div class="input-group">
<input type="text" name="colors" value="{{ color|e }}">
<button type="button" class="button-danger" onclick="removeOptionInput(this)" title="Удалить цвет"><i class="fas fa-times"></i></button>
</div>
{% endfor %}
</div>
<button type="button" class="button-secondary add-item-btn" onclick="addOptionInput('edit-color-inputs-{{ loop.index0 }}', 'colors', 'Цвет')"><i class="fas fa-plus"></i> Добавить цвет</button>
<label>Размеры:</label>
<div id="edit-size-inputs-{{ loop.index0 }}">
{% for size in product.get('sizes', []) %}
<div class="input-group">
<input type="text" name="sizes" value="{{ size|e }}">
<button type="button" class="button-danger" onclick="removeOptionInput(this)" title="Удалить размер"><i class="fas fa-times"></i></button>
</div>
{% endfor %}
</div>
<button type="button" class="button-secondary add-item-btn" onclick="addOptionInput('edit-size-inputs-{{ loop.index0 }}', 'sizes', 'Размер')"><i class="fas fa-plus"></i> Добавить размер</button>
<label>Узоры:</label>
<div id="edit-pattern-inputs-{{ loop.index0 }}">
{% for pattern in product.get('patterns', []) %}
<div class="input-group">
<input type="text" name="patterns" value="{{ pattern|e }}">
<button type="button" class="button-danger" onclick="removeOptionInput(this)" title="Удалить узор"><i class="fas fa-times"></i></button>
</div>
{% endfor %}
</div>
<button type="button" class="button-secondary add-item-btn" onclick="addOptionInput('edit-pattern-inputs-{{ loop.index0 }}', 'patterns', 'Узор')"><i class="fas fa-plus"></i> Добавить узор</button>
</div>
<button type="submit">Сохранить изменения</button>
</form>
</details>
<form method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить этот товар?');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="index" value="{{ loop.index0 }}">
<button type="submit" class="button-danger" title="Удалить товар"><i class="fas fa-trash-alt"></i> Удалить</button>
</form>
</div>
</div>
{% else %}
<p>Нет товаров для отображения. Добавьте новый товар, используя форму выше.</p>
{% endfor %}
</div>
</div>
</div> <!-- end container -->
<script>
function addOptionInput(containerId, inputName, placeholderText) {
const container = document.getElementById(containerId);
if (!container) {
console.error("Container not found:", containerId);
return;
}
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group';
const input = document.createElement('input');
input.type = 'text';
input.name = inputName;
input.placeholder = placeholderText + ' ' + (container.children.length + 1);
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'button-danger';
removeButton.innerHTML = '<i class="fas fa-times"></i>'; // Use icon
removeButton.title = 'Удалить ' + placeholderText.toLowerCase();
removeButton.onclick = function() { removeOptionInput(this); };
inputGroup.appendChild(input);
inputGroup.appendChild(removeButton);
container.appendChild(inputGroup);
input.focus(); // Focus the newly added input
}
function removeOptionInput(button) {
// Find the parent '.input-group' and remove it
const inputGroup = button.closest('.input-group');
if (inputGroup) {
inputGroup.remove();
} else {
console.error("Could not find parent '.input-group' for remove button.");
}
}
// Initialize existing edit forms with at least one input if empty
document.addEventListener('DOMContentLoaded', () => {
// Add initial empty input fields for 'Add Product' form
addOptionInput('add-color-inputs', 'colors', 'Цвет');
addOptionInput('add-size-inputs', 'sizes', 'Размер');
addOptionInput('add-pattern-inputs', 'patterns', 'Узор');
// Ensure edit forms also have at least one input field if needed
const editForms = document.querySelectorAll('.edit-form');
editForms.forEach(form => {
const index = form.querySelector('input[name="index"]').value;
checkAndAddInitialInput(`edit-color-inputs-${index}`, 'colors', 'Цвет');
checkAndAddInitialInput(`edit-size-inputs-${index}`, 'sizes', 'Размер');
checkAndAddInitialInput(`edit-pattern-inputs-${index}`, 'patterns', 'Узор');
});
});
function checkAndAddInitialInput(containerId, inputName, placeholder){
const container = document.getElementById(containerId);
if(container && container.children.length === 0){
addOptionInput(containerId, inputName, placeholder);
}
}
</script>
</body>
</html>
'''
# Pass categories for the dropdowns in add/edit forms
return render_template_string(admin_html, products=products, categories=categories, repo_id=REPO_ID)
# Route to download the local database file
@app.route('/admin/download_local_db')
def download_local_db():
# Add authentication/authorization check here if needed
try:
# Ensure DATA_FILE is within the application directory or a safe path
# For simplicity, assuming it's in the current directory
safe_directory = os.path.abspath(".") # Current directory
return send_from_directory(directory=safe_directory, path=DATA_FILE, as_attachment=True)
except FileNotFoundError:
logging.error(f"Attempted to download local DB, but {DATA_FILE} not found.")
return "Локальный файл базы данных не найден.", 404
except Exception as e:
logging.error(f"Error during local DB download: {e}")
return "Произошла ошибка при скачивании файла.", 500
@app.route('/backup', methods=['POST'])
def backup():
# Add authentication/authorization check here if needed
logging.info("Manual backup requested.")
try:
upload_db_to_hf()
# Add a success message (e.g., using Flask flash messages)
# flash("Резервная копия успешно загружена на Hugging Face.", "success")
return redirect(url_for('admin')) # Redirect back to admin page
except Exception as e:
logging.error(f"Manual backup failed: {e}")
# Add an error message
# flash(f"Ошибка при создании резервной копии: {e}", "error")
return f"Ошибка при создании резервной копии: {e}", 500
@app.route('/download', methods=['GET']) # Changed to GET as it's retrieving data
def download():
# Add authentication/authorization check here if needed
logging.info("Manual download from HF requested.")
try:
download_db_from_hf()
# Force reload data in the current instance if necessary,
# or just inform the user they might need to restart/refresh.
# flash("База данных успешно скачана с Hugging Face. Данные обновлены.", "success")
return redirect(url_for('admin')) # Redirect back to admin page
except RepositoryNotFoundError:
# flash("Ошибка: Репозиторий Hugging Face не найден.", "error")
return "Ошибка: Репозиторий Hugging Face не найден.", 404
except Exception as e:
logging.error(f"Manual download from HF failed: {e}")
# flash(f"Ошибка при скачивании базы данных: {e}", "error")
return f"Ошибка при скачивании базы данных: {e}", 500
if __name__ == '__main__':
# Start the periodic backup in a separate thread
# Ensure the thread stops when the main app exits using daemon=True
if HF_TOKEN_WRITE: # Only start if token is available
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
else:
logging.warning("Periodic backup thread not started: HF_TOKEN_WRITE is not set.")
# Attempt initial data load (downloads from HF if local file missing and configured)
try:
load_data()
except Exception as e:
# Log error but allow the app to start, potentially with empty data
logging.critical(f"Failed initial data load: {e}. The application might not function correctly.")
# Run the Flask app
# Use waitress or gunicorn for production instead of Flask's built-in server
port = int(os.environ.get("PORT", 7860)) # Use PORT environment variable if available
debug_mode = os.environ.get("FLASK_DEBUG", "false").lower() == "true" # Enable debug via env var
logging.info(f"Starting Flask app on 0.0.0.0:{port} with debug={debug_mode}")
app.run(debug=debug_mode, host='0.0.0.0', port=port)