Dasdeman commited on
Commit
f7b9253
·
verified ·
1 Parent(s): 1b1af0a

Upload 26 files

Browse files
.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": "## &#128267; Зарядна станція Unstop Retail 4032W LiFePO4\n\n**Готове рішення для безперебійної роботи магазинів та аптек.**\nМаксимально просте підключення — без виклику електрика.\n\n---\n\n### &#129520; Розумний баланс: тривала робота + захист\n\nСтанція розрахована на навантаження **до 1 кВт** — цього з запасом вистачає для всього критичного обладнання. Якщо хтось випадково підключить потужний прилад (>1000 Вт), станція **не згорить**, а автоматично перейде в режим захисту.\n\n---\n\n### &#128202; Характеристики\n\n| Параметр | Значення |\n|---|---|\n| &#128267; Ємність | **4032 Wh** (комірки А-класу, LiFePO4) |\n| &#9201; Автономність | **8+ годин** для точки з 1 касою |\n| &#9889; Заряджання | **0 → 100% за 3 години** |\n| &#128260; Ресурс | до **6000 циклів** |\n| &#128274; Безпека | BMS, захист від КЗ, стабільні 220 В |\n\n---\n\n### &#128293; Для чого підходить\n\nКасові апарати &#8226; Ваги &#8226; POS-термінали &#8226; Роутери &#8226; Робочі ПК &#8226; LED-освітлення\n\n---\n\n### &#128181; Вартість: **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": "## &#128267; Зарядна станція Unstop Retail 5032W LiFePO4\n\n**Готове рішення для безперебійної роботи магазинів та аптек.**\nМаксимально просте підключення — без виклику електрика.\n\n---\n\n### &#129520; Розумний баланс: тривала робота + захист\n\nСтанція розрахована на навантаження **до 1 кВт** — цього з запасом вистачає для всього критичного обладнання.\nЯкщо хтось випадково підключить потужний прилад (>1000 Вт), станція **не згорить**, а автоматично перейде в режим захисту.\n\n---\n\n### &#128202; Характеристики\n\n| Параметр | Значення |\n|---|---|\n| &#128267; Ємність | **5032 Wh** (комірки А-класу, LiFePO4) |\n| &#9201; Автономність | **10+ годин** для точки з 1 касою |\n| &#9889; Заряджання | **0 → 100% за 3.5 години** |\n| &#128260; Ресурс | до **6000 циклів** |\n| &#128274; Безпека | BMS, захист від КЗ, стабільні 220 В |\n\n---\n\n### &#128293; Для чого підходить\n\nКасові апарати &#8226; Ваги &#8226; POS-термінали &#8226; Роутери &#8226; Робочі ПК &#8226; LED-освітлення\n\n---\n\n### &#128181; Вартість: **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
+ ## &#128267; Зарядна станція Unstop Retail 4032W LiFePO4
2
+
3
+ **Готове рішення для безперебійної роботи магазинів та аптек.**
4
+ Максимально просте підключення — без виклику електрика.
5
+
6
+ ---
7
+
8
+ ### &#129520; Розумний баланс: тривала робота + захист
9
+
10
+ Станція розрахована на навантаження **до 1 кВт** — цього з запасом вистачає для всього критичного обладнання. Якщо хтось випадково підключить потужний прилад (>1000 Вт), станція **не згорить**, а автоматично перейде в режим захисту.
11
+
12
+ ---
13
+
14
+ ### &#128202; Характеристики
15
+
16
+ | Параметр | Значення |
17
+ |---|---|
18
+ | &#128267; Ємність | **4032 Wh** (комірки А-класу, LiFePO4) |
19
+ | &#9201; Автономність | **8+ годин** для точки з 1 касою |
20
+ | &#9889; Заряджання | **0 → 100% за 3 години** |
21
+ | &#128260; Ресурс | до **6000 циклів** |
22
+ | &#128274; Безпека | BMS, захист від КЗ, стабільні 220 В |
23
+
24
+ ---
25
+
26
+ ### &#128293; Для чого підходить
27
+
28
+ Касові апарати &#8226; Ваги &#8226; POS-термінали &#8226; Роутери &#8226; Робочі ПК &#8226; LED-освітлення
29
+
30
+ ---
31
+
32
+ ### &#128181; Вартість: **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
+ ## &#128267; Зарядна станція Unstop Retail 5032W LiFePO4
2
+
3
+ **Готове рішення для безперебійної роботи магазинів та аптек.**
4
+ Максимально просте підключення — без виклику електрика.
5
+
6
+ ---
7
+
8
+ ### &#129520; Розумний баланс: тривала робота + захист
9
+
10
+ Станція розрахована на навантаження **до 1 кВт** — цього з запасом вистачає для всього критичного обладнання.
11
+ Якщо хтось випадково підключить потужний прилад (>1000 Вт), станція **не згорить**, а автоматично перейде в режим захисту.
12
+
13
+ ---
14
+
15
+ ### &#128202; Характеристики
16
+
17
+ | Параметр | Значення |
18
+ |---|---|
19
+ | &#128267; Ємність | **5032 Wh** (комірки А-класу, LiFePO4) |
20
+ | &#9201; Автономність | **10+ годин** для точки з 1 касою |
21
+ | &#9889; Заряджання | **0 → 100% за 3.5 години** |
22
+ | &#128260; Ресурс | до **6000 циклів** |
23
+ | &#128274; Безпека | BMS, захист від КЗ, стабільні 220 В |
24
+
25
+ ---
26
+
27
+ ### &#128293; Для чого підходить
28
+
29
+ Касові апарати &#8226; Ваги &#8226; POS-термінали &#8226; Роутери &#8226; Робочі ПК &#8226; LED-освітлення
30
+
31
+ ---
32
+
33
+ ### &#128181; Вартість: **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>"