Spaces:
Sleeping
Sleeping
Upload 26 files
Browse files- .env +2 -0
- .gitignore +8 -0
- Dockerfile +16 -0
- Procfile +1 -0
- README.md +8 -0
- README.txt +31 -0
- admin_ui.py +179 -0
- app.py +51 -0
- assets/gallery.html +64 -0
- assets/landing.html +89 -0
- assets/shell.css +13 -0
- config.json +30 -0
- main.py +78 -0
- models/Unstop Retail 4032W/1.webp +0 -0
- models/Unstop Retail 4032W/2.webp +0 -0
- models/Unstop Retail 4032W/3.webp +0 -0
- models/Unstop Retail 4032W/Opis.txt +34 -0
- models/Unstop Retail 4032W/Price.txt +1 -0
- models/Unstop Retail 4032W/Subtitle.txt +1 -0
- models/Unstop Retail 5032W/1.webp +0 -0
- models/Unstop Retail 5032W/Opis.txt +35 -0
- models/Unstop Retail 5032W/Price.txt +1 -0
- models/Unstop Retail 5032W/Subtitle.txt +1 -0
- requirements.txt +7 -0
- showcase_ui.py +75 -0
- utils.py +142 -0
.env
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ADMIN_USER=admin
|
| 2 |
+
ADMIN_PASSWORD=admin
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
.env
|
| 5 |
+
venv/
|
| 6 |
+
.venv/
|
| 7 |
+
*.bat
|
| 8 |
+
Procfile
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Копіюємо залежності
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Копіюємо проект
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
# Відкриваємо порт
|
| 13 |
+
EXPOSE 7860
|
| 14 |
+
|
| 15 |
+
# Запуск
|
| 16 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
web: uvicorn app:app --host 0.0.0.0 --port 7860
|
README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Unstop Retail
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
README.txt
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
UNSTOP RETAIL — Структура проекту
|
| 2 |
+
==================================
|
| 3 |
+
|
| 4 |
+
Запуск:
|
| 5 |
+
Двічі клацнути start.bat
|
| 6 |
+
або: python main.py
|
| 7 |
+
|
| 8 |
+
Адреси:
|
| 9 |
+
Вітрина: http://localhost:7860/
|
| 10 |
+
Адмінка: http://localhost:7860/figvam/
|
| 11 |
+
|
| 12 |
+
Файли проекту:
|
| 13 |
+
main.py — точка входу, FastAPI + монтування Gradio
|
| 14 |
+
utils.py — робота з config.json, читання моделей, контактів
|
| 15 |
+
admin_ui.py — Gradio-інтерфейс адмінки (товари + контакти)
|
| 16 |
+
showcase_ui.py — Gradio-інтерфейс вітрини (рендер через Jinja2)
|
| 17 |
+
.env — логін/пароль адмінки (не передавати стороннім!)
|
| 18 |
+
config.json — база даних: список товарів + глобальні контакти
|
| 19 |
+
|
| 20 |
+
Папки:
|
| 21 |
+
assets/ — HTML-шаблони та CSS (gallery.html, landing.html, shell.css)
|
| 22 |
+
models/ — папки з фото та текстами для кожного товару
|
| 23 |
+
|
| 24 |
+
Структура моделі (папка всередині models/):
|
| 25 |
+
1.webp, 2.webp — фотографії товару
|
| 26 |
+
Opis.txt — детальний опис (Markdown)
|
| 27 |
+
Price.txt — ціна
|
| 28 |
+
Subtitle.txt — підзаголовок
|
| 29 |
+
|
| 30 |
+
Зміна пароля адмінки:
|
| 31 |
+
Відкрити .env і змінити ADMIN_PASSWORD=ваш_пароль
|
admin_ui.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, time, shutil
|
| 2 |
+
import gradio as gr
|
| 3 |
+
from utils import get_models_data, save_config, get_contacts, save_contacts
|
| 4 |
+
|
| 5 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def build_admin_app():
|
| 9 |
+
initial_models = get_models_data(BASE_DIR)
|
| 10 |
+
|
| 11 |
+
with gr.Blocks(title="Unstop Admin", analytics_enabled=False) as app_admin:
|
| 12 |
+
|
| 13 |
+
with gr.Tabs():
|
| 14 |
+
|
| 15 |
+
# ── ВКЛ 1: ТОВАРИ ──────────────────────────────────────────────
|
| 16 |
+
with gr.Tab("🛒 Товари"):
|
| 17 |
+
with gr.Row():
|
| 18 |
+
model_selector = gr.Dropdown(
|
| 19 |
+
choices=[m['name'] for m in initial_models],
|
| 20 |
+
label="Оберіть товар для редагування",
|
| 21 |
+
value=initial_models[0]['name'] if initial_models else None,
|
| 22 |
+
scale=4
|
| 23 |
+
)
|
| 24 |
+
add_btn = gr.Button("➕ Створити новий", variant="primary", scale=1)
|
| 25 |
+
del_btn = gr.Button("🗑 Видалити товар", variant="stop", scale=1)
|
| 26 |
+
|
| 27 |
+
status_msg = gr.Markdown("")
|
| 28 |
+
|
| 29 |
+
with gr.Group():
|
| 30 |
+
gr.Markdown("## 🗂 Структура картки товару")
|
| 31 |
+
with gr.Row():
|
| 32 |
+
with gr.Column(scale=1, variant="panel"):
|
| 33 |
+
gr.Markdown("### 🖼 Фотографії")
|
| 34 |
+
m_folder = gr.Textbox(label="Назва папки (у /models/)", info="Порожнє = автостворення")
|
| 35 |
+
m_images = gr.File(label="Завантажити нові фото", file_count="multiple", type="filepath")
|
| 36 |
+
with gr.Row():
|
| 37 |
+
upload_btn = gr.Button("✅ Завантажити")
|
| 38 |
+
clear_img_btn = gr.Button("🗑 Очистити папку")
|
| 39 |
+
|
| 40 |
+
with gr.Column(scale=1, variant="panel"):
|
| 41 |
+
gr.Markdown("### 📄 Основна інформація")
|
| 42 |
+
m_name = gr.Textbox(label="Назва (вкладка)", placeholder="Unstop Retail 4032W")
|
| 43 |
+
m_price = gr.Textbox(label="💵 Ціна", placeholder="49 000 грн")
|
| 44 |
+
m_title = gr.Textbox(label="⚡ Заголовок H1")
|
| 45 |
+
m_subtitle = gr.Textbox(label="🔋 Підзаголовок")
|
| 46 |
+
|
| 47 |
+
with gr.Row():
|
| 48 |
+
with gr.Column(variant="panel"):
|
| 49 |
+
gr.Markdown("### 📝 Детальний опис (Markdown)")
|
| 50 |
+
m_opis = gr.Textbox(label="Текст опису", lines=12, show_label=False)
|
| 51 |
+
|
| 52 |
+
with gr.Row():
|
| 53 |
+
save_btn = gr.Button("💾 ЗБЕРЕГТИ ЗМІНИ ТОВАРУ", variant="primary", size="lg")
|
| 54 |
+
|
| 55 |
+
# ── ВКЛ 2: НАЛАШТУВАННЯ САЙТУ ──────────────────────────────────
|
| 56 |
+
with gr.Tab("⚙️ Налаштування сайту"):
|
| 57 |
+
gr.Markdown("## 📞 Глобальні контакти")
|
| 58 |
+
_c = get_contacts(BASE_DIR)
|
| 59 |
+
|
| 60 |
+
c_phone = gr.Textbox(label="Телефон (для tel:)", value=_c.get("phone", ""), placeholder="+380675745662")
|
| 61 |
+
c_phone_display = gr.Textbox(label="Телефон (відображення)", value=_c.get("phone_display", ""), placeholder="+38 067 574 56 62")
|
| 62 |
+
c_tg_link = gr.Textbox(label="Telegram посилання", value=_c.get("tg_link", ""), placeholder="https://t.me/...")
|
| 63 |
+
c_tg_display = gr.Textbox(label="Telegram підпис кнопки", value=_c.get("tg_display", ""), placeholder="Написати в Telegram")
|
| 64 |
+
c_address = gr.Textbox(label="Адреса (HTML дозволено)", value=_c.get("address", ""), placeholder="м. Харків, ...")
|
| 65 |
+
c_map_src = gr.Textbox(label="Src для <iframe> карти", value=_c.get("map_iframe", ""), lines=2)
|
| 66 |
+
|
| 67 |
+
contacts_status = gr.Markdown("")
|
| 68 |
+
save_contacts_btn = gr.Button("💾 ЗБЕРЕГТИ КОНТАКТИ", variant="primary", size="lg")
|
| 69 |
+
|
| 70 |
+
# ── ОБРОБНИКИ: ТОВАРИ ──────────────────────────────────────────────
|
| 71 |
+
|
| 72 |
+
def load_model_data(sel_name):
|
| 73 |
+
if not sel_name:
|
| 74 |
+
return "", "", "", "", "", "", ""
|
| 75 |
+
for m in get_models_data(BASE_DIR):
|
| 76 |
+
if m['name'] == sel_name:
|
| 77 |
+
return m['name'], m['price'], m['title'], m['subtitle'], m.get('folder', ''), m['opis_raw'], ""
|
| 78 |
+
return "", "", "", "", "", "", ""
|
| 79 |
+
|
| 80 |
+
model_selector.change(
|
| 81 |
+
load_model_data, inputs=model_selector,
|
| 82 |
+
outputs=[m_name, m_price, m_title, m_subtitle, m_folder, m_opis, status_msg]
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def save_model_data(sel_name, name, price, title, sub, folder, opis):
|
| 86 |
+
if not sel_name:
|
| 87 |
+
return gr.update(), "⚠️ Немає моделі для збереження"
|
| 88 |
+
models = get_models_data(BASE_DIR)
|
| 89 |
+
for m in models:
|
| 90 |
+
if m['name'] == sel_name:
|
| 91 |
+
m['name'], m['price'], m['title'], m['subtitle'], m['folder'], m['opis_raw'] = \
|
| 92 |
+
name, price, title, sub, folder, opis
|
| 93 |
+
break
|
| 94 |
+
save_config(BASE_DIR, models)
|
| 95 |
+
return gr.update(choices=[x['name'] for x in models], value=name), "✅ Збережено! Оновіть вітрину (F5)."
|
| 96 |
+
|
| 97 |
+
save_btn.click(
|
| 98 |
+
save_model_data,
|
| 99 |
+
inputs=[model_selector, m_name, m_price, m_title, m_subtitle, m_folder, m_opis],
|
| 100 |
+
outputs=[model_selector, status_msg]
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
def add_new_model():
|
| 104 |
+
models = get_models_data(BASE_DIR)
|
| 105 |
+
new_name = f"Новий товар {len(models) + 1}"
|
| 106 |
+
models.append({"id": f"m_{int(time.time())}", "folder": "", "name": new_name,
|
| 107 |
+
"title": "Новий заголовок", "subtitle": "Короткий опис",
|
| 108 |
+
"price": "0 грн", "opis_raw": ""})
|
| 109 |
+
save_config(BASE_DIR, models)
|
| 110 |
+
return gr.update(choices=[x['name'] for x in models], value=new_name), f"✅ Додано «{new_name}»!"
|
| 111 |
+
|
| 112 |
+
add_btn.click(add_new_model, outputs=[model_selector, status_msg])
|
| 113 |
+
|
| 114 |
+
def delete_selected_model(sel_name):
|
| 115 |
+
if not sel_name:
|
| 116 |
+
return gr.update(), "⚠️ Не обрано товар"
|
| 117 |
+
models = [m for m in get_models_data(BASE_DIR) if m['name'] != sel_name]
|
| 118 |
+
save_config(BASE_DIR, models)
|
| 119 |
+
new_sel = models[0]['name'] if models else None
|
| 120 |
+
return gr.update(choices=[x['name'] for x in models], value=new_sel), f"🗑 «{sel_name}» видалено!"
|
| 121 |
+
|
| 122 |
+
del_btn.click(delete_selected_model, inputs=[model_selector], outputs=[model_selector, status_msg])
|
| 123 |
+
|
| 124 |
+
def handle_upload(sel_name, current_folder, files):
|
| 125 |
+
if not files:
|
| 126 |
+
return current_folder, "⚠️ Виберіть файли."
|
| 127 |
+
target_folder = current_folder.strip() or "".join(c if c.isalnum() else "_" for c in sel_name)
|
| 128 |
+
target_dir = os.path.join(BASE_DIR, "models", target_folder)
|
| 129 |
+
os.makedirs(target_dir, exist_ok=True)
|
| 130 |
+
count = 0
|
| 131 |
+
for f in files:
|
| 132 |
+
shutil.copy(f, os.path.join(target_dir, os.path.basename(f)))
|
| 133 |
+
count += 1
|
| 134 |
+
models = get_models_data(BASE_DIR)
|
| 135 |
+
for m in models:
|
| 136 |
+
if m['name'] == sel_name:
|
| 137 |
+
m['folder'] = target_folder
|
| 138 |
+
save_config(BASE_DIR, models)
|
| 139 |
+
return gr.update(value=target_folder), f"✅ Завантажено {count} фото у '{target_folder}'!"
|
| 140 |
+
|
| 141 |
+
upload_btn.click(handle_upload, inputs=[model_selector, m_folder, m_images], outputs=[m_folder, status_msg])
|
| 142 |
+
|
| 143 |
+
def handle_clear_images(current_folder):
|
| 144 |
+
target_folder = current_folder.strip()
|
| 145 |
+
if not target_folder:
|
| 146 |
+
return "⚠️ Папка не вказана."
|
| 147 |
+
target_dir = os.path.join(BASE_DIR, "models", target_folder)
|
| 148 |
+
count = 0
|
| 149 |
+
if os.path.exists(target_dir):
|
| 150 |
+
for f in os.listdir(target_dir):
|
| 151 |
+
if f.lower().endswith(('.webp', '.png', '.jpg', '.jpeg')):
|
| 152 |
+
try:
|
| 153 |
+
os.remove(os.path.join(target_dir, f)); count += 1
|
| 154 |
+
except Exception:
|
| 155 |
+
pass
|
| 156 |
+
return f"🗑 Видалено {count} фото!"
|
| 157 |
+
|
| 158 |
+
clear_img_btn.click(handle_clear_images, inputs=[m_folder], outputs=[status_msg])
|
| 159 |
+
|
| 160 |
+
# ── ОБРОБНИКИ: КОНТАКТИ ────────────────────────────────────────────
|
| 161 |
+
|
| 162 |
+
def do_save_contacts(phone, phone_display, tg_link, tg_display, address, map_src):
|
| 163 |
+
save_contacts(BASE_DIR, {
|
| 164 |
+
"phone": phone.strip(),
|
| 165 |
+
"phone_display": phone_display.strip(),
|
| 166 |
+
"tg_link": tg_link.strip(),
|
| 167 |
+
"tg_display": tg_display.strip(),
|
| 168 |
+
"address": address.strip(),
|
| 169 |
+
"map_iframe": map_src.strip(),
|
| 170 |
+
})
|
| 171 |
+
return "✅ Контакти збережено! Оновіть вітрину (F5)."
|
| 172 |
+
|
| 173 |
+
save_contacts_btn.click(
|
| 174 |
+
do_save_contacts,
|
| 175 |
+
inputs=[c_phone, c_phone_display, c_tg_link, c_tg_display, c_address, c_map_src],
|
| 176 |
+
outputs=[contacts_status]
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
return app_admin
|
app.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import gradio as gr
|
| 3 |
+
from fastapi import FastAPI, Request
|
| 4 |
+
from fastapi.responses import RedirectResponse, PlainTextResponse
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 7 |
+
from slowapi.util import get_remote_address
|
| 8 |
+
from slowapi.errors import RateLimitExceeded
|
| 9 |
+
from showcase_ui import build_showcase_app
|
| 10 |
+
from admin_ui import build_admin_app
|
| 11 |
+
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
ADMIN_USER = os.getenv("ADMIN_USER", "admin")
|
| 15 |
+
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
|
| 16 |
+
|
| 17 |
+
limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
|
| 18 |
+
|
| 19 |
+
app_showcase = build_showcase_app()
|
| 20 |
+
app_admin = build_admin_app()
|
| 21 |
+
|
| 22 |
+
app = FastAPI()
|
| 23 |
+
app.state.limiter = limiter
|
| 24 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 25 |
+
|
| 26 |
+
BLOCKED_PATHS = (
|
| 27 |
+
"/config.json", "/.env", "/utils.py", "/main.py",
|
| 28 |
+
"/admin_ui.py", "/showcase_ui.py", "/.git",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
@app.middleware("http")
|
| 32 |
+
async def security_middleware(request: Request, call_next):
|
| 33 |
+
path = request.url.path.lower()
|
| 34 |
+
for blocked in BLOCKED_PATHS:
|
| 35 |
+
if path.startswith(blocked):
|
| 36 |
+
return PlainTextResponse("404 Not Found", status_code=404)
|
| 37 |
+
if ".." in path or "%2e%2e" in path:
|
| 38 |
+
return PlainTextResponse("400 Bad Request", status_code=400)
|
| 39 |
+
response = await call_next(request)
|
| 40 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 41 |
+
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
| 42 |
+
return response
|
| 43 |
+
|
| 44 |
+
@app.get("/figvam")
|
| 45 |
+
def redirect_admin():
|
| 46 |
+
return RedirectResponse(url="/figvam/")
|
| 47 |
+
|
| 48 |
+
app = gr.mount_gradio_app(app, app_admin, path="/figvam",
|
| 49 |
+
auth=(ADMIN_USER, ADMIN_PASSWORD),
|
| 50 |
+
auth_message="Unstop Admin")
|
| 51 |
+
app = gr.mount_gradio_app(app, app_showcase, path="/")
|
assets/gallery.html
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html><html><head><meta charset="utf-8">
|
| 2 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
| 3 |
+
<style>
|
| 4 |
+
*{box-sizing:border-box;margin:0;padding:0}html,body{width:100%;height:100%;background:transparent;overflow:hidden}.car{position:relative;width:100%;height:100%;background:#e8edf2;border-radius:8px;overflow:hidden;cursor:zoom-in;user-select:none;touch-action:pan-y}.car img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;transition:opacity .25s}.car img.hidden{opacity:0;pointer-events:none}.car img.visible{opacity:1}.btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,.42);color:#fff;border:none;border-radius:50%;width:40px;height:40px;font-size:1.5rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .18s;z-index:5;-webkit-tap-highlight-color:transparent}.btn:hover{background:#F59E0B;color:#000}.prev{left:8px}.next{right:8px}.dots{position:absolute;bottom:8px;width:100%;display:flex;justify-content:center;gap:7px;z-index:5;pointer-events:none}.dot{width:9px;height:9px;border-radius:50%;background:rgba(255,255,255,.45);cursor:pointer;transition:background .2s;pointer-events:auto;-webkit-tap-highlight-color:transparent}.dot.on{background:#F59E0B}.lb{display:none;position:fixed;inset:0;background:rgba(0,0,0,.98);z-index:9999;align-items:center;justify-content:center}.lb.open{display:flex}.lb img{max-width:100vw;max-height:100vh;object-fit:contain;animation:pop .2s ease;pointer-events:none;user-select:none}@keyframes pop{from{transform:scale(.88);opacity:0}to{transform:scale(1);opacity:1}}.lb-x{position:absolute;top:14px;right:18px;color:#fff;font-size:2.4rem;cursor:pointer;width:48px;height:48px;display:flex;align-items:center;justify-content:center;opacity:.75;transition:opacity .2s;-webkit-tap-highlight-color:transparent;line-height:1;z-index:10000}.lb-x:hover{opacity:1}.lb-btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(255,255,255,.13);color:#fff;border:none;border-radius:50%;width:52px;height:52px;font-size:2rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .2s;-webkit-tap-highlight-color:transparent;z-index:10000}.lb-btn:hover{background:#F59E0B;color:#000}.lbp{left:14px}.lbn{right:14px}.lb-num{position:absolute;bottom:14px;width:100%;text-align:center;color:rgba(255,255,255,.7);font-size:.9rem;pointer-events:none;z-index:10000}
|
| 5 |
+
</style></head><body>
|
| 6 |
+
<div class="car" id="car">
|
| 7 |
+
<button class="btn prev" id="prev">‹</button>
|
| 8 |
+
<button class="btn next" id="next">›</button>
|
| 9 |
+
<div class="dots" id="dots"></div>
|
| 10 |
+
</div>
|
| 11 |
+
<div class="lb" id="lb">
|
| 12 |
+
<span class="lb-x" id="lbx">×</span>
|
| 13 |
+
<button class="lb-btn lbp" id="lbp">‹</button>
|
| 14 |
+
<img id="lbimg" src="" alt="">
|
| 15 |
+
<button class="lb-btn lbn" id="lbn">›</button>
|
| 16 |
+
<div class="lb-num" id="lbnum"></div>
|
| 17 |
+
</div>
|
| 18 |
+
<script>
|
| 19 |
+
var IMGS={{imgs_json | safe}},N={{n}},cur=0,lbOpen=false;
|
| 20 |
+
var car=document.getElementById('car'), dotsEl=document.getElementById('dots'), lb=document.getElementById('lb'), lbImg=document.getElementById('lbimg'), lbNum=document.getElementById('lbnum');
|
| 21 |
+
var slides=[];
|
| 22 |
+
IMGS.forEach(function(src,i){
|
| 23 |
+
var img=document.createElement('img');
|
| 24 |
+
img.src=src; img.className=i===0?'visible':'hidden';
|
| 25 |
+
car.insertBefore(img,document.getElementById('prev'));
|
| 26 |
+
slides.push(img);
|
| 27 |
+
var dot=document.createElement('span');
|
| 28 |
+
dot.className='dot'+(i===0?' on':'');
|
| 29 |
+
(function(idx){dot.addEventListener('click',function(e){e.stopPropagation();goTo(idx);})})(i);
|
| 30 |
+
dotsEl.appendChild(dot);
|
| 31 |
+
});
|
| 32 |
+
var dots=dotsEl.querySelectorAll('.dot');
|
| 33 |
+
function goTo(n){
|
| 34 |
+
slides[cur].className='hidden'; dots[cur].classList.remove('on');
|
| 35 |
+
cur=(n+N)%N;
|
| 36 |
+
slides[cur].className='visible'; dots[cur].classList.add('on');
|
| 37 |
+
}
|
| 38 |
+
document.getElementById('prev').addEventListener('click',function(e){e.stopPropagation();goTo(cur-1);});
|
| 39 |
+
document.getElementById('next').addEventListener('click',function(e){e.stopPropagation();goTo(cur+1);});
|
| 40 |
+
var ts=0;
|
| 41 |
+
car.addEventListener('touchstart',function(e){if(!lbOpen)ts=e.touches[0].clientX;},{passive:true});
|
| 42 |
+
car.addEventListener('touchend',function(e){if(lbOpen)return;var dx=e.changedTouches[0].clientX-ts;if(Math.abs(dx)>40)goTo(dx<0?cur+1:cur-1);},{passive:true});
|
| 43 |
+
car.addEventListener('click',function(){openLB(cur);});
|
| 44 |
+
function openLB(i){
|
| 45 |
+
cur=i; lbImg.src=IMGS[i]; lbNum.textContent=(i+1)+' / '+N;
|
| 46 |
+
lb.classList.add('open'); lbOpen=true;
|
| 47 |
+
var doc = document.documentElement;
|
| 48 |
+
var req = doc.requestFullscreen || doc.webkitRequestFullscreen || doc.mozRequestFullScreen || doc.msRequestFullscreen;
|
| 49 |
+
if(req) req.call(doc).catch(function(){});
|
| 50 |
+
}
|
| 51 |
+
function closeLB(){
|
| 52 |
+
lb.classList.remove('open'); lbOpen=false;
|
| 53 |
+
var exit = document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen;
|
| 54 |
+
if(exit && document.fullscreenElement) exit.call(document).catch(function(){});
|
| 55 |
+
}
|
| 56 |
+
function lbNav(d){cur=(cur+d+N)%N;lbImg.src=IMGS[cur];lbNum.textContent=(cur+1)+' / '+N;}
|
| 57 |
+
document.getElementById('lbx').addEventListener('click',closeLB);
|
| 58 |
+
document.getElementById('lbp').addEventListener('click',function(e){e.stopPropagation();lbNav(-1);});
|
| 59 |
+
document.getElementById('lbn').addEventListener('click',function(e){e.stopPropagation();lbNav(1);});
|
| 60 |
+
lb.addEventListener('click',function(e){if(e.target===lb)closeLB();});
|
| 61 |
+
var lts=0;
|
| 62 |
+
lb.addEventListener('touchstart',function(e){lts=e.touches[0].clientX;},{passive:true});
|
| 63 |
+
lb.addEventListener('touchend',function(e){var dx=e.changedTouches[0].clientX-lts;if(Math.abs(dx)>40)lbNav(dx<0?1:-1);},{passive:true});
|
| 64 |
+
</script></body></html>
|
assets/landing.html
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<style>
|
| 2 |
+
:root { --bg:#F0F4F8;--surface:#FFFFFF;--border:#DDE3EC; --text:#1E293B;--text-soft:#64748B; --accent:#F59E0B;--accent-d:#D97706;--accent-fg:#0F172A; --hdr-bg:linear-gradient(135deg,#1E3A5F 0%,#0F2744 100%); --shadow:0 4px 20px rgba(0,0,0,.09); --shadow-btn:0 6px 16px rgba(245,158,11,.35); --r:14px;--r-sm:8px; }
|
| 3 |
+
@media(prefers-color-scheme:dark){ :root{ --bg:#0B1220;--surface:#1A2438;--border:#2D3F58; --text:#E8EFF7;--text-soft:#7A96B8; --accent:#FBBF24;--accent-d:#F59E0B;--accent-fg:#0B1220; --hdr-bg:linear-gradient(135deg,#091A30 0%,#040D1A 100%); --shadow:0 4px 24px rgba(0,0,0,.45); --shadow-btn:0 6px 18px rgba(251,191,36,.3); } }
|
| 4 |
+
.ur*{box-sizing:border-box;margin:0;padding:0}.ur{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);padding:12px;width:100%;overflow-x:hidden;}
|
| 5 |
+
|
| 6 |
+
/* --- CSS ЛОГИКА ВКЛАДОК БЕЗ JS --- */
|
| 7 |
+
.ur-radio-tab
|
| 8 |
+
{
|
| 9 |
+
display: none !important;
|
| 10 |
+
position: absolute !important;
|
| 11 |
+
opacity: 0 !important;
|
| 12 |
+
width: 0 !important;
|
| 13 |
+
height: 0 !important;
|
| 14 |
+
pointer-events: none !important;
|
| 15 |
+
}
|
| 16 |
+
.ur-tabs { display:flex; gap:10px; margin-bottom:14px; overflow-x:auto; padding-bottom:4px; }
|
| 17 |
+
.ur-tabs::-webkit-scrollbar { height:6px; }
|
| 18 |
+
.ur-tabs::-webkit-scrollbar-thumb { background:var(--border); border-radius:4px; }
|
| 19 |
+
.ur-tab-label { background:var(--surface); border:1px solid var(--border); color:var(--text-soft); padding:10px 18px; border-radius:var(--r-sm); cursor:pointer; font-weight:600; white-space:nowrap; transition:all .2s; font-size:.95rem; box-shadow:0 2px 5px rgba(0,0,0,.02); user-select:none; }
|
| 20 |
+
.ur-tab-label:hover { border-color:var(--accent); color:var(--text); }
|
| 21 |
+
.ur-panel { display: none; animation:fadeIn .3s ease; }
|
| 22 |
+
@keyframes fadeIn { from { opacity:0; transform:translateY(5px); } to { opacity:1; transform:translateY(0); } }
|
| 23 |
+
|
| 24 |
+
/* Динамическая связь радио-кнопок с панелями через Jinja2 */
|
| 25 |
+
{% for m in models %}
|
| 26 |
+
#tab-{{ m.id }}:checked ~ .ur-panels #panel-{{ m.id }} { display: block; }
|
| 27 |
+
#tab-{{ m.id }}:checked ~ .ur-tabs label[for="tab-{{ m.id }}"] { background:var(--accent); color:var(--accent-fg); border-color:var(--accent); box-shadow:var(--shadow-btn); }
|
| 28 |
+
{% endfor %}
|
| 29 |
+
/* --------------------------------- */
|
| 30 |
+
|
| 31 |
+
.ur-hdr{background:var(--hdr-bg);border-radius:var(--r);padding:26px 32px;margin-bottom:14px;box-shadow:var(--shadow)}.ur-hdr h1{color:var(--accent)!important;font-size:clamp(1.5rem,5vw,2.2rem);font-weight:800;line-height:1.2;margin-bottom:6px}.ur-hdr .sub{color:#fff!important;font-size:clamp(.85rem,3vw,1rem);opacity:.88;line-height:1.5;display:block}.ur-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:18px;box-shadow:var(--shadow)}.ur-row1{display:grid;grid-template-columns:3fr 2fr;gap:14px;margin-bottom:14px;align-items:stretch}.ur-gallery-frame{width:100%;height:100%;min-height:450px;flex-grow:1;border:none;border-radius:8px;display:block;background:var(--surface);}
|
| 32 |
+
@media(max-width:700px){ .ur-row1{grid-template-columns:1fr} .ur-gallery-frame{height:350px;} }
|
| 33 |
+
@media(max-width:480px){ .ur{padding:0;} .ur-hdr{padding:16px;border-radius:0;margin-bottom:8px;} .ur-card{padding:14px;border-radius:0;border-left:none;border-right:none;margin-bottom:8px;} .ur-btn{padding:14px;font-size:1rem} .ur-phone{font-size:1.1rem} .ur-map{height:140px} .ur-gallery-frame{border-radius:0;} }
|
| 34 |
+
.ur-contacts{display:flex;flex-direction:column}.ur-contacts h3{color:var(--accent);font-size:.88rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}.ur-addr{color:var(--text);font-size:.95rem;line-height:1.65;margin-bottom:10px}.ur-phone{display:block;color:var(--accent)!important;text-decoration:none;font-size:1.25rem;font-weight:800;margin-bottom:12px}.ur-phone:hover{color:var(--accent-d)!important}.ur-map{width:100%;min-height:160px;flex-grow:1;border:0;border-radius:var(--r-sm);display:block;margin-bottom:12px}.ur-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:13px;margin-bottom:9px;border-radius:var(--r-sm);font-weight:700;font-size:.96rem;text-decoration:none!important;transition:transform .18s,box-shadow .18s,background .18s;-webkit-tap-highlight-color:transparent}.ur-btn:last-child{margin-bottom:0}.ur-btn-tg{border:2px solid var(--accent);background:transparent;color:var(--text)!important}.ur-btn-tg:hover,.ur-btn-tg:active{background:var(--accent)!important;color:var(--accent-fg)!important;transform:translateY(-2px);box-shadow:var(--shadow-btn)}.ur-btn-tel{background:var(--accent);color:var(--accent-fg)!important;border:none}.ur-btn-tel:hover,.ur-btn-tel:active{background:var(--accent-d)!important;transform:translateY(-2px);box-shadow:var(--shadow-btn)}.ur-row2{margin-bottom:14px}.ur-desc h2{color:var(--accent)!important;font-size:1.1rem;margin-bottom:10px}.ur-desc h3{color:var(--accent)!important;font-size:.97rem;margin:12px 0 5px}.ur-desc p{color:var(--text);font-size:.93rem;line-height:1.65;margin-bottom:7px}.ur-desc ul{padding-left:18px;margin-bottom:9px}.ur-desc li{color:var(--text);font-size:.93rem;line-height:1.7}.ur-desc hr{border:none;border-top:1px solid var(--border);margin:10px 0}.ur-desc table{width:auto;max-width:100%;border-collapse:collapse;font-size:.9rem;margin-bottom:10px;table-layout:auto}.ur-desc th,.ur-desc td{border:1px solid var(--border);padding:7px 12px;color:var(--text);text-align:left;white-space:nowrap}.ur-desc td:last-child{white-space:normal}.ur-desc th{background:rgba(245,158,11,.12);font-weight:700}.ur-desc blockquote{border-left:3px solid var(--accent);padding:6px 12px;color:var(--text-soft);font-style:italic;font-size:.92rem;margin:10px 0;background:rgba(245,158,11,.05);border-radius:0 var(--r-sm) var(--r-sm) 0}.ur-footer{text-align:center;padding:8px 0 4px;color:var(--text-soft);font-size:.82rem;line-height:1.8;border-top:1px solid var(--border)}.ur-footer a{color:var(--accent);text-decoration:none;font-weight:600}
|
| 35 |
+
</style>
|
| 36 |
+
|
| 37 |
+
<div class="ur">
|
| 38 |
+
{% for m in models %}
|
| 39 |
+
<input type="radio" name="ur-model-tabs" id="tab-{{ m.id }}" class="ur-radio-tab" {% if loop.first %}checked{% endif %}>
|
| 40 |
+
{% endfor %}
|
| 41 |
+
|
| 42 |
+
{% if models|length > 1 %}
|
| 43 |
+
<div class="ur-tabs">
|
| 44 |
+
{% for m in models %}
|
| 45 |
+
<label for="tab-{{ m.id }}" class="ur-tab-label">{{ m.name }}</label>
|
| 46 |
+
{% endfor %}
|
| 47 |
+
</div>
|
| 48 |
+
{% endif %}
|
| 49 |
+
|
| 50 |
+
<div class="ur-panels">
|
| 51 |
+
{% for m in models %}
|
| 52 |
+
<div class="ur-panel" id="panel-{{ m.id }}">
|
| 53 |
+
|
| 54 |
+
<div class="ur-hdr">
|
| 55 |
+
<h1>⚡ {{ m.title }}</h1>
|
| 56 |
+
<span class="sub">{{ m.subtitle }}</span>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div class="ur-row1">
|
| 60 |
+
<div class="ur-card" style="padding:10px; display:flex; flex-direction:column;">
|
| 61 |
+
{{ m.gallery_html | safe }}
|
| 62 |
+
</div>
|
| 63 |
+
<div class="ur-card ur-contacts">
|
| 64 |
+
<div style="background:rgba(245,158,11,.1);border:2px solid var(--accent);border-radius:var(--r-sm);padding:12px 16px;margin-bottom:14px;text-align:center;">
|
| 65 |
+
<div style="font-size:.8rem;color:var(--text-soft);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px;">💵 Ціна</div>
|
| 66 |
+
<div style="font-size:1.9rem;font-weight:900;color:var(--accent);line-height:1;">{{ m.price }}</div>
|
| 67 |
+
</div>
|
| 68 |
+
<h3>📍 Де нас знайти</h3>
|
| 69 |
+
<div class="ur-addr">🏛 {{ contacts.address | safe }}</div>
|
| 70 |
+
<a href="tel:{{ contacts.phone }}" class="ur-phone">📞 {{ contacts.phone_display }}</a>
|
| 71 |
+
<h3>🗺 Карта</h3>
|
| 72 |
+
<iframe class="ur-map" src="{{ contacts.map_iframe }}" allowfullscreen loading="lazy"></iframe>
|
| 73 |
+
<a href="{{ contacts.tg_link }}" target="_blank" class="ur-btn ur-btn-tg">✈️ {{ contacts.tg_display }}</a>
|
| 74 |
+
<a href="tel:{{ contacts.phone }}" class="ur-btn ur-btn-tel">📞 Зателефонувати</a>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div class="ur-row2">
|
| 79 |
+
<div class="ur-card ur-desc">{{ m.desc_html | safe }}</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
</div>
|
| 83 |
+
{% endfor %}
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div class="ur-footer">
|
| 87 |
+
© 2026 Unstop Retail — {{ contacts.address }} • <a href="tel:{{ contacts.phone }}">{{ contacts.phone_display }}</a>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
assets/shell.css
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
body, html { margin: 0 !important; padding: 0 !important; }
|
| 2 |
+
#root, .gradio-container, .gradio-container > .main, .gradio-container > .main > .wrap, .contain {
|
| 3 |
+
max-width: 100vw !important;
|
| 4 |
+
width: 100% !important;
|
| 5 |
+
padding-left: 0 !important;
|
| 6 |
+
padding-right: 0 !important;
|
| 7 |
+
margin: 0 !important;
|
| 8 |
+
border: none !important;
|
| 9 |
+
}
|
| 10 |
+
[class*="sm:px-"], [class*="md:px-"], [class*="lg:px-"], [class*="xl:px-"] {
|
| 11 |
+
padding-left: 0 !important;
|
| 12 |
+
padding-right: 0 !important;
|
| 13 |
+
}
|
config.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"contacts": {
|
| 3 |
+
"phone": "+380675745662",
|
| 4 |
+
"phone_display": "+38 067 574 56 62",
|
| 5 |
+
"tg_link": "https://t.me/unstop_retail",
|
| 6 |
+
"tg_display": "Написати в Telegram",
|
| 7 |
+
"address": "м. Харків, Основ'янський район",
|
| 8 |
+
"map_iframe": "https://www.google.com/maps/d/embed?mid=1pn9YLjrvuNJtrxwioRFoS-RC3eAdPAA&ehbc=2E312F&noprof=1"
|
| 9 |
+
},
|
| 10 |
+
"models": [
|
| 11 |
+
{
|
| 12 |
+
"id": "m_0",
|
| 13 |
+
"folder": "Unstop Retail 4032W",
|
| 14 |
+
"name": "Unstop Retail 4032W",
|
| 15 |
+
"title": "Unstop Retail 4032W",
|
| 16 |
+
"subtitle": "🔋 LiFePO4 • 4032 Wh • 8+ годин автономної роботи для вашого бізнесу",
|
| 17 |
+
"price": "49 000 грн",
|
| 18 |
+
"opis_raw": "## 🔋 Зарядна станція Unstop Retail 4032W LiFePO4\n\n**Готове рішення для безперебійної роботи магазинів та аптек.**\nМаксимально просте підключення — без виклику електрика.\n\n---\n\n### 🧰 Розумний баланс: тривала робота + захист\n\nСтанція розрахована на навантаження **до 1 кВт** — цього з запасом вистачає для всього критичного обладнання. Якщо хтось випадково підключить потужний прилад (>1000 Вт), станція **не згорить**, а автоматично перейде в режим захисту.\n\n---\n\n### 📊 Характеристики\n\n| Параметр | Значення |\n|---|---|\n| 🔋 Ємність | **4032 Wh** (комірки А-класу, LiFePO4) |\n| ⏱ Автономність | **8+ годин** для точки з 1 касою |\n| ⚡ Заряджання | **0 → 100% за 3 години** |\n| 🔄 Ресурс | до **6000 циклів** |\n| 🔒 Безпека | BMS, захист від КЗ, стабільні 220 В |\n\n---\n\n### 🔥 Для чого підходить\n\nКасові апарати • Ваги • POS-термінали • Роутери • Робочі ПК • LED-освітлення\n\n---\n\n### 💵 Вартість: **49 000 грн**\n\n> Забезпечте стабільну роботу вашого бізнесу вже сьогодні!\n"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "m_1",
|
| 22 |
+
"folder": "Unstop Retail 5032W",
|
| 23 |
+
"name": "Unstop Retail 5032W",
|
| 24 |
+
"title": "Unstop Retail 5032W",
|
| 25 |
+
"subtitle": "🔋 LiFePO4 • 4032 Wh • 8+ годин автономної роботи для вашого бізнесу",
|
| 26 |
+
"price": "55 000 грн",
|
| 27 |
+
"opis_raw": "## 🔋 Зарядна станція Unstop Retail 5032W LiFePO4\n\n**Готове рішення для безперебійної роботи магазинів та аптек.**\nМаксимально просте підключення — без виклику електрика.\n\n---\n\n### 🧰 Розумний баланс: тривала робота + захист\n\nСтанція розрахована на навантаження **до 1 кВт** — цього з запасом вистачає для всього критичного обладнання.\nЯкщо хтось випадково підключить потужний прилад (>1000 Вт), станція **не згорить**, а автоматично перейде в режим захисту.\n\n---\n\n### 📊 Характеристики\n\n| Параметр | Значення |\n|---|---|\n| 🔋 Ємність | **5032 Wh** (комірки А-класу, LiFePO4) |\n| ⏱ Автономність | **10+ годин** для точки з 1 касою |\n| ⚡ Заряджання | **0 → 100% за 3.5 години** |\n| 🔄 Ресурс | до **6000 циклів** |\n| 🔒 Безпека | BMS, захист від КЗ, стабільні 220 В |\n\n---\n\n### 🔥 Для чого підходить\n\nКасові апарати • Ваги • POS-термінали • Роутери • Робочі ПК • LED-освітлення\n\n---\n\n### 💵 Вартість: **55 000 грн**\n\n> Забезпечте стабільну роботу вашого бізне"
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
main.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, socket, threading, webbrowser, time
|
| 2 |
+
import gradio as gr
|
| 3 |
+
from fastapi import FastAPI, Request
|
| 4 |
+
from fastapi.responses import RedirectResponse, PlainTextResponse
|
| 5 |
+
import uvicorn
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 8 |
+
from slowapi.util import get_remote_address
|
| 9 |
+
from slowapi.errors import RateLimitExceeded
|
| 10 |
+
from showcase_ui import build_showcase_app
|
| 11 |
+
from admin_ui import build_admin_app
|
| 12 |
+
|
| 13 |
+
load_dotenv()
|
| 14 |
+
|
| 15 |
+
HOST = "0.0.0.0"
|
| 16 |
+
PORT = int(os.getenv("PORT", 7860))
|
| 17 |
+
ADMIN_USER = os.getenv("ADMIN_USER", "admin")
|
| 18 |
+
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
|
| 19 |
+
|
| 20 |
+
# ── RATE LIMITER ──────────────────────────────────────────────────────
|
| 21 |
+
limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
|
| 22 |
+
|
| 23 |
+
# ── ДОДАТКИ ───────────────────────────────────────────────────────────
|
| 24 |
+
app_showcase = build_showcase_app()
|
| 25 |
+
app_admin = build_admin_app()
|
| 26 |
+
|
| 27 |
+
# ── FASTAPI ───────────────────────────────────────────────────────────
|
| 28 |
+
app = FastAPI()
|
| 29 |
+
app.state.limiter = limiter
|
| 30 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 31 |
+
|
| 32 |
+
BLOCKED_PATHS = (
|
| 33 |
+
"/config.json", "/.env", "/utils.py", "/main.py",
|
| 34 |
+
"/admin_ui.py", "/showcase_ui.py", "/queue/", "/upload",
|
| 35 |
+
"/.git", "/static/../",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
@app.middleware("http")
|
| 39 |
+
async def security_middleware(request: Request, call_next):
|
| 40 |
+
path = request.url.path.lower()
|
| 41 |
+
for blocked in BLOCKED_PATHS:
|
| 42 |
+
if path.startswith(blocked):
|
| 43 |
+
return PlainTextResponse("404 Not Found", status_code=404)
|
| 44 |
+
if ".." in path or "%2e" in path or "%2f" in path:
|
| 45 |
+
return PlainTextResponse("400 Bad Request", status_code=400)
|
| 46 |
+
ua = request.headers.get("user-agent", "").lower()
|
| 47 |
+
bad_agents = ("sqlmap", "nikto", "nmap", "masscan", "zgrab")
|
| 48 |
+
if any(b in ua for b in bad_agents):
|
| 49 |
+
return PlainTextResponse("403 Forbidden", status_code=403)
|
| 50 |
+
response = await call_next(request)
|
| 51 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 52 |
+
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
| 53 |
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 54 |
+
response.headers["Referrer-Policy"] = "no-referrer"
|
| 55 |
+
return response
|
| 56 |
+
|
| 57 |
+
@app.get("/figvam")
|
| 58 |
+
def redirect_admin():
|
| 59 |
+
return RedirectResponse(url="/figvam/")
|
| 60 |
+
|
| 61 |
+
app = gr.mount_gradio_app(app, app_admin, path="/figvam",
|
| 62 |
+
auth=(ADMIN_USER, ADMIN_PASSWORD),
|
| 63 |
+
auth_message="Unstop Admin")
|
| 64 |
+
app = gr.mount_gradio_app(app, app_showcase, path="/")
|
| 65 |
+
|
| 66 |
+
# ── ЛОКАЛЬНИЙ ЗАПУСК ──────────────────────────────────────────────────
|
| 67 |
+
def wait_and_open():
|
| 68 |
+
for _ in range(40):
|
| 69 |
+
try:
|
| 70 |
+
with socket.create_connection(("localhost", PORT), timeout=1):
|
| 71 |
+
webbrowser.open(f"http://localhost:{PORT}")
|
| 72 |
+
return
|
| 73 |
+
except OSError:
|
| 74 |
+
time.sleep(0.5)
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
threading.Thread(target=wait_and_open, daemon=True).start()
|
| 78 |
+
uvicorn.run(app, host=HOST, port=PORT, log_level="warning")
|
models/Unstop Retail 4032W/1.webp
ADDED
|
models/Unstop Retail 4032W/2.webp
ADDED
|
models/Unstop Retail 4032W/3.webp
ADDED
|
models/Unstop Retail 4032W/Opis.txt
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## 🔋 Зарядна станція Unstop Retail 4032W LiFePO4
|
| 2 |
+
|
| 3 |
+
**Готове рішення для безперебійної роботи магазинів та аптек.**
|
| 4 |
+
Максимально просте підключення — без виклику електрика.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
### 🧰 Розумний баланс: тривала робота + захист
|
| 9 |
+
|
| 10 |
+
Станція розрахована на навантаження **до 1 кВт** — цього з запасом вистачає для всього критичного обладнання. Якщо хтось випадково підключить потужний прилад (>1000 Вт), станція **не згорить**, а автоматично перейде в режим захисту.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
### 📊 Характеристики
|
| 15 |
+
|
| 16 |
+
| Параметр | Значення |
|
| 17 |
+
|---|---|
|
| 18 |
+
| 🔋 Ємність | **4032 Wh** (комірки А-класу, LiFePO4) |
|
| 19 |
+
| ⏱ Автономність | **8+ годин** для точки з 1 касою |
|
| 20 |
+
| ⚡ Заряджання | **0 → 100% за 3 години** |
|
| 21 |
+
| 🔄 Ресурс | до **6000 циклів** |
|
| 22 |
+
| 🔒 Безпека | BMS, захист від КЗ, стабільні 220 В |
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
### 🔥 Для чого підходить
|
| 27 |
+
|
| 28 |
+
Касові апарати • Ваги • POS-термінали • Роутери • Робочі ПК • LED-освітлення
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
### 💵 Вартість: **49 000 грн**
|
| 33 |
+
|
| 34 |
+
> Забезпечте стабільну роботу вашого бізнесу вже сьогодні!
|
models/Unstop Retail 4032W/Price.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
49 000 грн
|
models/Unstop Retail 4032W/Subtitle.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
🔋 LiFePO4 • 4032 Wh • 8+ годин автономної роботи для вашого бізнесу
|
models/Unstop Retail 5032W/1.webp
ADDED
|
models/Unstop Retail 5032W/Opis.txt
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## 🔋 Зарядна станція Unstop Retail 5032W LiFePO4
|
| 2 |
+
|
| 3 |
+
**Готове рішення для безперебійної роботи магазинів та аптек.**
|
| 4 |
+
Максимально просте підключення — без виклику електрика.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
### 🧰 Розумний баланс: тривала робота + захист
|
| 9 |
+
|
| 10 |
+
Станція розрахована на навантаження **до 1 кВт** — цього з запасом вистачає для всього критичного обладнання.
|
| 11 |
+
Якщо хтось випадково підключить потужний прилад (>1000 Вт), станція **не згорить**, а автоматично перейде в режим захисту.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
### 📊 Характеристики
|
| 16 |
+
|
| 17 |
+
| Параметр | Значення |
|
| 18 |
+
|---|---|
|
| 19 |
+
| 🔋 Ємність | **5032 Wh** (комірки А-класу, LiFePO4) |
|
| 20 |
+
| ⏱ Автономність | **10+ годин** для точки з 1 касою |
|
| 21 |
+
| ⚡ Заряджання | **0 → 100% за 3.5 години** |
|
| 22 |
+
| 🔄 Ресурс | до **6000 циклів** |
|
| 23 |
+
| 🔒 Безпека | BMS, захист від КЗ, стабільні 220 В |
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
### 🔥 Для чого підходить
|
| 28 |
+
|
| 29 |
+
Касові апарати • Ваги • POS-термінали • Роутери • Робочі ПК • LED-освітлення
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
### 💵 Вартість: **55 000 грн**
|
| 34 |
+
|
| 35 |
+
> Забезпечте стабільну роботу вашого бізне
|
models/Unstop Retail 5032W/Price.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
55 000 грн
|
models/Unstop Retail 5032W/Subtitle.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
🔋 LiFePO4 • 4032 Wh • 8+ годин автономної роботи для вашого бізнесу
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==5.9.1
|
| 2 |
+
huggingface_hub==0.27.0
|
| 3 |
+
fastapi
|
| 4 |
+
uvicorn
|
| 5 |
+
jinja2
|
| 6 |
+
python-dotenv
|
| 7 |
+
slowapi
|
showcase_ui.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, html, json
|
| 2 |
+
import gradio as gr
|
| 3 |
+
from jinja2 import Environment, FileSystemLoader
|
| 4 |
+
from utils import read_file, get_models_data, get_contacts, img_b64, opis_to_html
|
| 5 |
+
|
| 6 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 7 |
+
ASSETS_DIR = os.path.join(BASE_DIR, "assets")
|
| 8 |
+
|
| 9 |
+
env = Environment(loader=FileSystemLoader(ASSETS_DIR), autoescape=True)
|
| 10 |
+
|
| 11 |
+
SHOWCASE_EXTRA_CSS = """
|
| 12 |
+
footer, footer *, .footer,
|
| 13 |
+
div[class*="footer"], .built-with, [class*="built-with"],
|
| 14 |
+
[class*="ApiLink"], [class*="api-link"], .show-api,
|
| 15 |
+
a[href*="gradio.app"], a[href*="huggingface"],
|
| 16 |
+
button[title*="API"], button[title*="Setting"],
|
| 17 |
+
button[aria-label*="Setting"],
|
| 18 |
+
.top-panel, div.top-panel, [class*="top-panel"]
|
| 19 |
+
{ display: none !important; visibility: hidden !important;
|
| 20 |
+
height: 0 !important; overflow: hidden !important; }
|
| 21 |
+
.gradio-container { min-height: unset !important; }
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
HIDE_FOOTER_JS = """
|
| 25 |
+
() => {
|
| 26 |
+
const kill = () => {
|
| 27 |
+
document.querySelectorAll('footer, .footer, [class*="footer"]').forEach(el => el.remove());
|
| 28 |
+
document.querySelectorAll('.show-api, [class*="ApiLink"], [class*="api-link"]').forEach(el => el.remove());
|
| 29 |
+
document.querySelectorAll('button[title*="Setting"], button[aria-label*="Setting"]').forEach(el => el.remove());
|
| 30 |
+
document.querySelectorAll('a[href*="gradio.app"]').forEach(el => { let p = el.closest('div'); if(p) p.remove(); });
|
| 31 |
+
};
|
| 32 |
+
kill();
|
| 33 |
+
setTimeout(kill, 300);
|
| 34 |
+
setTimeout(kill, 1000);
|
| 35 |
+
new MutationObserver(kill).observe(document.body, {childList:true, subtree:true});
|
| 36 |
+
}
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
def build_gallery(images):
|
| 40 |
+
if not images:
|
| 41 |
+
return '<div style="padding:40px;text-align:center;color:#888">Фото відсутні</div>'
|
| 42 |
+
imgs_json = json.dumps([img_b64(p) for p in images])
|
| 43 |
+
gallery_tpl = env.get_template("gallery.html")
|
| 44 |
+
gallery_html = gallery_tpl.render(imgs_json=imgs_json, n=len(images))
|
| 45 |
+
return f'<iframe srcdoc="{html.escape(gallery_html)}" allow="fullscreen" class="ur-gallery-frame" scrolling="no"></iframe>'
|
| 46 |
+
|
| 47 |
+
def render_showcase():
|
| 48 |
+
models_data = get_models_data(BASE_DIR)
|
| 49 |
+
contacts = get_contacts(BASE_DIR)
|
| 50 |
+
for m in models_data:
|
| 51 |
+
m['desc_html'] = opis_to_html(m['opis_raw'])
|
| 52 |
+
m['gallery_html'] = build_gallery(m['images'])
|
| 53 |
+
landing_tpl = env.get_template("landing.html")
|
| 54 |
+
rendered = landing_tpl.render(models=models_data, contacts=contacts)
|
| 55 |
+
return rendered, models_data
|
| 56 |
+
|
| 57 |
+
shell_css = read_file(os.path.join(ASSETS_DIR, "shell.css")) + SHOWCASE_EXTRA_CSS
|
| 58 |
+
|
| 59 |
+
def build_showcase_app():
|
| 60 |
+
# Gradio 4: css= и js= работают в конструкторе
|
| 61 |
+
with gr.Blocks(
|
| 62 |
+
title="Unstop Retail",
|
| 63 |
+
css=shell_css,
|
| 64 |
+
js=HIDE_FOOTER_JS,
|
| 65 |
+
analytics_enabled=False,
|
| 66 |
+
) as app_showcase:
|
| 67 |
+
showcase_html = gr.HTML()
|
| 68 |
+
|
| 69 |
+
def get_current_html():
|
| 70 |
+
html_content, _ = render_showcase()
|
| 71 |
+
return html_content
|
| 72 |
+
|
| 73 |
+
app_showcase.load(fn=get_current_html, inputs=[], outputs=[showcase_html])
|
| 74 |
+
|
| 75 |
+
return app_showcase
|
utils.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, base64, re, json
|
| 2 |
+
|
| 3 |
+
DEFAULT_CONTACTS = {
|
| 4 |
+
"phone": "+380675745662",
|
| 5 |
+
"phone_display": "+38 067 574 56 62",
|
| 6 |
+
"tg_link": "https://t.me/unstop_retail",
|
| 7 |
+
"tg_display": "Telegram",
|
| 8 |
+
"map_iframe": 'https://www.google.com/maps/d/embed?mid=1pn9YLjrvuNJtrxwioRFoS-RC3eAdPAA&ehbc=2E312F&noprof=1',
|
| 9 |
+
"address": "м. Харків, Основ'янський район"
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
def read_file(path, fallback=""):
|
| 13 |
+
if os.path.exists(path):
|
| 14 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 15 |
+
return f.read()
|
| 16 |
+
return fallback
|
| 17 |
+
|
| 18 |
+
def img_b64(path):
|
| 19 |
+
ext = os.path.splitext(path)[1].lower()
|
| 20 |
+
mime = "image/png" if ext == ".png" else "image/jpeg" if ext in [".jpg", ".jpeg"] else "image/webp"
|
| 21 |
+
with open(path, "rb") as f:
|
| 22 |
+
return f"data:{mime};base64," + base64.b64encode(f.read()).decode()
|
| 23 |
+
|
| 24 |
+
def _read_config(base_dir):
|
| 25 |
+
config_path = os.path.join(base_dir, "config.json")
|
| 26 |
+
if os.path.exists(config_path):
|
| 27 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 28 |
+
return json.load(f)
|
| 29 |
+
return {"models": [], "contacts": DEFAULT_CONTACTS.copy()}
|
| 30 |
+
|
| 31 |
+
def _write_config(base_dir, data):
|
| 32 |
+
config_path = os.path.join(base_dir, "config.json")
|
| 33 |
+
with open(config_path, "w", encoding="utf-8") as f:
|
| 34 |
+
json.dump(data, f, ensure_ascii=False, indent=4)
|
| 35 |
+
|
| 36 |
+
def get_contacts(base_dir):
|
| 37 |
+
cfg = _read_config(base_dir)
|
| 38 |
+
contacts = cfg.get("contacts", {})
|
| 39 |
+
# Fill missing keys with defaults
|
| 40 |
+
for k, v in DEFAULT_CONTACTS.items():
|
| 41 |
+
contacts.setdefault(k, v)
|
| 42 |
+
return contacts
|
| 43 |
+
|
| 44 |
+
def save_contacts(base_dir, contacts: dict):
|
| 45 |
+
cfg = _read_config(base_dir)
|
| 46 |
+
cfg["contacts"] = contacts
|
| 47 |
+
_write_config(base_dir, cfg)
|
| 48 |
+
|
| 49 |
+
def get_models_data(base_dir):
|
| 50 |
+
config_path = os.path.join(base_dir, "config.json")
|
| 51 |
+
models_dir = os.path.join(base_dir, "models")
|
| 52 |
+
|
| 53 |
+
if not os.path.exists(config_path):
|
| 54 |
+
models_list = []
|
| 55 |
+
if os.path.exists(models_dir) and os.path.isdir(models_dir):
|
| 56 |
+
for i, folder in enumerate(sorted(os.listdir(models_dir))):
|
| 57 |
+
folder_path = os.path.join(models_dir, folder)
|
| 58 |
+
if os.path.isdir(folder_path):
|
| 59 |
+
opis = read_file(os.path.join(folder_path, "Opis.txt"), fallback="")
|
| 60 |
+
price = read_file(os.path.join(folder_path, "Price.txt"), fallback="49 000 грн").strip()
|
| 61 |
+
subtitle = read_file(os.path.join(folder_path, "Subtitle.txt"), fallback="🔋 Надійна енергія для вашого бізнесу").strip()
|
| 62 |
+
models_list.append({
|
| 63 |
+
"id": f"m_{i}", "folder": folder, "name": folder,
|
| 64 |
+
"title": folder, "subtitle": subtitle, "price": price, "opis_raw": opis
|
| 65 |
+
})
|
| 66 |
+
if not models_list:
|
| 67 |
+
models_list.append({
|
| 68 |
+
"id": "m_main", "folder": "", "name": "Базова",
|
| 69 |
+
"title": "Unstop Retail 4032W",
|
| 70 |
+
"subtitle": "🔋 LiFePO4 • 4032 Wh • 8+ годин автономної роботи",
|
| 71 |
+
"price": "49 000 грн",
|
| 72 |
+
"opis_raw": read_file(os.path.join(base_dir, "Opis.txt"), fallback="")
|
| 73 |
+
})
|
| 74 |
+
_write_config(base_dir, {"models": models_list, "contacts": DEFAULT_CONTACTS.copy()})
|
| 75 |
+
|
| 76 |
+
cfg = _read_config(base_dir)
|
| 77 |
+
models = cfg.get("models", [])
|
| 78 |
+
|
| 79 |
+
for m in models:
|
| 80 |
+
folder_name = m.get("folder", "")
|
| 81 |
+
folder_path = os.path.join(models_dir, folder_name) if folder_name else base_dir
|
| 82 |
+
images = []
|
| 83 |
+
if os.path.exists(folder_path):
|
| 84 |
+
images = sorted([
|
| 85 |
+
os.path.join(folder_path, img) for img in os.listdir(folder_path)
|
| 86 |
+
if img.lower().endswith(('.webp', '.png', '.jpg', '.jpeg'))
|
| 87 |
+
])
|
| 88 |
+
if not images and not folder_name:
|
| 89 |
+
candidates = ["image (1).webp", "image (2).webp", "image.webp"]
|
| 90 |
+
images = [os.path.join(base_dir, p) for p in candidates if os.path.exists(os.path.join(base_dir, p))]
|
| 91 |
+
m["images"] = images
|
| 92 |
+
|
| 93 |
+
return models
|
| 94 |
+
|
| 95 |
+
def save_config(base_dir, models_data):
|
| 96 |
+
cfg = _read_config(base_dir)
|
| 97 |
+
clean_models = []
|
| 98 |
+
for m in models_data:
|
| 99 |
+
clean_models.append({
|
| 100 |
+
"id": m.get("id"), "folder": m.get("folder", ""),
|
| 101 |
+
"name": m.get("name", ""), "title": m.get("title", ""),
|
| 102 |
+
"subtitle": m.get("subtitle", ""), "price": m.get("price", ""),
|
| 103 |
+
"opis_raw": m.get("opis_raw", "")
|
| 104 |
+
})
|
| 105 |
+
cfg["models"] = clean_models
|
| 106 |
+
_write_config(base_dir, cfg)
|
| 107 |
+
|
| 108 |
+
def opis_to_html(text):
|
| 109 |
+
if not text: return "<p>Опис не знайдено.</p>"
|
| 110 |
+
lines, parts, in_tbl, tbuf = text.split("\n"), [], False, []
|
| 111 |
+
for line in lines:
|
| 112 |
+
if line.startswith("## "): parts.append(f"<h2>{line[3:].strip()}</h2>"); continue
|
| 113 |
+
if line.startswith("### "): parts.append(f"<h3>{line[4:].strip()}</h3>"); continue
|
| 114 |
+
if line.strip() in ("---", "***", "___"): parts.append("<hr>"); continue
|
| 115 |
+
if "|" in line and line.strip().startswith("|"):
|
| 116 |
+
if not in_tbl: in_tbl, tbuf = True, []
|
| 117 |
+
tbuf.append(line); continue
|
| 118 |
+
if in_tbl:
|
| 119 |
+
parts.append(_tbl(tbuf)); in_tbl = False
|
| 120 |
+
if line.startswith("> "):
|
| 121 |
+
parts.append(f'<blockquote>{line[2:].strip()}</blockquote>')
|
| 122 |
+
elif line.strip():
|
| 123 |
+
l = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", line)
|
| 124 |
+
l = re.sub(r"\*(.+?)\*", r"<em>\1</em>", l)
|
| 125 |
+
if l.strip().startswith("- "):
|
| 126 |
+
parts.append(f"<li>{l.strip()[2:]}</li>")
|
| 127 |
+
else:
|
| 128 |
+
parts.append(f"<p>{l.strip()}</p>")
|
| 129 |
+
else:
|
| 130 |
+
parts.append("")
|
| 131 |
+
if in_tbl: parts.append(_tbl(tbuf))
|
| 132 |
+
res = "\n".join(parts)
|
| 133 |
+
return re.sub(r"((?:<li>.*?</li>\n?)+)", r"<ul>\1</ul>", res)
|
| 134 |
+
|
| 135 |
+
def _tbl(rows):
|
| 136 |
+
html_tbl = "<table>"
|
| 137 |
+
for i, row in enumerate(rows):
|
| 138 |
+
cells = [c.strip() for c in row.strip().strip("|").split("|")]
|
| 139 |
+
if all(set(c) <= set("-: ") for c in cells): continue
|
| 140 |
+
tag = "th" if i == 0 else "td"
|
| 141 |
+
html_tbl += "<tr>" + "".join(f"<{tag}>{c}</{tag}>" for c in cells) + "</tr>"
|
| 142 |
+
return html_tbl + "</table>"
|