Spaces:
Sleeping
Sleeping
| from flask import Flask, render_template_string, request, redirect, url_for, flash | |
| 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, HfHubHTTPError | |
| from werkzeug.utils import secure_filename | |
| from dotenv import load_dotenv | |
| import requests | |
| import io | |
| load_dotenv() | |
| app = Flask(__name__) | |
| app.secret_key = 'raina_hvac_secret_key_v2_projects_dynamic' | |
| DATA_FILE = 'data.json' | |
| SYNC_FILES = [DATA_FILE] | |
| REPO_ID = "Kgshop/raina" | |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") | |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") | |
| CONTACT_PHONE = "+996 773 901 313" | |
| WHATSAPP_PHONE = "996773901313" | |
| DOWNLOAD_RETRIES = 3 | |
| DOWNLOAD_DELAY = 5 | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | |
| def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY): | |
| if not HF_TOKEN_READ and not HF_TOKEN_WRITE: | |
| logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.") | |
| token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE | |
| files_to_download = [specific_file] if specific_file else SYNC_FILES | |
| logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...") | |
| all_successful = True | |
| for file_name in files_to_download: | |
| success = False | |
| for attempt in range(retries + 1): | |
| try: | |
| logging.info(f"Downloading {file_name} (Attempt {attempt + 1}/{retries + 1})...") | |
| hf_hub_download( | |
| repo_id=REPO_ID, filename=file_name, repo_type="dataset", token=token_to_use, | |
| local_dir=".", local_dir_use_symlinks=False, force_download=True, resume_download=False | |
| ) | |
| logging.info(f"Successfully downloaded {file_name}.") | |
| success = True | |
| break | |
| except RepositoryNotFoundError: | |
| logging.error(f"Repository {REPO_ID} not found. Download cancelled.") | |
| return False | |
| except HfHubHTTPError as e: | |
| if e.response.status_code == 404: | |
| logging.warning(f"File {file_name} not found in repo {REPO_ID}. Creating empty local file.") | |
| if not os.path.exists(file_name): | |
| try: | |
| with open(file_name, 'w', encoding='utf-8') as f: | |
| json.dump({'equipment': [], 'categories': [], 'services': [], 'projects': [], 'settings': {'prices_enabled': True}}, f) | |
| except Exception as create_e: | |
| logging.error(f"Failed to create empty local file {file_name}: {create_e}") | |
| success = True | |
| break | |
| else: | |
| logging.error(f"HTTP error downloading {file_name}: {e}. Retrying...") | |
| except Exception as e: | |
| logging.error(f"Unexpected error downloading {file_name}: {e}. Retrying...", exc_info=True) | |
| if attempt < retries: | |
| time.sleep(delay) | |
| if not success: | |
| logging.error(f"Failed to download {file_name} after {retries + 1} attempts.") | |
| all_successful = False | |
| return all_successful | |
| def upload_db_to_hf(specific_file=None): | |
| if not HF_TOKEN_WRITE: | |
| logging.warning("HF_TOKEN (for writing) not set. Skipping upload.") | |
| return | |
| try: | |
| api = HfApi() | |
| files_to_upload = [specific_file] if specific_file else SYNC_FILES | |
| logging.info(f"Starting upload of {files_to_upload} to {REPO_ID}...") | |
| for file_name in files_to_upload: | |
| if os.path.exists(file_name): | |
| api.upload_file( | |
| path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID, | |
| repo_type="dataset", token=HF_TOKEN_WRITE, | |
| commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" | |
| ) | |
| logging.info(f"File {file_name} successfully uploaded.") | |
| else: | |
| logging.warning(f"File {file_name} not found locally, skipping upload.") | |
| except Exception as e: | |
| logging.error(f"Error during Hugging Face upload: {e}", exc_info=True) | |
| def periodic_backup(): | |
| backup_interval = 1800 | |
| while True: | |
| time.sleep(backup_interval) | |
| logging.info("Starting periodic backup...") | |
| upload_db_to_hf() | |
| logging.info("Periodic backup finished.") | |
| def load_data(): | |
| default_data = {'equipment': [], 'categories': [], 'services': [], 'projects': [], 'settings': {'prices_enabled': True}} | |
| try: | |
| with open(DATA_FILE, 'r', encoding='utf-8') as file: | |
| data = json.load(file) | |
| if not isinstance(data, dict): | |
| raise ValueError("Data is not a dictionary") | |
| if 'equipment' not in data: data['equipment'] = [] | |
| if 'categories' not in data: data['categories'] = [] | |
| if 'services' not in data: data['services'] = [] | |
| if 'projects' not in data: data['projects'] = [] | |
| if 'settings' not in data: data['settings'] = {'prices_enabled': True} | |
| if 'prices_enabled' not in data['settings']: data['settings']['prices_enabled'] = True | |
| return data | |
| except (FileNotFoundError, json.JSONDecodeError, ValueError): | |
| logging.warning(f"Local file {DATA_FILE} not found or corrupt. Attempting download.") | |
| if download_db_from_hf(specific_file=DATA_FILE): | |
| return load_data() | |
| return default_data | |
| def save_data(data): | |
| try: | |
| if not isinstance(data, dict): | |
| logging.error("Attempted to save invalid data structure. Aborting.") | |
| return | |
| with open(DATA_FILE, 'w', encoding='utf-8') as file: | |
| json.dump(data, file, ensure_ascii=False, indent=4) | |
| logging.info(f"Data saved to {DATA_FILE}") | |
| upload_db_to_hf(specific_file=DATA_FILE) | |
| except Exception as e: | |
| logging.error(f"Error saving data: {e}", exc_info=True) | |
| LANDING_TEMPLATE = ''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Раина Климат Систем - Вентиляция и Кондиционирование</title> | |
| <meta name="description" content="Профессиональные услуги по проектированию, монтажу и обслуживанию систем вентиляции и кондиционирования в Кыргызстане."> | |
| <meta name="google-site-verification" content="V6EqDTA9Oj9V1OfNbnBHj5RKrdcXlABD8tqfEFVUHJY" /> | |
| <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=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg-dark: #111827; | |
| --bg-medium: #1F2937; | |
| --bg-light-card: #374151; | |
| --text-primary: #F3F4F6; | |
| --text-secondary: #D1D5DB; | |
| --text-muted: #9CA3AF; | |
| --accent-primary: #8B5CF6; | |
| --accent-secondary: #6D28D9; | |
| --accent-glow: rgba(139, 92, 246, 0.25); | |
| --border-color: #374151; | |
| --border-hover-color: var(--accent-primary); | |
| --section-padding: clamp(4rem, 10vw, 6rem); | |
| --card-border-radius: 12px; | |
| --button-border-radius: 30px; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; scroll-behavior: smooth; } | |
| body { | |
| font-family: 'Montserrat', sans-serif; | |
| background-color: var(--bg-dark); | |
| color: var(--text-primary); | |
| line-height: 1.7; | |
| font-size: 16px; | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| } | |
| .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; } | |
| section { padding: var(--section-padding) 0; overflow: hidden; } | |
| h1, h2, h3 { font-weight: 700; color: var(--text-primary); line-height: 1.3; } | |
| h1 { font-size: clamp(2.5rem, 7vw, 4.5rem); text-shadow: 0 2px 10px rgba(0,0,0,0.3); } | |
| h2 { | |
| font-size: clamp(2rem, 5vw, 3.2rem); | |
| text-align: center; | |
| margin-bottom: 70px; | |
| position: relative; | |
| } | |
| h2::after { | |
| content: ''; display: block; width: 70px; height: 5px; | |
| background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); | |
| margin: 20px auto 0; | |
| border-radius: 3px; | |
| } | |
| h3 { font-size: clamp(1.3rem, 3.5vw, 1.8rem); color: var(--accent-primary); margin-bottom: 15px; } | |
| p { margin-bottom: 1.2rem; color: var(--text-secondary); font-size: 1.05rem; } | |
| .btn { | |
| display: inline-block; | |
| padding: 14px 32px; | |
| background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); | |
| color: #fff; | |
| border-radius: var(--button-border-radius); | |
| text-decoration: none; | |
| font-weight: 600; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 5px 20px var(--accent-glow); | |
| border: none; | |
| cursor: pointer; | |
| } | |
| .btn:hover { | |
| transform: translateY(-4px) scale(1.03); | |
| box-shadow: 0 10px 30px var(--accent-glow); | |
| background: linear-gradient(90deg, var(--accent-secondary), var(--accent-primary)); | |
| } | |
| .header { | |
| position: fixed; top: 0; left: 0; width: 100%; z-index: 1000; | |
| padding: 20px 0; | |
| background-color: transparent; | |
| transition: all 0.35s ease-out; | |
| } | |
| .header.scrolled { | |
| padding: 15px 0; | |
| background-color: rgba(17, 24, 39, 0.85); | |
| backdrop-filter: blur(12px); | |
| box-shadow: 0 3px 15px rgba(0,0,0,0.2); | |
| } | |
| .navbar { display: flex; justify-content: space-between; align-items: center; } | |
| .logo { font-size: clamp(1.6rem, 4vw, 2rem); font-weight: 700; color: #fff; text-decoration: none; letter-spacing: -1px; } | |
| .nav-links { display: flex; gap: 35px; list-style: none; } | |
| .nav-links a { | |
| color: var(--text-primary); | |
| text-decoration: none; | |
| font-weight: 500; | |
| padding-bottom: 5px; | |
| position: relative; | |
| } | |
| .nav-links a::after { | |
| content: ''; | |
| position: absolute; | |
| width: 0; | |
| height: 2px; | |
| bottom: 0; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: var(--accent-primary); | |
| transition: width 0.3s ease; | |
| } | |
| .nav-links a:hover { color: var(--accent-primary); } | |
| .nav-links a:hover::after { width: 100%; } | |
| .menu-toggle { display: none; font-size: 1.8rem; cursor: pointer; border: none; background: none; color: white; } | |
| #hero { | |
| min-height: 100vh; | |
| display: flex; align-items: center; | |
| background-image: linear-gradient(rgba(17, 24, 39, 0.75), rgba(17, 24, 39, 1)), url(https://images.unsplash.com/photo-1558221639-2c7158995165?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1740&q=80); | |
| background-size: cover; | |
| background-position: center; | |
| background-attachment: fixed; | |
| } | |
| .hero-content { text-align: center; max-width: 850px; margin: 0 auto; } | |
| .hero-content p { font-size: clamp(1.1rem, 3vw, 1.3rem); margin: 35px 0; max-width: 650px; margin-left: auto; margin-right: auto; color: var(--text-secondary); } | |
| .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 70px; align-items: center; } | |
| .about-img { width: 100%; border-radius: var(--card-border-radius); box-shadow: 0 15px 40px rgba(0,0,0,0.5); } | |
| .services-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 35px; } | |
| .service-card, .turnkey-card { | |
| background-color: var(--bg-medium); | |
| padding: 35px; | |
| border-radius: var(--card-border-radius); | |
| border: 1px solid var(--border-color); | |
| transition: all 0.3s ease; | |
| box-shadow: 0 8px 25px rgba(0,0,0,0.15); | |
| } | |
| .service-card:hover, .turnkey-card:hover { | |
| transform: translateY(-8px); | |
| border-color: var(--border-hover-color); | |
| box-shadow: 0 12px 35px var(--accent-glow); | |
| } | |
| .service-card i { font-size: 3rem; color: var(--accent-primary); margin-bottom: 25px; display: block; } | |
| .turnkey-card { padding: 0; display: flex; flex-direction: column; overflow: hidden; } | |
| .turnkey-img { width: 100%; height: 220px; object-fit: cover; border-radius: var(--card-border-radius) var(--card-border-radius) 0 0; transition: transform 0.3s ease; } | |
| .turnkey-card:hover .turnkey-img { transform: scale(1.05); } | |
| .turnkey-content { padding: 35px; flex-grow: 1;} | |
| .turnkey-content h3 i { transition: color 0.3s ease; } | |
| .turnkey-card:hover .turnkey-content h3 i { color: var(--accent-secondary); } | |
| #turnkey { background-color: var(--bg-medium); } | |
| #contact { background-color: var(--bg-medium); } | |
| .equipment-filters { display: flex; justify-content: center; flex-wrap: wrap; gap: 20px; margin-bottom: 50px; } | |
| .filter-btn { | |
| padding: 10px 25px; | |
| border: 1px solid var(--accent-primary); | |
| background-color: transparent; | |
| color: var(--accent-primary); | |
| border-radius: var(--button-border-radius); | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| font-weight: 500; | |
| } | |
| .filter-btn.active, .filter-btn:hover { background-color: var(--accent-primary); color: #fff; } | |
| .equipment-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 35px; } | |
| .equipment-card { | |
| background-color: var(--bg-light-card); | |
| border-radius: var(--card-border-radius); | |
| overflow: hidden; | |
| text-align: center; | |
| padding: 25px; | |
| border: 1px solid var(--border-color); | |
| transition: all 0.3s ease; | |
| cursor: pointer; | |
| } | |
| .equipment-card:hover { | |
| transform: translateY(-8px); | |
| border-color: var(--border-hover-color); | |
| box-shadow: 0 10px 30px var(--accent-glow); | |
| } | |
| .equipment-card img { width: 100%; height: 200px; object-fit: contain; margin-bottom: 20px; transition: transform 0.3s ease; } | |
| .equipment-card:hover img { transform: scale(1.05); } | |
| .equipment-card h3 { font-size: 1.3rem; margin-bottom: 10px; color: var(--text-primary); } | |
| .equipment-card .price { font-size: 1.4rem; font-weight: 700; color: var(--accent-primary); margin: 15px 0; } | |
| .equipment-card .btn { margin-top: 10px; padding: 10px 22px; font-size: 0.95rem; } | |
| .projects-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 35px; } | |
| .project-card { | |
| position: relative; | |
| border-radius: var(--card-border-radius); | |
| overflow: hidden; | |
| min-height: 420px; | |
| cursor: pointer; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.3); | |
| transition: box-shadow 0.3s ease; | |
| } | |
| .project-card:hover { box-shadow: 0 15px 45px rgba(0,0,0,0.4); } | |
| .project-card img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); } | |
| .project-overlay { | |
| position: absolute; bottom: 0; left: 0; right: 0; | |
| background: linear-gradient(to top, rgba(17, 24, 39, 0.95) 10%, rgba(17, 24, 39, 0) 100%); | |
| padding: 50px 25px 25px; | |
| transition: background 0.4s ease; | |
| } | |
| .project-card h3 { margin-bottom: 8px; font-size: 1.5rem; color: #fff; } | |
| .project-card p { | |
| margin-bottom: 0; | |
| transition: opacity 0.4s ease, max-height 0.5s ease, transform 0.4s ease; | |
| opacity: 0; max-height: 0; | |
| overflow: hidden; | |
| transform: translateY(10px); | |
| color: var(--text-secondary); | |
| } | |
| .project-card:hover img { transform: scale(1.1); } | |
| .project-card:hover .project-overlay { background: linear-gradient(to top, rgba(17, 24, 39, 1) 40%, rgba(17, 24, 39, 0) 100%); } | |
| .project-card:hover p { opacity: 1; max-height: 200px; transform: translateY(0); } | |
| .contact-content { text-align: center; } | |
| .contact-content > p { max-width: 600px; margin-left: auto; margin-right: auto; } | |
| .contact-info { margin-top: 50px; display: flex; flex-direction: column; align-items: center; gap: 25px; } | |
| .contact-info p { font-size: 1.3rem; margin-bottom: 0; color: var(--text-primary); } | |
| .contact-info a { color: var(--accent-primary); text-decoration: none; font-weight: 600; transition: color 0.3s ease; } | |
| .contact-info a:hover { color: var(--accent-secondary); } | |
| .contact-info .btn { font-size: 1.1rem; color: #fff !important; } | |
| .contact-info .btn i { margin-right: 10px; } | |
| .footer { text-align: center; padding: 40px 0; background-color: #0c111d; border-top: 1px solid var(--border-color); } | |
| .footer p { color: var(--text-muted); font-size: 0.95rem; } | |
| @media (max-width: 992px) { | |
| .grid-2 { grid-template-columns: 1fr; text-align: center; } | |
| .about-img { margin-bottom: 40px; max-width: 500px; margin-left: auto; margin-right: auto;} | |
| } | |
| @media (max-width: 768px) { | |
| .nav-links { | |
| position: fixed; top: 0; right: -100%; | |
| width: min(80vw, 320px); height: 100vh; | |
| background-color: var(--bg-medium); | |
| flex-direction: column; justify-content: center; align-items: center; | |
| transition: right 0.45s cubic-bezier(0.68, -0.55, 0.27, 1.55); | |
| box-shadow: -8px 0 25px rgba(0,0,0,0.25); | |
| gap: 40px; | |
| } | |
| .nav-links.active { right: 0; } | |
| .menu-toggle { display: block; z-index: 1001; } | |
| h1 { font-size: 2.2rem; } | |
| h2 { margin-bottom: 50px; font-size: 1.8rem; } | |
| .projects-grid { grid-template-columns: 1fr; } | |
| .services-grid { grid-template-columns: 1fr; } | |
| .equipment-grid { grid-template-columns: 1fr; } | |
| .btn { padding: 12px 28px; } | |
| } | |
| .modal { | |
| display: none; | |
| position: fixed; | |
| z-index: 1001; | |
| left: 0; top: 0; width: 100%; height: 100%; | |
| overflow: auto; | |
| background-color: rgba(17, 24, 39, 0.9); | |
| padding-top: 5vh; | |
| backdrop-filter: blur(5px); | |
| } | |
| .modal-content { | |
| position: relative; | |
| margin: 5% auto; | |
| padding: 30px; | |
| width: 90%; | |
| max-width: 800px; | |
| background-color: var(--bg-medium); | |
| border-radius: var(--card-border-radius); | |
| text-align: center; | |
| box-shadow: 0 10px 40px rgba(0,0,0,0.5); | |
| border: 1px solid var(--border-color); | |
| } | |
| .modal-content img { | |
| max-width: 100%; | |
| max-height: 65vh; | |
| border-radius: 10px; | |
| margin-bottom: 25px; | |
| object-fit: contain; | |
| } | |
| .modal-content h3 { margin-bottom: 15px; font-size: 1.8rem; color: var(--accent-primary); } | |
| .modal-content p { color: var(--text-secondary); font-size: 1.1rem; line-height: 1.8; } | |
| .close-button { | |
| position: absolute; | |
| top: 20px; | |
| right: 25px; | |
| font-size: 2.8rem; | |
| font-weight: bold; | |
| color: var(--text-primary); | |
| cursor: pointer; | |
| background: none; | |
| border: none; | |
| transition: color 0.3s ease, transform 0.3s ease; | |
| } | |
| .close-button:hover, .close-button:focus { color: var(--accent-primary); transform: rotate(90deg); } | |
| .carousel-nav { margin-top: 25px; } | |
| .carousel-nav button { | |
| background-color: var(--accent-primary); | |
| color: white; | |
| border: none; | |
| padding: 12px 18px; | |
| border-radius: var(--button-border-radius); | |
| margin: 0 8px; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| font-size: 1.3rem; | |
| } | |
| .carousel-nav button:hover { background-color: var(--accent-secondary); transform: scale(1.1); } | |
| .carousel-nav button:disabled { background-color: var(--text-muted); cursor: not-allowed; transform: scale(1); } | |
| </style> | |
| </head> | |
| <body> | |
| <header class="header"> | |
| <div class="container navbar"> | |
| <a href="#" class="logo">Раина Климат Систем</a> | |
| <ul class="nav-links"> | |
| <li><a href="#about">О компании</a></li> | |
| <li><a href="#services">Услуги</a></li> | |
| <li><a href="#turnkey">Под ключ</a></li> | |
| <li><a href="#equipment">Оборудование</a></li> | |
| <li><a href="#contact">Контакты</a></li> | |
| </ul> | |
| <button class="menu-toggle" aria-label="Открыть меню"><i class="fas fa-bars"></i></button> | |
| </div> | |
| </header> | |
| <section id="hero"> | |
| <div class="container hero-content"> | |
| <h1>Раина Климат Систем: Профессиональные Климатические Решения</h1> | |
| <p>Мы предлагаем комплексный подход к созданию идеального микроклимата в ваших помещениях, обеспечивая высочайшее качество услуг и продукции.</p> | |
| <a href="#contact" class="btn">Получить консультацию</a> | |
| </div> | |
| </section> | |
| <section id="about"> | |
| <div class="container"> | |
| <h2>О Нашей Компании</h2> | |
| <div class="grid-2"> | |
| <img src="https://images.unsplash.com/photo-1542744173-8e7e53415bb0?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1740&q=80" alt="Команда Раина" class="about-img"> | |
| <div> | |
| <h3>Профессионализм и Экспертиза</h3> | |
| <p>Наша команда состоит из высококвалифицированных инженеров и техников, обладающих глубокими знаниями и опытом в области HVAC. Мы постоянно совершенствуем свои навыки и внедряем передовые технологии.</p> | |
| <h3>Наша Миссия</h3> | |
| <p>Мы стремимся создавать оптимальный микроклимат для каждого клиента, обеспечивая комфорт, здоровье и высокую производительность через надежные и энергоэффективные климатические системы.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section id="services"> | |
| <div class="container"> | |
| <h2>Наши Услуги</h2> | |
| <div class="services-grid"> | |
| <div class="service-card"><i class="fas fa-drafting-compass"></i><h3>Проектирование</h3><p>Точные расчеты, 3D-модели и вся необходимая проектная документация для ваших систем.</p></div> | |
| <div class="service-card"><i class="fas fa-tools"></i><h3>Монтаж</h3><p>Профессиональная установка всех типов систем вентиляции и кондиционирования, от бытовых до промышленных.</p></div> | |
| <div class="service-card"><i class="fas fa-headset"></i><h3>Сервис 24/7</h3><p>Плановое техническое обслуживание и оперативный аварийный ремонт для бесперебойной работы ваших систем.</p></div> | |
| <div class="service-card"><i class="fas fa-sync-alt"></i><h3>Модернизация</h3><p>Повышение энергоэффективности и снижение эксплуатационных расходов за счет оптимизации существующих систем.</p></div> | |
| </div> | |
| </div> | |
| </section> | |
| <section id="turnkey"> | |
| <div class="container"> | |
| <h2>Услуги "под ключ"</h2> | |
| {% if services %} | |
| <div class="services-grid"> | |
| {% for service in services %} | |
| <div class="turnkey-card" onclick="showDetailsModal('service', {{ loop.index0 }})"> | |
| {% if service.photo %} | |
| <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/services/{{ service.photo }}" alt="{{ service.title }}" class="turnkey-img"> | |
| {% endif %} | |
| <div class="turnkey-content"> | |
| <h3><i class="{{ service.icon }} fa-fw" style="margin-right: 10px;"></i>{{ service.title }}</h3> | |
| <p>{{ service.description }}</p> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| {% else %} | |
| <p style="text-align: center; color: var(--text-muted);">Информация об услугах "под ключ" скоро появится на сайте.</p> | |
| {% endif %} | |
| </div> | |
| </section> | |
| <section id="equipment"> | |
| <div class="container"> | |
| <h2>Наше Оборудование</h2> | |
| {% set prices_enabled = data.settings.prices_enabled %} | |
| {% if equipment %} | |
| <div class="equipment-filters"> | |
| <button class="filter-btn active" data-filter="all">Все</button> | |
| {% for category in categories %} | |
| <button class="filter-btn" data-filter="{{ category }}">{{ category }}</button> | |
| {% endfor %} | |
| </div> | |
| <div class="equipment-grid"> | |
| {% for item in equipment %} | |
| <div class="equipment-card" data-category="{{ item.get('category', 'all') }}" onclick="showDetailsModal('equipment', {{ loop.index0 }})"> | |
| {% if item.photo %} | |
| <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/equipment/{{ item.photo }}" alt="{{ item.name }}"> | |
| {% else %} | |
| <img src="https://via.placeholder.com/250x180.png?text=No+Image" alt="No Image" style="filter: grayscale(0.8) opacity(0.6);"> | |
| {% endif %} | |
| <h3>{{ item.name }}</h3> | |
| {% if prices_enabled and item.price > 0 %} | |
| <p class="price">{{ "%.2f"|format(item.price) }} KGS</p> | |
| <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте, интересует оборудование: {{ item.name|urlencode }}" target="_blank" class="btn">Запросить</a> | |
| {% else %} | |
| <p class="price">Уточнить цену</p> | |
| <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте, хочу узнать цену {{ item.name|urlencode }}" target="_blank" class="btn">Уточнить цену</a> | |
| {% endif %} | |
| </div> | |
| {% endfor %} | |
| </div> | |
| {% else %} | |
| <p style="text-align: center; color: var(--text-muted);">Каталог оборудования скоро будет доступен.</p> | |
| {% endif %} | |
| </div> | |
| </section> | |
| <section id="contact"> | |
| <div class="container contact-content"> | |
| <h2>Контакты</h2> | |
| <p>Свяжитесь с нами для профессиональной консультации и подбора оптимального климатического решения для вашего объекта.</p> | |
| <div class="contact-info"> | |
| <p><strong>Телефон:</strong> <a href="tel:{{ contact_phone }}">{{ contact_phone }}</a></p> | |
| <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте, я хотел(а) бы получить консультацию по вашим услугам." target="_blank" class="btn"><i class="fab fa-whatsapp"></i> Написать в WhatsApp</a> | |
| </div> | |
| <div style="margin-top: 50px; font-size: 0.9rem; color: var(--text-muted);"> | |
| <p><strong>Реквизиты:</strong> ОсОО «Раина Климат Систем», ИНН: 00812202110194, ОКПО: 31290279</p> | |
| </div> | |
| </div> | |
| </section> | |
| <footer class="footer"> | |
| <p>© {{ now.year }} ОсОО "Раина Климат Систем". Все права защищены.</p> | |
| </footer> | |
| <div id="detailsModal" class="modal"> | |
| <div class="modal-content"> | |
| <button class="close-button" onclick="closeDetailsModal()" aria-label="Закрыть">×</button> | |
| <div id="modal-body"></div> | |
| <div class="carousel-nav"> | |
| <button id="prevBtn" onclick="changeModalItem(-1)" aria-label="Предыдущий">❮</button> | |
| <button id="nextBtn" onclick="changeModalItem(1)" aria-label="Следующий">❯</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let currentData = null; | |
| let currentType = null; | |
| let currentIndex = -1; | |
| let allItems = []; | |
| function showDetailsModal(type, index) { | |
| const data = {{ data | tojson }}; | |
| currentData = data; | |
| currentType = type; | |
| currentIndex = index; | |
| if (type === 'service') allItems = data.services || []; | |
| else if (type === 'equipment') allItems = data.equipment || []; | |
| else if (type === 'project') allItems = data.projects || []; | |
| updateModalContent(); | |
| document.getElementById('detailsModal').style.display = 'block'; | |
| document.body.style.overflow = 'hidden'; | |
| } | |
| function updateModalContent() { | |
| if (!allItems || allItems.length === 0 || currentIndex < 0 || currentIndex >= allItems.length) { | |
| return; | |
| } | |
| const item = allItems[currentIndex]; | |
| const modalBody = document.getElementById('modal-body'); | |
| modalBody.innerHTML = ''; | |
| let content = ''; | |
| const dataSettings = {{ data.settings | tojson }}; | |
| const pricesEnabled = dataSettings.prices_enabled; | |
| const whatsappPhone = '{{ whatsapp_phone }}'; | |
| if (currentType === 'service') { | |
| content = ` | |
| ${item.photo ? `<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/services/${item.photo}" alt="${item.title}">` : ''} | |
| <h3><i class="${item.icon || 'fas fa-tools'} fa-fw" style="margin-right: 10px; color: var(--accent-primary);"></i>${item.title || 'Услуга'}</h3> | |
| <p>${item.description || 'Описание отсутствует.'}</p> | |
| `; | |
| } else if (currentType === 'equipment') { | |
| let priceHtml = ''; | |
| let buttonHref = ''; | |
| let buttonText = ''; | |
| const itemName = item.name || ''; | |
| const itemPrice = item.price || 0; | |
| if (pricesEnabled && itemPrice > 0) { | |
| priceHtml = `<p class="price" style="font-size: 1.8rem; color: var(--accent-primary); margin: 20px 0;">${itemPrice.toFixed(2)} KGS</p>`; | |
| buttonText = 'Запросить'; | |
| buttonHref = `https://api.whatsapp.com/send?phone=${whatsappPhone}&text=Здравствуйте, интересует оборудование: ${encodeURIComponent(itemName)}`; | |
| } else { | |
| priceHtml = `<p class="price" style="font-size: 1.8rem; color: var(--accent-primary); margin: 20px 0;">Уточнить цену</p>`; | |
| buttonText = 'Уточнить цену'; | |
| buttonHref = `https://api.whatsapp.com/send?phone=${whatsappPhone}&text=Здравствуйте, хочу узнать цену ${encodeURIComponent(itemName)}`; | |
| } | |
| content = ` | |
| ${item.photo ? `<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/equipment/${item.photo}" alt="${item.name}">` : '<img src="https://via.placeholder.com/400x300.png?text=No+Image" alt="No Image Available">'} | |
| <h3>${itemName}</h3> | |
| <p><strong>Категория:</strong> ${item.category || 'Не указана'}</p> | |
| ${priceHtml} | |
| <a href="${buttonHref}" target="_blank" class="btn" style="padding: 14px 30px; font-size: 1.05rem;">${buttonText}</a> | |
| `; | |
| } else if (currentType === 'project') { | |
| content = ` | |
| ${item.photo ? `<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/projects/${item.photo}" alt="${item.title}">` : '<img src="https://via.placeholder.com/400x300.png?text=Project+Image+Not+Available" alt="Project Image Not Available">'} | |
| <h3>${item.title || 'Проект'}</h3> | |
| <p>${item.description || 'Описание отсутствует.'}</p> | |
| `; | |
| } | |
| modalBody.innerHTML = content; | |
| updateCarouselNav(); | |
| } | |
| function changeModalItem(direction) { | |
| if (!allItems || allItems.length === 0) return; | |
| let newIndex = currentIndex + direction; | |
| if (newIndex < 0) newIndex = allItems.length - 1; | |
| if (newIndex >= allItems.length) newIndex = 0; | |
| currentIndex = newIndex; | |
| updateModalContent(); | |
| } | |
| function updateCarouselNav() { | |
| const prevBtn = document.getElementById('prevBtn'); | |
| const nextBtn = document.getElementById('nextBtn'); | |
| if (!allItems || allItems.length <= 1) { | |
| prevBtn.style.display = 'none'; | |
| nextBtn.style.display = 'none'; | |
| } else { | |
| prevBtn.style.display = 'inline-block'; | |
| nextBtn.style.display = 'inline-block'; | |
| // Since we are wrapping around, navigation is always possible if length > 1 | |
| prevBtn.disabled = false; // currentIndex === 0; | |
| nextBtn.disabled = false; // currentIndex === allItems.length - 1; | |
| } | |
| } | |
| function closeDetailsModal() { | |
| document.getElementById('detailsModal').style.display = 'none'; | |
| document.body.style.overflow = ''; | |
| } | |
| document.addEventListener('keydown', function(event) { | |
| if (event.key === 'Escape' || event.key === 'Esc') { | |
| if (document.getElementById('detailsModal').style.display === 'block') { | |
| closeDetailsModal(); | |
| } | |
| } | |
| }); | |
| document.getElementById('detailsModal').addEventListener('click', function(event) { | |
| if (event.target === this) { | |
| closeDetailsModal(); | |
| } | |
| }); | |
| document.addEventListener('DOMContentLoaded', function() { | |
| const header = document.querySelector('.header'); | |
| const menuToggle = document.querySelector('.menu-toggle'); | |
| const navLinks = document.querySelector('.nav-links'); | |
| window.addEventListener('scroll', () => { | |
| header.classList.toggle('scrolled', window.scrollY > 50); | |
| }); | |
| menuToggle.addEventListener('click', () => { | |
| navLinks.classList.toggle('active'); | |
| menuToggle.setAttribute('aria-expanded', navLinks.classList.contains('active')); | |
| }); | |
| document.querySelectorAll('.nav-links a').forEach(link => { | |
| link.addEventListener('click', () => { | |
| navLinks.classList.remove('active'); | |
| menuToggle.setAttribute('aria-expanded', 'false'); | |
| }); | |
| }); | |
| const filterContainer = document.querySelector('.equipment-filters'); | |
| if (filterContainer) { | |
| filterContainer.addEventListener('click', (e) => { | |
| if (!e.target.matches('.filter-btn')) return; | |
| const currentActive = filterContainer.querySelector('.filter-btn.active'); | |
| if(currentActive) currentActive.classList.remove('active'); | |
| e.target.classList.add('active'); | |
| const filter = e.target.dataset.filter; | |
| document.querySelectorAll('.equipment-card').forEach(card => { | |
| card.style.display = (filter === 'all' || card.dataset.category === filter) ? 'block' : 'none'; | |
| }); | |
| }); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| ''' | |
| ADMIN_TEMPLATE = ''' | |
| <!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.5.1/css/all.min.css"> | |
| <style> | |
| body { font-family: 'Poppins', sans-serif; background-color: #f4f7f9; color: #333; padding: 20px; line-height: 1.6; } | |
| .container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); } | |
| .header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;} | |
| h1, h2, h3 { font-weight: 600; color: #6a0dad; margin-bottom: 15px; } | |
| h1 { font-size: 1.8rem; } | |
| h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; } | |
| .section { margin-bottom: 30px; padding: 20px; background-color: #fafafa; border: 1px solid #e9e9e9; border-radius: 8px; } | |
| form { margin-bottom: 20px; } | |
| label { font-weight: 500; margin-top: 10px; display: block; color: #555; font-size: 0.9rem;} | |
| input[type="text"], input[type="number"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; } | |
| input[type="file"] { padding: 8px; cursor: pointer; border: 1px solid #ddd;} | |
| button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #9b59b6; color: white; font-weight: 500; cursor: pointer; transition: all 0.3s ease; margin-top: 15px; text-decoration: none; } | |
| button:hover, .button:hover { background-color: #8e44ad; } | |
| .delete-button { background-color: #e74c3c; } | |
| .delete-button:hover { background-color: #c0392b; } | |
| .item-list { display: grid; gap: 20px; } | |
| .item { background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #eee; } | |
| .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; } | |
| .edit-form-container { margin-top: 15px; padding: 20px; background: #fdf9ff; border: 1px dashed #ddd; border-radius: 6px; display: none; } | |
| details { background-color: #fafafa; border: 1px solid #e9e9e9; border-radius: 8px; margin-bottom: 20px; } | |
| details > summary { cursor: pointer; font-weight: 600; color: #8e44ad; display: block; padding: 15px; position: relative; list-style: none; } | |
| details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); } | |
| details[open] > summary::after { transform: translateY(-50%) rotate(180deg); } | |
| .photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; object-fit: cover;} | |
| .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; } | |
| .message.success { background-color: #d4edda; color: #155724; } | |
| .message.error { background-color: #f8d7da; color: #721c24; } | |
| .message.warning { background-color: #fff3cd; color: #856404; } | |
| .settings-input-group { display: flex; flex-direction: column; align-items: flex-start;} | |
| .settings-input-group label { display: flex; align-items: center; gap: 10px; margin-top: 15px;} | |
| .settings-input-group input[type="checkbox"] { width: auto; margin: 0; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header"><h1><i class="fas fa-tools"></i> Админ-панель "Раина Климат Систем"</h1><a href="{{ url_for('landing') }}" class="button"><i class="fas fa-home"></i> Перейти на сайт</a></div> | |
| {% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}{% for category, message in messages %}<div class="message {{ category }}">{{ message }}</div>{% endfor %}{% endif %}{% endwith %} | |
| <div class="section"> | |
| <h2><i class="fas fa-cog"></i> Настройки сайта</h2> | |
| <div class="settings-input-group"> | |
| <form method="POST"><input type="hidden" name="action" value="update_settings"> | |
| <label> | |
| <input type="checkbox" name="prices_enabled" value="true" {% if settings.prices_enabled %}checked{% endif %}> | |
| Цены включены (Снимите флажок, чтобы скрыть цены на сайте) | |
| </label> | |
| <button type="submit">Сохранить настройки</button> | |
| </form> | |
| </div> | |
| <h2 style="margin-top: 20px;"><i class="fas fa-sync-alt"></i> Синхронизация</h2> | |
| <form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;"><button type="submit" class="button">Загрузить на сервер</button></form> | |
| <form method="POST" action="{{ url_for('force_download') }}" style="display: inline;"><button type="submit" class="button">Скачать с сервера</button></form> | |
| </div> | |
| <div class="section"> | |
| <h2><i class="fas fa-star"></i> Реализованные проекты</h2> | |
| <details><summary>Добавить проект</summary> | |
| <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="add_project"> | |
| <label>Название*:</label><input type="text" name="title" required> | |
| <label>Описание*:</label><textarea name="description" rows="3" required></textarea> | |
| <label>Фото*:</label><input type="file" name="photo" accept="image/*" required> | |
| <button type="submit">Добавить проект</button> | |
| </form> | |
| </details> | |
| <div class="item-list"> | |
| {% for project in projects %} | |
| <div class="item"> | |
| <p><strong>{{ project.title }}</strong>: {{ project.description }}</p> | |
| {% if project.photo %}<div class="photo-preview"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/projects/{{ project.photo }}" alt="Project Photo"></div>{% endif %} | |
| <div class="item-actions"> | |
| <button onclick="toggleEditForm('edit-project-{{ loop.index0 }}')">Редактировать</button> | |
| <form method="POST" style="margin:0;"><input type="hidden" name="action" value="delete_project"><input type="hidden" name="index" value="{{ loop.index0 }}"><button type="submit" class="delete-button">Удалить</button></form> | |
| </div> | |
| <div id="edit-project-{{ loop.index0 }}" class="edit-form-container"> | |
| <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="edit_project"><input type="hidden" name="index" value="{{ loop.index0 }}"> | |
| <label>Название*:</label><input type="text" name="title" value="{{ project.title }}" required> | |
| <label>Описание*:</label><textarea name="description" rows="3" required>{{ project.description }}</textarea> | |
| <label>Заменить фото:</label><input type="file" name="photo" accept="image/*"> | |
| <button type="submit">Сохранить</button> | |
| </form> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <h2><i class="fas fa-concierge-bell"></i> Услуги "под ключ"</h2> | |
| <details><summary>Добавить услугу</summary> | |
| <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="add_service"> | |
| <label>Заголовок*:</label><input type="text" name="title" required> | |
| <label>Иконка (FontAwesome)*:</label><input type="text" name="icon" placeholder="fas fa-tools" required> | |
| <label>Описание*:</label><textarea name="description" rows="3" required></textarea> | |
| <label>Фото:</label><input type="file" name="photo" accept="image/*"> | |
| <button type="submit">Добавить услугу</button> | |
| </form> | |
| </details> | |
| <div class="item-list"> | |
| {% for service in services %} | |
| <div class="item"> | |
| <p><i class="{{ service.icon }} fa-fw"></i> <strong>{{ service.title }}</strong>: {{ service.description }}</p> | |
| {% if service.photo %}<div class="photo-preview"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/services/{{ service.photo }}" alt="Service Photo"></div>{% endif %} | |
| <div class="item-actions"> | |
| <button onclick="toggleEditForm('edit-service-{{ loop.index0 }}')">Редактировать</button> | |
| <form method="POST" style="margin:0;"><input type="hidden" name="action" value="delete_service"><input type="hidden" name="index" value="{{ loop.index0 }}"><button type="submit" class="delete-button">Удалить</button></form> | |
| </div> | |
| <div id="edit-service-{{ loop.index0 }}" class="edit-form-container"> | |
| <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="edit_service"><input type="hidden" name="index" value="{{ loop.index0 }}"> | |
| <label>Заголовок*:</label><input type="text" name="title" value="{{ service.title }}" required> | |
| <label>Иконка*:</label><input type="text" name="icon" value="{{ service.icon }}" required> | |
| <label>Описание*:</label><textarea name="description" rows="3" required>{{ service.description }}</textarea> | |
| <label>Заменить фото:</label><input type="file" name="photo" accept="image/*"> | |
| <button type="submit">Сохранить</button> | |
| </form> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <h2><i class="fas fa-box-open"></i> Оборудование</h2> | |
| <details><summary>Добавить категорию</summary> | |
| <form method="POST"><input type="hidden" name="action" value="add_category"><label>Название:</label><input type="text" name="category_name" required><button type="submit">Добавить</button></form> | |
| </details> | |
| <div class="item-list"> | |
| {% for category in categories %} | |
| <div class="item" style="display: flex; justify-content: space-between; align-items: center;"> | |
| <span>{{ category }}</span> | |
| <form method="POST" style="margin: 0;"><input type="hidden" name="action" value="delete_category"><input type="hidden" name="category_name" value="{{ category }}"><button type="submit" class="delete-button" style="margin:0;">Удалить</button></form> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| <details style="margin-top:20px;"><summary>Добавить оборудование</summary> | |
| <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="add_equipment"> | |
| <label>Название*:</label><input type="text" name="name" required> | |
| <label>Цена (KGS):</label><input type="number" name="price" step="0.01" min="0"> | |
| <label>Категория:</label><select name="category"><option value="Без категории">Без категории</option>{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}</select> | |
| <label>Фото:</label><input type="file" name="photo" accept="image/*"> | |
| <button type="submit">Добавить</button> | |
| </form> | |
| </details> | |
| <div class="item-list"> | |
| {% for item in equipment %} | |
| <div class="item"> | |
| <p><strong>{{ item.name }}</strong> ({{ item.category }}) - {{ "%.2f"|format(item.price) }} KGS</p> | |
| {% if item.photo %}<div class="photo-preview"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/equipment/{{ item.photo }}" alt="Equipment Photo"></div>{% endif %} | |
| <div class="item-actions"> | |
| <button onclick="toggleEditForm('edit-eq-{{ loop.index0 }}')">Редактировать</button> | |
| <form method="POST" style="margin:0;"><input type="hidden" name="action" value="delete_equipment"><input type="hidden" name="index" value="{{ loop.index0 }}"><button type="submit" class="delete-button">Удалить</button></form> | |
| </div> | |
| <div id="edit-eq-{{ loop.index0 }}" class="edit-form-container"> | |
| <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="edit_equipment"><input type="hidden" name="index" value="{{ loop.index0 }}"> | |
| <label>Название*:</label><input type="text" name="name" value="{{ item.name }}" required> | |
| <label>Цена (KGS):</label><input type="number" name="price" value="{{ item.price if item.price else '' }}" step="0.01" min="0"> | |
| <label>Категория:</label><select name="category">{% for cat in categories %}<option value="{{ cat }}" {% if item.category == cat %}selected{% endif %}>{{ cat }}</option>{% endfor %}</select> | |
| <label>Заменить фото:</label><input type="file" name="photo" accept="image/*"> | |
| <button type="submit">Сохранить</button> | |
| </form> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| </div> | |
| <script>function toggleEditForm(id) { document.getElementById(id).style.display = document.getElementById(id).style.display === 'block' ? 'none' : 'block'; }</script> | |
| </body> | |
| </html> | |
| ''' | |
| def landing(): | |
| data = load_data() | |
| return render_template_string( | |
| LANDING_TEMPLATE, | |
| services=data.get('services', []), | |
| equipment=data.get('equipment', []), | |
| categories=sorted(data.get('categories', [])), | |
| projects=data.get('projects', []), | |
| repo_id=REPO_ID, | |
| contact_phone=CONTACT_PHONE, | |
| whatsapp_phone=WHATSAPP_PHONE, | |
| now=datetime.utcnow(), | |
| data=data | |
| ) | |
| def admin(): | |
| data = load_data() | |
| if request.method == 'POST': | |
| action = request.form.get('action') | |
| logging.info(f"Admin action: {action}") | |
| try: | |
| if action == 'update_settings': | |
| data['settings']['prices_enabled'] = 'prices_enabled' in request.form | |
| flash("Настройки сайта обновлены.", 'success') | |
| elif action == 'add_category': | |
| name = request.form.get('category_name', '').strip() | |
| if name and name not in data['categories']: | |
| data['categories'].append(name) | |
| flash(f"Категория '{name}' добавлена.", 'success') | |
| else: flash("Категория уже существует или пуста.", 'error') | |
| elif action == 'delete_category': | |
| name = request.form.get('category_name') | |
| if name in data['categories']: | |
| data['categories'].remove(name) | |
| flash(f"Категория '{name}' удалена.", 'success') | |
| elif action in ['add_equipment', 'edit_equipment']: | |
| name = request.form.get('name', '').strip() | |
| price_str = request.form.get('price', '').strip() | |
| price = round(float(price_str), 2) if price_str else 0 | |
| category = request.form.get('category') | |
| if not name: | |
| flash("Название оборудования обязательно.", 'error') | |
| return redirect(url_for('admin')) | |
| item_data = {'name': name, 'price': price, 'category': category} | |
| photo = request.files.get('photo') | |
| if action == 'add_equipment': | |
| if photo and photo.filename: | |
| item_data['photo'] = upload_photo_to_hf(photo, name, 'equipment') | |
| data['equipment'].append(item_data) | |
| flash(f"Оборудование '{name}' добавлено.", 'success') | |
| else: | |
| index = int(request.form.get('index')) | |
| original_item = data['equipment'][index] | |
| if photo and photo.filename: | |
| delete_photo_from_hf(original_item.get('photo'), 'equipment') | |
| item_data['photo'] = upload_photo_to_hf(photo, name, 'equipment') | |
| else: | |
| item_data['photo'] = original_item.get('photo') | |
| data['equipment'][index] = item_data | |
| flash(f"Оборудование '{name}' обновлено.", 'success') | |
| elif action == 'delete_equipment': | |
| index = int(request.form.get('index')) | |
| item = data['equipment'].pop(index) | |
| delete_photo_from_hf(item.get('photo'), 'equipment') | |
| flash(f"Оборудование '{item.get('name')}' удалено.", 'success') | |
| elif action in ['add_service', 'edit_service']: | |
| title = request.form.get('title', '').strip() | |
| item_data = {'title': title, 'icon': request.form.get('icon'), 'description': request.form.get('description')} | |
| photo = request.files.get('photo') | |
| if not title: | |
| flash("Заголовок услуги обязателен.", 'error') | |
| return redirect(url_for('admin')) | |
| if action == 'add_service': | |
| if photo and photo.filename: | |
| item_data['photo'] = upload_photo_to_hf(photo, title, 'services') | |
| data['services'].append(item_data) | |
| flash(f"Услуга '{title}' добавлена.", 'success') | |
| else: | |
| index = int(request.form.get('index')) | |
| original_item = data['services'][index] | |
| if photo and photo.filename: | |
| delete_photo_from_hf(original_item.get('photo'), 'services') | |
| item_data['photo'] = upload_photo_to_hf(photo, title, 'services') | |
| else: | |
| item_data['photo'] = original_item.get('photo') | |
| data['services'][index] = item_data | |
| flash(f"Услуга '{title}' обновлена.", 'success') | |
| elif action == 'delete_service': | |
| index = int(request.form.get('index')) | |
| item = data['services'].pop(index) | |
| delete_photo_from_hf(item.get('photo'), 'services') | |
| flash(f"Услуга '{item.get('title')}' удалена.", 'success') | |
| elif action in ['add_project', 'edit_project']: | |
| title = request.form.get('title', '').strip() | |
| item_data = {'title': title, 'description': request.form.get('description')} | |
| photo = request.files.get('photo') | |
| if not title: | |
| flash("Заголовок проекта обязателен.", 'error') | |
| return redirect(url_for('admin')) | |
| if action == 'add_project': | |
| if photo and photo.filename: | |
| item_data['photo'] = upload_photo_to_hf(photo, title, 'projects') | |
| data['projects'].append(item_data) | |
| flash(f"Проект '{title}' добавлен.", 'success') | |
| else: | |
| flash("Фото обязательно для нового проекта.", 'error') | |
| else: | |
| index = int(request.form.get('index')) | |
| original_item = data['projects'][index] | |
| if photo and photo.filename: | |
| delete_photo_from_hf(original_item.get('photo'), 'projects') | |
| item_data['photo'] = upload_photo_to_hf(photo, title, 'projects') | |
| else: | |
| item_data['photo'] = original_item.get('photo') | |
| data['projects'][index] = item_data | |
| flash(f"Проект '{title}' обновлен.", 'success') | |
| elif action == 'delete_project': | |
| index = int(request.form.get('index')) | |
| item = data['projects'].pop(index) | |
| delete_photo_from_hf(item.get('photo'), 'projects') | |
| flash(f"Проект '{item.get('title')}' удален.", 'success') | |
| save_data(data) | |
| return redirect(url_for('admin')) | |
| except Exception as e: | |
| logging.error(f"Admin action '{action}' failed: {e}", exc_info=True) | |
| flash(f"Произошла ошибка: {e}", 'error') | |
| return redirect(url_for('admin')) | |
| return render_template_string( | |
| ADMIN_TEMPLATE, | |
| equipment=data.get('equipment', []), | |
| categories=sorted(data.get('categories', [])), | |
| services=data.get('services', []), | |
| projects=data.get('projects', []), | |
| repo_id=REPO_ID, | |
| settings=data.get('settings', {'prices_enabled': True}) | |
| ) | |
| def upload_photo_to_hf(photo, item_name, folder): | |
| if not photo or not photo.filename or not HF_TOKEN_WRITE: | |
| return None | |
| try: | |
| api = HfApi() | |
| safe_name = secure_filename(item_name.replace(' ', '_'))[:50] | |
| ext = os.path.splitext(photo.filename)[1].lower() | |
| if not ext: ext = ".jpg" | |
| photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}" | |
| photo_file_obj = io.BytesIO(photo.read()) | |
| api.upload_file( | |
| path_or_fileobj=photo_file_obj, path_in_repo=f"{folder}/{photo_filename}", | |
| repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, | |
| commit_message=f"Upload photo {photo_filename} for {folder}" | |
| ) | |
| logging.info(f"Uploaded photo {photo_filename} to {folder}") | |
| return photo_filename | |
| except Exception as e: | |
| logging.error(f"Error uploading photo {photo.filename}: {e}", exc_info=True) | |
| flash(f"Ошибка загрузки фото {photo.filename}. Проверьте формат и размер.", 'error') | |
| return None | |
| def delete_photo_from_hf(photo_filename, folder): | |
| if not photo_filename or not HF_TOKEN_WRITE: | |
| return | |
| try: | |
| api = HfApi() | |
| api.delete_files( | |
| repo_id=REPO_ID, paths_in_repo=[f"{folder}/{photo_filename}"], | |
| repo_type="dataset", token=HF_TOKEN_WRITE, | |
| commit_message=f"Delete photo {photo_filename} from {folder}" | |
| ) | |
| logging.info(f"Deleted photo {photo_filename} from {folder}") | |
| except HfHubHTTPError as e: | |
| if e.response.status_code != 404: | |
| logging.error(f"HTTP error deleting photo {photo_filename}: {e}") | |
| else: | |
| logging.info(f"Photo {photo_filename} not found on HF for deletion, or already deleted.") | |
| except Exception as e: | |
| logging.error(f"Unexpected error deleting photo {photo_filename}: {e}") | |
| def force_upload(): | |
| upload_db_to_hf() | |
| flash("Данные загружены на сервер.", 'success') | |
| return redirect(url_for('admin')) | |
| def force_download(): | |
| if download_db_from_hf(): | |
| flash("Данные успешно скачаны с сервера.", 'success') | |
| else: | |
| flash("Ошибка при скачивании данных с сервера.", 'error') | |
| return redirect(url_for('admin')) | |
| if __name__ == '__main__': | |
| logging.info("Application starting up...") | |
| if not download_db_from_hf(): | |
| logging.warning("Initial database download failed. Application might start with empty or outdated data.") | |
| if HF_TOKEN_WRITE: | |
| threading.Thread(target=periodic_backup, daemon=True).start() | |
| port = int(os.environ.get('PORT', 7860)) | |
| app.run(debug=False, host='0.0.0.0', port=port) |