Bc / app.py
Kgshop's picture
Update app.py
bf0723b verified
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">&laquo;</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">&raquo;</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">&laquo;</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">&raquo;</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)