| import os |
| import io |
| import base64 |
| import json |
| import logging |
| import threading |
| import time |
| import math |
| from datetime import datetime, timedelta, timezone |
| from uuid import uuid4 |
| import random |
| import string |
| from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify, session, make_response |
| from PIL import Image |
| import google.generativeai as genai |
| 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 |
|
|
| load_dotenv() |
|
|
| app = Flask(__name__) |
| app.secret_key = 'your_unique_secret_key_gippo_312_shop_54321_no_login' |
| DATA_FILE = 'data.json' |
| SYNC_FILES = [DATA_FILE] |
| REPO_ID = "Kgshop/bc" |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") |
| GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") |
|
|
| DOWNLOAD_RETRIES = 3 |
| DOWNLOAD_DELAY = 5 |
| ALMATY_TZ = timezone(timedelta(hours=6)) |
|
|
| db_lock = threading.RLock() |
|
|
| CURRENCIES = { |
| 'KGS': 'Кыргызский сом', |
| 'KZT': 'Казахстанский тенге', |
| 'UAH': 'Украинская гривна', |
| 'RUB': 'Российский рубль', |
| 'USD': 'Доллар США', |
| 'EUR': 'Евро' |
| } |
|
|
| COLOR_SCHEMES = { |
| 'default': 'Бирюзовый (по умолч.)', |
| 'forest': 'Лесной зеленый', |
| 'ocean': 'Глубокий синий', |
| 'sunset': 'Закатный оранжевый', |
| 'lavender': 'Лавандовый', |
| 'vintage': 'Винтажный', |
| 'dark': 'Полночь (тёмная)', |
| 'cosmic': 'Космическая ночь', |
| 'minty': 'Свежая мята', |
| 'mocha': 'Кофейный мокко', |
| 'crimson': 'Багровый рассвет', |
| 'solar': 'Солнечная вспышка', |
| 'cyberpunk': 'Киберпанк неон', |
| 'neon': 'Неоновая вспышка', |
| 'pastel': 'Пастельный (светлый)', |
| 'emerald': 'Изумрудный город', |
| 'gold': 'Роскошное золото', |
| 'sakura': 'Цветение сакуры (светлый)', |
| 'arctic': 'Арктический лед (светлый)', |
| 'volcano': 'Магма', |
| 'monochrome_light': 'Классика (Светлая)', |
| 'monochrome_dark': 'Классика (Темная)', |
| 'nord': 'Скандинавский Норд', |
| 'dracula': 'Дракула (Темный)', |
| 'ruby': 'Глубокий Рубин', |
| 'sapphire': 'Королевский Сапфир', |
| 'amethyst': 'Аметистовый блеск' |
| } |
|
|
| ICONS = { |
| 'fa-link': 'Ссылка (вебсайт)', |
| 'fa-phone': 'Телефон', |
| 'fa-whatsapp': 'WhatsApp', |
| 'fa-telegram': 'Telegram', |
| 'fa-instagram': 'Instagram', |
| 'fa-envelope': 'Email', |
| 'fa-map-marker-alt': 'Локация/Адрес', |
| 'fa-youtube': 'YouTube', |
| 'fa-tiktok': 'TikTok' |
| } |
|
|
| 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: |
| pass |
| 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 |
| all_successful = True |
| for file_name in files_to_download: |
| success = False |
| for attempt in range(retries + 1): |
| try: |
| local_path = 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 |
| ) |
| success = True |
| break |
| except RepositoryNotFoundError: |
| return False |
| except HfHubHTTPError as e: |
| if e.response.status_code == 404: |
| if attempt == 0 and not os.path.exists(file_name): |
| try: |
| if file_name == DATA_FILE: |
| with open(file_name, 'w', encoding='utf-8') as f: |
| json.dump({}, f) |
| except Exception: |
| pass |
| success = False |
| break |
| else: |
| pass |
| except requests.exceptions.RequestException: |
| pass |
| except Exception: |
| pass |
| if attempt < retries: |
| time.sleep(delay) |
| if not success: |
| all_successful = False |
| return all_successful |
|
|
| def upload_db_to_hf(specific_file=None): |
| if not HF_TOKEN_WRITE: |
| return |
| try: |
| api = HfApi() |
| files_to_upload = [specific_file] if specific_file else SYNC_FILES |
| for file_name in files_to_upload: |
| if os.path.exists(file_name): |
| try: |
| 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(ALMATY_TZ).strftime('%Y-%m-%d %H:%M:%S')}" |
| ) |
| except Exception: |
| pass |
| except Exception: |
| pass |
|
|
| def periodic_backup(): |
| backup_interval = 1800 |
| while True: |
| time.sleep(backup_interval) |
| upload_db_to_hf() |
|
|
| def load_data(): |
| with db_lock: |
| try: |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| if not isinstance(data, dict): |
| data = {} |
| except (FileNotFoundError, json.JSONDecodeError): |
| if download_db_from_hf(specific_file=DATA_FILE): |
| try: |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| if not isinstance(data, dict): |
| data = {} |
| except (FileNotFoundError, json.JSONDecodeError): |
| data = {} |
| else: |
| data = {} |
| return data |
|
|
| def save_data(data): |
| with db_lock: |
| try: |
| with open(DATA_FILE, 'w', encoding='utf-8') as file: |
| json.dump(data, file, ensure_ascii=False, indent=4) |
| except Exception: |
| pass |
| upload_db_to_hf(specific_file=DATA_FILE) |
|
|
| def get_env_data(env_id): |
| with db_lock: |
| all_data = load_data() |
| default_settings = { |
| "vcard_firstname": "Имя", |
| "vcard_lastname": "Фамилия", |
| "vcard_job": "Специалист", |
| "organization_name": "Моя Компания", |
| "currency_code": "KGS", |
| "chat_avatar": None, |
| "color_scheme": "default", |
| "admin_password_enabled": False, |
| "admin_password": "", |
| "categories_as_lines": False, |
| "about_text": "Привет! Это моя онлайн-визитка." |
| } |
|
|
| env_data = all_data.get(env_id, {}) |
| if not env_data: |
| env_data = { |
| 'products': [], 'categories':[], 'blocks':[], |
| 'settings': default_settings |
| } |
|
|
| if 'products' not in env_data: env_data['products'] = [] |
| if 'categories' not in env_data: env_data['categories'] = [] |
| if 'settings' not in env_data: env_data['settings'] = default_settings |
| if 'blocks' not in env_data: env_data['blocks'] = [] |
| |
| settings_changed = False |
| for key, value in default_settings.items(): |
| if key not in env_data['settings']: |
| env_data['settings'][key] = value |
| settings_changed = True |
|
|
| products_changed = False |
| for product in env_data['products']: |
| if 'product_id' not in product: |
| product['product_id'] = uuid4().hex |
| products_changed = True |
| if 'views' not in product: |
| product['views'] = 0 |
| products_changed = True |
| if 'price' not in product: |
| product['price'] = 0.0 |
| products_changed = True |
|
|
| if products_changed or settings_changed: |
| save_env_data(env_id, env_data) |
|
|
| return env_data |
|
|
| def save_env_data(env_id, env_data): |
| with db_lock: |
| all_data = load_data() |
| all_data[env_id] = env_data |
| save_data(all_data) |
|
|
| def configure_gemini(): |
| if not GOOGLE_API_KEY: |
| return False |
| try: |
| genai.configure(api_key=GOOGLE_API_KEY) |
| return True |
| except Exception: |
| return False |
|
|
| def generate_ai_description_from_image(image_data, language): |
| if not configure_gemini(): |
| raise ValueError("Google AI API не настроен.") |
| try: |
| if not image_data: |
| raise ValueError("Файл изображения не найден.") |
| image_stream = io.BytesIO(image_data) |
| image = Image.open(image_stream).convert('RGB') |
| except Exception: |
| raise ValueError("Не удалось обработать изображение.") |
| base_prompt = "Напиши привлекательное описание для этого товара/услуги. Текст должен быть емким, продающим, с использованием эмодзи. Не пиши цены, адреса и телефоны." |
| lang_suffix = "" |
| if language == "Русский": |
| lang_suffix = " Пиши на русском языке." |
| elif language == "Кыргызский": |
| lang_suffix = " Пиши на кыргызском языке." |
| elif language == "Казахский": |
| lang_suffix = " Пиши на казахском языке." |
| elif language == "Узбекский": |
| lang_suffix = " Пиши на узбекском языке." |
| final_prompt = f"{base_prompt}{lang_suffix}" |
| try: |
| model = genai.GenerativeModel('gemma-4-31b-it') |
| response = model.generate_content([final_prompt, image]) |
| if hasattr(response, 'text'): |
| return response.text |
| else: |
| if response.parts: |
| return "".join(part.text for part in response.parts if hasattr(part, 'text')) |
| else: |
| response.resolve() |
| return response.text |
| except Exception as e: |
| raise ValueError(f"Ошибка при генерации контента: {e}") |
|
|
| LANDING_PAGE_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Платформа Онлайн-Визиток</title> |
| <style> |
| body, html { margin: 0; padding: 0; height: 100%; overflow: hidden; } |
| iframe { border: none; width: 100%; height: 100%; } |
| </style> |
| </head> |
| <body> |
| <iframe src="https://v0-ai-agent-landing-page-smoky-six.vercel.app/"></iframe> |
| </body> |
| </html> |
| ''' |
|
|
| LOGIN_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=Montserrat:wght@400;600&display=swap" rel="stylesheet"> |
| <style> |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: #f4f6f9; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 20px; } |
| .login-container { background: #fff; padding: 40px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); text-align: center; width: 100%; max-width: 350px; } |
| h2 { color: #135D66; margin-bottom: 20px; } |
| input[type="password"] { width: 100%; padding: 12px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 8px; font-size: 1rem; min-height: 44px; } |
| button { width: 100%; padding: 12px; background-color: #48D1CC; color: #003C43; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 1rem; transition: background 0.3s; min-height: 44px; } |
| button:hover { background-color: #77E4D8; } |
| .error { color: #E57373; margin-bottom: 15px; font-size: 0.9rem; } |
| </style> |
| </head> |
| <body> |
| <div class="login-container"> |
| <h2>Вход</h2> |
| {% with messages = get_flashed_messages(with_categories=true) %} |
| {% if messages %} |
| {% for category, message in messages %} |
| <div class="error">{{ message }}</div> |
| {% endfor %} |
| {% endif %} |
| {% endwith %} |
| <form method="POST"> |
| <input type="password" name="password" placeholder="Введите пароль" required autofocus> |
| <button type="submit">Войти</button> |
| </form> |
| </div> |
| </body> |
| </html> |
| ''' |
|
|
| ADMHOSTO_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=Montserrat:wght@300;400;500;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> |
| :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; } |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; margin: 0; } |
| .container { max-width: 900px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); } |
| h1 { font-weight: 600; color: var(--bg-medium); margin-bottom: 25px; text-align: center; } |
| .section { margin-bottom: 30px; } |
| .add-env-form { margin-bottom: 20px; text-align: center; } |
| #search-env { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; font-family: 'Montserrat', sans-serif; min-height: 44px;} |
| .button { padding: 10px 18px; border: none; border-radius: 8px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 44px; } |
| .button:hover { background-color: var(--accent-hover); } |
| .env-list { list-style: none; padding: 0; } |
| .env-item { background: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 15px; } |
| .env-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; } |
| .env-id { font-weight: 600; color: var(--bg-medium); font-size: 1.2rem; } |
| .env-actions { display: flex; gap: 10px; flex-wrap: wrap; } |
| .env-pwd { background: #f1f3f5; padding: 10px; border-radius: 8px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } |
| .env-pwd input[type="text"] { padding: 10px; border: 1px solid #ccc; border-radius: 6px; flex-grow: 1; min-height: 44px;} |
| .delete-button { background-color: var(--danger); color: white; } |
| .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; text-align: center; } |
| .message.success { background-color: #d4edda; color: #155724; } |
| .message.error { background-color: #f8d7da; color: #721c24; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1><i class="fas fa-id-card"></i> Управление Визитками</h1> |
| {% 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"> |
| <form method="POST" action="{{ url_for('create_environment') }}" class="add-env-form"> |
| <button type="submit" class="button" style="font-size:1.1rem; padding: 15px 30px;"><i class="fas fa-plus-circle"></i> Создать новую визитку</button> |
| </form> |
| </div> |
| <div class="section"> |
| <input type="text" id="search-env" placeholder="Поиск по ID или Названию..."> |
| </div> |
| <div class="section"> |
| <h2><i class="fas fa-list-ul"></i> Существующие визитки</h2> |
| {% if environments %} |
| <ul class="env-list"> |
| {% for env in environments %} |
| <li class="env-item"> |
| <div class="env-header"> |
| <div style="display:flex; align-items:center; gap: 10px; flex-wrap: wrap;"> |
| <span class="env-id">{{ env.org_name }} (ID: {{ env.id }})</span> |
| </div> |
| <div class="env-actions"> |
| <a href="{{ url_for('admin', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-tools"></i> Настройки</a> |
| <a href="{{ url_for('catalog', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-external-link-alt"></i> Посмотреть</a> |
| <form method="POST" action="{{ url_for('delete_environment', env_id=env.id) }}" style="display:inline;" onsubmit="if(!confirm('Вы уверены, что хотите удалить визитку {{ env.id }}? Это действие необратимо.')) return false;"> |
| <button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </div> |
| </div> |
| <div class="env-pwd"> |
| <form method="POST" action="{{ url_for('update_env_pwd', env_id=env.id) }}" style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap; width: 100%;"> |
| <label style="display: flex; align-items: center; gap: 5px;"><input type="checkbox" name="pwd_enabled" {% if env.pwd_enabled %}checked{% endif %} style="width: 20px; height: 20px;"> Вкл. пароль админа</label> |
| <input type="text" name="password" value="{{ env.password }}" placeholder="Пароль"> |
| <button type="submit" class="button" style="font-size: 0.9rem;">Сохранить</button> |
| </form> |
| </div> |
| </li> |
| {% endfor %} |
| </ul> |
| {% else %} |
| <p>Пока не создано ни одной визитки.</p> |
| {% endif %} |
| </div> |
| </div> |
| <script> |
| document.getElementById('search-env').addEventListener('input', function() { |
| const searchTerm = this.value.toLowerCase().trim(); |
| const envItems = document.querySelectorAll('.env-item'); |
| envItems.forEach(item => { |
| const envId = item.querySelector('.env-id').textContent.toLowerCase(); |
| if (envId.includes(searchTerm)) { item.style.display = 'flex'; } else { item.style.display = 'none'; } |
| }); |
| }); |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| CATALOG_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <title>{{ settings.vcard_firstname }} {{ settings.vcard_lastname }} - Визитка</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=Montserrat:wght@400;500;600;700;800&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css"> |
| <style> |
| {% if settings.color_scheme == 'forest' %} |
| :root { --bg-dark: #2F4F4F; --bg-medium: #556B2F; --accent: #8FBC8F; --accent-hover: #98FB98; --text-light: #F5F5DC; --text-dark: #1A2F1A; --danger: #CD5C5C; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'ocean' %} |
| :root { --bg-dark: #000080; --bg-medium: #1E90FF; --accent: #87CEEB; --accent-hover: #ADD8E6; --text-light: #F0F8FF; --text-dark: #000033; --danger: #FF6347; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'sunset' %} |
| :root { --bg-dark: #4A2511; --bg-medium: #D2691E; --accent: #FFA500; --accent-hover: #FFB733; --text-light: #FFF8DC; --text-dark: #4A2511; --danger: #DC143C; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'lavender' %} |
| :root { --bg-dark: #483D8B; --bg-medium: #8A2BE2; --accent: #D8BFD8; --accent-hover: #E6E6FA; --text-light: #F8F4FF; --text-dark: #2D1B36; --danger: #DB7093; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'vintage' %} |
| :root { --bg-dark: #5D4037; --bg-medium: #8D6E63; --accent: #D7CCC8; --accent-hover: #EFEBE9; --text-light: #EFEBE9; --text-dark: #3E2723; --danger: #BF360C; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'dark' %} |
| :root { --bg-dark: #121212; --bg-medium: #1E1E1E; --accent: #BB86FC; --accent-hover: #A764FC; --text-light: #E1E1E1; --text-dark: #121212; --danger: #CF6679; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'cosmic' %} |
| :root { --bg-dark: #0D1136; --bg-medium: #303F9F; --accent: #536DFE; --accent-hover: #7986CB; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #F50057; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'minty' %} |
| :root { --bg-dark: #004D40; --bg-medium: #00796B; --accent: #4DB6AC; --accent-hover: #80CBC4; --text-light: #E0F2F1; --text-dark: #00332A; --danger: #ef5350; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'mocha' %} |
| :root { --bg-dark: #3E2723; --bg-medium: #5D4037; --accent: #A1887F; --accent-hover: #BCAAA4; --text-light: #EFEBE9; --text-dark: #261412; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'crimson' %} |
| :root { --bg-dark: #4A148C; --bg-medium: #9C27B0; --accent: #CE93D8; --accent-hover: #E1BEE7; --text-light: #FFFFFF; --text-dark: #2D0854; --danger: #E91E63; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'solar' %} |
| :root { --bg-dark: #BF360C; --bg-medium: #FB8C00; --accent: #FFCA28; --accent-hover: #FFD54F; --text-light: #FFF3E0; --text-dark: #3E2723; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'cyberpunk' %} |
| :root { --bg-dark: #000000; --bg-medium: #0D0221; --accent: #00F0FF; --accent-hover: #81F5FF; --text-light: #FFFFFF; --text-dark: #000000; --danger: #F50057; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'neon' %} |
| :root { --bg-dark: #0F0C29; --bg-medium: #302B63; --accent: #FF00CC; --accent-hover: #FF66CC; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #FF0000; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'pastel' %} |
| :root { --bg-dark: #FCE4EC; --bg-medium: #F8BBD0; --accent: #F06292; --accent-hover: #F48FB1; --text-light: #4A148C; --text-dark: #FFFFFF; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.5); } |
| {% elif settings.color_scheme == 'emerald' %} |
| :root { --bg-dark: #004D40; --bg-medium: #00695C; --accent: #1DE9B6; --accent-hover: #64FFDA; --text-light: #E0F2F1; --text-dark: #00332A; --danger: #FF5252; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'gold' %} |
| :root { --bg-dark: #1C1C1C; --bg-medium: #3A3A3A; --accent: #D4AF37; --accent-hover: #F3E5AB; --text-light: #F5F5F5; --text-dark: #1C1C1C; --danger: #B71C1C; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'sakura' %} |
| :root { --bg-dark: #FFEBEE; --bg-medium: #FFCDD2; --accent: #FF8A80; --accent-hover: #FFBCAF; --text-light: #4A148C; --text-dark: #FFFFFF; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.5); } |
| {% elif settings.color_scheme == 'arctic' %} |
| :root { --bg-dark: #E3F2FD; --bg-medium: #BBDEFB; --accent: #4FC3F7; --accent-hover: #81D4FA; --text-light: #0D47A1; --text-dark: #000000; --danger: #EF5350; --card-bg: rgba(255,255,255,0.5); } |
| {% elif settings.color_scheme == 'volcano' %} |
| :root { --bg-dark: #212121; --bg-medium: #B71C1C; --accent: #FF3D00; --accent-hover: #FF6E40; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #D50000; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'monochrome_light' %} |
| :root { --bg-dark: #F5F5F5; --bg-medium: #E0E0E0; --accent: #000000; --accent-hover: #333333; --text-light: #000000; --text-dark: #FFFFFF; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.5); } |
| {% elif settings.color_scheme == 'monochrome_dark' %} |
| :root { --bg-dark: #121212; --bg-medium: #2A2A2A; --accent: #FFFFFF; --accent-hover: #E0E0E0; --text-light: #FFFFFF; --text-dark: #000000; --danger: #EF5350; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'nord' %} |
| :root { --bg-dark: #2E3440; --bg-medium: #3B4252; --accent: #88C0D0; --accent-hover: #81A1C1; --text-light: #D8DEE9; --text-dark: #2E3440; --danger: #BF616A; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'dracula' %} |
| :root { --bg-dark: #282A36; --bg-medium: #44475A; --accent: #FF79C6; --accent-hover: #BD93F9; --text-light: #F8F8F2; --text-dark: #282A36; --danger: #FF5555; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'ruby' %} |
| :root { --bg-dark: #4A0E17; --bg-medium: #7B1826; --accent: #FFB3B3; --accent-hover: #FFD9D9; --text-light: #FFF0F0; --text-dark: #4A0E17; --danger: #FF4D4D; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'sapphire' %} |
| :root { --bg-dark: #0B192C; --bg-medium: #1A365D; --accent: #FFD700; --accent-hover: #FFF176; --text-light: #F7FAFC; --text-dark: #0B192C; --danger: #E53E3E; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'amethyst' %} |
| :root { --bg-dark: #2D1B36; --bg-medium: #4A2C59; --accent: #B28DFF; --accent-hover: #D4C4FB; --text-light: #F4EBFF; --text-dark: #2D1B36; --danger: #FF6B6B; --card-bg: rgba(255,255,255,0.1); } |
| {% else %} |
| :root { --bg-dark: #003C43; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-light: #E3FEF7; --text-dark: #003C43; --danger: #E57373; --card-bg: rgba(255,255,255,0.1); } |
| {% endif %} |
| |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| html { -webkit-tap-highlight-color: transparent; scroll-behavior: smooth; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-dark); color: var(--text-light); line-height: 1.6; } |
| |
| .main-wrapper { max-width: 680px; margin: 0 auto; padding: 40px 20px 80px 20px; display: flex; flex-direction: column; align-items: center; position: relative; } |
| |
| .qr-btn { position: absolute; top: 20px; right: 20px; font-size: 1.5rem; color: var(--accent); background: var(--card-bg); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; width: 45px; height: 45px; display: flex; align-items: center; justify-content: center; cursor: pointer; backdrop-filter: blur(5px); transition: all 0.2s; z-index: 100; } |
| .qr-btn:hover { background: var(--accent); color: var(--text-dark); transform: scale(1.05); } |
| |
| .profile-header { text-align: center; margin-bottom: 30px; width: 100%; } |
| .avatar { width: 120px; height: 120px; border-radius: 50%; border: 3px solid var(--accent); box-shadow: 0 0 20px rgba(0,0,0,0.2); object-fit: cover; margin-bottom: 15px; } |
| .profile-name { font-size: 1.6rem; font-weight: 700; margin-bottom: 5px; color: var(--text-light); } |
| .profile-job { font-size: 1.1rem; font-weight: 500; opacity: 0.9; margin-bottom: 5px; } |
| .profile-company { font-size: 1rem; font-weight: 400; opacity: 0.7; } |
| .profile-about { margin-top: 15px; font-size: 0.95rem; line-height: 1.5; opacity: 0.85; max-width: 500px; margin-left: auto; margin-right: auto;} |
| |
| .save-contact-btn { display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%; max-width: 400px; padding: 16px; background: var(--accent); color: var(--text-dark); border-radius: 30px; text-decoration: none; font-weight: 700; font-size: 1.1rem; transition: transform 0.2s, box-shadow 0.2s; box-shadow: 0 4px 15px rgba(0,0,0,0.2); margin-bottom: 30px; border: none; cursor: pointer; } |
| .save-contact-btn:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0,0,0,0.3); background: var(--accent-hover); } |
| |
| .blocks-container { width: 100%; display: flex; flex-direction: column; gap: 15px; margin-bottom: 40px; } |
| .block-link { display: flex; align-items: center; justify-content: center; position: relative; background: var(--card-bg); color: var(--text-light); text-align: center; padding: 16px 20px; border-radius: 16px; text-decoration: none; font-weight: 600; font-size: 1.05rem; backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1); transition: transform 0.2s, background 0.2s; min-height: 60px; overflow: hidden; } |
| .block-link:hover { transform: scale(1.02); background: var(--bg-medium); border-color: var(--accent); } |
| .block-icon { position: absolute; left: 20px; font-size: 1.5rem; color: var(--accent); } |
| |
| .block-text { background: var(--card-bg); padding: 20px; border-radius: 16px; border: 1px solid rgba(255,255,255,0.1); text-align: center; color: var(--text-light); backdrop-filter: blur(10px); } |
| .block-text h3 { margin-bottom: 10px; font-size: 1.2rem; color: var(--accent); } |
| |
| .catalog-section { width: 100%; margin-top: 20px; } |
| .catalog-title { font-size: 1.4rem; font-weight: 700; margin-bottom: 20px; text-align: center; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; } |
| |
| .search-wrapper { position: relative; margin-bottom: 20px; width: 100%; } |
| #search-input { width: 100%; padding: 14px 20px 14px 45px; font-size: 1rem; border: 1px solid rgba(255,255,255,0.2); border-radius: 30px; outline: none; background-color: var(--card-bg); color: var(--text-light); transition: all 0.3s ease; backdrop-filter: blur(5px); } |
| #search-input::placeholder { color: var(--text-light); opacity: 0.6; } |
| #search-input:focus { background-color: rgba(255,255,255,0.15); border-color: var(--accent); } |
| .search-wrapper .fa-search { position: absolute; top: 50%; left: 18px; transform: translateY(-50%); color: var(--text-light); opacity: 0.6; font-size: 1.1rem; } |
| |
| .category-chips-container { margin-bottom: 20px; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding-bottom: 5px; } |
| .category-chips-container::-webkit-scrollbar { display: none; } |
| .category-chips { display: inline-flex; gap: 10px; } |
| .chip { padding: 10px 20px; border-radius: 20px; background-color: var(--card-bg); color: var(--text-light); border: 1px solid rgba(255,255,255,0.2); font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; backdrop-filter: blur(5px); } |
| .chip:hover, .chip.active { background-color: var(--accent); color: var(--text-dark); border-color: var(--accent); } |
| |
| .product-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; width: 100%; } |
| |
| .product-card { background: var(--card-bg); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; overflow: hidden; cursor: pointer; transition: transform 0.2s; backdrop-filter: blur(5px); display: flex; flex-direction: column; } |
| .product-card:hover { transform: translateY(-5px); border-color: rgba(255,255,255,0.3); } |
| .product-image-container { width: 100%; aspect-ratio: 1/1; position: relative; background-color: rgba(0,0,0,0.2); overflow: hidden; } |
| .product-image-container img { width: 100%; height: 100%; object-fit: cover; } |
| .product-info { padding: 10px; display: flex; flex-direction: column; flex-grow: 1; justify-content: space-between; } |
| .product-title { font-size: 0.9rem; font-weight: 600; color: var(--text-light); margin-bottom: 5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } |
| .product-price { font-size: 0.95rem; font-weight: 700; color: var(--accent); } |
| .no-results-message { text-align: center; padding: 30px; font-size: 1.1rem; opacity: 0.7; grid-column: 1 / -1; } |
| |
| .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(8px); overflow-y: auto; -webkit-overflow-scrolling: touch; } |
| .modal-content { background: var(--bg-dark); color: var(--text-light); margin: auto; padding: 0; border-radius: 20px; width: 100%; max-width: 500px; min-height: 100vh; position: relative; box-shadow: 0 15px 40px rgba(0,0,0,0.5); animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } |
| @media (min-width: 500px) { .modal-content { min-height: auto; margin: 5% auto; } } |
| @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } |
| |
| .qr-modal-content { background: var(--bg-dark); color: var(--text-light); margin: 15vh auto; padding: 40px 20px; border-radius: 20px; width: 90%; max-width: 350px; position: relative; box-shadow: 0 15px 40px rgba(0,0,0,0.5); text-align: center; animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } |
| |
| .close-btn { position: absolute; top: 15px; right: 15px; width: 40px; height: 40px; background: rgba(0,0,0,0.5); border-radius: 50%; display: flex; justify-content: center; align-items: center; color: white; font-size: 1.5rem; cursor: pointer; z-index: 10; border: none; backdrop-filter: blur(5px); } |
| .close-btn:hover { background: var(--danger); } |
| |
| .modal-body { padding: 25px; } |
| .modal-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 10px; color: var(--text-light); } |
| .modal-price { font-size: 1.4rem; font-weight: 800; color: var(--accent); margin-bottom: 20px; } |
| .modal-desc { font-size: 1rem; line-height: 1.6; opacity: 0.9; margin-bottom: 20px; white-space: pre-wrap; } |
| .modal-category { display: inline-block; padding: 5px 12px; background: var(--card-bg); border-radius: 15px; font-size: 0.85rem; border: 1px solid rgba(255,255,255,0.2); margin-bottom: 15px; } |
| |
| .swiper-container { width: 100%; aspect-ratio: 1/1; background: #000; border-radius: 20px 20px 0 0; } |
| @media (min-width: 500px) { .swiper-container { border-radius: 20px 20px 0 0; } } |
| .swiper-slide { display: flex; justify-content: center; align-items: center; } |
| .swiper-slide img { width: 100%; height: 100%; object-fit: contain; } |
| |
| .pagination { display: flex; justify-content: center; gap: 8px; margin-top: 30px; padding-bottom: 20px; align-items: center; flex-wrap: wrap; } |
| .pagination button { width: 40px; height: 40px; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; background: var(--card-bg); color: var(--text-light); font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 1rem; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(5px); } |
| .pagination button.active { background: var(--accent); color: var(--text-dark); border-color: var(--accent); } |
| .pagination button:hover:not(.active) { background: rgba(255,255,255,0.2); } |
| |
| .dark-theme .modal-content, .dark-theme .qr-modal-content { background: #222; } |
| </style> |
| </head> |
| <body class="{{ 'dark-theme' if settings.color_scheme in['dark', 'cyberpunk', 'neon', 'volcano', 'gold', 'monochrome_dark', 'nord', 'dracula', 'ruby', 'sapphire', 'amethyst', 'cosmic', 'crimson', 'mocha', 'minty', 'solar'] else '' }}"> |
| |
| <div class="main-wrapper"> |
| <div class="qr-btn" onclick="openQrModal()"> |
| <i class="fas fa-qrcode"></i> |
| </div> |
| |
| <div class="profile-header"> |
| <img src="{{ chat_avatar_url }}" alt="Avatar" class="avatar"> |
| <div class="profile-name">{{ settings.vcard_firstname }} {{ settings.vcard_lastname }}</div> |
| {% if settings.vcard_job %}<div class="profile-job">{{ settings.vcard_job }}</div>{% endif %} |
| {% if settings.organization_name %}<div class="profile-company">{{ settings.organization_name }}</div>{% endif %} |
| {% if settings.about_text %}<div class="profile-about">{{ settings.about_text|replace('\\n', '<br>')|safe }}</div>{% endif %} |
| </div> |
| |
| <a href="{{ url_for('download_vcard', env_id=env_id) }}" class="save-contact-btn"> |
| <i class="fas fa-address-book"></i> Сохранить в контакты |
| </a> |
| |
| {% if blocks %} |
| <div class="blocks-container"> |
| {% for block in blocks %} |
| {% if block.type == 'link' %} |
| <a href="{{ block.url }}" class="block-link" target="_blank" rel="noopener noreferrer"> |
| {% if block.icon %} |
| {% set prefix = 'fab' if block.icon in ['fa-whatsapp', 'fa-telegram', 'fa-instagram', 'fa-youtube', 'fa-tiktok'] else 'fas' %} |
| <i class="{{ prefix }} {{ block.icon }} block-icon"></i> |
| {% endif %} |
| <span>{{ block.title }}</span> |
| </a> |
| {% elif block.type == 'text' %} |
| <div class="block-text"> |
| {% if block.title %}<h3>{{ block.title }}</h3>{% endif %} |
| <p>{{ block.content|replace('\\n', '<br>')|safe }}</p> |
| </div> |
| {% endif %} |
| {% endfor %} |
| </div> |
| {% endif %} |
| |
| {% if products_json != '[]' %} |
| <div class="catalog-section"> |
| <h2 class="catalog-title">Каталог / Услуги</h2> |
| <div class="search-wrapper"> |
| <i class="fas fa-search"></i> |
| <input type="text" id="search-input" placeholder="Поиск..."> |
| </div> |
| |
| <div class="category-chips-container"> |
| <div class="category-chips" id="category-chips"></div> |
| </div> |
| |
| <div id="catalog-content"></div> |
| </div> |
| {% endif %} |
| </div> |
| |
| <div id="productModal" class="modal"> |
| <div class="modal-content"> |
| <button class="close-btn" onclick="closeModal('productModal')"><i class="fas fa-times"></i></button> |
| <div id="modalContent">Загрузка...</div> |
| </div> |
| </div> |
| |
| <div id="qrModal" class="modal"> |
| <div class="qr-modal-content"> |
| <button class="close-btn" onclick="closeModal('qrModal')" style="top: -15px; right: -15px; background: var(--danger);"><i class="fas fa-times"></i></button> |
| <h2 style="margin-bottom: 20px; color: var(--accent);">Мой QR-код</h2> |
| <div style="background: white; padding: 15px; border-radius: 15px; display: inline-block;"> |
| <img id="qrImage" src="" alt="QR Code" style="width: 200px; height: 200px; display: block;"> |
| </div> |
| <p style="margin-top: 20px; font-size: 0.9rem; opacity: 0.8;">Отсканируйте, чтобы открыть визитку</p> |
| </div> |
| </div> |
| |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script> |
| <script> |
| const allProducts = {{ products_json|safe }}; |
| const orderedCategories = {{ ordered_categories|tojson|safe }}; |
| const repoId = '{{ repo_id }}'; |
| const currencyCode = '{{ currency_code }}'; |
| const orgName = `{{ settings.organization_name }}`.replace(/`/g, ''); |
| |
| const itemsPerPage = 10; |
| let currentPage = 1; |
| let currentCategory = 'all'; |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| const chipsContainer = document.getElementById('category-chips'); |
| if(chipsContainer && orderedCategories.length > 0) { |
| let chipsHtml = `<button class="chip active" onclick="setCategory('all', this)">Все</button>`; |
| orderedCategories.forEach(cat => { |
| chipsHtml += `<button class="chip" onclick="setCategory('${cat.replace(/'/g, "\\'")}', this)">${cat}</button>`; |
| }); |
| chipsContainer.innerHTML = chipsHtml; |
| } |
| |
| const searchInput = document.getElementById('search-input'); |
| if(searchInput) { |
| searchInput.addEventListener('input', () => { |
| currentPage = 1; |
| renderCatalog(); |
| }); |
| } |
| |
| window.addEventListener('click', function(event) { if (event.target.classList.contains('modal')) { closeModal(event.target.id); } }); |
| |
| renderCatalog(); |
| }); |
| |
| function setCategory(cat, btn) { |
| document.querySelectorAll('.chip').forEach(c => c.classList.remove('active')); |
| if(btn) btn.classList.add('active'); |
| currentCategory = cat; |
| currentPage = 1; |
| const searchInput = document.getElementById('search-input'); |
| if(searchInput) searchInput.value = ''; |
| renderCatalog(); |
| } |
| |
| function buildProductCard(product) { |
| let photoUrl = (product.photos && product.photos.length > 0) |
| ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${product.photos[0]}` |
| : `https://via.placeholder.com/300x300.png?text=Нет+фото`; |
| |
| let priceText = product.price > 0 ? `<div class="product-price">${parseFloat(product.price).toFixed(0)} ${currencyCode}</div>` : ''; |
| |
| return ` |
| <div class="product-card" onclick="openModalById('${product.product_id}')"> |
| <div class="product-image-container"> |
| <img src="${photoUrl}" alt="${product.name}" loading="lazy"> |
| </div> |
| <div class="product-info"> |
| <div class="product-title">${product.name}</div> |
| ${priceText} |
| </div> |
| </div> |
| `; |
| } |
| |
| function renderCatalog() { |
| const searchInput = document.getElementById('search-input'); |
| const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : ''; |
| const container = document.getElementById('catalog-content'); |
| if(!container) return; |
| |
| let filtered = allProducts.filter(p => { |
| let matchCat = currentCategory === 'all' || p.category === currentCategory; |
| let matchSearch = searchTerm === '' || (p.name || '').toLowerCase().includes(searchTerm) || (p.description || '').toLowerCase().includes(searchTerm); |
| return matchCat && matchSearch; |
| }); |
| |
| const totalPages = Math.ceil(filtered.length / itemsPerPage) || 1; |
| if (currentPage > totalPages) currentPage = totalPages; |
| |
| const start = (currentPage - 1) * itemsPerPage; |
| const paginated = filtered.slice(start, start + itemsPerPage); |
| |
| if (filtered.length === 0) { |
| container.innerHTML = '<div class="no-results-message">По вашему запросу ничего не найдено.</div>'; |
| return; |
| } |
| |
| let html = '<div class="product-grid">'; |
| paginated.forEach(product => { |
| html += buildProductCard(product); |
| }); |
| html += '</div>'; |
| |
| if (totalPages > 1) { |
| html += '<div class="pagination">'; |
| if (currentPage > 1) { |
| html += `<button onclick="changePage(${currentPage - 1})"><i class="fas fa-chevron-left"></i></button>`; |
| } |
| for (let i = 1; i <= totalPages; i++) { |
| if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) { |
| html += `<button class="${i === currentPage ? 'active' : ''}" onclick="changePage(${i})">${i}</button>`; |
| } else if (i === currentPage - 2 || i === currentPage + 2) { |
| html += `<span style="color:var(--text-light)">...</span>`; |
| } |
| } |
| if (currentPage < totalPages) { |
| html += `<button onclick="changePage(${currentPage + 1})"><i class="fas fa-chevron-right"></i></button>`; |
| } |
| html += '</div>'; |
| } |
| container.innerHTML = html; |
| } |
| |
| function changePage(page) { |
| currentPage = page; |
| renderCatalog(); |
| const catalogSec = document.querySelector('.catalog-section'); |
| if(catalogSec) catalogSec.scrollIntoView({behavior: 'smooth'}); |
| } |
| |
| function getProductById(productId) { return allProducts.find(p => p.product_id === productId); } |
| |
| function openModalById(productId) { |
| const product = getProductById(productId); |
| if (!product) return; |
| |
| const modalContent = document.getElementById('modalContent'); |
| |
| let photosHtml = ''; |
| let paginationHtml = ''; |
| if (product.photos && product.photos.length > 0) { |
| photosHtml = product.photos.map(photo => ` |
| <div class="swiper-slide"> |
| <img src="https://huggingface.co/datasets/${repoId}/resolve/main/photos/${photo}" alt="${product.name}"> |
| </div> |
| `).join(''); |
| if(product.photos.length > 1) { |
| paginationHtml = `<div class="swiper-pagination"></div>`; |
| } |
| } else { |
| photosHtml = ` |
| <div class="swiper-slide"> |
| <img src="https://via.placeholder.com/500x500.png?text=Нет+фото" alt="No image"> |
| </div> |
| `; |
| } |
| |
| let priceHtml = product.price > 0 ? `<div class="modal-price">${parseFloat(product.price).toFixed(2)} ${currencyCode}</div>` : ''; |
| let catHtml = product.category && product.category !== 'Без категории' ? `<div class="modal-category">${product.category}</div>` : ''; |
| let descHtml = product.description ? `<div class="modal-desc">${product.description.replace(/\\n/g, '<br>')}</div>` : ''; |
| |
| modalContent.innerHTML = ` |
| <div class="swiper-container"> |
| <div class="swiper-wrapper"> |
| ${photosHtml} |
| </div> |
| ${paginationHtml} |
| </div> |
| <div class="modal-body"> |
| ${catHtml} |
| <h2 class="modal-title">${product.name}</h2> |
| ${priceHtml} |
| ${descHtml} |
| </div> |
| `; |
| |
| const modal = document.getElementById('productModal'); |
| modal.style.display = "block"; |
| document.body.style.overflow = 'hidden'; |
| |
| if(product.photos && product.photos.length > 1) { |
| new Swiper('#productModal .swiper-container', { |
| slidesPerView: 1, |
| pagination: { el: '.swiper-pagination', clickable: true } |
| }); |
| } |
| } |
| |
| function openQrModal() { |
| const currentDomain = window.location.origin; |
| const targetUrl = currentDomain + "{{ url_for('catalog', env_id=env_id) }}"; |
| const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${encodeURIComponent(targetUrl)}&margin=10`; |
| document.getElementById('qrImage').src = qrApiUrl; |
| document.getElementById('qrModal').style.display = 'block'; |
| document.body.style.overflow = 'hidden'; |
| } |
| |
| function closeModal(modalId) { |
| const modal = document.getElementById(modalId); |
| if (modal) { |
| modal.style.display = "none"; |
| document.body.style.overflow = 'auto'; |
| } |
| } |
| </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>Настройки Визитки - {{ settings.organization_name }}</title> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;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> |
| :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; --danger-hover: #EF5350; } |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; line-height: 1.6; margin: 0; } |
| .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;} |
| .header .logo-title-container img { height: 50px; width: 50px; border-radius: 50%; object-fit: cover; border: 2px solid var(--bg-medium);} |
| h1, h2, h3 { font-weight: 600; color: var(--bg-medium); margin-bottom: 15px; } |
| h1 { font-size: 1.8rem; } |
| h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; } |
| h3 { font-size: 1.2rem; color: #004D40; margin-top: 20px; } |
| .section { margin-bottom: 30px; padding: 20px; background-color: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; } |
| form { margin-bottom: 20px; } |
| label { font-weight: 500; margin-top: 10px; display: block; color: #666; font-size: 0.95rem;} |
| input[type="text"], input[type="number"], input[type="password"], input[type="tel"], textarea, select { width: 100%; padding: 12px; margin-top: 5px; border: 1px solid #e0e0e0; border-radius: 8px; font-size: 1rem; box-sizing: border-box; transition: border-color 0.3s ease; background-color: #fff; min-height: 44px;} |
| input:focus, textarea:focus, select:focus { border-color: var(--bg-medium); outline: none; box-shadow: 0 0 0 2px rgba(19, 93, 102, 0.1); } |
| textarea { min-height: 80px; resize: vertical; } |
| input[type="file"] { padding: 8px; background-color: #ffffff; cursor: pointer; border: 1px solid #e0e0e0;} |
| input[type="file"]::file-selector-button { padding: 8px 12px; border-radius: 6px; background-color: #f0f0f0; border: 1px solid #e0e0e0; cursor: pointer; margin-right: 10px;} |
| input[type="checkbox"] { margin-right: 8px; vertical-align: middle; width: 20px; height: 20px; cursor: pointer;} |
| label.inline-label { display: inline-block; margin-top: 10px; font-weight: normal; cursor: pointer; } |
| button, .button { padding: 10px 20px; border: none; border-radius: 8px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 1rem; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; line-height: 1.2; min-height: 44px; justify-content: center;} |
| button:hover, .button:hover { background-color: var(--accent-hover); } |
| button:active, .button:active { transform: scale(0.98); } |
| .delete-button { background-color: var(--danger); color: white; } |
| .delete-button:hover { background-color: var(--danger-hover); } |
| .add-button { background-color: var(--bg-medium); color: white; } |
| .add-button:hover { background-color: #003C43; } |
| .item-list { display: grid; gap: 20px; } |
| .item { background: #fff; padding: 15px 20px; border-radius: 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.05); border: 1px solid #f0f0f0; } |
| .item p { margin: 5px 0; font-size: 0.95rem; color: #666; } |
| .item strong { color: var(--text-dark); } |
| .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } |
| .edit-form-container { margin-top: 15px; padding: 20px; background: #E0F2F1; border: 1px dashed #B2DFDB; border-radius: 8px; display: none; } |
| details { background-color: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 20px; } |
| details > summary { cursor: pointer; font-weight: 600; color: var(--bg-medium); display: block; padding: 18px 20px; list-style: none; position: relative; font-size: 1.1rem; } |
| details > summary:hover { background-color: #fafafa; } |
| details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: var(--bg-medium); } |
| details[open] > summary::after { transform: translateY(-50%) rotate(180deg); } |
| details[open] > summary { border-bottom: 1px solid #e0e0e0; } |
| details .form-content { padding: 20px; } |
| .photo-preview img { max-width: 80px; max-height: 80px; border-radius: 8px; margin: 5px 5px 0 0; border: 1px solid #e0e0e0; object-fit: cover;} |
| |
| .flex-container { display: flex; flex-wrap: wrap; gap: 20px; } |
| .flex-item { flex: 1; min-width: 100%; } |
| @media (min-width: 768px) { .flex-item { min-width: calc(50% - 10px); } } |
| |
| .message { padding: 12px 15px; border-radius: 8px; margin-bottom: 15px; font-size: 0.95rem; font-weight: 500;} |
| .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;} |
| .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;} |
| .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; } |
| |
| .ai-generate-button { background-color: #8D6EC8; color: white; margin-top: 10px; margin-bottom: 10px; } |
| .ai-generate-button:hover { background-color: #7B4DB5; } |
| |
| .current-avatar { max-width: 60px; max-height: 60px; border-radius: 50%; vertical-align: middle; margin-left: 10px; border: 2px solid var(--bg-medium);} |
| |
| .block-item { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 15px; border: 1px solid #e0e0e0; border-radius: 12px; margin-bottom: 10px; flex-wrap: wrap; gap: 10px;} |
| .block-controls { display: flex; gap: 8px; } |
| .btn-small { padding: 8px 12px; font-size: 0.9rem; min-height: auto;} |
| |
| .pagination { display: flex; justify-content: center; gap: 8px; margin-top: 30px; flex-wrap: wrap; align-items: center; } |
| .pagination .button { min-width: 44px; text-align: center; padding: 10px 15px; margin: 0; border-radius: 8px; } |
| |
| #loadingOverlay { |
| display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; |
| background: rgba(0,0,0,0.8); z-index: 10000; flex-direction: column; |
| justify-content: center; align-items: center; color: white; text-align: center; |
| } |
| .spinner { |
| width: 60px; height: 60px; border: 6px solid #f3f3f3; |
| border-top: 6px solid var(--accent); border-radius: 50%; |
| animation: spin 1s linear infinite; margin-bottom: 20px; |
| } |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
| |
| @media (max-width: 600px) { |
| .header { flex-direction: column; align-items: flex-start; } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="loadingOverlay"> |
| <div class="spinner"></div> |
| <h2>Сохранение данных...</h2> |
| </div> |
| <div class="container"> |
| <div class="header"> |
| <div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;"> |
| <img src="{{ chat_avatar_url }}" alt="Logo"> |
| <h1><i class="fas fa-id-badge"></i> Настройки Визитки</h1> |
| </div> |
| <div style="display: flex; gap: 10px; flex-wrap: wrap;"> |
| <a href="{{ url_for('catalog', env_id=env_id) }}" class="button" style="background-color: var(--bg-medium); color: white;"><i class="fas fa-external-link-alt"></i> Открыть визитку</a> |
| <button onclick="downloadQR()" class="button" style="background-color: #8A2BE2; color: white;"><i class="fas fa-qrcode"></i> QR-код</button> |
| {% if settings.admin_password_enabled %} |
| <a href="{{ url_for('admin_logout', env_id=env_id) }}" class="button" style="background-color: #6c757d; color: white;"><i class="fas fa-sign-out-alt"></i> Выход</a> |
| {% endif %} |
| </div> |
| </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"> |
| <details open> |
| <summary><i class="fas fa-user-edit"></i> Профиль и Внешний вид</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()"> |
| <input type="hidden" name="action" value="update_settings"> |
| |
| <div style="display:flex; gap:15px; flex-wrap:wrap;"> |
| <div style="flex:1; min-width:200px;"> |
| <label>Имя:</label> |
| <input type="text" name="vcard_firstname" value="{{ settings.vcard_firstname }}"> |
| </div> |
| <div style="flex:1; min-width:200px;"> |
| <label>Фамилия:</label> |
| <input type="text" name="vcard_lastname" value="{{ settings.vcard_lastname }}"> |
| </div> |
| </div> |
| |
| <label>Должность / Специальность:</label> |
| <input type="text" name="vcard_job" value="{{ settings.vcard_job }}"> |
| |
| <label>Название организации:</label> |
| <input type="text" name="organization_name" value="{{ settings.organization_name }}"> |
| |
| <label>Коротко о себе / Статус:</label> |
| <textarea name="about_text" rows="3">{{ settings.about_text }}</textarea> |
| |
| <label>Аватар (Фото профиля):</label> |
| <input type="file" name="chat_avatar" accept="image/*"> |
| {% if settings.chat_avatar %} |
| <p style="font-size: 0.95rem; margin-top: 10px;">Текущий аватар: <img src="{{ chat_avatar_url }}" class="current-avatar"></p> |
| {% endif %} |
| |
| <label>Цветовая схема:</label> |
| <select name="color_scheme"> |
| {% for key, name in color_schemes.items() %} |
| <option value="{{ key }}" {% if settings.color_scheme == key %}selected{% endif %}>{{ name }}</option> |
| {% endfor %} |
| </select> |
| |
| <label>Валюта (для товаров/услуг):</label> |
| <select name="currency_code"> |
| {% for code, name in currencies.items() %} |
| <option value="{{ code }}" {% if settings.currency_code == code %}selected{% endif %}>{{ name }} ({{ code }})</option> |
| {% endfor %} |
| </select> |
| |
| <div style="background: #f1f3f5; padding: 20px; border-radius: 12px; margin-top: 25px;"> |
| <h4 style="margin-top: 0; color: var(--bg-medium); font-size: 1.1rem;"><i class="fas fa-lock"></i> Пароль для входа сюда</h4> |
| <label style="display: flex; align-items: center; gap: 8px;"><input type="checkbox" name="admin_password_enabled" {% if settings.admin_password_enabled %}checked{% endif %}> Требовать пароль</label> |
| <label style="margin-top: 15px;">Пароль:</label> |
| <input type="text" name="admin_password" value="{{ settings.admin_password }}" placeholder="Текущий пароль"> |
| </div> |
| |
| <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить настройки</button> |
| </form> |
| </div> |
| </details> |
| </div> |
| |
| <div class="section"> |
| <h2><i class="fas fa-link"></i> Кнопки и Ссылки (для Визитки)</h2> |
| <details> |
| <summary><i class="fas fa-plus-circle"></i> Добавить кнопку</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}"> |
| <input type="hidden" name="action" value="add_block"> |
| <label>Тип блока:</label> |
| <select name="block_type" id="block_type" onchange="toggleBlockFields()"> |
| <option value="link">Кнопка-ссылка</option> |
| <option value="text">Текстовый блок</option> |
| </select> |
| <label>Заголовок / Текст кнопки:</label> |
| <input type="text" name="block_title" required> |
| |
| <div id="block_icon_div"> |
| <label>Иконка:</label> |
| <select name="block_icon"> |
| <option value="">-- Без иконки --</option> |
| {% for icon_class, icon_name in icons.items() %} |
| <option value="{{ icon_class }}">{{ icon_name }}</option> |
| {% endfor %} |
| </select> |
| </div> |
| |
| <div id="block_url_div"> |
| <label>URL / Номер / Email (Вставьте ссылку, или телефон начиная с +):</label> |
| <input type="text" name="block_url" placeholder="https://... или +996... или mail@.com"> |
| </div> |
| <div id="block_content_div" style="display: none;"> |
| <label>Текст блока:</label> |
| <textarea name="block_content" rows="3"></textarea> |
| </div> |
| <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button> |
| </form> |
| </div> |
| </details> |
| <div style="margin-top: 15px;"> |
| {% if blocks %} |
| {% for block in blocks %} |
| <div class="block-item"> |
| <div style="flex-grow: 1;"> |
| <strong style="font-size: 1.1rem;"> |
| {% if block.icon %} |
| {% set prefix = 'fab' if block.icon in ['fa-whatsapp', 'fa-telegram', 'fa-instagram', 'fa-youtube', 'fa-tiktok'] else 'fas' %} |
| <i class="{{ prefix }} {{ block.icon }}" style="color:var(--accent);"></i> |
| {% endif %} |
| {{ block.title }} |
| </strong> |
| <span style="color: #888; font-size: 0.9rem;">({{ 'Ссылка' if block.type == 'link' else 'Текст' }})</span> |
| {% if block.type == 'link' %}<br><small style="font-size: 0.9rem;"><a href="{{ block.url }}" target="_blank" rel="noopener noreferrer">{{ block.url }}</a></small>{% endif %} |
| </div> |
| <div class="block-controls"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;"> |
| <input type="hidden" name="action" value="move_block_up"> |
| <input type="hidden" name="block_id" value="{{ block.id }}"> |
| <button type="submit" class="button btn-small" style="background: #6c757d;" {% if loop.first %}disabled{% endif %}><i class="fas fa-arrow-up"></i></button> |
| </form> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;"> |
| <input type="hidden" name="action" value="move_block_down"> |
| <input type="hidden" name="block_id" value="{{ block.id }}"> |
| <button type="submit" class="button btn-small" style="background: #6c757d;" {% if loop.last %}disabled{% endif %}><i class="fas fa-arrow-down"></i></button> |
| </form> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;" onsubmit="if(!confirm('Удалить блок?')) return false;"> |
| <input type="hidden" name="action" value="delete_block"> |
| <input type="hidden" name="block_id" value="{{ block.id }}"> |
| <button type="submit" class="button delete-button btn-small"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </div> |
| </div> |
| {% endfor %} |
| {% else %} |
| <p style="font-size: 1.05rem;">Кнопки не добавлены.</p> |
| {% endif %} |
| </div> |
| <script> |
| function toggleBlockFields() { |
| const type = document.getElementById('block_type').value; |
| document.getElementById('block_url_div').style.display = type === 'link' ? 'block' : 'none'; |
| document.getElementById('block_icon_div').style.display = type === 'link' ? 'block' : 'none'; |
| document.getElementById('block_content_div').style.display = type === 'text' ? 'block' : 'none'; |
| } |
| </script> |
| </div> |
| |
| <div class="section"> |
| <h2><i class="fas fa-tags"></i> Категории товаров/услуг</h2> |
| <details> |
| <summary><i class="fas fa-plus-circle"></i> Добавить новую категорию</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}"> |
| <input type="hidden" name="action" value="add_category"> |
| <label>Название новой категории:</label> |
| <input type="text" name="category_name" required> |
| <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button> |
| </form> |
| </div> |
| </details> |
| {% if categories %} |
| <div class="item-list" style="margin-top:20px;"> |
| {% for category in categories %} |
| <div class="item" style="display: flex; justify-content: space-between; align-items: center;"> |
| <span style="font-size: 1.05rem; font-weight: 500;">{{ category }}</span> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;" onsubmit="if(!confirm('Вы уверены? Товары этой категории будут помечены как \\'Без категории\\'.')) return false;"> |
| <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;"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </div> |
| {% endfor %} |
| </div> |
| {% else %} |
| <p style="font-size: 1.05rem; margin-top:15px;">Категорий пока нет.</p> |
| {% endif %} |
| </div> |
| |
| <div class="section"> |
| <h2><i class="fas fa-box-open"></i> Товары и Услуги</h2> |
| <details> |
| <summary><i class="fas fa-plus-circle"></i> Добавить товар/услугу</summary> |
| <div class="form-content"> |
| <form id="add-product-form" method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()"> |
| <input type="hidden" name="action" value="add_product"> |
| <label>Название *:</label> |
| <input type="text" name="name" required> |
| |
| <label>Цена (Оставьте 0, если не нужно показывать):</label> |
| <input type="number" name="price" step="0.01" value="0"> |
| |
| <label>Фотографии (до 10 шт.):</label> |
| <input type="file" name="photos" accept="image/*" multiple> |
| |
| <label>Описание:</label> |
| <textarea id="add_description" name="description" rows="4"></textarea> |
| |
| <div style="display: flex; gap: 10px; align-items: center; margin-top: 10px; flex-wrap: wrap;"> |
| <button type="button" class="button ai-generate-button" style="margin: 0;" onclick="generateDescription('add_product-form', 'add_description', 'add_gen_lang')"><i class="fas fa-magic"></i> Сгенерировать AI-описание по первому фото</button> |
| <select id="add_gen_lang" name="gen_lang" style="width: auto; margin: 0;"> |
| <option value="Русский">Русский</option><option value="Кыргызский">Кыргызский</option><option value="Казахский">Казахский</option><option value="Узбекский">Узбекский</option><option value="Английский">Английский</option> |
| </select> |
| </div> |
| |
| <label style="margin-top: 15px;">Категория:</label> |
| <select name="category"> |
| <option value="Без категории">Без категории</option> |
| {% for category in categories %} |
| <option value="{{ category }}">{{ category }}</option> |
| {% endfor %} |
| </select> |
| |
| <button type="submit" class="add-button" style="margin-top: 25px;"><i class="fas fa-save"></i> Добавить</button> |
| </form> |
| </div> |
| </details> |
| |
| <h3 style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; margin-top: 30px;">Список товаров/услуг:</h3> |
| <form method="GET" action="{{ url_for('admin', env_id=env_id) }}" style="margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap;" id="admin-search-form"> |
| <input type="text" name="q" value="{{ search_q }}" placeholder="Поиск..." style="flex-grow: 1; margin: 0;" id="admin-search-input"> |
| <button type="submit" class="button" style="margin: 0;"><i class="fas fa-search"></i> Поиск</button> |
| {% if search_q %} |
| <a href="{{ url_for('admin', env_id=env_id) }}" class="button" style="background-color: #6c757d; margin: 0; justify-content: center;"><i class="fas fa-times"></i> Сброс</a> |
| {% endif %} |
| </form> |
| |
| {% if paginated_products %} |
| <div class="item-list" id="admin-products-list"> |
| {% for product in paginated_products %} |
| <div class="item"> |
| <div style="display: flex; gap: 15px; align-items: flex-start;"> |
| <div class="photo-preview" style="flex-shrink: 0;"> |
| {% if product.get('photos') %} |
| <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="Фото"> |
| {% else %} |
| <img src="https://via.placeholder.com/80x80.png?text=Нет+фото" alt="Нет фото"> |
| {% endif %} |
| </div> |
| <div style="flex-grow: 1;"> |
| <h3 style="margin-top: 0; margin-bottom: 5px; color: var(--text-dark); font-size: 1.15rem;">{{ product['name'] }}</h3> |
| <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p> |
| <p><strong>Цена:</strong> {% if product.get('price', 0) > 0 %}{{ "%.2f"|format(product.get('price', 0)) }} {{ currency_code }}{% else %}Не указана{% endif %}</p> |
| </div> |
| </div> |
| |
| <div class="item-actions"> |
| <button type="button" class="button" onclick="toggleEditForm('edit-form-{{ product.product_id }}')"><i class="fas fa-edit"></i> Редактировать</button> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin:0;" onsubmit="if(!confirm('Удалить элемент?')) return false; showLoadingOverlay(); return true;"> |
| <input type="hidden" name="action" value="delete_product"> |
| <input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}"> |
| <button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button> |
| </form> |
| </div> |
| |
| <div id="edit-form-{{ product.product_id }}" class="edit-form-container"> |
| <h4 style="margin-top: 0; font-size: 1.1rem;"><i class="fas fa-edit"></i> Редактирование</h4> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()"> |
| <input type="hidden" name="action" value="edit_product"> |
| <input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}"> |
| |
| <label>Название *:</label> |
| <input type="text" name="name" value="{{ product['name'] }}" required> |
| |
| <label>Цена:</label> |
| <input type="number" name="price" step="0.01" value="{{ product.get('price', 0) }}"> |
| |
| <label>Заменить фотографии (выбор новых удалит старые):</label> |
| <input type="file" name="photos" accept="image/*" multiple> |
| |
| <label>Описание:</label> |
| <textarea name="description" rows="4">{{ product.get('description', '') }}</textarea> |
| |
| <label>Категория:</label> |
| <select name="category"> |
| <option value="Без категории">Без категории</option> |
| {% for category in categories %} |
| <option value="{{ category }}" {% if product.get('category') == category %}selected{% endif %}>{{ category }}</option> |
| {% endfor %} |
| </select> |
| |
| <button type="submit" class="add-button" style="margin-top: 25px;"><i class="fas fa-save"></i> Сохранить</button> |
| </form> |
| </div> |
| </div> |
| {% endfor %} |
| </div> |
| |
| {% if total_pages > 1 %} |
| <div class="pagination"> |
| {% if page > 1 %} |
| <a href="{{ url_for('admin', env_id=env_id, p=page-1, q=search_q) }}" class="button">«</a> |
| {% endif %} |
| |
| {% for p_num in range(1, total_pages + 1) %} |
| {% if p_num == 1 or p_num == total_pages or (p_num >= page - 2 and p_num <= page + 2) %} |
| <a href="{{ url_for('admin', env_id=env_id, p=p_num, q=search_q) }}" class="button {% if p_num == page %}active{% endif %}" style="{% if p_num == page %}background-color: var(--accent); color: var(--text-dark);{% else %}background-color: var(--bg-medium); color: white;{% endif %}">{{ p_num }}</a> |
| {% elif p_num == page - 3 or p_num == page + 3 %} |
| <span style="padding: 10px; color: var(--bg-medium); font-weight: bold;">...</span> |
| {% endif %} |
| {% endfor %} |
| |
| {% if page < total_pages %} |
| <a href="{{ url_for('admin', env_id=env_id, p=page+1, q=search_q) }}" class="button">»</a> |
| {% endif %} |
| </div> |
| {% endif %} |
| |
| {% else %} |
| <p style="font-size: 1.1rem; text-align: center; padding: 40px;">Записей пока нет или по вашему запросу ничего не найдено.</p> |
| {% endif %} |
| </div> |
| </div> |
| |
| <script> |
| document.addEventListener("DOMContentLoaded", function() { |
| const adminSearchInput = document.getElementById('admin-search-input'); |
| const adminSearchForm = document.getElementById('admin-search-form'); |
| |
| if (adminSearchInput && adminSearchForm) { |
| if (adminSearchInput.value.length > 0) { |
| adminSearchInput.focus(); |
| const valLen = adminSearchInput.value.length; |
| adminSearchInput.setSelectionRange(valLen, valLen); |
| } |
| |
| let debounceTimer; |
| adminSearchInput.addEventListener('input', function() { |
| clearTimeout(debounceTimer); |
| debounceTimer = setTimeout(() => { |
| adminSearchForm.submit(); |
| }, 700); |
| }); |
| } |
| }); |
| |
| function showLoadingOverlay() { |
| document.getElementById('loadingOverlay').style.display = 'flex'; |
| } |
| |
| function toggleEditForm(formId) { |
| const formContainer = document.getElementById(formId); |
| if (formContainer) { |
| formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none'; |
| } |
| } |
| |
| async function generateDescription(formId, descriptionTextareaId, languageSelectId) { |
| const form = document.getElementById(formId); |
| const photoInput = form.querySelector('input[type="file"]'); |
| const descriptionTextarea = document.getElementById(descriptionTextareaId); |
| const languageSelect = document.getElementById(languageSelectId); |
| |
| if (!photoInput || !photoInput.files || photoInput.files.length === 0) { |
| return alert("Загрузите фото перед генерацией."); |
| } |
| |
| descriptionTextarea.value = 'Генерация...'; |
| const reader = new FileReader(); |
| reader.onload = async (e) => { |
| const base64Image = e.target.result.split(',')[1]; |
| try { |
| const response = await fetch('/generate_description_ai', { |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ image: base64Image, language: languageSelect.value }) |
| }); |
| const result = await response.json(); |
| descriptionTextarea.value = result.text || result.error; |
| } catch (error) { descriptionTextarea.value = `Ошибка: ${error.message}`; } |
| }; |
| reader.readAsDataURL(photoInput.files[0]); |
| } |
| |
| function downloadQR() { |
| const currentDomain = window.location.origin; |
| const targetUrl = currentDomain + "{{ url_for('catalog', env_id=env_id) }}"; |
| const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${encodeURIComponent(targetUrl)}`; |
| |
| fetch(qrApiUrl) |
| .then(response => response.blob()) |
| .then(blob => { |
| const link = document.createElement('a'); |
| link.href = URL.createObjectURL(blob); |
| link.download = 'QR_code_{{ env_id }}.png'; |
| document.body.appendChild(link); |
| link.click(); |
| document.body.removeChild(link); |
| }) |
| .catch(err => alert('Ошибка при скачивании QR-кода')); |
| } |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| @app.route('/') |
| def index(): |
| return render_template_string(LANDING_PAGE_TEMPLATE) |
|
|
| @app.route('/admhosto', methods=['GET']) |
| def admhosto(): |
| data = load_data() |
| environments_data = [] |
| for env_id, env_data in data.items(): |
| settings = env_data.get('settings', {}) |
| org_name = settings.get("organization_name", f"Визитка {env_id}") |
| environments_data.append({ |
| "id": env_id, |
| "org_name": org_name, |
| "pwd_enabled": settings.get("admin_password_enabled", False), |
| "password": settings.get("admin_password", "") |
| }) |
| environments_data.sort(key=lambda x: x['id']) |
| return render_template_string(ADMHOSTO_TEMPLATE, environments=environments_data) |
|
|
| @app.route('/admhosto/create', methods=['POST']) |
| def create_environment(): |
| all_data = load_data() |
| while True: |
| new_id = ''.join(random.choices(string.digits, k=6)) |
| if new_id not in all_data: |
| break |
| all_data[new_id] = { |
| 'products': [], 'categories':[], 'blocks':[], |
| 'settings': { |
| "vcard_firstname": "Имя", |
| "vcard_lastname": "Фамилия", |
| "vcard_job": "Специалист", |
| "organization_name": "Моя Компания", |
| "currency_code": "KGS", |
| "chat_avatar": None, |
| "color_scheme": "default", |
| "admin_password_enabled": False, |
| "admin_password": "", |
| "categories_as_lines": False, |
| "about_text": "Привет! Это моя онлайн-визитка." |
| } |
| } |
| save_data(all_data) |
| flash(f'Новая визитка с ID {new_id} успешно создана.', 'success') |
| return redirect(url_for('admhosto')) |
|
|
| @app.route('/admhosto/update_pwd/<env_id>', methods=['POST']) |
| def update_env_pwd(env_id): |
| all_data = load_data() |
| if env_id in all_data: |
| pwd_enabled = 'pwd_enabled' in request.form |
| password = request.form.get('password', '').strip() |
| all_data[env_id]['settings']['admin_password_enabled'] = pwd_enabled |
| all_data[env_id]['settings']['admin_password'] = password |
| save_data(all_data) |
| flash(f'Пароль для визитки {env_id} обновлен.', 'success') |
| else: |
| flash(f'Визитка {env_id} не найдена.', 'error') |
| return redirect(url_for('admhosto')) |
|
|
| @app.route('/admhosto/delete/<env_id>', methods=['POST']) |
| def delete_environment(env_id): |
| all_data = load_data() |
| if env_id in all_data: |
| del all_data[env_id] |
| save_data(all_data) |
| flash(f'Визитка {env_id} была удалена.', 'success') |
| else: |
| flash(f'Визитка {env_id} не найдена.', 'error') |
| return redirect(url_for('admhosto')) |
|
|
| @app.route('/<env_id>/login', methods=['GET', 'POST']) |
| def admin_login(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| |
| if not settings.get('admin_password_enabled'): |
| return redirect(url_for('admin', env_id=env_id)) |
| |
| if request.method == 'POST': |
| pwd = request.form.get('password', '') |
| if pwd == settings.get('admin_password', ''): |
| session[f'admin_auth_{env_id}'] = True |
| return redirect(url_for('admin', env_id=env_id)) |
| else: |
| flash('Неверный пароль', 'error') |
| |
| return render_template_string(LOGIN_TEMPLATE, env_id=env_id) |
|
|
| @app.route('/<env_id>/logout') |
| def admin_logout(env_id): |
| session.pop(f'admin_auth_{env_id}', None) |
| return redirect(url_for('admin_login', env_id=env_id)) |
|
|
| @app.route('/<env_id>/vcard') |
| def download_vcard(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| blocks = data.get('blocks', []) |
|
|
| first_name = settings.get("vcard_firstname", "") |
| last_name = settings.get("vcard_lastname", "") |
| org = settings.get("organization_name", "") |
| title = settings.get("vcard_job", "") |
| note = settings.get("about_text", "").replace('\n', '\\n') |
|
|
| card_url = url_for('catalog', env_id=env_id, _external=True) |
|
|
| vcard = [ |
| "BEGIN:VCARD", |
| "VERSION:3.0", |
| f"N:{last_name};{first_name};;;", |
| f"FN:{first_name} {last_name}".strip(), |
| ] |
| |
| if org: vcard.append(f"ORG:{org}") |
| if title: vcard.append(f"TITLE:{title}") |
| if note: vcard.append(f"NOTE:{note}") |
| |
| vcard.append(f"URL;type=pref:{card_url}") |
|
|
| for b in blocks: |
| if b.get('type') == 'link' and b.get('url'): |
| url = b.get('url', '').strip() |
| if url.startswith('+') or url.replace('-', '').replace(' ', '').isdigit(): |
| vcard.append(f"TEL;TYPE=CELL:{url}") |
| elif '@' in url and not url.startswith('http'): |
| vcard.append(f"EMAIL;TYPE=WORK:{url.replace('mailto:', '')}") |
| else: |
| vcard.append(f"URL:{url}") |
|
|
| vcard.append("END:VCARD") |
| vcard_str = "\r\n".join(vcard) |
|
|
| response = make_response(vcard_str) |
| response.headers["Content-Disposition"] = f"attachment; filename=contact_{env_id}.vcf" |
| response.headers["Content-Type"] = "text/vcard; charset=utf-8" |
| return response |
|
|
| @app.route('/<env_id>/catalog') |
| def catalog(env_id): |
| data = get_env_data(env_id) |
| all_products = data.get('products',[]) |
| settings = data.get('settings', {}) |
| blocks = data.get('blocks',[]) |
| |
| product_categories = set(p.get('category', 'Без категории') for p in all_products) |
| admin_categories = set(data.get('categories',[])) |
| all_cat_names = sorted(list(product_categories.union(admin_categories))) |
| |
| products_sorted_for_js = sorted(all_products, key=lambda p: p.get('name', '').lower()) |
| |
| products_by_category = {cat:[] for cat in all_cat_names} |
| for product in all_products: |
| products_by_category[product.get('category', 'Без категории')].append(product) |
| ordered_categories = [cat for cat in all_cat_names if products_by_category.get(cat)] |
| |
| chat_avatar_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/avatars/{settings['chat_avatar']}" if settings.get('chat_avatar') else "https://huggingface.co/spaces/gippo312/admin/resolve/main/Picsart_25-11-04_12-02-21-390.png" |
| |
| return render_template_string( |
| CATALOG_TEMPLATE, ordered_categories=ordered_categories, |
| products_json=json.dumps(products_sorted_for_js), repo_id=REPO_ID, currency_code=settings.get('currency_code', 'KGS'), |
| settings=settings, chat_avatar_url=chat_avatar_url, env_id=env_id, blocks=blocks |
| ) |
|
|
| @app.route('/<env_id>/admin', methods=['GET', 'POST']) |
| def admin(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): |
| return redirect(url_for('admin_login', env_id=env_id)) |
| |
| products = data.get('products',[]) |
| categories = data.get('categories',[]) |
| blocks = data.get('blocks',[]) |
|
|
| page = request.args.get('p', 1, type=int) |
| search_q = request.args.get('q', '').strip() |
|
|
| if request.method == 'POST': |
| action = request.form.get('action') |
| try: |
| if action == 'add_block': |
| b_type = request.form.get('block_type') |
| b_title = request.form.get('block_title', '').strip() |
| b_icon = request.form.get('block_icon', '') |
| b_url = request.form.get('block_url', '').strip() |
| |
| if b_type == 'link' and b_url: |
| if not b_url.startswith(('http://', 'https://', 'mailto:', 'tel:')) and not b_url.startswith('+'): |
| if '@' in b_url: |
| b_url = 'mailto:' + b_url |
| else: |
| b_url = 'https://' + b_url |
| elif b_url.startswith('+'): |
| b_url = 'tel:' + b_url.replace(' ', '').replace('-', '') |
|
|
| b_content = request.form.get('block_content', '').strip() |
| blocks.append({ |
| 'id': uuid4().hex[:8], |
| 'type': b_type, |
| 'title': b_title, |
| 'icon': b_icon if b_type == 'link' else '', |
| 'url': b_url, |
| 'content': b_content |
| }) |
| data['blocks'] = blocks |
| save_env_data(env_id, data) |
| flash("Блок добавлен.", "success") |
| |
| elif action == 'delete_block': |
| b_id = request.form.get('block_id') |
| data['blocks'] = [b for b in blocks if b.get('id') != b_id] |
| save_env_data(env_id, data) |
| flash("Блок удален.", "success") |
| |
| elif action == 'move_block_up': |
| b_id = request.form.get('block_id') |
| idx = next((i for i, b in enumerate(blocks) if b.get('id') == b_id), -1) |
| if idx > 0: |
| blocks[idx], blocks[idx-1] = blocks[idx-1], blocks[idx] |
| data['blocks'] = blocks |
| save_env_data(env_id, data) |
| flash("Блок перемещен выше.", "success") |
| |
| elif action == 'move_block_down': |
| b_id = request.form.get('block_id') |
| idx = next((i for i, b in enumerate(blocks) if b.get('id') == b_id), -1) |
| if idx != -1 and idx < len(blocks) - 1: |
| blocks[idx], blocks[idx+1] = blocks[idx+1], blocks[idx] |
| data['blocks'] = blocks |
| save_env_data(env_id, data) |
| flash("Блок перемещен ниже.", "success") |
| |
| elif action == 'add_category': |
| category_name = request.form.get('category_name', '').strip() |
| if category_name and category_name not in categories: |
| categories.append(category_name) |
| data['categories'] = categories |
| save_env_data(env_id, data) |
| flash(f"Категория '{category_name}' успешно добавлена.", 'success') |
| elif not category_name: |
| flash("Название категории не может быть пустым.", 'error') |
| else: |
| flash(f"Категория '{category_name}' уже существует.", 'error') |
|
|
| 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_count = 0 |
| for product in products: |
| if product.get('category') == category_to_delete: |
| product['category'] = 'Без категории' |
| updated_count += 1 |
| data['categories'] = categories |
| data['products'] = products |
| save_env_data(env_id, data) |
| flash(f"Категория '{category_to_delete}' удалена. {updated_count} товаров/услуг обновлено.", 'success') |
| else: |
| flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error') |
| |
| elif action == 'update_settings': |
| settings['admin_password_enabled'] = 'admin_password_enabled' in request.form |
| settings['admin_password'] = request.form.get('admin_password', '').strip() |
| |
| settings['vcard_firstname'] = request.form.get('vcard_firstname', '').strip() |
| settings['vcard_lastname'] = request.form.get('vcard_lastname', '').strip() |
| settings['vcard_job'] = request.form.get('vcard_job', '').strip() |
| settings['organization_name'] = request.form.get('organization_name', '').strip() |
| settings['about_text'] = request.form.get('about_text', '').strip() |
| |
| settings['currency_code'] = request.form.get('currency_code', 'KGS') |
| settings['color_scheme'] = request.form.get('color_scheme', 'default') |
|
|
| avatar_file = request.files.get('chat_avatar') |
| if avatar_file and avatar_file.filename: |
| if HF_TOKEN_WRITE: |
| try: |
| api = HfApi() |
| old_avatar = settings.get('chat_avatar') |
| if old_avatar: |
| try: api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"avatars/{old_avatar}"], repo_type="dataset", token=HF_TOKEN_WRITE) |
| except Exception: pass |
| ext = os.path.splitext(avatar_file.filename)[1].lower() |
| avatar_filename = f"avatar_{env_id}_{int(time.time())}{ext}" |
| uploads_dir = 'uploads_temp' |
| os.makedirs(uploads_dir, exist_ok=True) |
| temp_path = os.path.join(uploads_dir, avatar_filename) |
| avatar_file.save(temp_path) |
| api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"avatars/{avatar_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
| settings['chat_avatar'] = avatar_filename |
| os.remove(temp_path) |
| flash("Аватар успешно обновлен.", 'success') |
| except Exception as e: |
| flash(f"Ошибка при загрузке аватара: {e}", 'error') |
| else: |
| flash("HF_TOKEN (write) не настроен. Аватар не был загружен.", "warning") |
|
|
| data['settings'] = settings |
| save_env_data(env_id, data) |
| flash("Настройки успешно обновлены.", 'success') |
|
|
| elif action == 'add_product' or action == 'edit_product': |
| product_id = request.form.get('product_id') |
| product_data = {} |
| is_edit = action == 'edit_product' |
| |
| if is_edit: |
| product_data = next((p for p in products if p.get('product_id') == product_id), None) |
| if not product_data: |
| flash(f"Ошибка: запись не найдена.", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
| else: |
| product_data['views'] = 0 |
| |
| product_data['name'] = request.form.get('name', '').strip() |
| product_data['description'] = request.form.get('description', '').strip() |
| |
| try: |
| product_data['price'] = float(request.form.get('price', 0)) |
| except ValueError: |
| product_data['price'] = 0.0 |
|
|
| category = request.form.get('category') |
| product_data['category'] = category if category in categories else 'Без категории' |
|
|
| if not product_data['name']: |
| flash("Название обязательно.", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
|
|
| photos_files = request.files.getlist('photos') |
| if photos_files and any(f.filename for f in photos_files): |
| if HF_TOKEN_WRITE: |
| uploads_dir = 'uploads_temp' |
| os.makedirs(uploads_dir, exist_ok=True) |
| api = HfApi() |
| new_photos_list = [] |
| photo_limit = 10 |
| uploaded_count = 0 |
| for photo in photos_files: |
| if uploaded_count >= photo_limit: break |
| if photo and photo.filename: |
| try: |
| ext = os.path.splitext(photo.filename)[1].lower() |
| if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']: continue |
| safe_name = secure_filename(product_data['name'].replace(' ', '_'))[:50] |
| photo_filename = f"{safe_name}_{uuid4().hex[:8]}{ext}" |
| temp_path = os.path.join(uploads_dir, photo_filename) |
| photo.save(temp_path) |
| api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
| new_photos_list.append(photo_filename) |
| os.remove(temp_path) |
| uploaded_count += 1 |
| except Exception as e: |
| flash(f"Ошибка при загрузке фото {photo.filename}: {e}", 'error') |
| if new_photos_list and is_edit and product_data.get('photos'): |
| try: api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in product_data['photos']], repo_type="dataset", token=HF_TOKEN_WRITE) |
| except Exception: pass |
| if new_photos_list: |
| product_data['photos'] = new_photos_list |
| else: |
| flash("HF_TOKEN не настроен. Фотографии не загружены.", "warning") |
| |
| if is_edit: |
| product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1) |
| if product_index != -1: |
| products[product_index] = product_data |
| flash(f"Запись '{product_data['name']}' обновлена.", 'success') |
| else: |
| product_data['product_id'] = uuid4().hex |
| products.append(product_data) |
| flash(f"Запись '{product_data['name']}' добавлена.", 'success') |
| |
| data['products'] = products |
| save_env_data(env_id, data) |
|
|
| elif action == 'delete_product': |
| product_id = request.form.get('product_id') |
| product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1) |
| if product_index == -1: |
| flash(f"Ошибка удаления: запись не найдена.", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
| deleted_product = products.pop(product_index) |
| product_name = deleted_product.get('name', 'N/A') |
| photos_to_delete = deleted_product.get('photos', []) |
| if photos_to_delete and HF_TOKEN_WRITE: |
| try: |
| api = HfApi() |
| api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in photos_to_delete], repo_type="dataset", token=HF_TOKEN_WRITE) |
| except Exception: pass |
| data['products'] = products |
| save_env_data(env_id, data) |
| flash(f"Запись '{product_name}' удалена.", 'success') |
| else: |
| flash(f"Неизвестное действие: {action}", 'warning') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
| except Exception as e: |
| flash(f"Ошибка при выполнении действия: {e}", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
|
|
| filtered_products = products |
| if search_q: |
| q_lower = search_q.lower() |
| filtered_products = [p for p in products if q_lower in p.get('name', '').lower() or q_lower in p.get('description', '').lower()] |
| |
| filtered_products = sorted(filtered_products, key=lambda p: p.get('name', '').lower()) |
| |
| PER_PAGE = 20 |
| total_items = len(filtered_products) |
| total_pages = math.ceil(total_items / PER_PAGE) if total_items > 0 else 1 |
| |
| if page < 1: page = 1 |
| if page > total_pages: page = total_pages |
| |
| start_idx = (page - 1) * PER_PAGE |
| end_idx = start_idx + PER_PAGE |
| paginated_products = filtered_products[start_idx:end_idx] |
|
|
| display_categories = sorted(categories) |
| display_settings = settings |
| chat_avatar_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/avatars/{display_settings['chat_avatar']}" if display_settings.get('chat_avatar') else "https://huggingface.co/spaces/gippo312/admin/resolve/main/Picsart_25-11-04_12-02-21-390.png" |
|
|
| return render_template_string( |
| ADMIN_TEMPLATE, paginated_products=paginated_products, total_pages=total_pages, page=page, search_q=search_q, categories=display_categories, |
| settings=display_settings, blocks=blocks, repo_id=REPO_ID, currency_code=display_settings.get('currency_code', 'KGS'), chat_avatar_url=chat_avatar_url, |
| currencies=CURRENCIES, color_schemes=COLOR_SCHEMES, icons=ICONS, env_id=env_id |
| ) |
|
|
| @app.route('/generate_description_ai', methods=['POST']) |
| def handle_generate_description_ai(): |
| request_data = request.get_json() |
| base64_image = request_data.get('image') |
| language = request_data.get('language', 'Русский') |
| if not base64_image: return jsonify({"error": "Изображение не найдено в запросе."}), 400 |
| try: |
| image_data = base64.b64decode(base64_image) |
| result_text = generate_ai_description_from_image(image_data, language) |
| return jsonify({"text": result_text}) |
| except ValueError as ve: return jsonify({"error": str(ve)}), 400 |
| except Exception as e: return jsonify({"error": f"Внутренняя ошибка сервера: {e}"}), 500 |
|
|
| if __name__ == '__main__': |
| configure_gemini() |
| download_db_from_hf() |
| load_data() |
| if HF_TOKEN_WRITE: |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) |
| backup_thread.start() |
| port = int(os.environ.get('PORT', 7860)) |
| app.run(debug=False, host='0.0.0.0', port=port) |
| import os |
| import io |
| import base64 |
| import json |
| import logging |
| import threading |
| import time |
| import math |
| from datetime import datetime, timedelta, timezone |
| from uuid import uuid4 |
| import random |
| import string |
| from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify, session, make_response |
| from PIL import Image |
| import google.generativeai as genai |
| 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 |
|
|
| load_dotenv() |
|
|
| app = Flask(__name__) |
| app.secret_key = 'your_unique_secret_key_gippo_312_shop_54321_no_login' |
| DATA_FILE = 'data.json' |
| SYNC_FILES = [DATA_FILE] |
| REPO_ID = "Kgshop/bc" |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") |
| GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") |
|
|
| DOWNLOAD_RETRIES = 3 |
| DOWNLOAD_DELAY = 5 |
| ALMATY_TZ = timezone(timedelta(hours=6)) |
|
|
| db_lock = threading.RLock() |
|
|
| CURRENCIES = { |
| 'KGS': 'Кыргызский сом', |
| 'KZT': 'Казахстанский тенге', |
| 'UAH': 'Украинская гривна', |
| 'RUB': 'Российский рубль', |
| 'USD': 'Доллар США', |
| 'EUR': 'Евро' |
| } |
|
|
| COLOR_SCHEMES = { |
| 'default': 'Бирюзовый (по умолч.)', |
| 'forest': 'Лесной зеленый', |
| 'ocean': 'Глубокий синий', |
| 'sunset': 'Закатный оранжевый', |
| 'lavender': 'Лавандовый', |
| 'vintage': 'Винтажный', |
| 'dark': 'Полночь (тёмная)', |
| 'cosmic': 'Космическая ночь', |
| 'minty': 'Свежая мята', |
| 'mocha': 'Кофейный мокко', |
| 'crimson': 'Багровый рассвет', |
| 'solar': 'Солнечная вспышка', |
| 'cyberpunk': 'Киберпанк неон', |
| 'neon': 'Неоновая вспышка', |
| 'pastel': 'Пастельный (светлый)', |
| 'emerald': 'Изумрудный город', |
| 'gold': 'Роскошное золото', |
| 'sakura': 'Цветение сакуры (светлый)', |
| 'arctic': 'Арктический лед (светлый)', |
| 'volcano': 'Магма', |
| 'monochrome_light': 'Классика (Светлая)', |
| 'monochrome_dark': 'Классика (Темная)', |
| 'nord': 'Скандинавский Норд', |
| 'dracula': 'Дракула (Темный)', |
| 'ruby': 'Глубокий Рубин', |
| 'sapphire': 'Королевский Сапфир', |
| 'amethyst': 'Аметистовый блеск' |
| } |
|
|
| ICONS = { |
| 'fa-link': 'Ссылка (вебсайт)', |
| 'fa-phone': 'Телефон', |
| 'fa-whatsapp': 'WhatsApp', |
| 'fa-telegram': 'Telegram', |
| 'fa-instagram': 'Instagram', |
| 'fa-envelope': 'Email', |
| 'fa-map-marker-alt': 'Локация/Адрес', |
| 'fa-youtube': 'YouTube', |
| 'fa-tiktok': 'TikTok' |
| } |
|
|
| 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: |
| pass |
| 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 |
| all_successful = True |
| for file_name in files_to_download: |
| success = False |
| for attempt in range(retries + 1): |
| try: |
| local_path = 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 |
| ) |
| success = True |
| break |
| except RepositoryNotFoundError: |
| return False |
| except HfHubHTTPError as e: |
| if e.response.status_code == 404: |
| if attempt == 0 and not os.path.exists(file_name): |
| try: |
| if file_name == DATA_FILE: |
| with open(file_name, 'w', encoding='utf-8') as f: |
| json.dump({}, f) |
| except Exception: |
| pass |
| success = False |
| break |
| else: |
| pass |
| except requests.exceptions.RequestException: |
| pass |
| except Exception: |
| pass |
| if attempt < retries: |
| time.sleep(delay) |
| if not success: |
| all_successful = False |
| return all_successful |
|
|
| def upload_db_to_hf(specific_file=None): |
| if not HF_TOKEN_WRITE: |
| return |
| try: |
| api = HfApi() |
| files_to_upload = [specific_file] if specific_file else SYNC_FILES |
| for file_name in files_to_upload: |
| if os.path.exists(file_name): |
| try: |
| 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(ALMATY_TZ).strftime('%Y-%m-%d %H:%M:%S')}" |
| ) |
| except Exception: |
| pass |
| except Exception: |
| pass |
|
|
| def periodic_backup(): |
| backup_interval = 1800 |
| while True: |
| time.sleep(backup_interval) |
| upload_db_to_hf() |
|
|
| def load_data(): |
| with db_lock: |
| try: |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| if not isinstance(data, dict): |
| data = {} |
| except (FileNotFoundError, json.JSONDecodeError): |
| if download_db_from_hf(specific_file=DATA_FILE): |
| try: |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| if not isinstance(data, dict): |
| data = {} |
| except (FileNotFoundError, json.JSONDecodeError): |
| data = {} |
| else: |
| data = {} |
| return data |
|
|
| def save_data(data): |
| with db_lock: |
| try: |
| with open(DATA_FILE, 'w', encoding='utf-8') as file: |
| json.dump(data, file, ensure_ascii=False, indent=4) |
| except Exception: |
| pass |
| upload_db_to_hf(specific_file=DATA_FILE) |
|
|
| def get_env_data(env_id): |
| with db_lock: |
| all_data = load_data() |
| default_settings = { |
| "vcard_firstname": "Имя", |
| "vcard_lastname": "Фамилия", |
| "vcard_job": "Специалист", |
| "organization_name": "Моя Компания", |
| "currency_code": "KGS", |
| "chat_avatar": None, |
| "color_scheme": "default", |
| "admin_password_enabled": False, |
| "admin_password": "", |
| "categories_as_lines": False, |
| "about_text": "Привет! Это моя онлайн-визитка." |
| } |
|
|
| env_data = all_data.get(env_id, {}) |
| if not env_data: |
| env_data = { |
| 'products': [], 'categories':[], 'blocks':[], |
| 'settings': default_settings |
| } |
|
|
| if 'products' not in env_data: env_data['products'] = [] |
| if 'categories' not in env_data: env_data['categories'] = [] |
| if 'settings' not in env_data: env_data['settings'] = default_settings |
| if 'blocks' not in env_data: env_data['blocks'] = [] |
| |
| settings_changed = False |
| for key, value in default_settings.items(): |
| if key not in env_data['settings']: |
| env_data['settings'][key] = value |
| settings_changed = True |
|
|
| products_changed = False |
| for product in env_data['products']: |
| if 'product_id' not in product: |
| product['product_id'] = uuid4().hex |
| products_changed = True |
| if 'views' not in product: |
| product['views'] = 0 |
| products_changed = True |
| if 'price' not in product: |
| product['price'] = 0.0 |
| products_changed = True |
|
|
| if products_changed or settings_changed: |
| save_env_data(env_id, env_data) |
|
|
| return env_data |
|
|
| def save_env_data(env_id, env_data): |
| with db_lock: |
| all_data = load_data() |
| all_data[env_id] = env_data |
| save_data(all_data) |
|
|
| def configure_gemini(): |
| if not GOOGLE_API_KEY: |
| return False |
| try: |
| genai.configure(api_key=GOOGLE_API_KEY) |
| return True |
| except Exception: |
| return False |
|
|
| def generate_ai_description_from_image(image_data, language): |
| if not configure_gemini(): |
| raise ValueError("Google AI API не настроен.") |
| try: |
| if not image_data: |
| raise ValueError("Файл изображения не найден.") |
| image_stream = io.BytesIO(image_data) |
| image = Image.open(image_stream).convert('RGB') |
| except Exception: |
| raise ValueError("Не удалось обработать изображение.") |
| base_prompt = "Напиши привлекательное описание для этого товара/услуги. Текст должен быть емким, продающим, с использованием эмодзи. Не пиши цены, адреса и телефоны." |
| lang_suffix = "" |
| if language == "Русский": |
| lang_suffix = " Пиши на русском языке." |
| elif language == "Кыргызский": |
| lang_suffix = " Пиши на кыргызском языке." |
| elif language == "Казахский": |
| lang_suffix = " Пиши на казахском языке." |
| elif language == "Узбекский": |
| lang_suffix = " Пиши на узбекском языке." |
| final_prompt = f"{base_prompt}{lang_suffix}" |
| try: |
| model = genai.GenerativeModel('gemma-4-31b-it') |
| response = model.generate_content([final_prompt, image]) |
| if hasattr(response, 'text'): |
| return response.text |
| else: |
| if response.parts: |
| return "".join(part.text for part in response.parts if hasattr(part, 'text')) |
| else: |
| response.resolve() |
| return response.text |
| except Exception as e: |
| raise ValueError(f"Ошибка при генерации контента: {e}") |
|
|
| LANDING_PAGE_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Платформа Онлайн-Визиток</title> |
| <style> |
| body, html { margin: 0; padding: 0; height: 100%; overflow: hidden; } |
| iframe { border: none; width: 100%; height: 100%; } |
| </style> |
| </head> |
| <body> |
| <iframe src="https://v0-ai-agent-landing-page-smoky-six.vercel.app/"></iframe> |
| </body> |
| </html> |
| ''' |
|
|
| LOGIN_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=Montserrat:wght@400;600&display=swap" rel="stylesheet"> |
| <style> |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: #f4f6f9; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 20px; } |
| .login-container { background: #fff; padding: 40px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); text-align: center; width: 100%; max-width: 350px; } |
| h2 { color: #135D66; margin-bottom: 20px; } |
| input[type="password"] { width: 100%; padding: 12px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 8px; font-size: 1rem; min-height: 44px; } |
| button { width: 100%; padding: 12px; background-color: #48D1CC; color: #003C43; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 1rem; transition: background 0.3s; min-height: 44px; } |
| button:hover { background-color: #77E4D8; } |
| .error { color: #E57373; margin-bottom: 15px; font-size: 0.9rem; } |
| </style> |
| </head> |
| <body> |
| <div class="login-container"> |
| <h2>Вход</h2> |
| {% with messages = get_flashed_messages(with_categories=true) %} |
| {% if messages %} |
| {% for category, message in messages %} |
| <div class="error">{{ message }}</div> |
| {% endfor %} |
| {% endif %} |
| {% endwith %} |
| <form method="POST"> |
| <input type="password" name="password" placeholder="Введите пароль" required autofocus> |
| <button type="submit">Войти</button> |
| </form> |
| </div> |
| </body> |
| </html> |
| ''' |
|
|
| ADMHOSTO_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=Montserrat:wght@300;400;500;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> |
| :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; } |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; margin: 0; } |
| .container { max-width: 900px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); } |
| h1 { font-weight: 600; color: var(--bg-medium); margin-bottom: 25px; text-align: center; } |
| .section { margin-bottom: 30px; } |
| .add-env-form { margin-bottom: 20px; text-align: center; } |
| #search-env { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; font-family: 'Montserrat', sans-serif; min-height: 44px;} |
| .button { padding: 10px 18px; border: none; border-radius: 8px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 44px; } |
| .button:hover { background-color: var(--accent-hover); } |
| .env-list { list-style: none; padding: 0; } |
| .env-item { background: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 15px; } |
| .env-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; } |
| .env-id { font-weight: 600; color: var(--bg-medium); font-size: 1.2rem; } |
| .env-actions { display: flex; gap: 10px; flex-wrap: wrap; } |
| .env-pwd { background: #f1f3f5; padding: 10px; border-radius: 8px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } |
| .env-pwd input[type="text"] { padding: 10px; border: 1px solid #ccc; border-radius: 6px; flex-grow: 1; min-height: 44px;} |
| .delete-button { background-color: var(--danger); color: white; } |
| .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; text-align: center; } |
| .message.success { background-color: #d4edda; color: #155724; } |
| .message.error { background-color: #f8d7da; color: #721c24; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1><i class="fas fa-id-card"></i> Управление Визитками</h1> |
| {% 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"> |
| <form method="POST" action="{{ url_for('create_environment') }}" class="add-env-form"> |
| <button type="submit" class="button" style="font-size:1.1rem; padding: 15px 30px;"><i class="fas fa-plus-circle"></i> Создать новую визитку</button> |
| </form> |
| </div> |
| <div class="section"> |
| <input type="text" id="search-env" placeholder="Поиск по ID или Названию..."> |
| </div> |
| <div class="section"> |
| <h2><i class="fas fa-list-ul"></i> Существующие визитки</h2> |
| {% if environments %} |
| <ul class="env-list"> |
| {% for env in environments %} |
| <li class="env-item"> |
| <div class="env-header"> |
| <div style="display:flex; align-items:center; gap: 10px; flex-wrap: wrap;"> |
| <span class="env-id">{{ env.org_name }} (ID: {{ env.id }})</span> |
| </div> |
| <div class="env-actions"> |
| <a href="{{ url_for('admin', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-tools"></i> Настройки</a> |
| <a href="{{ url_for('catalog', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-external-link-alt"></i> Посмотреть</a> |
| <form method="POST" action="{{ url_for('delete_environment', env_id=env.id) }}" style="display:inline;" onsubmit="if(!confirm('Вы уверены, что хотите удалить визитку {{ env.id }}? Это действие необратимо.')) return false;"> |
| <button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </div> |
| </div> |
| <div class="env-pwd"> |
| <form method="POST" action="{{ url_for('update_env_pwd', env_id=env.id) }}" style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap; width: 100%;"> |
| <label style="display: flex; align-items: center; gap: 5px;"><input type="checkbox" name="pwd_enabled" {% if env.pwd_enabled %}checked{% endif %} style="width: 20px; height: 20px;"> Вкл. пароль админа</label> |
| <input type="text" name="password" value="{{ env.password }}" placeholder="Пароль"> |
| <button type="submit" class="button" style="font-size: 0.9rem;">Сохранить</button> |
| </form> |
| </div> |
| </li> |
| {% endfor %} |
| </ul> |
| {% else %} |
| <p>Пока не создано ни одной визитки.</p> |
| {% endif %} |
| </div> |
| </div> |
| <script> |
| document.getElementById('search-env').addEventListener('input', function() { |
| const searchTerm = this.value.toLowerCase().trim(); |
| const envItems = document.querySelectorAll('.env-item'); |
| envItems.forEach(item => { |
| const envId = item.querySelector('.env-id').textContent.toLowerCase(); |
| if (envId.includes(searchTerm)) { item.style.display = 'flex'; } else { item.style.display = 'none'; } |
| }); |
| }); |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| CATALOG_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <title>{{ settings.vcard_firstname }} {{ settings.vcard_lastname }} - Визитка</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=Montserrat:wght@400;500;600;700;800&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css"> |
| <style> |
| {% if settings.color_scheme == 'forest' %} |
| :root { --bg-dark: #2F4F4F; --bg-medium: #556B2F; --accent: #8FBC8F; --accent-hover: #98FB98; --text-light: #F5F5DC; --text-dark: #1A2F1A; --danger: #CD5C5C; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'ocean' %} |
| :root { --bg-dark: #000080; --bg-medium: #1E90FF; --accent: #87CEEB; --accent-hover: #ADD8E6; --text-light: #F0F8FF; --text-dark: #000033; --danger: #FF6347; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'sunset' %} |
| :root { --bg-dark: #4A2511; --bg-medium: #D2691E; --accent: #FFA500; --accent-hover: #FFB733; --text-light: #FFF8DC; --text-dark: #4A2511; --danger: #DC143C; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'lavender' %} |
| :root { --bg-dark: #483D8B; --bg-medium: #8A2BE2; --accent: #D8BFD8; --accent-hover: #E6E6FA; --text-light: #F8F4FF; --text-dark: #2D1B36; --danger: #DB7093; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'vintage' %} |
| :root { --bg-dark: #5D4037; --bg-medium: #8D6E63; --accent: #D7CCC8; --accent-hover: #EFEBE9; --text-light: #EFEBE9; --text-dark: #3E2723; --danger: #BF360C; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'dark' %} |
| :root { --bg-dark: #121212; --bg-medium: #1E1E1E; --accent: #BB86FC; --accent-hover: #A764FC; --text-light: #E1E1E1; --text-dark: #121212; --danger: #CF6679; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'cosmic' %} |
| :root { --bg-dark: #0D1136; --bg-medium: #303F9F; --accent: #536DFE; --accent-hover: #7986CB; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #F50057; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'minty' %} |
| :root { --bg-dark: #004D40; --bg-medium: #00796B; --accent: #4DB6AC; --accent-hover: #80CBC4; --text-light: #E0F2F1; --text-dark: #00332A; --danger: #ef5350; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'mocha' %} |
| :root { --bg-dark: #3E2723; --bg-medium: #5D4037; --accent: #A1887F; --accent-hover: #BCAAA4; --text-light: #EFEBE9; --text-dark: #261412; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'crimson' %} |
| :root { --bg-dark: #4A148C; --bg-medium: #9C27B0; --accent: #CE93D8; --accent-hover: #E1BEE7; --text-light: #FFFFFF; --text-dark: #2D0854; --danger: #E91E63; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'solar' %} |
| :root { --bg-dark: #BF360C; --bg-medium: #FB8C00; --accent: #FFCA28; --accent-hover: #FFD54F; --text-light: #FFF3E0; --text-dark: #3E2723; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'cyberpunk' %} |
| :root { --bg-dark: #000000; --bg-medium: #0D0221; --accent: #00F0FF; --accent-hover: #81F5FF; --text-light: #FFFFFF; --text-dark: #000000; --danger: #F50057; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'neon' %} |
| :root { --bg-dark: #0F0C29; --bg-medium: #302B63; --accent: #FF00CC; --accent-hover: #FF66CC; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #FF0000; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'pastel' %} |
| :root { --bg-dark: #FCE4EC; --bg-medium: #F8BBD0; --accent: #F06292; --accent-hover: #F48FB1; --text-light: #4A148C; --text-dark: #FFFFFF; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.5); } |
| {% elif settings.color_scheme == 'emerald' %} |
| :root { --bg-dark: #004D40; --bg-medium: #00695C; --accent: #1DE9B6; --accent-hover: #64FFDA; --text-light: #E0F2F1; --text-dark: #00332A; --danger: #FF5252; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'gold' %} |
| :root { --bg-dark: #1C1C1C; --bg-medium: #3A3A3A; --accent: #D4AF37; --accent-hover: #F3E5AB; --text-light: #F5F5F5; --text-dark: #1C1C1C; --danger: #B71C1C; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'sakura' %} |
| :root { --bg-dark: #FFEBEE; --bg-medium: #FFCDD2; --accent: #FF8A80; --accent-hover: #FFBCAF; --text-light: #4A148C; --text-dark: #FFFFFF; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.5); } |
| {% elif settings.color_scheme == 'arctic' %} |
| :root { --bg-dark: #E3F2FD; --bg-medium: #BBDEFB; --accent: #4FC3F7; --accent-hover: #81D4FA; --text-light: #0D47A1; --text-dark: #000000; --danger: #EF5350; --card-bg: rgba(255,255,255,0.5); } |
| {% elif settings.color_scheme == 'volcano' %} |
| :root { --bg-dark: #212121; --bg-medium: #B71C1C; --accent: #FF3D00; --accent-hover: #FF6E40; --text-light: #FFFFFF; --text-dark: #FFFFFF; --danger: #D50000; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'monochrome_light' %} |
| :root { --bg-dark: #F5F5F5; --bg-medium: #E0E0E0; --accent: #000000; --accent-hover: #333333; --text-light: #000000; --text-dark: #FFFFFF; --danger: #D32F2F; --card-bg: rgba(255,255,255,0.5); } |
| {% elif settings.color_scheme == 'monochrome_dark' %} |
| :root { --bg-dark: #121212; --bg-medium: #2A2A2A; --accent: #FFFFFF; --accent-hover: #E0E0E0; --text-light: #FFFFFF; --text-dark: #000000; --danger: #EF5350; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'nord' %} |
| :root { --bg-dark: #2E3440; --bg-medium: #3B4252; --accent: #88C0D0; --accent-hover: #81A1C1; --text-light: #D8DEE9; --text-dark: #2E3440; --danger: #BF616A; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'dracula' %} |
| :root { --bg-dark: #282A36; --bg-medium: #44475A; --accent: #FF79C6; --accent-hover: #BD93F9; --text-light: #F8F8F2; --text-dark: #282A36; --danger: #FF5555; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'ruby' %} |
| :root { --bg-dark: #4A0E17; --bg-medium: #7B1826; --accent: #FFB3B3; --accent-hover: #FFD9D9; --text-light: #FFF0F0; --text-dark: #4A0E17; --danger: #FF4D4D; --card-bg: rgba(255,255,255,0.1); } |
| {% elif settings.color_scheme == 'sapphire' %} |
| :root { --bg-dark: #0B192C; --bg-medium: #1A365D; --accent: #FFD700; --accent-hover: #FFF176; --text-light: #F7FAFC; --text-dark: #0B192C; --danger: #E53E3E; --card-bg: rgba(255,255,255,0.05); } |
| {% elif settings.color_scheme == 'amethyst' %} |
| :root { --bg-dark: #2D1B36; --bg-medium: #4A2C59; --accent: #B28DFF; --accent-hover: #D4C4FB; --text-light: #F4EBFF; --text-dark: #2D1B36; --danger: #FF6B6B; --card-bg: rgba(255,255,255,0.1); } |
| {% else %} |
| :root { --bg-dark: #003C43; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-light: #E3FEF7; --text-dark: #003C43; --danger: #E57373; --card-bg: rgba(255,255,255,0.1); } |
| {% endif %} |
| |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| html { -webkit-tap-highlight-color: transparent; scroll-behavior: smooth; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-dark); color: var(--text-light); line-height: 1.6; } |
| |
| .main-wrapper { max-width: 680px; margin: 0 auto; padding: 40px 20px 80px 20px; display: flex; flex-direction: column; align-items: center; position: relative; } |
| |
| .qr-btn { position: absolute; top: 20px; right: 20px; font-size: 1.5rem; color: var(--accent); background: var(--card-bg); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; width: 45px; height: 45px; display: flex; align-items: center; justify-content: center; cursor: pointer; backdrop-filter: blur(5px); transition: all 0.2s; z-index: 100; } |
| .qr-btn:hover { background: var(--accent); color: var(--text-dark); transform: scale(1.05); } |
| |
| .profile-header { text-align: center; margin-bottom: 30px; width: 100%; } |
| .avatar { width: 120px; height: 120px; border-radius: 50%; border: 3px solid var(--accent); box-shadow: 0 0 20px rgba(0,0,0,0.2); object-fit: cover; margin-bottom: 15px; } |
| .profile-name { font-size: 1.6rem; font-weight: 700; margin-bottom: 5px; color: var(--text-light); } |
| .profile-job { font-size: 1.1rem; font-weight: 500; opacity: 0.9; margin-bottom: 5px; } |
| .profile-company { font-size: 1rem; font-weight: 400; opacity: 0.7; } |
| .profile-about { margin-top: 15px; font-size: 0.95rem; line-height: 1.5; opacity: 0.85; max-width: 500px; margin-left: auto; margin-right: auto;} |
| |
| .save-contact-btn { display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%; max-width: 400px; padding: 16px; background: var(--accent); color: var(--text-dark); border-radius: 30px; text-decoration: none; font-weight: 700; font-size: 1.1rem; transition: transform 0.2s, box-shadow 0.2s; box-shadow: 0 4px 15px rgba(0,0,0,0.2); margin-bottom: 30px; border: none; cursor: pointer; } |
| .save-contact-btn:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0,0,0,0.3); background: var(--accent-hover); } |
| |
| .blocks-container { width: 100%; display: flex; flex-direction: column; gap: 15px; margin-bottom: 40px; } |
| .block-link { display: flex; align-items: center; justify-content: center; position: relative; background: var(--card-bg); color: var(--text-light); text-align: center; padding: 16px 20px; border-radius: 16px; text-decoration: none; font-weight: 600; font-size: 1.05rem; backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1); transition: transform 0.2s, background 0.2s; min-height: 60px; overflow: hidden; } |
| .block-link:hover { transform: scale(1.02); background: var(--bg-medium); border-color: var(--accent); } |
| .block-icon { position: absolute; left: 20px; font-size: 1.5rem; color: var(--accent); } |
| |
| .block-text { background: var(--card-bg); padding: 20px; border-radius: 16px; border: 1px solid rgba(255,255,255,0.1); text-align: center; color: var(--text-light); backdrop-filter: blur(10px); } |
| .block-text h3 { margin-bottom: 10px; font-size: 1.2rem; color: var(--accent); } |
| |
| .catalog-section { width: 100%; margin-top: 20px; } |
| .catalog-title { font-size: 1.4rem; font-weight: 700; margin-bottom: 20px; text-align: center; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; } |
| |
| .search-wrapper { position: relative; margin-bottom: 20px; width: 100%; } |
| #search-input { width: 100%; padding: 14px 20px 14px 45px; font-size: 1rem; border: 1px solid rgba(255,255,255,0.2); border-radius: 30px; outline: none; background-color: var(--card-bg); color: var(--text-light); transition: all 0.3s ease; backdrop-filter: blur(5px); } |
| #search-input::placeholder { color: var(--text-light); opacity: 0.6; } |
| #search-input:focus { background-color: rgba(255,255,255,0.15); border-color: var(--accent); } |
| .search-wrapper .fa-search { position: absolute; top: 50%; left: 18px; transform: translateY(-50%); color: var(--text-light); opacity: 0.6; font-size: 1.1rem; } |
| |
| .category-chips-container { margin-bottom: 20px; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding-bottom: 5px; } |
| .category-chips-container::-webkit-scrollbar { display: none; } |
| .category-chips { display: inline-flex; gap: 10px; } |
| .chip { padding: 10px 20px; border-radius: 20px; background-color: var(--card-bg); color: var(--text-light); border: 1px solid rgba(255,255,255,0.2); font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; backdrop-filter: blur(5px); } |
| .chip:hover, .chip.active { background-color: var(--accent); color: var(--text-dark); border-color: var(--accent); } |
| |
| .product-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; width: 100%; } |
| |
| .product-card { background: var(--card-bg); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; overflow: hidden; cursor: pointer; transition: transform 0.2s; backdrop-filter: blur(5px); display: flex; flex-direction: column; } |
| .product-card:hover { transform: translateY(-5px); border-color: rgba(255,255,255,0.3); } |
| .product-image-container { width: 100%; aspect-ratio: 1/1; position: relative; background-color: rgba(0,0,0,0.2); overflow: hidden; } |
| .product-image-container img { width: 100%; height: 100%; object-fit: cover; } |
| .product-info { padding: 10px; display: flex; flex-direction: column; flex-grow: 1; justify-content: space-between; } |
| .product-title { font-size: 0.9rem; font-weight: 600; color: var(--text-light); margin-bottom: 5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } |
| .product-price { font-size: 0.95rem; font-weight: 700; color: var(--accent); } |
| .no-results-message { text-align: center; padding: 30px; font-size: 1.1rem; opacity: 0.7; grid-column: 1 / -1; } |
| |
| .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(8px); overflow-y: auto; -webkit-overflow-scrolling: touch; } |
| .modal-content { background: var(--bg-dark); color: var(--text-light); margin: auto; padding: 0; border-radius: 20px; width: 100%; max-width: 500px; min-height: 100vh; position: relative; box-shadow: 0 15px 40px rgba(0,0,0,0.5); animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } |
| @media (min-width: 500px) { .modal-content { min-height: auto; margin: 5% auto; } } |
| @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } |
| |
| .qr-modal-content { background: var(--bg-dark); color: var(--text-light); margin: 15vh auto; padding: 40px 20px; border-radius: 20px; width: 90%; max-width: 350px; position: relative; box-shadow: 0 15px 40px rgba(0,0,0,0.5); text-align: center; animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } |
| |
| .close-btn { position: absolute; top: 15px; right: 15px; width: 40px; height: 40px; background: rgba(0,0,0,0.5); border-radius: 50%; display: flex; justify-content: center; align-items: center; color: white; font-size: 1.5rem; cursor: pointer; z-index: 10; border: none; backdrop-filter: blur(5px); } |
| .close-btn:hover { background: var(--danger); } |
| |
| .modal-body { padding: 25px; } |
| .modal-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 10px; color: var(--text-light); } |
| .modal-price { font-size: 1.4rem; font-weight: 800; color: var(--accent); margin-bottom: 20px; } |
| .modal-desc { font-size: 1rem; line-height: 1.6; opacity: 0.9; margin-bottom: 20px; white-space: pre-wrap; } |
| .modal-category { display: inline-block; padding: 5px 12px; background: var(--card-bg); border-radius: 15px; font-size: 0.85rem; border: 1px solid rgba(255,255,255,0.2); margin-bottom: 15px; } |
| |
| .swiper-container { width: 100%; aspect-ratio: 1/1; background: #000; border-radius: 20px 20px 0 0; } |
| @media (min-width: 500px) { .swiper-container { border-radius: 20px 20px 0 0; } } |
| .swiper-slide { display: flex; justify-content: center; align-items: center; } |
| .swiper-slide img { width: 100%; height: 100%; object-fit: contain; } |
| |
| .pagination { display: flex; justify-content: center; gap: 8px; margin-top: 30px; padding-bottom: 20px; align-items: center; flex-wrap: wrap; } |
| .pagination button { width: 40px; height: 40px; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; background: var(--card-bg); color: var(--text-light); font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 1rem; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(5px); } |
| .pagination button.active { background: var(--accent); color: var(--text-dark); border-color: var(--accent); } |
| .pagination button:hover:not(.active) { background: rgba(255,255,255,0.2); } |
| |
| .dark-theme .modal-content, .dark-theme .qr-modal-content { background: #222; } |
| </style> |
| </head> |
| <body class="{{ 'dark-theme' if settings.color_scheme in['dark', 'cyberpunk', 'neon', 'volcano', 'gold', 'monochrome_dark', 'nord', 'dracula', 'ruby', 'sapphire', 'amethyst', 'cosmic', 'crimson', 'mocha', 'minty', 'solar'] else '' }}"> |
| |
| <div class="main-wrapper"> |
| <div class="qr-btn" onclick="openQrModal()"> |
| <i class="fas fa-qrcode"></i> |
| </div> |
| |
| <div class="profile-header"> |
| <img src="{{ chat_avatar_url }}" alt="Avatar" class="avatar"> |
| <div class="profile-name">{{ settings.vcard_firstname }} {{ settings.vcard_lastname }}</div> |
| {% if settings.vcard_job %}<div class="profile-job">{{ settings.vcard_job }}</div>{% endif %} |
| {% if settings.organization_name %}<div class="profile-company">{{ settings.organization_name }}</div>{% endif %} |
| {% if settings.about_text %}<div class="profile-about">{{ settings.about_text|replace('\\n', '<br>')|safe }}</div>{% endif %} |
| </div> |
| |
| <a href="{{ url_for('download_vcard', env_id=env_id) }}" class="save-contact-btn"> |
| <i class="fas fa-address-book"></i> Сохранить в контакты |
| </a> |
| |
| {% if blocks %} |
| <div class="blocks-container"> |
| {% for block in blocks %} |
| {% if block.type == 'link' %} |
| <a href="{{ block.url }}" class="block-link" target="_blank" rel="noopener noreferrer"> |
| {% if block.icon %} |
| {% set prefix = 'fab' if block.icon in ['fa-whatsapp', 'fa-telegram', 'fa-instagram', 'fa-youtube', 'fa-tiktok'] else 'fas' %} |
| <i class="{{ prefix }} {{ block.icon }} block-icon"></i> |
| {% endif %} |
| <span>{{ block.title }}</span> |
| </a> |
| {% elif block.type == 'text' %} |
| <div class="block-text"> |
| {% if block.title %}<h3>{{ block.title }}</h3>{% endif %} |
| <p>{{ block.content|replace('\\n', '<br>')|safe }}</p> |
| </div> |
| {% endif %} |
| {% endfor %} |
| </div> |
| {% endif %} |
| |
| {% if products_json != '[]' %} |
| <div class="catalog-section"> |
| <h2 class="catalog-title">Каталог / Услуги</h2> |
| <div class="search-wrapper"> |
| <i class="fas fa-search"></i> |
| <input type="text" id="search-input" placeholder="Поиск..."> |
| </div> |
| |
| <div class="category-chips-container"> |
| <div class="category-chips" id="category-chips"></div> |
| </div> |
| |
| <div id="catalog-content"></div> |
| </div> |
| {% endif %} |
| </div> |
| |
| <div id="productModal" class="modal"> |
| <div class="modal-content"> |
| <button class="close-btn" onclick="closeModal('productModal')"><i class="fas fa-times"></i></button> |
| <div id="modalContent">Загрузка...</div> |
| </div> |
| </div> |
| |
| <div id="qrModal" class="modal"> |
| <div class="qr-modal-content"> |
| <button class="close-btn" onclick="closeModal('qrModal')" style="top: -15px; right: -15px; background: var(--danger);"><i class="fas fa-times"></i></button> |
| <h2 style="margin-bottom: 20px; color: var(--accent);">Мой QR-код</h2> |
| <div style="background: white; padding: 15px; border-radius: 15px; display: inline-block;"> |
| <img id="qrImage" src="" alt="QR Code" style="width: 200px; height: 200px; display: block;"> |
| </div> |
| <p style="margin-top: 20px; font-size: 0.9rem; opacity: 0.8;">Отсканируйте, чтобы открыть визитку</p> |
| </div> |
| </div> |
| |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script> |
| <script> |
| const allProducts = {{ products_json|safe }}; |
| const orderedCategories = {{ ordered_categories|tojson|safe }}; |
| const repoId = '{{ repo_id }}'; |
| const currencyCode = '{{ currency_code }}'; |
| const orgName = `{{ settings.organization_name }}`.replace(/`/g, ''); |
| |
| const itemsPerPage = 10; |
| let currentPage = 1; |
| let currentCategory = 'all'; |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| const chipsContainer = document.getElementById('category-chips'); |
| if(chipsContainer && orderedCategories.length > 0) { |
| let chipsHtml = `<button class="chip active" onclick="setCategory('all', this)">Все</button>`; |
| orderedCategories.forEach(cat => { |
| chipsHtml += `<button class="chip" onclick="setCategory('${cat.replace(/'/g, "\\'")}', this)">${cat}</button>`; |
| }); |
| chipsContainer.innerHTML = chipsHtml; |
| } |
| |
| const searchInput = document.getElementById('search-input'); |
| if(searchInput) { |
| searchInput.addEventListener('input', () => { |
| currentPage = 1; |
| renderCatalog(); |
| }); |
| } |
| |
| window.addEventListener('click', function(event) { if (event.target.classList.contains('modal')) { closeModal(event.target.id); } }); |
| |
| renderCatalog(); |
| }); |
| |
| function setCategory(cat, btn) { |
| document.querySelectorAll('.chip').forEach(c => c.classList.remove('active')); |
| if(btn) btn.classList.add('active'); |
| currentCategory = cat; |
| currentPage = 1; |
| const searchInput = document.getElementById('search-input'); |
| if(searchInput) searchInput.value = ''; |
| renderCatalog(); |
| } |
| |
| function buildProductCard(product) { |
| let photoUrl = (product.photos && product.photos.length > 0) |
| ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${product.photos[0]}` |
| : `https://via.placeholder.com/300x300.png?text=Нет+фото`; |
| |
| let priceText = product.price > 0 ? `<div class="product-price">${parseFloat(product.price).toFixed(0)} ${currencyCode}</div>` : ''; |
| |
| return ` |
| <div class="product-card" onclick="openModalById('${product.product_id}')"> |
| <div class="product-image-container"> |
| <img src="${photoUrl}" alt="${product.name}" loading="lazy"> |
| </div> |
| <div class="product-info"> |
| <div class="product-title">${product.name}</div> |
| ${priceText} |
| </div> |
| </div> |
| `; |
| } |
| |
| function renderCatalog() { |
| const searchInput = document.getElementById('search-input'); |
| const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : ''; |
| const container = document.getElementById('catalog-content'); |
| if(!container) return; |
| |
| let filtered = allProducts.filter(p => { |
| let matchCat = currentCategory === 'all' || p.category === currentCategory; |
| let matchSearch = searchTerm === '' || (p.name || '').toLowerCase().includes(searchTerm) || (p.description || '').toLowerCase().includes(searchTerm); |
| return matchCat && matchSearch; |
| }); |
| |
| const totalPages = Math.ceil(filtered.length / itemsPerPage) || 1; |
| if (currentPage > totalPages) currentPage = totalPages; |
| |
| const start = (currentPage - 1) * itemsPerPage; |
| const paginated = filtered.slice(start, start + itemsPerPage); |
| |
| if (filtered.length === 0) { |
| container.innerHTML = '<div class="no-results-message">По вашему запросу ничего не найдено.</div>'; |
| return; |
| } |
| |
| let html = '<div class="product-grid">'; |
| paginated.forEach(product => { |
| html += buildProductCard(product); |
| }); |
| html += '</div>'; |
| |
| if (totalPages > 1) { |
| html += '<div class="pagination">'; |
| if (currentPage > 1) { |
| html += `<button onclick="changePage(${currentPage - 1})"><i class="fas fa-chevron-left"></i></button>`; |
| } |
| for (let i = 1; i <= totalPages; i++) { |
| if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) { |
| html += `<button class="${i === currentPage ? 'active' : ''}" onclick="changePage(${i})">${i}</button>`; |
| } else if (i === currentPage - 2 || i === currentPage + 2) { |
| html += `<span style="color:var(--text-light)">...</span>`; |
| } |
| } |
| if (currentPage < totalPages) { |
| html += `<button onclick="changePage(${currentPage + 1})"><i class="fas fa-chevron-right"></i></button>`; |
| } |
| html += '</div>'; |
| } |
| container.innerHTML = html; |
| } |
| |
| function changePage(page) { |
| currentPage = page; |
| renderCatalog(); |
| const catalogSec = document.querySelector('.catalog-section'); |
| if(catalogSec) catalogSec.scrollIntoView({behavior: 'smooth'}); |
| } |
| |
| function getProductById(productId) { return allProducts.find(p => p.product_id === productId); } |
| |
| function openModalById(productId) { |
| const product = getProductById(productId); |
| if (!product) return; |
| |
| const modalContent = document.getElementById('modalContent'); |
| |
| let photosHtml = ''; |
| let paginationHtml = ''; |
| if (product.photos && product.photos.length > 0) { |
| photosHtml = product.photos.map(photo => ` |
| <div class="swiper-slide"> |
| <img src="https://huggingface.co/datasets/${repoId}/resolve/main/photos/${photo}" alt="${product.name}"> |
| </div> |
| `).join(''); |
| if(product.photos.length > 1) { |
| paginationHtml = `<div class="swiper-pagination"></div>`; |
| } |
| } else { |
| photosHtml = ` |
| <div class="swiper-slide"> |
| <img src="https://via.placeholder.com/500x500.png?text=Нет+фото" alt="No image"> |
| </div> |
| `; |
| } |
| |
| let priceHtml = product.price > 0 ? `<div class="modal-price">${parseFloat(product.price).toFixed(2)} ${currencyCode}</div>` : ''; |
| let catHtml = product.category && product.category !== 'Без категории' ? `<div class="modal-category">${product.category}</div>` : ''; |
| let descHtml = product.description ? `<div class="modal-desc">${product.description.replace(/\\n/g, '<br>')}</div>` : ''; |
| |
| modalContent.innerHTML = ` |
| <div class="swiper-container"> |
| <div class="swiper-wrapper"> |
| ${photosHtml} |
| </div> |
| ${paginationHtml} |
| </div> |
| <div class="modal-body"> |
| ${catHtml} |
| <h2 class="modal-title">${product.name}</h2> |
| ${priceHtml} |
| ${descHtml} |
| </div> |
| `; |
| |
| const modal = document.getElementById('productModal'); |
| modal.style.display = "block"; |
| document.body.style.overflow = 'hidden'; |
| |
| if(product.photos && product.photos.length > 1) { |
| new Swiper('#productModal .swiper-container', { |
| slidesPerView: 1, |
| pagination: { el: '.swiper-pagination', clickable: true } |
| }); |
| } |
| } |
| |
| function openQrModal() { |
| const currentDomain = window.location.origin; |
| const targetUrl = currentDomain + "{{ url_for('catalog', env_id=env_id) }}"; |
| const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${encodeURIComponent(targetUrl)}&margin=10`; |
| document.getElementById('qrImage').src = qrApiUrl; |
| document.getElementById('qrModal').style.display = 'block'; |
| document.body.style.overflow = 'hidden'; |
| } |
| |
| function closeModal(modalId) { |
| const modal = document.getElementById(modalId); |
| if (modal) { |
| modal.style.display = "none"; |
| document.body.style.overflow = 'auto'; |
| } |
| } |
| </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>Настройки Визитки - {{ settings.organization_name }}</title> |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;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> |
| :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; --danger-hover: #EF5350; } |
| * { box-sizing: border-box; } |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; line-height: 1.6; margin: 0; } |
| .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;} |
| .header .logo-title-container img { height: 50px; width: 50px; border-radius: 50%; object-fit: cover; border: 2px solid var(--bg-medium);} |
| h1, h2, h3 { font-weight: 600; color: var(--bg-medium); margin-bottom: 15px; } |
| h1 { font-size: 1.8rem; } |
| h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; } |
| h3 { font-size: 1.2rem; color: #004D40; margin-top: 20px; } |
| .section { margin-bottom: 30px; padding: 20px; background-color: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; } |
| form { margin-bottom: 20px; } |
| label { font-weight: 500; margin-top: 10px; display: block; color: #666; font-size: 0.95rem;} |
| input[type="text"], input[type="number"], input[type="password"], input[type="tel"], textarea, select { width: 100%; padding: 12px; margin-top: 5px; border: 1px solid #e0e0e0; border-radius: 8px; font-size: 1rem; box-sizing: border-box; transition: border-color 0.3s ease; background-color: #fff; min-height: 44px;} |
| input:focus, textarea:focus, select:focus { border-color: var(--bg-medium); outline: none; box-shadow: 0 0 0 2px rgba(19, 93, 102, 0.1); } |
| textarea { min-height: 80px; resize: vertical; } |
| input[type="file"] { padding: 8px; background-color: #ffffff; cursor: pointer; border: 1px solid #e0e0e0;} |
| input[type="file"]::file-selector-button { padding: 8px 12px; border-radius: 6px; background-color: #f0f0f0; border: 1px solid #e0e0e0; cursor: pointer; margin-right: 10px;} |
| input[type="checkbox"] { margin-right: 8px; vertical-align: middle; width: 20px; height: 20px; cursor: pointer;} |
| label.inline-label { display: inline-block; margin-top: 10px; font-weight: normal; cursor: pointer; } |
| button, .button { padding: 10px 20px; border: none; border-radius: 8px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 1rem; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; line-height: 1.2; min-height: 44px; justify-content: center;} |
| button:hover, .button:hover { background-color: var(--accent-hover); } |
| button:active, .button:active { transform: scale(0.98); } |
| .delete-button { background-color: var(--danger); color: white; } |
| .delete-button:hover { background-color: var(--danger-hover); } |
| .add-button { background-color: var(--bg-medium); color: white; } |
| .add-button:hover { background-color: #003C43; } |
| .item-list { display: grid; gap: 20px; } |
| .item { background: #fff; padding: 15px 20px; border-radius: 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.05); border: 1px solid #f0f0f0; } |
| .item p { margin: 5px 0; font-size: 0.95rem; color: #666; } |
| .item strong { color: var(--text-dark); } |
| .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } |
| .edit-form-container { margin-top: 15px; padding: 20px; background: #E0F2F1; border: 1px dashed #B2DFDB; border-radius: 8px; display: none; } |
| details { background-color: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 20px; } |
| details > summary { cursor: pointer; font-weight: 600; color: var(--bg-medium); display: block; padding: 18px 20px; list-style: none; position: relative; font-size: 1.1rem; } |
| details > summary:hover { background-color: #fafafa; } |
| details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: var(--bg-medium); } |
| details[open] > summary::after { transform: translateY(-50%) rotate(180deg); } |
| details[open] > summary { border-bottom: 1px solid #e0e0e0; } |
| details .form-content { padding: 20px; } |
| .photo-preview img { max-width: 80px; max-height: 80px; border-radius: 8px; margin: 5px 5px 0 0; border: 1px solid #e0e0e0; object-fit: cover;} |
| |
| .flex-container { display: flex; flex-wrap: wrap; gap: 20px; } |
| .flex-item { flex: 1; min-width: 100%; } |
| @media (min-width: 768px) { .flex-item { min-width: calc(50% - 10px); } } |
| |
| .message { padding: 12px 15px; border-radius: 8px; margin-bottom: 15px; font-size: 0.95rem; font-weight: 500;} |
| .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;} |
| .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;} |
| .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; } |
| |
| .ai-generate-button { background-color: #8D6EC8; color: white; margin-top: 10px; margin-bottom: 10px; } |
| .ai-generate-button:hover { background-color: #7B4DB5; } |
| |
| .current-avatar { max-width: 60px; max-height: 60px; border-radius: 50%; vertical-align: middle; margin-left: 10px; border: 2px solid var(--bg-medium);} |
| |
| .block-item { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 15px; border: 1px solid #e0e0e0; border-radius: 12px; margin-bottom: 10px; flex-wrap: wrap; gap: 10px;} |
| .block-controls { display: flex; gap: 8px; } |
| .btn-small { padding: 8px 12px; font-size: 0.9rem; min-height: auto;} |
| |
| .pagination { display: flex; justify-content: center; gap: 8px; margin-top: 30px; flex-wrap: wrap; align-items: center; } |
| .pagination .button { min-width: 44px; text-align: center; padding: 10px 15px; margin: 0; border-radius: 8px; } |
| |
| #loadingOverlay { |
| display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; |
| background: rgba(0,0,0,0.8); z-index: 10000; flex-direction: column; |
| justify-content: center; align-items: center; color: white; text-align: center; |
| } |
| .spinner { |
| width: 60px; height: 60px; border: 6px solid #f3f3f3; |
| border-top: 6px solid var(--accent); border-radius: 50%; |
| animation: spin 1s linear infinite; margin-bottom: 20px; |
| } |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
| |
| @media (max-width: 600px) { |
| .header { flex-direction: column; align-items: flex-start; } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="loadingOverlay"> |
| <div class="spinner"></div> |
| <h2>Сохранение данных...</h2> |
| </div> |
| <div class="container"> |
| <div class="header"> |
| <div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;"> |
| <img src="{{ chat_avatar_url }}" alt="Logo"> |
| <h1><i class="fas fa-id-badge"></i> Настройки Визитки</h1> |
| </div> |
| <div style="display: flex; gap: 10px; flex-wrap: wrap;"> |
| <a href="{{ url_for('catalog', env_id=env_id) }}" class="button" style="background-color: var(--bg-medium); color: white;"><i class="fas fa-external-link-alt"></i> Открыть визитку</a> |
| <button onclick="downloadQR()" class="button" style="background-color: #8A2BE2; color: white;"><i class="fas fa-qrcode"></i> QR-код</button> |
| {% if settings.admin_password_enabled %} |
| <a href="{{ url_for('admin_logout', env_id=env_id) }}" class="button" style="background-color: #6c757d; color: white;"><i class="fas fa-sign-out-alt"></i> Выход</a> |
| {% endif %} |
| </div> |
| </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"> |
| <details open> |
| <summary><i class="fas fa-user-edit"></i> Профиль и Внешний вид</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()"> |
| <input type="hidden" name="action" value="update_settings"> |
| |
| <div style="display:flex; gap:15px; flex-wrap:wrap;"> |
| <div style="flex:1; min-width:200px;"> |
| <label>Имя:</label> |
| <input type="text" name="vcard_firstname" value="{{ settings.vcard_firstname }}"> |
| </div> |
| <div style="flex:1; min-width:200px;"> |
| <label>Фамилия:</label> |
| <input type="text" name="vcard_lastname" value="{{ settings.vcard_lastname }}"> |
| </div> |
| </div> |
| |
| <label>Должность / Специальность:</label> |
| <input type="text" name="vcard_job" value="{{ settings.vcard_job }}"> |
| |
| <label>Название организации:</label> |
| <input type="text" name="organization_name" value="{{ settings.organization_name }}"> |
| |
| <label>Коротко о себе / Статус:</label> |
| <textarea name="about_text" rows="3">{{ settings.about_text }}</textarea> |
| |
| <label>Аватар (Фото профиля):</label> |
| <input type="file" name="chat_avatar" accept="image/*"> |
| {% if settings.chat_avatar %} |
| <p style="font-size: 0.95rem; margin-top: 10px;">Текущий аватар: <img src="{{ chat_avatar_url }}" class="current-avatar"></p> |
| {% endif %} |
| |
| <label>Цветовая схема:</label> |
| <select name="color_scheme"> |
| {% for key, name in color_schemes.items() %} |
| <option value="{{ key }}" {% if settings.color_scheme == key %}selected{% endif %}>{{ name }}</option> |
| {% endfor %} |
| </select> |
| |
| <label>Валюта (для товаров/услуг):</label> |
| <select name="currency_code"> |
| {% for code, name in currencies.items() %} |
| <option value="{{ code }}" {% if settings.currency_code == code %}selected{% endif %}>{{ name }} ({{ code }})</option> |
| {% endfor %} |
| </select> |
| |
| <div style="background: #f1f3f5; padding: 20px; border-radius: 12px; margin-top: 25px;"> |
| <h4 style="margin-top: 0; color: var(--bg-medium); font-size: 1.1rem;"><i class="fas fa-lock"></i> Пароль для входа сюда</h4> |
| <label style="display: flex; align-items: center; gap: 8px;"><input type="checkbox" name="admin_password_enabled" {% if settings.admin_password_enabled %}checked{% endif %}> Требовать пароль</label> |
| <label style="margin-top: 15px;">Пароль:</label> |
| <input type="text" name="admin_password" value="{{ settings.admin_password }}" placeholder="Текущий пароль"> |
| </div> |
| |
| <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить настройки</button> |
| </form> |
| </div> |
| </details> |
| </div> |
| |
| <div class="section"> |
| <h2><i class="fas fa-link"></i> Кнопки и Ссылки (для Визитки)</h2> |
| <details> |
| <summary><i class="fas fa-plus-circle"></i> Добавить кнопку</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}"> |
| <input type="hidden" name="action" value="add_block"> |
| <label>Тип блока:</label> |
| <select name="block_type" id="block_type" onchange="toggleBlockFields()"> |
| <option value="link">Кнопка-ссылка</option> |
| <option value="text">Текстовый блок</option> |
| </select> |
| <label>Заголовок / Текст кнопки:</label> |
| <input type="text" name="block_title" required> |
| |
| <div id="block_icon_div"> |
| <label>Иконка:</label> |
| <select name="block_icon"> |
| <option value="">-- Без иконки --</option> |
| {% for icon_class, icon_name in icons.items() %} |
| <option value="{{ icon_class }}">{{ icon_name }}</option> |
| {% endfor %} |
| </select> |
| </div> |
| |
| <div id="block_url_div"> |
| <label>URL / Номер / Email (Вставьте ссылку, или телефон начиная с +):</label> |
| <input type="text" name="block_url" placeholder="https://... или +996... или mail@.com"> |
| </div> |
| <div id="block_content_div" style="display: none;"> |
| <label>Текст блока:</label> |
| <textarea name="block_content" rows="3"></textarea> |
| </div> |
| <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button> |
| </form> |
| </div> |
| </details> |
| <div style="margin-top: 15px;"> |
| {% if blocks %} |
| {% for block in blocks %} |
| <div class="block-item"> |
| <div style="flex-grow: 1;"> |
| <strong style="font-size: 1.1rem;"> |
| {% if block.icon %} |
| {% set prefix = 'fab' if block.icon in ['fa-whatsapp', 'fa-telegram', 'fa-instagram', 'fa-youtube', 'fa-tiktok'] else 'fas' %} |
| <i class="{{ prefix }} {{ block.icon }}" style="color:var(--accent);"></i> |
| {% endif %} |
| {{ block.title }} |
| </strong> |
| <span style="color: #888; font-size: 0.9rem;">({{ 'Ссылка' if block.type == 'link' else 'Текст' }})</span> |
| {% if block.type == 'link' %}<br><small style="font-size: 0.9rem;"><a href="{{ block.url }}" target="_blank" rel="noopener noreferrer">{{ block.url }}</a></small>{% endif %} |
| </div> |
| <div class="block-controls"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;"> |
| <input type="hidden" name="action" value="move_block_up"> |
| <input type="hidden" name="block_id" value="{{ block.id }}"> |
| <button type="submit" class="button btn-small" style="background: #6c757d;" {% if loop.first %}disabled{% endif %}><i class="fas fa-arrow-up"></i></button> |
| </form> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;"> |
| <input type="hidden" name="action" value="move_block_down"> |
| <input type="hidden" name="block_id" value="{{ block.id }}"> |
| <button type="submit" class="button btn-small" style="background: #6c757d;" {% if loop.last %}disabled{% endif %}><i class="fas fa-arrow-down"></i></button> |
| </form> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;" onsubmit="if(!confirm('Удалить блок?')) return false;"> |
| <input type="hidden" name="action" value="delete_block"> |
| <input type="hidden" name="block_id" value="{{ block.id }}"> |
| <button type="submit" class="button delete-button btn-small"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </div> |
| </div> |
| {% endfor %} |
| {% else %} |
| <p style="font-size: 1.05rem;">Кнопки не добавлены.</p> |
| {% endif %} |
| </div> |
| <script> |
| function toggleBlockFields() { |
| const type = document.getElementById('block_type').value; |
| document.getElementById('block_url_div').style.display = type === 'link' ? 'block' : 'none'; |
| document.getElementById('block_icon_div').style.display = type === 'link' ? 'block' : 'none'; |
| document.getElementById('block_content_div').style.display = type === 'text' ? 'block' : 'none'; |
| } |
| </script> |
| </div> |
| |
| <div class="section"> |
| <h2><i class="fas fa-tags"></i> Категории товаров/услуг</h2> |
| <details> |
| <summary><i class="fas fa-plus-circle"></i> Добавить новую категорию</summary> |
| <div class="form-content"> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}"> |
| <input type="hidden" name="action" value="add_category"> |
| <label>Название новой категории:</label> |
| <input type="text" name="category_name" required> |
| <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button> |
| </form> |
| </div> |
| </details> |
| {% if categories %} |
| <div class="item-list" style="margin-top:20px;"> |
| {% for category in categories %} |
| <div class="item" style="display: flex; justify-content: space-between; align-items: center;"> |
| <span style="font-size: 1.05rem; font-weight: 500;">{{ category }}</span> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin: 0;" onsubmit="if(!confirm('Вы уверены? Товары этой категории будут помечены как \\'Без категории\\'.')) return false;"> |
| <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;"><i class="fas fa-trash-alt"></i></button> |
| </form> |
| </div> |
| {% endfor %} |
| </div> |
| {% else %} |
| <p style="font-size: 1.05rem; margin-top:15px;">Категорий пока нет.</p> |
| {% endif %} |
| </div> |
| |
| <div class="section"> |
| <h2><i class="fas fa-box-open"></i> Товары и Услуги</h2> |
| <details> |
| <summary><i class="fas fa-plus-circle"></i> Добавить товар/услугу</summary> |
| <div class="form-content"> |
| <form id="add-product-form" method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()"> |
| <input type="hidden" name="action" value="add_product"> |
| <label>Название *:</label> |
| <input type="text" name="name" required> |
| |
| <label>Цена (Оставьте 0, если не нужно показывать):</label> |
| <input type="number" name="price" step="0.01" value="0"> |
| |
| <label>Фотографии (до 10 шт.):</label> |
| <input type="file" name="photos" accept="image/*" multiple> |
| |
| <label>Описание:</label> |
| <textarea id="add_description" name="description" rows="4"></textarea> |
| |
| <div style="display: flex; gap: 10px; align-items: center; margin-top: 10px; flex-wrap: wrap;"> |
| <button type="button" class="button ai-generate-button" style="margin: 0;" onclick="generateDescription('add_product-form', 'add_description', 'add_gen_lang')"><i class="fas fa-magic"></i> Сгенерировать AI-описание по первому фото</button> |
| <select id="add_gen_lang" name="gen_lang" style="width: auto; margin: 0;"> |
| <option value="Русский">Русский</option><option value="Кыргызский">Кыргызский</option><option value="Казахский">Казахский</option><option value="Узбекский">Узбекский</option><option value="Английский">Английский</option> |
| </select> |
| </div> |
| |
| <label style="margin-top: 15px;">Категория:</label> |
| <select name="category"> |
| <option value="Без категории">Без категории</option> |
| {% for category in categories %} |
| <option value="{{ category }}">{{ category }}</option> |
| {% endfor %} |
| </select> |
| |
| <button type="submit" class="add-button" style="margin-top: 25px;"><i class="fas fa-save"></i> Добавить</button> |
| </form> |
| </div> |
| </details> |
| |
| <h3 style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; margin-top: 30px;">Список товаров/услуг:</h3> |
| <form method="GET" action="{{ url_for('admin', env_id=env_id) }}" style="margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap;" id="admin-search-form"> |
| <input type="text" name="q" value="{{ search_q }}" placeholder="Поиск..." style="flex-grow: 1; margin: 0;" id="admin-search-input"> |
| <button type="submit" class="button" style="margin: 0;"><i class="fas fa-search"></i> Поиск</button> |
| {% if search_q %} |
| <a href="{{ url_for('admin', env_id=env_id) }}" class="button" style="background-color: #6c757d; margin: 0; justify-content: center;"><i class="fas fa-times"></i> Сброс</a> |
| {% endif %} |
| </form> |
| |
| {% if paginated_products %} |
| <div class="item-list" id="admin-products-list"> |
| {% for product in paginated_products %} |
| <div class="item"> |
| <div style="display: flex; gap: 15px; align-items: flex-start;"> |
| <div class="photo-preview" style="flex-shrink: 0;"> |
| {% if product.get('photos') %} |
| <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="Фото"> |
| {% else %} |
| <img src="https://via.placeholder.com/80x80.png?text=Нет+фото" alt="Нет фото"> |
| {% endif %} |
| </div> |
| <div style="flex-grow: 1;"> |
| <h3 style="margin-top: 0; margin-bottom: 5px; color: var(--text-dark); font-size: 1.15rem;">{{ product['name'] }}</h3> |
| <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p> |
| <p><strong>Цена:</strong> {% if product.get('price', 0) > 0 %}{{ "%.2f"|format(product.get('price', 0)) }} {{ currency_code }}{% else %}Не указана{% endif %}</p> |
| </div> |
| </div> |
| |
| <div class="item-actions"> |
| <button type="button" class="button" onclick="toggleEditForm('edit-form-{{ product.product_id }}')"><i class="fas fa-edit"></i> Редактировать</button> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" style="margin:0;" onsubmit="if(!confirm('Удалить элемент?')) return false; showLoadingOverlay(); return true;"> |
| <input type="hidden" name="action" value="delete_product"> |
| <input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}"> |
| <button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button> |
| </form> |
| </div> |
| |
| <div id="edit-form-{{ product.product_id }}" class="edit-form-container"> |
| <h4 style="margin-top: 0; font-size: 1.1rem;"><i class="fas fa-edit"></i> Редактирование</h4> |
| <form method="POST" action="{{ url_for('admin', env_id=env_id, p=page, q=search_q) }}" enctype="multipart/form-data" onsubmit="showLoadingOverlay()"> |
| <input type="hidden" name="action" value="edit_product"> |
| <input type="hidden" name="product_id" value="{{ product.get('product_id', '') }}"> |
| |
| <label>Название *:</label> |
| <input type="text" name="name" value="{{ product['name'] }}" required> |
| |
| <label>Цена:</label> |
| <input type="number" name="price" step="0.01" value="{{ product.get('price', 0) }}"> |
| |
| <label>Заменить фотографии (выбор новых удалит старые):</label> |
| <input type="file" name="photos" accept="image/*" multiple> |
| |
| <label>Описание:</label> |
| <textarea name="description" rows="4">{{ product.get('description', '') }}</textarea> |
| |
| <label>Категория:</label> |
| <select name="category"> |
| <option value="Без категории">Без категории</option> |
| {% for category in categories %} |
| <option value="{{ category }}" {% if product.get('category') == category %}selected{% endif %}>{{ category }}</option> |
| {% endfor %} |
| </select> |
| |
| <button type="submit" class="add-button" style="margin-top: 25px;"><i class="fas fa-save"></i> Сохранить</button> |
| </form> |
| </div> |
| </div> |
| {% endfor %} |
| </div> |
| |
| {% if total_pages > 1 %} |
| <div class="pagination"> |
| {% if page > 1 %} |
| <a href="{{ url_for('admin', env_id=env_id, p=page-1, q=search_q) }}" class="button">«</a> |
| {% endif %} |
| |
| {% for p_num in range(1, total_pages + 1) %} |
| {% if p_num == 1 or p_num == total_pages or (p_num >= page - 2 and p_num <= page + 2) %} |
| <a href="{{ url_for('admin', env_id=env_id, p=p_num, q=search_q) }}" class="button {% if p_num == page %}active{% endif %}" style="{% if p_num == page %}background-color: var(--accent); color: var(--text-dark);{% else %}background-color: var(--bg-medium); color: white;{% endif %}">{{ p_num }}</a> |
| {% elif p_num == page - 3 or p_num == page + 3 %} |
| <span style="padding: 10px; color: var(--bg-medium); font-weight: bold;">...</span> |
| {% endif %} |
| {% endfor %} |
| |
| {% if page < total_pages %} |
| <a href="{{ url_for('admin', env_id=env_id, p=page+1, q=search_q) }}" class="button">»</a> |
| {% endif %} |
| </div> |
| {% endif %} |
| |
| {% else %} |
| <p style="font-size: 1.1rem; text-align: center; padding: 40px;">Записей пока нет или по вашему запросу ничего не найдено.</p> |
| {% endif %} |
| </div> |
| </div> |
| |
| <script> |
| document.addEventListener("DOMContentLoaded", function() { |
| const adminSearchInput = document.getElementById('admin-search-input'); |
| const adminSearchForm = document.getElementById('admin-search-form'); |
| |
| if (adminSearchInput && adminSearchForm) { |
| if (adminSearchInput.value.length > 0) { |
| adminSearchInput.focus(); |
| const valLen = adminSearchInput.value.length; |
| adminSearchInput.setSelectionRange(valLen, valLen); |
| } |
| |
| let debounceTimer; |
| adminSearchInput.addEventListener('input', function() { |
| clearTimeout(debounceTimer); |
| debounceTimer = setTimeout(() => { |
| adminSearchForm.submit(); |
| }, 700); |
| }); |
| } |
| }); |
| |
| function showLoadingOverlay() { |
| document.getElementById('loadingOverlay').style.display = 'flex'; |
| } |
| |
| function toggleEditForm(formId) { |
| const formContainer = document.getElementById(formId); |
| if (formContainer) { |
| formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none'; |
| } |
| } |
| |
| async function generateDescription(formId, descriptionTextareaId, languageSelectId) { |
| const form = document.getElementById(formId); |
| const photoInput = form.querySelector('input[type="file"]'); |
| const descriptionTextarea = document.getElementById(descriptionTextareaId); |
| const languageSelect = document.getElementById(languageSelectId); |
| |
| if (!photoInput || !photoInput.files || photoInput.files.length === 0) { |
| return alert("Загрузите фото перед генерацией."); |
| } |
| |
| descriptionTextarea.value = 'Генерация...'; |
| const reader = new FileReader(); |
| reader.onload = async (e) => { |
| const base64Image = e.target.result.split(',')[1]; |
| try { |
| const response = await fetch('/generate_description_ai', { |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ image: base64Image, language: languageSelect.value }) |
| }); |
| const result = await response.json(); |
| descriptionTextarea.value = result.text || result.error; |
| } catch (error) { descriptionTextarea.value = `Ошибка: ${error.message}`; } |
| }; |
| reader.readAsDataURL(photoInput.files[0]); |
| } |
| |
| function downloadQR() { |
| const currentDomain = window.location.origin; |
| const targetUrl = currentDomain + "{{ url_for('catalog', env_id=env_id) }}"; |
| const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${encodeURIComponent(targetUrl)}`; |
| |
| fetch(qrApiUrl) |
| .then(response => response.blob()) |
| .then(blob => { |
| const link = document.createElement('a'); |
| link.href = URL.createObjectURL(blob); |
| link.download = 'QR_code_{{ env_id }}.png'; |
| document.body.appendChild(link); |
| link.click(); |
| document.body.removeChild(link); |
| }) |
| .catch(err => alert('Ошибка при скачивании QR-кода')); |
| } |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| @app.route('/') |
| def index(): |
| return render_template_string(LANDING_PAGE_TEMPLATE) |
|
|
| @app.route('/admhosto', methods=['GET']) |
| def admhosto(): |
| data = load_data() |
| environments_data = [] |
| for env_id, env_data in data.items(): |
| settings = env_data.get('settings', {}) |
| org_name = settings.get("organization_name", f"Визитка {env_id}") |
| environments_data.append({ |
| "id": env_id, |
| "org_name": org_name, |
| "pwd_enabled": settings.get("admin_password_enabled", False), |
| "password": settings.get("admin_password", "") |
| }) |
| environments_data.sort(key=lambda x: x['id']) |
| return render_template_string(ADMHOSTO_TEMPLATE, environments=environments_data) |
|
|
| @app.route('/admhosto/create', methods=['POST']) |
| def create_environment(): |
| all_data = load_data() |
| while True: |
| new_id = ''.join(random.choices(string.digits, k=6)) |
| if new_id not in all_data: |
| break |
| all_data[new_id] = { |
| 'products': [], 'categories':[], 'blocks':[], |
| 'settings': { |
| "vcard_firstname": "Имя", |
| "vcard_lastname": "Фамилия", |
| "vcard_job": "Специалист", |
| "organization_name": "Моя Компания", |
| "currency_code": "KGS", |
| "chat_avatar": None, |
| "color_scheme": "default", |
| "admin_password_enabled": False, |
| "admin_password": "", |
| "categories_as_lines": False, |
| "about_text": "Привет! Это моя онлайн-визитка." |
| } |
| } |
| save_data(all_data) |
| flash(f'Новая визитка с ID {new_id} успешно создана.', 'success') |
| return redirect(url_for('admhosto')) |
|
|
| @app.route('/admhosto/update_pwd/<env_id>', methods=['POST']) |
| def update_env_pwd(env_id): |
| all_data = load_data() |
| if env_id in all_data: |
| pwd_enabled = 'pwd_enabled' in request.form |
| password = request.form.get('password', '').strip() |
| all_data[env_id]['settings']['admin_password_enabled'] = pwd_enabled |
| all_data[env_id]['settings']['admin_password'] = password |
| save_data(all_data) |
| flash(f'Пароль для визитки {env_id} обновлен.', 'success') |
| else: |
| flash(f'Визитка {env_id} не найдена.', 'error') |
| return redirect(url_for('admhosto')) |
|
|
| @app.route('/admhosto/delete/<env_id>', methods=['POST']) |
| def delete_environment(env_id): |
| all_data = load_data() |
| if env_id in all_data: |
| del all_data[env_id] |
| save_data(all_data) |
| flash(f'Визитка {env_id} была удалена.', 'success') |
| else: |
| flash(f'Визитка {env_id} не найдена.', 'error') |
| return redirect(url_for('admhosto')) |
|
|
| @app.route('/<env_id>/login', methods=['GET', 'POST']) |
| def admin_login(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| |
| if not settings.get('admin_password_enabled'): |
| return redirect(url_for('admin', env_id=env_id)) |
| |
| if request.method == 'POST': |
| pwd = request.form.get('password', '') |
| if pwd == settings.get('admin_password', ''): |
| session[f'admin_auth_{env_id}'] = True |
| return redirect(url_for('admin', env_id=env_id)) |
| else: |
| flash('Неверный пароль', 'error') |
| |
| return render_template_string(LOGIN_TEMPLATE, env_id=env_id) |
|
|
| @app.route('/<env_id>/logout') |
| def admin_logout(env_id): |
| session.pop(f'admin_auth_{env_id}', None) |
| return redirect(url_for('admin_login', env_id=env_id)) |
|
|
| @app.route('/<env_id>/vcard') |
| def download_vcard(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| blocks = data.get('blocks', []) |
|
|
| first_name = settings.get("vcard_firstname", "") |
| last_name = settings.get("vcard_lastname", "") |
| org = settings.get("organization_name", "") |
| title = settings.get("vcard_job", "") |
| note = settings.get("about_text", "").replace('\n', '\\n') |
|
|
| card_url = url_for('catalog', env_id=env_id, _external=True) |
|
|
| vcard = [ |
| "BEGIN:VCARD", |
| "VERSION:3.0", |
| f"N:{last_name};{first_name};;;", |
| f"FN:{first_name} {last_name}".strip(), |
| ] |
| |
| if org: vcard.append(f"ORG:{org}") |
| if title: vcard.append(f"TITLE:{title}") |
| if note: vcard.append(f"NOTE:{note}") |
| |
| vcard.append(f"URL;type=pref:{card_url}") |
|
|
| for b in blocks: |
| if b.get('type') == 'link' and b.get('url'): |
| url = b.get('url', '').strip() |
| if url.startswith('+') or url.replace('-', '').replace(' ', '').isdigit(): |
| vcard.append(f"TEL;TYPE=CELL:{url}") |
| elif '@' in url and not url.startswith('http'): |
| vcard.append(f"EMAIL;TYPE=WORK:{url.replace('mailto:', '')}") |
| else: |
| vcard.append(f"URL:{url}") |
|
|
| vcard.append("END:VCARD") |
| vcard_str = "\r\n".join(vcard) |
|
|
| response = make_response(vcard_str) |
| response.headers["Content-Disposition"] = f"attachment; filename=contact_{env_id}.vcf" |
| response.headers["Content-Type"] = "text/vcard; charset=utf-8" |
| return response |
|
|
| @app.route('/<env_id>/catalog') |
| def catalog(env_id): |
| data = get_env_data(env_id) |
| all_products = data.get('products',[]) |
| settings = data.get('settings', {}) |
| blocks = data.get('blocks',[]) |
| |
| product_categories = set(p.get('category', 'Без категории') for p in all_products) |
| admin_categories = set(data.get('categories',[])) |
| all_cat_names = sorted(list(product_categories.union(admin_categories))) |
| |
| products_sorted_for_js = sorted(all_products, key=lambda p: p.get('name', '').lower()) |
| |
| products_by_category = {cat:[] for cat in all_cat_names} |
| for product in all_products: |
| products_by_category[product.get('category', 'Без категории')].append(product) |
| ordered_categories = [cat for cat in all_cat_names if products_by_category.get(cat)] |
| |
| chat_avatar_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/avatars/{settings['chat_avatar']}" if settings.get('chat_avatar') else "https://huggingface.co/spaces/gippo312/admin/resolve/main/Picsart_25-11-04_12-02-21-390.png" |
| |
| return render_template_string( |
| CATALOG_TEMPLATE, ordered_categories=ordered_categories, |
| products_json=json.dumps(products_sorted_for_js), repo_id=REPO_ID, currency_code=settings.get('currency_code', 'KGS'), |
| settings=settings, chat_avatar_url=chat_avatar_url, env_id=env_id, blocks=blocks |
| ) |
|
|
| @app.route('/<env_id>/admin', methods=['GET', 'POST']) |
| def admin(env_id): |
| data = get_env_data(env_id) |
| settings = data.get('settings', {}) |
| |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): |
| return redirect(url_for('admin_login', env_id=env_id)) |
| |
| products = data.get('products',[]) |
| categories = data.get('categories',[]) |
| blocks = data.get('blocks',[]) |
|
|
| page = request.args.get('p', 1, type=int) |
| search_q = request.args.get('q', '').strip() |
|
|
| if request.method == 'POST': |
| action = request.form.get('action') |
| try: |
| if action == 'add_block': |
| b_type = request.form.get('block_type') |
| b_title = request.form.get('block_title', '').strip() |
| b_icon = request.form.get('block_icon', '') |
| b_url = request.form.get('block_url', '').strip() |
| |
| if b_type == 'link' and b_url: |
| if not b_url.startswith(('http://', 'https://', 'mailto:', 'tel:')) and not b_url.startswith('+'): |
| if '@' in b_url: |
| b_url = 'mailto:' + b_url |
| else: |
| b_url = 'https://' + b_url |
| elif b_url.startswith('+'): |
| b_url = 'tel:' + b_url.replace(' ', '').replace('-', '') |
|
|
| b_content = request.form.get('block_content', '').strip() |
| blocks.append({ |
| 'id': uuid4().hex[:8], |
| 'type': b_type, |
| 'title': b_title, |
| 'icon': b_icon if b_type == 'link' else '', |
| 'url': b_url, |
| 'content': b_content |
| }) |
| data['blocks'] = blocks |
| save_env_data(env_id, data) |
| flash("Блок добавлен.", "success") |
| |
| elif action == 'delete_block': |
| b_id = request.form.get('block_id') |
| data['blocks'] = [b for b in blocks if b.get('id') != b_id] |
| save_env_data(env_id, data) |
| flash("Блок удален.", "success") |
| |
| elif action == 'move_block_up': |
| b_id = request.form.get('block_id') |
| idx = next((i for i, b in enumerate(blocks) if b.get('id') == b_id), -1) |
| if idx > 0: |
| blocks[idx], blocks[idx-1] = blocks[idx-1], blocks[idx] |
| data['blocks'] = blocks |
| save_env_data(env_id, data) |
| flash("Блок перемещен выше.", "success") |
| |
| elif action == 'move_block_down': |
| b_id = request.form.get('block_id') |
| idx = next((i for i, b in enumerate(blocks) if b.get('id') == b_id), -1) |
| if idx != -1 and idx < len(blocks) - 1: |
| blocks[idx], blocks[idx+1] = blocks[idx+1], blocks[idx] |
| data['blocks'] = blocks |
| save_env_data(env_id, data) |
| flash("Блок перемещен ниже.", "success") |
| |
| elif action == 'add_category': |
| category_name = request.form.get('category_name', '').strip() |
| if category_name and category_name not in categories: |
| categories.append(category_name) |
| data['categories'] = categories |
| save_env_data(env_id, data) |
| flash(f"Категория '{category_name}' успешно добавлена.", 'success') |
| elif not category_name: |
| flash("Название категории не может быть пустым.", 'error') |
| else: |
| flash(f"Категория '{category_name}' уже существует.", 'error') |
|
|
| 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_count = 0 |
| for product in products: |
| if product.get('category') == category_to_delete: |
| product['category'] = 'Без категории' |
| updated_count += 1 |
| data['categories'] = categories |
| data['products'] = products |
| save_env_data(env_id, data) |
| flash(f"Категория '{category_to_delete}' удалена. {updated_count} товаров/услуг обновлено.", 'success') |
| else: |
| flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error') |
| |
| elif action == 'update_settings': |
| settings['admin_password_enabled'] = 'admin_password_enabled' in request.form |
| settings['admin_password'] = request.form.get('admin_password', '').strip() |
| |
| settings['vcard_firstname'] = request.form.get('vcard_firstname', '').strip() |
| settings['vcard_lastname'] = request.form.get('vcard_lastname', '').strip() |
| settings['vcard_job'] = request.form.get('vcard_job', '').strip() |
| settings['organization_name'] = request.form.get('organization_name', '').strip() |
| settings['about_text'] = request.form.get('about_text', '').strip() |
| |
| settings['currency_code'] = request.form.get('currency_code', 'KGS') |
| settings['color_scheme'] = request.form.get('color_scheme', 'default') |
|
|
| avatar_file = request.files.get('chat_avatar') |
| if avatar_file and avatar_file.filename: |
| if HF_TOKEN_WRITE: |
| try: |
| api = HfApi() |
| old_avatar = settings.get('chat_avatar') |
| if old_avatar: |
| try: api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"avatars/{old_avatar}"], repo_type="dataset", token=HF_TOKEN_WRITE) |
| except Exception: pass |
| ext = os.path.splitext(avatar_file.filename)[1].lower() |
| avatar_filename = f"avatar_{env_id}_{int(time.time())}{ext}" |
| uploads_dir = 'uploads_temp' |
| os.makedirs(uploads_dir, exist_ok=True) |
| temp_path = os.path.join(uploads_dir, avatar_filename) |
| avatar_file.save(temp_path) |
| api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"avatars/{avatar_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
| settings['chat_avatar'] = avatar_filename |
| os.remove(temp_path) |
| flash("Аватар успешно обновлен.", 'success') |
| except Exception as e: |
| flash(f"Ошибка при загрузке аватара: {e}", 'error') |
| else: |
| flash("HF_TOKEN (write) не настроен. Аватар не был загружен.", "warning") |
|
|
| data['settings'] = settings |
| save_env_data(env_id, data) |
| flash("Настройки успешно обновлены.", 'success') |
|
|
| elif action == 'add_product' or action == 'edit_product': |
| product_id = request.form.get('product_id') |
| product_data = {} |
| is_edit = action == 'edit_product' |
| |
| if is_edit: |
| product_data = next((p for p in products if p.get('product_id') == product_id), None) |
| if not product_data: |
| flash(f"Ошибка: запись не найдена.", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
| else: |
| product_data['views'] = 0 |
| |
| product_data['name'] = request.form.get('name', '').strip() |
| product_data['description'] = request.form.get('description', '').strip() |
| |
| try: |
| product_data['price'] = float(request.form.get('price', 0)) |
| except ValueError: |
| product_data['price'] = 0.0 |
|
|
| category = request.form.get('category') |
| product_data['category'] = category if category in categories else 'Без категории' |
|
|
| if not product_data['name']: |
| flash("Название обязательно.", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
|
|
| photos_files = request.files.getlist('photos') |
| if photos_files and any(f.filename for f in photos_files): |
| if HF_TOKEN_WRITE: |
| uploads_dir = 'uploads_temp' |
| os.makedirs(uploads_dir, exist_ok=True) |
| api = HfApi() |
| new_photos_list = [] |
| photo_limit = 10 |
| uploaded_count = 0 |
| for photo in photos_files: |
| if uploaded_count >= photo_limit: break |
| if photo and photo.filename: |
| try: |
| ext = os.path.splitext(photo.filename)[1].lower() |
| if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']: continue |
| safe_name = secure_filename(product_data['name'].replace(' ', '_'))[:50] |
| photo_filename = f"{safe_name}_{uuid4().hex[:8]}{ext}" |
| temp_path = os.path.join(uploads_dir, photo_filename) |
| photo.save(temp_path) |
| api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
| new_photos_list.append(photo_filename) |
| os.remove(temp_path) |
| uploaded_count += 1 |
| except Exception as e: |
| flash(f"Ошибка при загрузке фото {photo.filename}: {e}", 'error') |
| if new_photos_list and is_edit and product_data.get('photos'): |
| try: api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in product_data['photos']], repo_type="dataset", token=HF_TOKEN_WRITE) |
| except Exception: pass |
| if new_photos_list: |
| product_data['photos'] = new_photos_list |
| else: |
| flash("HF_TOKEN не настроен. Фотографии не загружены.", "warning") |
| |
| if is_edit: |
| product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1) |
| if product_index != -1: |
| products[product_index] = product_data |
| flash(f"Запись '{product_data['name']}' обновлена.", 'success') |
| else: |
| product_data['product_id'] = uuid4().hex |
| products.append(product_data) |
| flash(f"Запись '{product_data['name']}' добавлена.", 'success') |
| |
| data['products'] = products |
| save_env_data(env_id, data) |
|
|
| elif action == 'delete_product': |
| product_id = request.form.get('product_id') |
| product_index = next((i for i, p in enumerate(products) if p.get('product_id') == product_id), -1) |
| if product_index == -1: |
| flash(f"Ошибка удаления: запись не найдена.", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
| deleted_product = products.pop(product_index) |
| product_name = deleted_product.get('name', 'N/A') |
| photos_to_delete = deleted_product.get('photos', []) |
| if photos_to_delete and HF_TOKEN_WRITE: |
| try: |
| api = HfApi() |
| api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in photos_to_delete], repo_type="dataset", token=HF_TOKEN_WRITE) |
| except Exception: pass |
| data['products'] = products |
| save_env_data(env_id, data) |
| flash(f"Запись '{product_name}' удалена.", 'success') |
| else: |
| flash(f"Неизвестное действие: {action}", 'warning') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
| except Exception as e: |
| flash(f"Ошибка при выполнении действия: {e}", 'error') |
| return redirect(url_for('admin', env_id=env_id, p=page, q=search_q)) |
|
|
| filtered_products = products |
| if search_q: |
| q_lower = search_q.lower() |
| filtered_products = [p for p in products if q_lower in p.get('name', '').lower() or q_lower in p.get('description', '').lower()] |
| |
| filtered_products = sorted(filtered_products, key=lambda p: p.get('name', '').lower()) |
| |
| PER_PAGE = 20 |
| total_items = len(filtered_products) |
| total_pages = math.ceil(total_items / PER_PAGE) if total_items > 0 else 1 |
| |
| if page < 1: page = 1 |
| if page > total_pages: page = total_pages |
| |
| start_idx = (page - 1) * PER_PAGE |
| end_idx = start_idx + PER_PAGE |
| paginated_products = filtered_products[start_idx:end_idx] |
|
|
| display_categories = sorted(categories) |
| display_settings = settings |
| chat_avatar_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/avatars/{display_settings['chat_avatar']}" if display_settings.get('chat_avatar') else "https://huggingface.co/spaces/gippo312/admin/resolve/main/Picsart_25-11-04_12-02-21-390.png" |
|
|
| return render_template_string( |
| ADMIN_TEMPLATE, paginated_products=paginated_products, total_pages=total_pages, page=page, search_q=search_q, categories=display_categories, |
| settings=display_settings, blocks=blocks, repo_id=REPO_ID, currency_code=display_settings.get('currency_code', 'KGS'), chat_avatar_url=chat_avatar_url, |
| currencies=CURRENCIES, color_schemes=COLOR_SCHEMES, icons=ICONS, env_id=env_id |
| ) |
|
|
| @app.route('/generate_description_ai', methods=['POST']) |
| def handle_generate_description_ai(): |
| request_data = request.get_json() |
| base64_image = request_data.get('image') |
| language = request_data.get('language', 'Русский') |
| if not base64_image: return jsonify({"error": "Изображение не найдено в запросе."}), 400 |
| try: |
| image_data = base64.b64decode(base64_image) |
| result_text = generate_ai_description_from_image(image_data, language) |
| return jsonify({"text": result_text}) |
| except ValueError as ve: return jsonify({"error": str(ve)}), 400 |
| except Exception as e: return jsonify({"error": f"Внутренняя ошибка сервера: {e}"}), 500 |
|
|
| if __name__ == '__main__': |
| configure_gemini() |
| download_db_from_hf() |
| load_data() |
| if HF_TOKEN_WRITE: |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) |
| backup_thread.start() |
| port = int(os.environ.get('PORT', 7860)) |
| app.run(debug=False, host='0.0.0.0', port=port) |
|
|