|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
def load_data(): |
|
|
try: |
|
|
|
|
|
if not os.path.exists(DATA_FILE): |
|
|
logging.info(f"{DATA_FILE} not found locally, attempting download from HF.") |
|
|
download_db_from_hf() |
|
|
|
|
|
|
|
|
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.") |
|
|
|
|
|
if isinstance(data, list): |
|
|
return {'products': data, 'categories': []} |
|
|
else: |
|
|
return {'products': [], 'categories': []} |
|
|
|
|
|
if not isinstance(data.get('products'), list): |
|
|
data['products'] = [] |
|
|
if not isinstance(data.get('categories'), list): |
|
|
data['categories'] = [] |
|
|
return data |
|
|
except FileNotFoundError: |
|
|
|
|
|
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.") |
|
|
|
|
|
try: |
|
|
os.rename(DATA_FILE, f"{DATA_FILE}.corrupted_{datetime.now().strftime('%Y%m%d%H%M%S')}") |
|
|
logging.info("Renamed corrupted data file.") |
|
|
|
|
|
download_db_from_hf() |
|
|
|
|
|
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: |
|
|
|
|
|
with open(temp_file, 'w', encoding='utf-8') as file: |
|
|
json.dump(data, file, ensure_ascii=False, indent=4) |
|
|
|
|
|
os.replace(temp_file, DATA_FILE) |
|
|
logging.info("Data successfully saved to JSON") |
|
|
|
|
|
|
|
|
threading.Thread(target=upload_db_to_hf).start() |
|
|
except Exception as e: |
|
|
logging.error(f"Error saving data: {e}") |
|
|
|
|
|
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 |
|
|
|
|
|
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.") |
|
|
|
|
|
|
|
|
return |
|
|
try: |
|
|
hf_hub_download( |
|
|
repo_id=REPO_ID, |
|
|
filename=DATA_FILE, |
|
|
repo_type="dataset", |
|
|
token=HF_TOKEN_READ, |
|
|
local_dir=".", |
|
|
local_dir_use_symlinks=False, |
|
|
force_download=True |
|
|
) |
|
|
logging.info("JSON database successfully downloaded from Hugging Face.") |
|
|
except RepositoryNotFoundError: |
|
|
logging.error(f"Repository '{REPO_ID}' not found on Hugging Face.") |
|
|
raise |
|
|
except Exception as e: |
|
|
logging.error(f"Error downloading JSON database from Hugging Face: {e}") |
|
|
raise |
|
|
|
|
|
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) |
|
|
logging.info("Initiating periodic backup...") |
|
|
try: |
|
|
|
|
|
|
|
|
upload_db_to_hf() |
|
|
except Exception as e: |
|
|
logging.error(f"Error during periodic backup: {e}") |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/') |
|
|
def catalog(): |
|
|
data = load_data() |
|
|
products = data.get('products', []) |
|
|
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">×</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">×</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">×</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} ₸ × ${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, "&") |
|
|
.replace(/</g, "<") |
|
|
.replace(/>/g, ">") |
|
|
.replace(/"/g, """) |
|
|
.replace(/'/g, "'"); |
|
|
} |
|
|
|
|
|
</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): |
|
|
|
|
|
data = load_data() |
|
|
products = data.get('products', []) |
|
|
if index < 0 or index >= len(products): |
|
|
return "<p>Продукт не найден.</p>", 404 |
|
|
|
|
|
product = products[index] |
|
|
|
|
|
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> |
|
|
''' |
|
|
|
|
|
return render_template_string(detail_html, product=product, repo_id=REPO_ID) |
|
|
|
|
|
|
|
|
|
|
|
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}", |
|
|
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: |
|
|
|
|
|
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(): |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
data = load_data() |
|
|
|
|
|
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() |
|
|
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) |
|
|
|
|
|
updated_products = [] |
|
|
for product in products: |
|
|
if product.get('category') == category_to_delete: |
|
|
product['category'] = 'Без категории' |
|
|
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': |
|
|
|
|
|
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', 'Без категории') |
|
|
|
|
|
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: |
|
|
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: |
|
|
|
|
|
original_filename = secure_filename(photo.filename) |
|
|
|
|
|
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}") |
|
|
|
|
|
|
|
|
if upload_photo_to_hf(temp_path, unique_filename, name): |
|
|
uploaded_photo_filenames.append(unique_filename) |
|
|
else: |
|
|
|
|
|
logging.warning(f"Failed to upload photo {unique_filename} to HF. It will not be added to the product.") |
|
|
|
|
|
|
|
|
except Exception as e_save: |
|
|
logging.error(f"Error saving uploaded file {original_filename}: {e_save}") |
|
|
|
|
|
if os.path.exists(temp_path): os.remove(temp_path) |
|
|
|
|
|
|
|
|
if action == 'add': |
|
|
new_product = { |
|
|
'name': name, |
|
|
'price': price, |
|
|
'description': description, |
|
|
'category': category if category in categories else 'Без категории', |
|
|
'photos': uploaded_photo_filenames, |
|
|
'colors': list(set(colors)), |
|
|
'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 |
|
|
|
|
|
|
|
|
existing_photos = products[index].get('photos', []) |
|
|
|
|
|
products[index]['photos'] = uploaded_photo_filenames if uploaded_photo_filenames else existing_photos |
|
|
|
|
|
|
|
|
products[index].update({ |
|
|
'name': name, |
|
|
'price': price, |
|
|
'description': description, |
|
|
'category': category if category in categories else 'Без категории', |
|
|
'colors': list(set(colors)), |
|
|
'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 |
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
logging.exception(f"An error occurred in admin POST action '{action}': {e}") |
|
|
|
|
|
return f"Произошла внутренняя ошибка сервера при обработке действия '{action}'. Подробности см. в логах сервера.", 500 |
|
|
|
|
|
|
|
|
|
|
|
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> |
|
|
''' |
|
|
|
|
|
return render_template_string(admin_html, products=products, categories=categories, repo_id=REPO_ID) |
|
|
|
|
|
|
|
|
@app.route('/admin/download_local_db') |
|
|
def download_local_db(): |
|
|
|
|
|
try: |
|
|
|
|
|
|
|
|
safe_directory = os.path.abspath(".") |
|
|
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(): |
|
|
|
|
|
logging.info("Manual backup requested.") |
|
|
try: |
|
|
upload_db_to_hf() |
|
|
|
|
|
|
|
|
return redirect(url_for('admin')) |
|
|
except Exception as e: |
|
|
logging.error(f"Manual backup failed: {e}") |
|
|
|
|
|
|
|
|
return f"Ошибка при создании резервной копии: {e}", 500 |
|
|
|
|
|
|
|
|
@app.route('/download', methods=['GET']) |
|
|
def download(): |
|
|
|
|
|
logging.info("Manual download from HF requested.") |
|
|
try: |
|
|
download_db_from_hf() |
|
|
|
|
|
|
|
|
|
|
|
return redirect(url_for('admin')) |
|
|
except RepositoryNotFoundError: |
|
|
|
|
|
return "Ошибка: Репозиторий Hugging Face не найден.", 404 |
|
|
except Exception as e: |
|
|
logging.error(f"Manual download from HF failed: {e}") |
|
|
|
|
|
return f"Ошибка при скачивании базы данных: {e}", 500 |
|
|
|
|
|
|
|
|
if __name__ == '__main__': |
|
|
|
|
|
|
|
|
if HF_TOKEN_WRITE: |
|
|
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.") |
|
|
|
|
|
|
|
|
try: |
|
|
load_data() |
|
|
except Exception as e: |
|
|
|
|
|
logging.critical(f"Failed initial data load: {e}. The application might not function correctly.") |
|
|
|
|
|
|
|
|
|
|
|
port = int(os.environ.get("PORT", 7860)) |
|
|
debug_mode = os.environ.get("FLASK_DEBUG", "false").lower() == "true" |
|
|
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) |
|
|
|
|
|
|
|
|
|