Kgshop commited on
Commit
7f56766
·
verified ·
1 Parent(s): 6154c87

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +760 -304
app.py CHANGED
@@ -1,4 +1,4 @@
1
- from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify
2
  import json
3
  import os
4
  import logging
@@ -9,16 +9,13 @@ from huggingface_hub import HfApi, hf_hub_download
9
  from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
10
  from werkzeug.utils import secure_filename
11
  from dotenv import load_dotenv
12
- import requests # Хотя requests может не использоваться явно, hf_hub использует его под капотом
13
 
14
  load_dotenv()
15
 
16
  app = Flask(__name__)
17
- app.secret_key = 'raina_hvac_secret_key_v3_modals_final'
18
  DATA_FILE = 'data.json'
19
- UPLOAD_FOLDER_TEMP = 'temp_uploads'
20
- os.makedirs(UPLOAD_FOLDER_TEMP, exist_ok=True)
21
-
22
 
23
  SYNC_FILES = [DATA_FILE]
24
 
@@ -39,16 +36,18 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
39
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
40
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
41
  files_to_download = [specific_file] if specific_file else SYNC_FILES
 
42
  all_successful = True
43
  for file_name in files_to_download:
44
  success = False
45
  for attempt in range(retries + 1):
46
  try:
47
- logging.info(f"Downloading {file_name} (Attempt {attempt + 1})...")
48
  hf_hub_download(
49
  repo_id=REPO_ID, filename=file_name, repo_type="dataset", token=token_to_use,
50
  local_dir=".", local_dir_use_symlinks=False, force_download=True, resume_download=False
51
  )
 
52
  success = True
53
  break
54
  except RepositoryNotFoundError:
@@ -56,27 +55,34 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
56
  return False
57
  except HfHubHTTPError as e:
58
  if e.response.status_code == 404:
59
- logging.warning(f"File {file_name} not found in repo. Creating empty local file.")
60
  if not os.path.exists(file_name):
61
  try:
62
  with open(file_name, 'w', encoding='utf-8') as f:
63
  json.dump({'equipment': [], 'categories': [], 'services': [], 'projects': []}, f)
64
  except Exception as create_e:
65
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
66
- success = True # Consider it success if we intended to create an empty file
67
  break
68
- else: logging.error(f"HTTP error downloading {file_name}: {e}. Retrying...")
 
69
  except Exception as e:
70
- logging.error(f"Error downloading {file_name}: {e}. Retrying...", exc_info=True)
71
- if attempt < retries: time.sleep(delay)
72
- if not success: all_successful = False
 
 
 
73
  return all_successful
74
 
75
  def upload_db_to_hf(specific_file=None):
76
- if not HF_TOKEN_WRITE: return
 
 
77
  try:
78
  api = HfApi()
79
  files_to_upload = [specific_file] if specific_file else SYNC_FILES
 
80
  for file_name in files_to_upload:
81
  if os.path.exists(file_name):
82
  api.upload_file(
@@ -84,28 +90,56 @@ def upload_db_to_hf(specific_file=None):
84
  repo_type="dataset", token=HF_TOKEN_WRITE,
85
  commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
86
  )
 
 
 
87
  except Exception as e:
88
- logging.error(f"Error during HF upload: {e}", exc_info=True)
89
 
90
  def periodic_backup():
 
91
  while True:
92
- time.sleep(1800)
 
93
  upload_db_to_hf()
 
94
 
95
  def load_data():
96
  default_data = {'equipment': [], 'categories': [], 'services': [], 'projects': []}
97
  try:
98
- with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file)
99
- for key in default_data: data.setdefault(key, [])
 
 
 
 
 
 
100
  return data
101
- except (FileNotFoundError, json.JSONDecodeError):
102
- if download_db_from_hf(specific_file=DATA_FILE): return load_data()
 
 
 
 
 
 
 
 
 
 
 
 
103
  return default_data
104
 
105
  def save_data(data):
106
  try:
 
 
 
107
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
108
  json.dump(data, file, ensure_ascii=False, indent=4)
 
109
  upload_db_to_hf(specific_file=DATA_FILE)
110
  except Exception as e:
111
  logging.error(f"Error saving data: {e}", exc_info=True)
@@ -117,11 +151,15 @@ LANDING_TEMPLATE = '''
117
  <meta charset="UTF-8">
118
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
119
  <title>ОсОО "Раина" - Вентиляция и Кондиционирование</title>
120
- <meta name="description" content="Профессиональные услуги по проектированию, монтажу и обслуживанию систем вентиляции и кондиционирования в Кыргызстане.">
121
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
122
  <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" rel="stylesheet">
123
  <style>
124
- :root { --dark-bg: #12121c; --card-bg: #1a1a2e; --primary-color: #a955ff; --secondary-color: #6a0dad; --text-color: #e0e0e0; --text-muted: #a0a0b0; --accent-glow: rgba(169, 85, 255, 0.3); }
 
 
 
 
125
  * { margin: 0; padding: 0; box-sizing: border-box; scroll-behavior: smooth; }
126
  body { font-family: 'Montserrat', sans-serif; background-color: var(--dark-bg); color: var(--text-color); line-height: 1.7; font-size: 16px; }
127
  .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
@@ -134,155 +172,325 @@ LANDING_TEMPLATE = '''
134
  p { margin-bottom: 1rem; color: var(--text-muted); }
135
  .btn { display: inline-block; padding: 12px 28px; background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); color: #fff; border-radius: 50px; text-decoration: none; font-weight: 600; transition: all 0.3s ease; box-shadow: 0 4px 15px var(--accent-glow); cursor: pointer; border: none; }
136
  .btn:hover { transform: translateY(-3px) scale(1.05); box-shadow: 0 8px 25px var(--accent-glow); }
137
- .header { position: fixed; top: 0; left: 0; width: 100%; z-index: 1000; padding: 15px 0; background-color: rgba(18, 18, 28, 0.85); backdrop-filter: blur(10px); }
 
138
  .navbar { display: flex; justify-content: space-between; align-items: center; }
139
  .logo { font-size: clamp(1.5rem, 4vw, 1.8rem); font-weight: 700; color: #fff; text-decoration: none; }
140
  .nav-links { display: flex; gap: 30px; list-style: none; }
141
- .nav-links a { color: var(--text-color); text-decoration: none; font-weight: 600; }
 
142
  .menu-toggle { display: none; font-size: 1.5rem; cursor: pointer; border: none; background: none; color: white; }
143
- #hero { min-height: 100vh; display: flex; align-items: center; background-image: linear-gradient(rgba(18, 18, 28, 0.7), rgba(18, 18, 28, 1)), url(https://images.unsplash.com/photo-1558221639-2c7158995165?auto=format&fit=crop&w=1740&q=80); background-size: cover; background-position: center; }
144
  .hero-content { text-align: center; max-width: 800px; margin: 0 auto; }
145
- .hero-content p { font-size: clamp(1rem, 2.5vw, 1.2rem); margin: 30px 0; }
146
  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; }
147
  .about-img { width: 100%; border-radius: 15px; box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
148
- .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 30px; }
149
- .card { background-color: var(--card-bg); padding: 30px; border-radius: 15px; border: 1px solid #2a2a4a; transition: all 0.3s ease; display:flex; flex-direction:column; justify-content: space-between;}
150
- .card:hover { transform: translateY(-5px); border-color: var(--primary-color); box-shadow: 0 8px 25px var(--accent-glow); }
151
- .card i.main-icon { font-size: 2.5rem; color: var(--primary-color); margin-bottom: 20px; }
152
- .card-img { width: 100%; height: 200px; object-fit: cover; border-radius: 15px 15px 0 0; }
153
- .card-content { padding: 0; flex-grow: 1; display:flex; flex-direction:column;}
154
- .card.img-card .card-content {padding: 30px;}
155
- .card-actions { margin-top: 20px;}
156
  .equipment-filters { display: flex; justify-content: center; flex-wrap: wrap; gap: 15px; margin-bottom: 40px; }
157
- .filter-btn { padding: 8px 20px; border: 1px solid var(--primary-color); background-color: transparent; color: var(--primary-color); border-radius: 20px; cursor: pointer; }
158
  .filter-btn.active, .filter-btn:hover { background-color: var(--primary-color); color: #fff; }
159
- .equipment-card { text-align: center; } /* Specific for equipment if needed */
 
160
  .equipment-card img { width: 100%; height: 180px; object-fit: contain; margin-bottom: 15px; }
 
161
  .equipment-card .price { font-size: 1.3rem; font-weight: 700; color: #fff; margin: 10px 0; }
162
  .projects-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px; }
163
- .project-card { position: relative; border-radius: 15px; overflow: hidden; min-height: 400px; background-color: var(--card-bg); display: flex; flex-direction: column;}
164
- .project-card .card-img { border-radius: 15px 15px 0 0; flex-shrink: 0; }
165
- .project-card .card-content { padding: 20px; flex-grow: 1; display:flex; flex-direction:column; justify-content: space-between;}
 
 
 
166
  #contact { background-color: var(--card-bg); }
 
 
 
 
167
  .footer { text-align: center; padding: 30px 0; background-color: #0d0d14; }
168
-
169
- .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.7); backdrop-filter: blur(5px); }
170
- .modal-content { background-color: var(--card-bg); margin: 5% auto; padding: 30px; border: 1px solid var(--primary-color); border-radius: 15px; width: 90%; max-width: 700px; position: relative; box-shadow: 0 10px 30px var(--accent-glow); animation: slideIn 0.3s ease-out; }
171
- .close-btn { color: var(--text-muted); float: right; font-size: 28px; font-weight: bold; cursor: pointer; line-height:1; }
172
- .close-btn:hover { color: var(--primary-color); }
173
- .modal-body h3 { margin-top:0; margin-bottom:20px; }
174
- .modal-body p {font-size: 1rem;}
175
- .modal-photos { display: flex; overflow-x: auto; gap: 10px; margin-top: 20px; padding-bottom:10px; }
176
- .modal-photos img { max-height: 300px; border-radius: 8px; cursor: zoom-in; }
177
- @keyframes slideIn { from {transform: translateY(-50px); opacity: 0;} to {transform: translateY(0); opacity: 1;} }
178
-
179
- @media (max-width: 992px) { .grid-2 { grid-template-columns: 1fr; text-align: center; } .about-img { margin-bottom: 30px; max-width: 500px; margin-left: auto; margin-right: auto;} }
 
 
 
 
 
 
180
  @media (max-width: 768px) {
181
- .nav-links { position: fixed; top: 0; right: -100%; width: min(75vw, 300px); height: 100vh; background-color: var(--card-bg); flex-direction: column; justify-content: center; align-items: center; transition: right 0.4s ease-in-out; }
182
  .nav-links.active { right: 0; }
183
  .menu-toggle { display: block; z-index: 1001; }
 
 
 
 
 
184
  }
185
  </style>
186
  </head>
187
  <body>
188
- <header class="header"><div class="container navbar"><a href="#" class="logo">Раина</a><ul class="nav-links"><li><a href="#about">О компании</a></li><li><a href="#services">Услуги</a></li><li><a href="#turnkey">Под ключ</a></li><li><a href="#equipment">Оборудование</a></li><li><a href="#projects">Проекты</a></li><li><a href="#contact">Контакты</a></li></ul><button class="menu-toggle" aria-label="Открыть меню"><i class="fas fa-bars"></i></button></div></header>
189
- <section id="hero"><div class="container hero-content"><h1>ОсОО "Раина": Ваш Партнер в Вентиляции и Кондиционировании</h1><p>15 лет опыта, тысячи проектов. Создаем комфорт и здоровье с помощью современных климатических систем.</p><a href="#contact" class="btn">Получить консультацию</a></div></section>
190
- <section id="about"><div class="container"><h2>О Нашей Компании</h2><div class="grid-2"><img src="https://images.unsplash.com/photo-1542744173-8e7e53415bb0?auto=format&fit=crop&w=1740&q=80" alt="Команда Раина" class="about-img"><div><h3>Основание и История</h3><p>Компания "Раина" с 2009 года является надежным партнером в области климатических решений.</p><h3>Наша Миссия</h3><p>Создание оптимального микроклимата для комфорта, здоровья и производительности наших клиентов.</p><h3>Профессиональная Команда</h3><p>Высококвалифицированные инженеры и техники с глубокими знаниями в HVAC.</p></div></div></div></section>
191
- <section id="services"><div class="container"><h2>Наши Услуги</h2><div class="card-grid">
192
- <div class="card"><i class="fas fa-drafting-compass main-icon"></i><div class="card-content"><h3>Проектирование</h3><p>Точные расчеты, 3D-модели и вся необходимая проектная документация.</p></div></div>
193
- <div class="card"><i class="fas fa-tools main-icon"></i><div class="card-content"><h3>Монтаж</h3><p>Профессиональная установка всех типов систем HVAC, от бытовых до промышленных.</p></div></div>
194
- <div class="card"><i class="fas fa-headset main-icon"></i><div class="card-content"><h3>Сервис 24/7</h3><p>Плановое обслуживание и оперативный аварийный ремонт в любое время.</p></div></div>
195
- <div class="card"><i class="fas fa-sync-alt main-icon"></i><div class="card-content"><h3>Модернизация</h3><p>Повышение энергоэффективности и снижение расходов на эксплуатацию.</p></div></div>
196
- </div></div></section>
197
- <section id="turnkey" style="background-color: var(--card-bg);"><div class="container"><h2>Услуги "под ключ"</h2>
198
- {% if services %}<div class="card-grid">
199
- {% for service in services %}
200
- <div class="card img-card">
201
- {% if service.photo %}<img src="{{ hf_url('services', service.photo) }}" alt="{{ service.title }}" class="card-img">{% endif %}
202
- <div class="card-content"><div><h3><i class="{{ service.icon }} fa-fw"></i> {{ service.title }}</h3><p>{{ service.description_short }}</p></div><div class="card-actions"><button class="btn" onclick="openModal('service', {{ loop.index0 }})">Подробнее</button></div></div>
203
- </div>{% endfor %}
204
- </div>{% else %}<p style="text-align: center;">Информация об услугах скоро появится.</p>{% endif %}
205
- </div></section>
206
- <section id="equipment"><div class="container"><h2>Наше Оборудование</h2>
207
- {% if equipment %}<div class="equipment-filters">
208
- <button class="filter-btn active" data-filter="all">Все</button>
209
- {% for category in categories %}<button class="filter-btn" data-filter="{{ category }}">{{ category }}</button>{% endfor %}
210
- </div><div class="card-grid">
211
- {% for item in equipment %}
212
- <div class="card equipment-card" data-category="{{ item.category|default('all') }}">
213
- {% if item.photos %}<img src="{{ hf_url('equipment', item.photos[0]) }}" alt="{{ item.name }}">{% endif %}
214
- <div class="card-content"><div><h3>{{ item.name }}</h3><p class="price">{{ "%.2f"|format(item.price) }} KGS</p></div><div class="card-actions"><button class="btn" onclick="openModal('equipment', {{ loop.index0 }})">Подробнее</button> <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Интересует: {{ item.name }}" target="_blank" class="btn" style="margin-top:10px;">Запросить</a></div></div>
215
- </div>{% endfor %}
216
- </div>{% else %}<p style="text-align: center;">Каталог оборудования скоро будет доступен.</p>{% endif %}
217
- </div></section>
218
- <section id="projects"><div class="container"><h2>Реализованные Проекты</h2>
219
- {% if projects %}<div class="card-grid">
220
- {% for project in projects %}
221
- <div class="project-card card img-card">
222
- {% if project.photos %}<img src="{{ hf_url('projects', project.photos[0]) }}" alt="{{ project.title }}" class="card-img">{% endif %}
223
- <div class="card-content"><div><h3>{{ project.title }}</h3><p>{{ project.description_short }}</p></div><div class="card-actions"><button class="btn" onclick="openModal('project', {{ loop.index0 }})">Подробнее</button></div></div>
224
- </div>{% endfor %}
225
- </div>{% else %}<p style="text-align: center;">Информация о проектах скоро появится.</p>{% endif %}
226
- </div></section>
227
- <section id="contact"><div class="container" style="text-align:center;"><h2>Контакты</h2><p>Готовы стать вашим надежным партнером.</p>
228
- <p><strong>Тел:</strong> <a href="tel:{{ contact_phone }}">{{ contact_phone }}</a></p>
229
- <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте!" target="_blank" class="btn"><i class="fab fa-whatsapp"></i> WhatsApp</a>
230
- <p style="margin-top:20px; font-size:0.9rem;">ОсОО «Раина», ИНН: 00812202110194</p>
231
- </div></section>
232
- <footer class="footer"><p>© {{ now.year }} ОсОО "Раина".</p></footer>
233
- <div id="itemModal" class="modal"><div class="modal-content"><span class="close-btn" onclick="closeModal()">×</span><div id="modalBody" class="modal-body"></div></div></div>
234
- <script>
235
- const allData = {{ all_data | tojson }};
236
- function hfUrl(folder, filename) { return `https://huggingface.co/datasets/{{ repo_id }}/resolve/main/${folder}/${filename}`; }
237
-
238
- function openModal(type, index) {
239
- let item;
240
- if (type === 'service') item = allData.services[index];
241
- else if (type === 'equipment') item = allData.equipment[index];
242
- else if (type === 'project') item = allData.projects[index];
243
- else return;
244
-
245
- const modalBody = document.getElementById('modalBody');
246
- let photosHtml = '';
247
- if (item.photos && item.photos.length > 0) {
248
- photosHtml = '<div class="modal-photos">';
249
- item.photos.forEach(p => { photosHtml += `<img src="${hfUrl(type === 'service' ? 'services' : type, p)}" alt="${item.title || item.name}">`; });
250
- photosHtml += '</div>';
251
- } else if (type === 'service' && item.photo) {
252
- photosHtml = `<img src="${hfUrl('services', item.photo)}" alt="${item.title}" style="max-width:100%; border-radius:8px; margin-top:15px;">`;
253
- }
254
 
255
- modalBody.innerHTML = `
256
- <h3>${item.icon ? `<i class="${item.icon} fa-fw"></i> ` : ''}${item.title || item.name}</h3>
257
- ${item.category ? `<p><strong>Категория:</strong> ${item.category}</p>` : ''}
258
- ${item.price ? `<p><strong>Цена:</strong> ${item.price.toFixed(2)} KGS</p>` : ''}
259
- <p>${item.description_full || item.description || 'Детальное описание отсутствует.'}</p>
260
- ${photosHtml}
261
- `;
262
- document.getElementById('itemModal').style.display = 'block';
263
- document.body.style.overflow = 'hidden';
264
- }
265
- function closeModal() { document.getElementById('itemModal').style.display = 'none'; document.body.style.overflow = 'auto';}
266
- window.onclick = function(event) { if (event.target == document.getElementById('itemModal')) closeModal(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  document.addEventListener('DOMContentLoaded', function() {
 
268
  const menuToggle = document.querySelector('.menu-toggle');
269
  const navLinks = document.querySelector('.nav-links');
270
- menuToggle.addEventListener('click', () => navLinks.classList.toggle('active'));
271
- document.querySelectorAll('.nav-links a').forEach(link => link.addEventListener('click', () => navLinks.classList.remove('active')));
 
 
 
272
  const filterContainer = document.querySelector('.equipment-filters');
273
  if (filterContainer) {
274
  filterContainer.addEventListener('click', (e) => {
275
  if (!e.target.matches('.filter-btn')) return;
276
- filterContainer.querySelector('.active').classList.remove('active');
 
 
277
  e.target.classList.add('active');
278
  const filter = e.target.dataset.filter;
279
  document.querySelectorAll('.equipment-card').forEach(card => {
280
  card.style.display = (filter === 'all' || card.dataset.category === filter) ? 'flex' : 'none';
281
  });
282
  });
283
- // Ensure equipment cards are flex for proper layout
284
- document.querySelectorAll('.equipment-card').forEach(card => card.style.display = 'flex');
285
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  });
287
  </script>
288
  </body>
@@ -293,243 +501,491 @@ ADMIN_TEMPLATE = '''
293
  <!DOCTYPE html>
294
  <html lang="ru">
295
  <head>
296
- <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
 
297
  <title>Админ-панель - Раина</title>
298
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
299
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
300
  <style>
301
- body { font-family: 'Poppins', sans-serif; background-color: #f4f7f9; color: #333; padding: 20px; }
302
- .container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; }
303
- .header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; }
304
- h1, h2 { font-weight: 600; color: #6a0dad; margin-bottom: 15px; }
305
- h1 { font-size: 1.8rem; } h2 { font-size: 1.5rem; margin-top: 30px; }
 
306
  .section { margin-bottom: 30px; padding: 20px; background-color: #fafafa; border: 1px solid #e9e9e9; border-radius: 8px; }
307
- label { display: block; margin-top: 10px; font-size: 0.9rem;}
308
- input[type="text"], input[type="number"], textarea, select { width: 100%; padding: 10px; margin-top: 5px; border: 1px solid #ddd; border-radius: 6px; }
309
- textarea {min-height: 80px;}
310
- input[type="file"] { padding: 8px; border: 1px solid #ddd; margin-top:5px; display:block;}
311
- button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #9b59b6; color: white; cursor: pointer; margin-top: 15px; }
312
- button:hover { background-color: #8e44ad; }
313
- .delete-button { background-color: #e74c3c; } .delete-button:hover { background-color: #c0392b; }
314
- .item { background: #fff; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom:10px; }
315
- .item-actions { margin-top: 10px; display: flex; gap: 10px; }
316
- .edit-form-container { margin-top: 10px; padding: 15px; background: #fdf9ff; border: 1px dashed #ddd; display: none; }
317
- details > summary { cursor: pointer; font-weight: 600; color: #8e44ad; padding: 10px; border-bottom:1px solid #eee; margin-bottom:10px;}
318
- .photo-preview img {max-width:50px; max-height:50px; margin-right:5px; border-radius:4px;}
319
- .message { padding: 10px; border-radius: 6px; margin-bottom: 15px; }
320
- .message.success { background-color: #d4edda; color: #155724; }
321
- .message.error { background-color: #f8d7da; color: #721c24; }
 
 
 
 
 
 
 
 
322
  </style>
323
  </head>
324
  <body>
325
  <div class="container">
326
- <div class="header"><h1><i class="fas fa-tools"></i> Админ-панель "Раина"</h1><a href="{{ url_for('landing') }}" style="text-decoration:none;"><button><i class="fas fa-home"></i> Сайт</button></a></div>
327
  {% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}{% for category, message in messages %}<div class="message {{ category }}">{{ message }}</div>{% endfor %}{% endif %}{% endwith %}
328
- <div class="section"><h2><i class="fas fa-sync-alt"></i> Синхронизация</h2>
329
- <form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;"><button type="submit">Загрузить на сервер</button></form>
330
- <form method="POST" action="{{ url_for('force_download') }}" style="display: inline;"><button type="submit">Скачать с сервера</button></form>
 
 
331
  </div>
332
 
333
- {% macro render_item_form(action_type, item_type, item=None, index=None, categories=None) %}
334
- <form method="POST" enctype="multipart/form-data">
335
- <input type="hidden" name="action" value="{{ action_type }}_{{ item_type }}">
336
- {% if index is not none %}<input type="hidden" name="index" value="{{ index }}">{% endif %}
337
- <label>Название/Заголовок*:</label><input type="text" name="title" value="{{ item.title if item else '' }}" required>
338
- {% if item_type == 'service' %}<label>Иконка (FontAwesome)*:</label><input type="text" name="icon" value="{{ item.icon if item else 'fas fa-check' }}" required>{% endif %}
339
- <label>Короткое описание (для карточки)*:</label><textarea name="description_short" rows="2" required>{{ item.description_short if item else '' }}</textarea>
340
- <label>Полное описание (для модального окна):</label><textarea name="description_full" rows="4">{{ item.description_full if item else '' }}</textarea>
341
- {% if item_type == 'equipment' %}
342
- <label>Цена (KGS)*:</label><input type="number" name="price" value="{{ item.price if item else '' }}" step="0.01" min="0" required>
343
- <label>Категория:</label><select name="category">
344
- <option value="Без категории" {% if item and item.category == 'Без категории' %}selected{% endif %}>Без категории</option>
345
- {% for cat in categories %}<option value="{{ cat }}" {% if item and item.category == cat %}selected{% endif %}>{{ cat }}</option>{% endfor %}
346
- </select>
347
- {% endif %}
348
- <label>{{ 'Заменить фото (для Услуг - 1 фото):' if item else 'Фото (для Услуг - 1 фото):' }}</label>
349
- <input type="file" name="photos" {{ 'multiple' if item_type != 'service' }} accept="image/*" {{ 'required' if action_type == 'add' and item_type != 'service' and not item else '' }}>
350
- {% if item and item.get('photos') %} <div class="photo-preview">Текущие фото: {% for p in item.photos %}<img src="{{ hf_url(item_type, p) }}">{% endfor %}</div> {% endif %}
351
- {% if item and item_type == 'service' and item.get('photo') %} <div class="photo-preview">Текущее фото: <img src="{{ hf_url('services', item.photo) }}"></div> {% endif %}
352
- <button type="submit">{{ 'Сохранить' if item else 'Добавить' }}</button>
353
- </form>
354
- {% endmacro %}
355
-
356
- {% for item_conf in config_sections %}
357
- <div class="section"><h2><i class="{{ item_conf.icon }}"></i> {{ item_conf.title }}</h2>
358
- <details><summary>Добавить {{ item_conf.singular }}</summary>{{ render_item_form('add', item_conf.key, categories=categories if item_conf.key == 'equipment' else None) }}</details>
359
- {% for item in data[item_conf.key] %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  <div class="item">
361
- <p><strong>{{ item.title if item.title else item.name }}</strong> {% if item_conf.key == 'service' %}(<i class="{{item.icon}}"></i>){% endif %} - {{item.description_short[:50]}}...</p>
 
 
362
  <div class="item-actions">
363
- <button onclick="toggleEditForm('edit-{{item_conf.key}}-{{loop.index0}}')">Редактировать</button>
364
- <form method="POST" style="margin:0;"><input type="hidden" name="action" value="delete_{{item_conf.key}}"><input type="hidden" name="index" value="{{loop.index0}}"><button type="submit" class="delete-button">Удалить</button></form>
365
  </div>
366
- <div id="edit-{{item_conf.key}}-{{loop.index0}}" class="edit-form-container">{{ render_item_form('edit', item_conf.key, item, loop.index0, categories=categories if item_conf.key == 'equipment' else None) }}</div>
367
- </div>{% endfor %}
368
- </div>{% endfor %}
369
-
370
- <div class="section"><h2><i class="fas fa-tags"></i> Категории оборудования</h2>
371
- <details><summary>Добавить категорию</summary><form method="POST"><input type="hidden" name="action" value="add_category"><label>Название:</label><input type="text" name="category_name" required><button type="submit">Добавить</button></form></details>
372
- {% for category in categories %}<div class="item" style="display:flex; justify-content:space-between; align-items:center;"><span>{{category}}</span><form method="POST" style="margin:0;"><input type="hidden" name="action" value="delete_category"><input type="hidden" name="category_name" value="{{category}}"><button type="submit" class="delete-button" style="margin:0;">Удалить</button></form></div>{% endfor %}
 
 
 
 
 
 
 
373
  </div>
374
- <script>function toggleEditForm(id){document.getElementById(id).style.display = document.getElementById(id).style.display === 'block'?'none':'block';}</script>
375
- </body></html>
 
376
  '''
377
 
378
- def hf_url_template_filter(folder, filename):
379
- return f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{folder}/{filename}"
380
- app.jinja_env.filters['hf_url'] = hf_url_template_filter
381
-
382
  @app.route('/')
383
  def landing():
384
  data = load_data()
385
- # Create short descriptions if not present for landing page cards
386
- for item_type in ['services', 'projects', 'equipment']:
387
- for item in data.get(item_type, []):
388
- if 'description_short' not in item:
389
- desc_full = item.get('description_full') or item.get('description', '')
390
- item['description_short'] = (desc_full[:100] + '...') if desc_full and len(desc_full) > 100 else desc_full
391
  return render_template_string(
392
  LANDING_TEMPLATE,
393
- all_data=data, # For JS modal
394
  services=data.get('services', []),
395
  equipment=data.get('equipment', []),
396
- categories=sorted(data.get('categories', [])),
397
  projects=data.get('projects', []),
398
  repo_id=REPO_ID,
399
  contact_phone=CONTACT_PHONE,
400
  whatsapp_phone=WHATSAPP_PHONE,
401
- now=datetime.utcnow(),
402
- hf_url=hf_url_template_filter # Make it available in template context
403
  )
404
 
405
  @app.route('/admin', methods=['GET', 'POST'])
406
  def admin():
407
  data = load_data()
408
- config_sections = [
409
- {'key': 'service', 'title': 'Услуги "под ключ"', 'singular': 'услугу', 'icon': 'fas fa-concierge-bell', 'fields': ['title', 'icon', 'description_short', 'description_full'], 'photo_type': 'single'},
410
- {'key': 'project', 'title': 'Реализованные проекты', 'singular': 'проект', 'icon': 'fas fa-star', 'fields': ['title', 'description_short', 'description_full'], 'photo_type': 'multiple'},
411
- {'key': 'equipment', 'title': 'Оборудование', 'singular': 'оборудование', 'icon': 'fas fa-box-open', 'fields': ['title', 'price', 'category', 'description_short', 'description_full'], 'photo_type': 'multiple'}
412
- ]
413
-
414
  if request.method == 'POST':
415
  action = request.form.get('action')
 
416
  try:
417
- action_type, item_key_raw = action.split('_', 1)
418
- item_key = item_key_raw + ('s' if item_key_raw in ['project', 'service'] and not item_key_raw.endswith('s') else '') # Ensure plural for dict keys like 'projects'
419
-
420
- current_config = next((c for c in config_sections if c['key'] == item_key_raw), None)
421
-
422
- if action_type in ['add', 'edit']:
423
- item_data = {field: request.form.get(field) for field in current_config['fields']}
424
- item_data['title'] = request.form.get('title') # Common field
425
- if 'price' in item_data: item_data['price'] = float(item_data['price'] or 0)
426
-
427
- # Handle photos
428
- uploaded_photos = []
429
- photo_files = request.files.getlist('photos')
430
- if photo_files and any(f.filename for f in photo_files):
431
- for photo_file in photo_files:
432
- if photo_file and photo_file.filename:
433
- filename = upload_photo_to_hf(photo_file, item_data['title'], item_key)
434
- if filename: uploaded_photos.append(filename)
435
- if not uploaded_photos and (action_type == 'add' and item_key_raw != 'service'): # Require photo on add for project/equipment
436
- flash(f"Фото обязательно для {current_config['singular']}.", 'error'); return redirect(url_for('admin'))
437
-
438
-
439
- if action_type == 'add':
440
- if current_config['photo_type'] == 'single': item_data['photo'] = uploaded_photos[0] if uploaded_photos else None
441
- else: item_data['photos'] = uploaded_photos
442
- data[item_key].append(item_data)
443
- flash(f"{current_config['singular'].capitalize()} '{item_data['title']}' добавлен(а).", 'success')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
 
445
- elif action_type == 'edit':
 
 
 
 
 
 
 
 
 
 
446
  index = int(request.form.get('index'))
447
- original_item = data[item_key][index]
448
- if uploaded_photos: # New photos were uploaded
449
- if current_config['photo_type'] == 'single':
450
- delete_photo_from_hf(original_item.get('photo'), item_key)
451
- item_data['photo'] = uploaded_photos[0]
 
 
 
452
  else:
453
- for old_photo in original_item.get('photos', []): delete_photo_from_hf(old_photo, item_key)
454
- item_data['photos'] = uploaded_photos
455
- else: # No new photos, keep old ones
456
- if current_config['photo_type'] == 'single': item_data['photo'] = original_item.get('photo')
457
- else: item_data['photos'] = original_item.get('photos', [])
458
- data[item_key][index] = item_data
459
- flash(f"{current_config['singular'].capitalize()} '{item_data['title']}' обновлен(а).", 'success')
460
-
461
- elif action_type == 'delete':
462
  index = int(request.form.get('index'))
463
- item_to_delete = data[item_key].pop(index)
464
- if current_config['photo_type'] == 'single': delete_photo_from_hf(item_to_delete.get('photo'), item_key)
465
- else:
466
- for p in item_to_delete.get('photos', []): delete_photo_from_hf(p, item_key)
467
- flash(f"{current_config['singular'].capitalize()} '{item_to_delete.get('title') or item_to_delete.get('name')}' удален(а).", 'success')
468
 
469
- elif action == 'add_category':
470
- name = request.form.get('category_name', '').strip()
471
- if name and name not in data['categories']: data['categories'].append(name); flash(f"Категория '{name}' добавлена.", 'success')
472
- else: flash("Категория уже существует или пуста.", 'error')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
 
474
- elif action == 'delete_category':
475
- name = request.form.get('category_name')
476
- if name in data['categories']: data['categories'].remove(name); flash(f"Категория '{name}' удалена.", 'success')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
 
478
  save_data(data)
479
  except Exception as e:
480
- logging.error(f"Admin action '{action}' failed: {e}", exc_info=True)
481
- flash(f"Произошла ошибка: {e}", 'error')
482
  return redirect(url_for('admin'))
483
 
484
  return render_template_string(
485
- ADMIN_TEMPLATE, data=data, categories=sorted(data.get('categories', [])),
486
- config_sections=config_sections, hf_url=hf_url_template_filter
 
 
 
 
487
  )
488
 
489
- def upload_photo_to_hf(photo_file_storage, item_name, folder):
490
- if not photo_file_storage or not photo_file_storage.filename or not HF_TOKEN_WRITE: return None
 
 
 
 
 
 
 
491
  try:
492
  api = HfApi()
493
- safe_name = secure_filename(item_name.replace(' ', '_'))[:50]
494
- ext = os.path.splitext(photo_file_storage.filename)[1].lower()
495
- if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
496
- flash(f"Неподдерживаемый формат файла: {photo_file_storage.filename}", 'warning'); return None
 
 
 
 
497
 
498
- # Save temporarily before upload
499
- temp_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
500
- temp_path = os.path.join(UPLOAD_FOLDER_TEMP, temp_filename)
501
- photo_file_storage.save(temp_path)
502
 
503
- # Upload from temp path
504
- hf_photo_filename = temp_filename # Use the same generated unique name
505
  api.upload_file(
506
- path_or_fileobj=temp_path, path_in_repo=f"{folder}/{hf_photo_filename}",
507
- repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE
 
 
 
 
508
  )
509
- os.remove(temp_path) # Clean up temp file
510
- return hf_photo_filename
511
  except Exception as e:
512
- logging.error(f"Error uploading photo {photo_file_storage.filename}: {e}", exc_info=True)
513
- flash(f"Ошибка загрузки фото {photo_file_storage.filename}.", 'error')
514
- if 'temp_path' in locals() and os.path.exists(temp_path): os.remove(temp_path)
515
  return None
516
 
517
  def delete_photo_from_hf(photo_filename, folder):
518
- if not photo_filename or not HF_TOKEN_WRITE: return
 
 
 
 
 
 
519
  try:
520
  api = HfApi()
521
- api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"{folder}/{photo_filename}"], repo_type="dataset", token=HF_TOKEN_WRITE)
 
 
 
 
 
 
 
 
 
522
  except HfHubHTTPError as e:
523
- if e.response.status_code != 404: logging.error(f"Error deleting photo {photo_filename}: {e}")
524
- except Exception as e: logging.error(f"Error deleting photo {photo_filename}: {e}")
 
 
 
 
 
 
 
525
 
526
  @app.route('/force_upload', methods=['POST'])
527
- def force_upload(): upload_db_to_hf(); flash("Данные загружены.", 'success'); return redirect(url_for('admin'))
 
 
 
 
528
  @app.route('/force_download', methods=['POST'])
529
- def force_download(): download_db_from_hf(); flash("Данные скачаны.", 'success'); return redirect(url_for('admin'))
 
 
 
 
 
530
 
531
  if __name__ == '__main__':
532
- download_db_from_hf()
533
- if HF_TOKEN_WRITE: threading.Thread(target=periodic_backup, daemon=True).start()
 
 
 
 
 
 
 
 
 
 
 
 
534
  port = int(os.environ.get('PORT', 7860))
535
  app.run(debug=False, host='0.0.0.0', port=port)
 
1
+ from flask import Flask, render_template_string, request, redirect, url_for, flash
2
  import json
3
  import os
4
  import logging
 
9
  from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
10
  from werkzeug.utils import secure_filename
11
  from dotenv import load_dotenv
12
+ import requests
13
 
14
  load_dotenv()
15
 
16
  app = Flask(__name__)
17
+ app.secret_key = 'raina_hvac_secret_key_v2_projects_dynamic'
18
  DATA_FILE = 'data.json'
 
 
 
19
 
20
  SYNC_FILES = [DATA_FILE]
21
 
 
36
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
37
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
38
  files_to_download = [specific_file] if specific_file else SYNC_FILES
39
+ logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
40
  all_successful = True
41
  for file_name in files_to_download:
42
  success = False
43
  for attempt in range(retries + 1):
44
  try:
45
+ logging.info(f"Downloading {file_name} (Attempt {attempt + 1}/{retries + 1})...")
46
  hf_hub_download(
47
  repo_id=REPO_ID, filename=file_name, repo_type="dataset", token=token_to_use,
48
  local_dir=".", local_dir_use_symlinks=False, force_download=True, resume_download=False
49
  )
50
+ logging.info(f"Successfully downloaded {file_name}.")
51
  success = True
52
  break
53
  except RepositoryNotFoundError:
 
55
  return False
56
  except HfHubHTTPError as e:
57
  if e.response.status_code == 404:
58
+ logging.warning(f"File {file_name} not found in repo {REPO_ID}. Creating empty local file.")
59
  if not os.path.exists(file_name):
60
  try:
61
  with open(file_name, 'w', encoding='utf-8') as f:
62
  json.dump({'equipment': [], 'categories': [], 'services': [], 'projects': []}, f)
63
  except Exception as create_e:
64
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
65
+ success = True
66
  break
67
+ else:
68
+ logging.error(f"HTTP error downloading {file_name}: {e}. Retrying...")
69
  except Exception as e:
70
+ logging.error(f"Unexpected error downloading {file_name}: {e}. Retrying...", exc_info=True)
71
+ if attempt < retries:
72
+ time.sleep(delay)
73
+ if not success:
74
+ logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
75
+ all_successful = False
76
  return all_successful
77
 
78
  def upload_db_to_hf(specific_file=None):
79
+ if not HF_TOKEN_WRITE:
80
+ logging.warning("HF_TOKEN (for writing) not set. Skipping upload.")
81
+ return
82
  try:
83
  api = HfApi()
84
  files_to_upload = [specific_file] if specific_file else SYNC_FILES
85
+ logging.info(f"Starting upload of {files_to_upload} to {REPO_ID}...")
86
  for file_name in files_to_upload:
87
  if os.path.exists(file_name):
88
  api.upload_file(
 
90
  repo_type="dataset", token=HF_TOKEN_WRITE,
91
  commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
92
  )
93
+ logging.info(f"File {file_name} successfully uploaded.")
94
+ else:
95
+ logging.warning(f"File {file_name} not found locally, skipping upload.")
96
  except Exception as e:
97
+ logging.error(f"Error during Hugging Face upload: {e}", exc_info=True)
98
 
99
  def periodic_backup():
100
+ backup_interval = 1800
101
  while True:
102
+ time.sleep(backup_interval)
103
+ logging.info("Starting periodic backup...")
104
  upload_db_to_hf()
105
+ logging.info("Periodic backup finished.")
106
 
107
  def load_data():
108
  default_data = {'equipment': [], 'categories': [], 'services': [], 'projects': []}
109
  try:
110
+ with open(DATA_FILE, 'r', encoding='utf-8') as file:
111
+ data = json.load(file)
112
+ if not isinstance(data, dict):
113
+ raise ValueError("Data is not a dictionary")
114
+ if 'equipment' not in data: data['equipment'] = []
115
+ if 'categories' not in data: data['categories'] = []
116
+ if 'services' not in data: data['services'] = []
117
+ if 'projects' not in data: data['projects'] = []
118
  return data
119
+ except (FileNotFoundError, json.JSONDecodeError, ValueError):
120
+ logging.warning(f"Local file {DATA_FILE} not found or corrupt. Attempting download.")
121
+ if download_db_from_hf(specific_file=DATA_FILE):
122
+ try:
123
+ with open(DATA_FILE, 'r', encoding='utf-8') as file:
124
+ data = json.load(file)
125
+ if not isinstance(data, dict): data = default_data
126
+ if 'equipment' not in data: data['equipment'] = []
127
+ if 'categories' not in data: data['categories'] = []
128
+ if 'services' not in data: data['services'] = []
129
+ if 'projects' not in data: data['projects'] = []
130
+ return data
131
+ except:
132
+ return default_data
133
  return default_data
134
 
135
  def save_data(data):
136
  try:
137
+ if not isinstance(data, dict):
138
+ logging.error("Attempted to save invalid data structure. Aborting.")
139
+ return
140
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
141
  json.dump(data, file, ensure_ascii=False, indent=4)
142
+ logging.info(f"Data saved to {DATA_FILE}")
143
  upload_db_to_hf(specific_file=DATA_FILE)
144
  except Exception as e:
145
  logging.error(f"Error saving data: {e}", exc_info=True)
 
151
  <meta charset="UTF-8">
152
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
153
  <title>ОсОО "Раина" - Вентиляция и Кондиционирование</title>
154
+ <meta name="description" content="Профессиональные услуги по проектированию, монтажу и обслуживанию систем вентиляции и кондиционирования в Кыргызстане. 15 лет опыта, более 1000 проектов.">
155
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
156
  <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" rel="stylesheet">
157
  <style>
158
+ :root {
159
+ --dark-bg: #12121c; --card-bg: #1a1a2e; --primary-color: #a955ff;
160
+ --secondary-color: #6a0dad; --text-color: #e0e0e0; --text-muted: #a0a0b0;
161
+ --accent-glow: rgba(169, 85, 255, 0.3);
162
+ }
163
  * { margin: 0; padding: 0; box-sizing: border-box; scroll-behavior: smooth; }
164
  body { font-family: 'Montserrat', sans-serif; background-color: var(--dark-bg); color: var(--text-color); line-height: 1.7; font-size: 16px; }
165
  .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
 
172
  p { margin-bottom: 1rem; color: var(--text-muted); }
173
  .btn { display: inline-block; padding: 12px 28px; background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); color: #fff; border-radius: 50px; text-decoration: none; font-weight: 600; transition: all 0.3s ease; box-shadow: 0 4px 15px var(--accent-glow); cursor: pointer; border: none; }
174
  .btn:hover { transform: translateY(-3px) scale(1.05); box-shadow: 0 8px 25px var(--accent-glow); }
175
+ .header { position: fixed; top: 0; left: 0; width: 100%; z-index: 1000; padding: 15px 0; background-color: rgba(18, 18, 28, 0.85); backdrop-filter: blur(10px); transition: all 0.3s ease; }
176
+ .header.scrolled { padding: 10px 0; box-shadow: 0 2px 10px rgba(0,0,0,0.3); }
177
  .navbar { display: flex; justify-content: space-between; align-items: center; }
178
  .logo { font-size: clamp(1.5rem, 4vw, 1.8rem); font-weight: 700; color: #fff; text-decoration: none; }
179
  .nav-links { display: flex; gap: 30px; list-style: none; }
180
+ .nav-links a { color: var(--text-color); text-decoration: none; font-weight: 600; transition: color 0.3s ease; }
181
+ .nav-links a:hover { color: var(--primary-color); }
182
  .menu-toggle { display: none; font-size: 1.5rem; cursor: pointer; border: none; background: none; color: white; }
183
+ #hero { min-height: 100vh; display: flex; align-items: center; background-image: linear-gradient(rgba(18, 18, 28, 0.7), rgba(18, 18, 28, 1)), url(https://images.unsplash.com/photo-1558221639-2c7158995165?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1740&q=80); background-size: cover; background-position: center; }
184
  .hero-content { text-align: center; max-width: 800px; margin: 0 auto; }
185
+ .hero-content p { font-size: clamp(1rem, 2.5vw, 1.2rem); margin: 30px 0; max-width: 600px; margin-left: auto; margin-right: auto;}
186
  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; }
187
  .about-img { width: 100%; border-radius: 15px; box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
188
+ .services-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 30px; }
189
+ .service-card, .turnkey-card { background-color: var(--card-bg); padding: 30px; border-radius: 15px; border: 1px solid #2a2a4a; transition: all 0.3s ease; }
190
+ .service-card:hover, .turnkey-card:hover { transform: translateY(-5px); border-color: var(--primary-color); box-shadow: 0 8px 25px var(--accent-glow); }
191
+ .service-card i { font-size: 2.5rem; color: var(--primary-color); margin-bottom: 20px; }
192
+ .turnkey-card { padding: 0; display: flex; flex-direction: column; }
193
+ .turnkey-img { width: 100%; height: 200px; object-fit: cover; border-radius: 15px 15px 0 0; }
194
+ .turnkey-content { padding: 30px; flex-grow: 1; display: flex; flex-direction: column;}
195
+ .turnkey-content p { flex-grow: 1; }
196
  .equipment-filters { display: flex; justify-content: center; flex-wrap: wrap; gap: 15px; margin-bottom: 40px; }
197
+ .filter-btn { padding: 8px 20px; border: 1px solid var(--primary-color); background-color: transparent; color: var(--primary-color); border-radius: 20px; cursor: pointer; transition: all 0.3s; }
198
  .filter-btn.active, .filter-btn:hover { background-color: var(--primary-color); color: #fff; }
199
+ .equipment-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 30px; }
200
+ .equipment-card { background-color: var(--card-bg); border-radius: 15px; overflow: hidden; text-align: center; padding: 20px; border: 1px solid #2a2a4a; transition: all 0.3s ease; display:flex; flex-direction:column; justify-content:space-between; }
201
  .equipment-card img { width: 100%; height: 180px; object-fit: contain; margin-bottom: 15px; }
202
+ .equipment-card h3 { font-size: 1.2rem; }
203
  .equipment-card .price { font-size: 1.3rem; font-weight: 700; color: #fff; margin: 10px 0; }
204
  .projects-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px; }
205
+ .project-card { position: relative; border-radius: 15px; overflow: hidden; min-height: 400px; display: flex; flex-direction: column;}
206
+ .project-card img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.4s ease; flex-grow: 1;}
207
+ .project-overlay { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(to top, rgba(18,18,28,1) 0%, rgba(18,18,28,0) 100%); padding: 40px 20px 20px; text-align: center;}
208
+ .project-card h3 { margin-bottom: 5px; font-size: 1.3rem; }
209
+ .project-card .project-description-summary { margin-bottom: 10px; color: var(--text-muted); font-size: 0.9em; }
210
+ .project-card:hover img { transform: scale(1.05); }
211
  #contact { background-color: var(--card-bg); }
212
+ .contact-content { text-align: center; }
213
+ .contact-info { margin-top: 40px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
214
+ .contact-info p { font-size: 1.2rem; margin-bottom: 0; }
215
+ .contact-info a { color: var(--primary-color); text-decoration: none; font-weight: 600; }
216
  .footer { text-align: center; padding: 30px 0; background-color: #0d0d14; }
217
+
218
+ .modal { display:none; position:fixed; z-index:2000; left:0; top:0; width:100%; height:100%; overflow:auto; background-color:rgba(0,0,0,0.7); backdrop-filter: blur(5px); }
219
+ .modal-content-wrapper { margin: 5% auto; padding:30px; width:90%; max-width:700px; background-color:var(--card-bg); border-radius:15px; position:relative; animation: fadeInModal 0.4s ease-out; border: 1px solid #2a2a4a; box-shadow: 0 10px 40px var(--accent-glow); }
220
+ .close-modal-btn { color:var(--text-muted); position: absolute; top: 15px; right: 20px; font-size:32px; font-weight:bold; cursor:pointer; transition: color 0.3s; }
221
+ .close-modal-btn:hover { color: var(--primary-color); }
222
+ #modalPhoto { width:100%; max-height:400px; object-fit:cover; border-radius:10px; margin-bottom:25px; display:none; border: 1px solid #2a2a4a;}
223
+ #modalTitle { color:var(--primary-color); margin-bottom:15px; font-size: 1.8rem; }
224
+ #modalDescription { color:var(--text-color); max-height: 300px; overflow-y: auto; line-height:1.8; font-size:1.05rem; }
225
+ #modalDescription::-webkit-scrollbar { width: 8px; }
226
+ #modalDescription::-webkit-scrollbar-thumb { background-color: var(--primary-color); border-radius: 4px; }
227
+ #modalDescription::-webkit-scrollbar-track { background-color: #2a2a4a; }
228
+ #modalPrice { font-size:1.4rem; font-weight:bold; color: #fff; margin-top:20px; display:none; text-align:right; }
229
+ @keyframes fadeInModal { from { opacity:0; transform: translateY(-20px) scale(0.98); } to { opacity:1; transform: translateY(0) scale(1); } }
230
+
231
+ @media (max-width: 992px) {
232
+ .grid-2 { grid-template-columns: 1fr; text-align: center; }
233
+ .about-img { margin-bottom: 30px; max-width: 500px; margin-left: auto; margin-right: auto;}
234
+ }
235
  @media (max-width: 768px) {
236
+ .nav-links { position: fixed; top: 0; right: -100%; width: min(75vw, 300px); height: 100vh; background-color: var(--card-bg); flex-direction: column; justify-content: center; align-items: center; transition: right 0.4s ease-in-out; box-shadow: -5px 0 15px rgba(0,0,0,0.2); }
237
  .nav-links.active { right: 0; }
238
  .menu-toggle { display: block; z-index: 1001; }
239
+ h2 { margin-bottom: 40px; }
240
+ .projects-grid { grid-template-columns: 1fr; }
241
+ .modal-content-wrapper { width: 95%; margin: 10% auto; }
242
+ #modalTitle { font-size: 1.5rem; }
243
+ #modalDescription { font-size: 1rem; }
244
  }
245
  </style>
246
  </head>
247
  <body>
248
+ <header class="header">
249
+ <div class="container navbar">
250
+ <a href="#" class="logo">Раина</a>
251
+ <ul class="nav-links">
252
+ <li><a href="#about" компании</a></li>
253
+ <li><a href="#services">Услуги</a></li>
254
+ <li><a href="#turnkey">Под ключ</a></li>
255
+ <li><a href="#equipment">Оборудование</a></li>
256
+ <li><a href="#projects">Проекты</a></li>
257
+ <li><a href="#contact">Контакты</a></li>
258
+ </ul>
259
+ <button class="menu-toggle" aria-label="Открыть меню"><i class="fas fa-bars"></i></button>
260
+ </div>
261
+ </header>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
+ <section id="hero">
264
+ <div class="container hero-content">
265
+ <h1>ОсОО "Раина": Ваш Партнер в Вентиляции и Кондиционировании</h1>
266
+ <p>15 лет опыта, более 1000 реализованных проектов. Мы создаем комфорт и здоровье в любом помещении с помощью самых современных климатических систем.</p>
267
+ <a href="#contact" class="btn">Получить консультацию</a>
268
+ </div>
269
+ </section>
270
+
271
+ <section id="about">
272
+ <div class="container">
273
+ <h2>О Нашей Компании</h2>
274
+ <div class="grid-2">
275
+ <img src="https://images.unsplash.com/photo-1542744173-8e7e53415bb0?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1740&q=80" alt="Команда Раина" class="about-img">
276
+ <div>
277
+ <h3>Основание и История</h3>
278
+ <p>Компания "Раина" была основана в 2009 году. За эти годы мы зарекомендовали себя как надежный партнер, стремящийся к инновациям и совершенству в области климатических решений.</p>
279
+ <h3>Наша Миссия</h3>
280
+ <p>Наша миссия — создание оптимального микроклимата для наших клиентов, обеспечивающего комфорт, здоровье и высокую производительность.</p>
281
+ <h3>Профессиональная Команда</h3>
282
+ <p>Наша команда состоит из высококвалифицированных инженеров и техников, обладающих глубокими знаниями и опытом в области HVAC.</p>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ </section>
287
+
288
+ <section id="services">
289
+ <div class="container">
290
+ <h2>Наши Услуги</h2>
291
+ <div class="services-grid">
292
+ <div class="service-card"><i class="fas fa-drafting-compass"></i><h3>Проектирование</h3><p>Точные расчеты, 3D-модели и вся необходимая проектная документация.</p></div>
293
+ <div class="service-card"><i class="fas fa-tools"></i><h3>Монтаж</h3><p>Профессиональная установка всех типов систем HVAC, от бытовых до промышленных.</p></div>
294
+ <div class="service-card"><i class="fas fa-headset"></i><h3>Сервис 24/7</h3><p>Плановое обслуживание и оперативный аварийный ремонт в любое время.</p></div>
295
+ <div class="service-card"><i class="fas fa-sync-alt"></i><h3>Модернизация</h3><p>Повышение энергоэффективности и снижение расходов на эксплуатацию.</p></div>
296
+ </div>
297
+ </div>
298
+ </section>
299
+
300
+ <section id="turnkey" style="background-color: var(--dark-bg); border-top: 1px solid #2a2a4a; border-bottom: 1px solid #2a2a4a;">
301
+ <div class="container">
302
+ <h2>Услуги "под ключ"</h2>
303
+ {% if services %}
304
+ <div class="services-grid">
305
+ {% for service in services %}
306
+ <div class="turnkey-card">
307
+ {% if service.photo %}
308
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/services/{{ service.photo }}" alt="{{ service.title }}" class="turnkey-img">
309
+ {% endif %}
310
+ <div class="turnkey-content">
311
+ <h3><i class="{{ service.icon }} fa-fw" style="margin-right: 8px; color: var(--primary-color);"></i>{{ service.title }}</h3>
312
+ <p>{{ service.description | truncate(120, True) if service.description else '' }}</p>
313
+ <button class="btn detail-btn" style="margin-top:auto; padding: 10px 20px; font-size:0.9rem;"
314
+ data-title="{{ service.title }}"
315
+ data-description="{{ service.description | e }}"
316
+ data-photo-url="{% if service.photo %}https://huggingface.co/datasets/{{ repo_id }}/resolve/main/services/{{ service.photo }}{% endif %}">
317
+ Подробнее
318
+ </button>
319
+ </div>
320
+ </div>
321
+ {% endfor %}
322
+ </div>
323
+ {% else %}
324
+ <p style="text-align: center;">Информация об услугах "под ключ" скоро появится на сайте.</p>
325
+ {% endif %}
326
+ </div>
327
+ </section>
328
+
329
+ <section id="equipment">
330
+ <div class="container">
331
+ <h2>Наше Оборудование</h2>
332
+ {% if equipment %}
333
+ <div class="equipment-filters">
334
+ <button class="filter-btn active" data-filter="all">Все</button>
335
+ {% for category in categories %}
336
+ <button class="filter-btn" data-filter="{{ category }}">{{ category }}</button>
337
+ {% endfor %}
338
+ </div>
339
+ <div class="equipment-grid">
340
+ {% for item in equipment %}
341
+ <div class="equipment-card" data-category="{{ item.get('category', 'all') }}">
342
+ <div>
343
+ {% if item.photo %}
344
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/equipment/{{ item.photo }}" alt="{{ item.name }}">
345
+ {% else %}
346
+ <img src="https://via.placeholder.com/250x180.png?text=No+Image" alt="No Image">
347
+ {% endif %}
348
+ <h3>{{ item.name }}</h3>
349
+ <p class="price">{{ "%.2f"|format(item.price) }} KGS</p>
350
+ </div>
351
+ <div style="display:flex; flex-direction: column; gap:10px; justify-content:center; margin-top:15px;">
352
+ <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте, интересует оборудование: {{ item.name | urlencode }}" target="_blank" class="btn" style="padding: 10px 20px; font-size: 0.9rem;">Запросить</a>
353
+ <button class="btn detail-btn" style="padding: 10px 20px; font-size:0.9rem; background: var(--text-muted);"
354
+ data-title="{{ item.name }}"
355
+ data-description="{{ item.get('description', 'Подробное описание скоро появится.') | e }}"
356
+ data-price="{{ '%.2f KGS'|format(item.price) }}"
357
+ data-photo-url="{% if item.photo %}https://huggingface.co/datasets/{{ repo_id }}/resolve/main/equipment/{{ item.photo }}{% endif %}">
358
+ Детали
359
+ </button>
360
+ </div>
361
+ </div>
362
+ {% endfor %}
363
+ </div>
364
+ {% else %}
365
+ <p style="text-align: center;">Каталог оборудования скоро будет доступен.</p>
366
+ {% endif %}
367
+ </div>
368
+ </section>
369
+
370
+ <section id="projects" style="background-color: var(--card-bg);">
371
+ <div class="container">
372
+ <h2>Реализованные Проекты</h2>
373
+ {% if projects %}
374
+ <div class="projects-grid">
375
+ {% for project in projects %}
376
+ <div class="project-card">
377
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/projects/{{ project.photo }}" alt="{{ project.title }}">
378
+ <div class="project-overlay">
379
+ <h3>{{ project.title }}</h3>
380
+ <p class="project-description-summary">{{ project.description | truncate(80, True) if project.description else '' }}</p>
381
+ <button class="btn detail-btn" style="margin-top:10px; padding: 8px 16px; font-size:0.85rem; opacity:0.9;"
382
+ data-title="{{ project.title }}"
383
+ data-description="{{ project.description | e }}"
384
+ data-photo-url="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/projects/{{ project.photo }}">
385
+ Узнать больше
386
+ </button>
387
+ </div>
388
+ </div>
389
+ {% endfor %}
390
+ </div>
391
+ {% else %}
392
+ <p style="text-align: center;">Информация о реализованных проектах скоро появится на сайте.</p>
393
+ {% endif %}
394
+ </div>
395
+ </section>
396
+
397
+ <section id="contact">
398
+ <div class="container contact-content">
399
+ <h2>Контакты</h2>
400
+ <p>Готовы стать вашим надежным партнером в создании идеального климата.</p>
401
+ <div class="contact-info">
402
+ <p><strong>Свяжитесь с нами:</strong> <a href="tel:{{ contact_phone }}">{{ contact_phone }}</a></p>
403
+ <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте, я хотел(а) бы получить консультацию по вашим услугам." target="_blank" class="btn"><i class="fab fa-whatsapp"></i> Напис��ть в WhatsApp</a>
404
+ </div>
405
+ <div style="margin-top: 40px; font-size: 0.9rem; color: var(--text-muted);">
406
+ <p><strong>Реквизиты:</strong> ОсОО «Раина», ИНН: 00812202110194, ОКПО: 31290279</p>
407
+ </div>
408
+ </div>
409
+ </section>
410
+
411
+ <footer class="footer">
412
+ <p>© {{ now.year }} ОсОО "Раина". Все права защищены.</p>
413
+ </footer>
414
+
415
+ <div id="itemDetailModal" class="modal">
416
+ <div class="modal-content-wrapper">
417
+ <span class="close-modal-btn">×</span>
418
+ <img id="modalPhoto" src="" alt="Detail photo">
419
+ <h3 id="modalTitle"></h3>
420
+ <div id="modalDescription"></div>
421
+ <p id="modalPrice"></p>
422
+ </div>
423
+ </div>
424
+
425
+ <script>
426
  document.addEventListener('DOMContentLoaded', function() {
427
+ const header = document.querySelector('.header');
428
  const menuToggle = document.querySelector('.menu-toggle');
429
  const navLinks = document.querySelector('.nav-links');
430
+ window.addEventListener('scroll', () => { header.classList.toggle('scrolled', window.scrollY > 50); });
431
+ menuToggle.addEventListener('click', () => { navLinks.classList.toggle('active'); });
432
+ document.querySelectorAll('.nav-links a').forEach(link => {
433
+ link.addEventListener('click', () => { navLinks.classList.remove('active'); });
434
+ });
435
  const filterContainer = document.querySelector('.equipment-filters');
436
  if (filterContainer) {
437
  filterContainer.addEventListener('click', (e) => {
438
  if (!e.target.matches('.filter-btn')) return;
439
+ if (filterContainer.querySelector('.active')) {
440
+ filterContainer.querySelector('.active').classList.remove('active');
441
+ }
442
  e.target.classList.add('active');
443
  const filter = e.target.dataset.filter;
444
  document.querySelectorAll('.equipment-card').forEach(card => {
445
  card.style.display = (filter === 'all' || card.dataset.category === filter) ? 'flex' : 'none';
446
  });
447
  });
 
 
448
  }
449
+
450
+ const modal = document.getElementById('itemDetailModal');
451
+ const modalPhoto = document.getElementById('modalPhoto');
452
+ const modalTitle = document.getElementById('modalTitle');
453
+ const modalDescription = document.getElementById('modalDescription');
454
+ const modalPrice = document.getElementById('modalPrice');
455
+ const closeModalBtn = modal.querySelector('.close-modal-btn');
456
+
457
+ function openItemModal(data) {
458
+ modalTitle.textContent = data.title || '';
459
+ modalDescription.innerHTML = data.description ? data.description.replace(/\\n/g, '<br>').replace(/\n/g, '<br>') : '';
460
+
461
+ if (data.photoUrl && data.photoUrl !== "None" && data.photoUrl !== "") {
462
+ modalPhoto.src = data.photoUrl;
463
+ modalPhoto.style.display = 'block';
464
+ modalPhoto.alt = data.title || 'Detail photo';
465
+ } else {
466
+ modalPhoto.style.display = 'none';
467
+ }
468
+
469
+ if (data.price) {
470
+ modalPrice.textContent = data.price;
471
+ modalPrice.style.display = 'block';
472
+ } else {
473
+ modalPrice.style.display = 'none';
474
+ }
475
+ modal.style.display = 'block';
476
+ }
477
+
478
+ closeModalBtn.onclick = function() { modal.style.display = 'none'; }
479
+ window.onclick = function(event) {
480
+ if (event.target == modal) { modal.style.display = 'none'; }
481
+ }
482
+
483
+ document.querySelectorAll('.detail-btn').forEach(button => {
484
+ button.addEventListener('click', function() {
485
+ const data = {
486
+ title: this.dataset.title,
487
+ description: this.dataset.description,
488
+ photoUrl: this.dataset.photoUrl,
489
+ price: this.dataset.price
490
+ };
491
+ openItemModal(data);
492
+ });
493
+ });
494
  });
495
  </script>
496
  </body>
 
501
  <!DOCTYPE html>
502
  <html lang="ru">
503
  <head>
504
+ <meta charset="UTF-8">
505
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
506
  <title>Админ-панель - Раина</title>
507
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
508
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
509
  <style>
510
+ body { font-family: 'Poppins', sans-serif; background-color: #f4f7f9; color: #333; padding: 20px; line-height: 1.6; }
511
+ .container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
512
+ .header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
513
+ h1, h2, h3 { font-weight: 600; color: #6a0dad; margin-bottom: 15px; }
514
+ h1 { font-size: 1.8rem; }
515
+ h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
516
  .section { margin-bottom: 30px; padding: 20px; background-color: #fafafa; border: 1px solid #e9e9e9; border-radius: 8px; }
517
+ form { margin-bottom: 20px; }
518
+ label { font-weight: 500; margin-top: 10px; display: block; color: #555; font-size: 0.9rem;}
519
+ input[type="text"], input[type="number"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; }
520
+ input[type="file"] { padding: 8px; cursor: pointer; border: 1px solid #ddd; width: 100%; box-sizing: border-box; margin-top:5px; }
521
+ button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #9b59b6; color: white; font-weight: 500; cursor: pointer; transition: all 0.3s ease; margin-top: 15px; text-decoration: none; font-size: 0.95rem;}
522
+ button:hover, .button:hover { background-color: #8e44ad; }
523
+ .delete-button { background-color: #e74c3c; }
524
+ .delete-button:hover { background-color: #c0392b; }
525
+ .item-list { display: grid; gap: 20px; }
526
+ .item { background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #eee; }
527
+ .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
528
+ .edit-form-container { margin-top: 15px; padding: 20px; background: #fdf9ff; border: 1px dashed #ddd; border-radius: 6px; display: none; }
529
+ details { background-color: #fafafa; border: 1px solid #e9e9e9; border-radius: 8px; margin-bottom: 20px; }
530
+ details > summary { cursor: pointer; font-weight: 600; color: #8e44ad; display: block; padding: 15px; position: relative; list-style: none; }
531
+ details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s; }
532
+ details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
533
+ .photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; object-fit: cover;}
534
+ .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;}
535
+ .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
536
+ .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
537
+ .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba;}
538
+ .current-photo { font-size: 0.85em; color: #777; margin-top: 5px;}
539
+ .current-photo img { max-width: 50px; max-height: 50px; vertical-align: middle; margin-right: 5px; border-radius: 3px;}
540
  </style>
541
  </head>
542
  <body>
543
  <div class="container">
544
+ <div class="header"><h1><i class="fas fa-tools"></i> Админ-панель "Раина"</h1><a href="{{ url_for('landing') }}" class="button"><i class="fas fa-home"></i> Перейти на сайт</a></div>
545
  {% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}{% for category, message in messages %}<div class="message {{ category }}">{{ message }}</div>{% endfor %}{% endif %}{% endwith %}
546
+
547
+ <div class="section">
548
+ <h2><i class="fas fa-sync-alt"></i> Синхронизация</h2>
549
+ <form method="POST" action="{{ url_for('force_upload') }}" style="display: inline-block; margin-right:10px;"><button type="submit" class="button"><i class="fas fa-cloud-upload-alt"></i> Загрузить на сервер</button></form>
550
+ <form method="POST" action="{{ url_for('force_download') }}" style="display: inline-block;"><button type="submit" class="button"><i class="fas fa-cloud-download-alt"></i> Скачать с сервера</button></form>
551
  </div>
552
 
553
+ <div class="section">
554
+ <h2><i class="fas fa-star"></i> Реализованные проекты</h2>
555
+ <details><summary>Добавить проект</summary>
556
+ <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="add_project">
557
+ <label>Название*:</label><input type="text" name="title" required>
558
+ <label>Описание*:</label><textarea name="description" rows="3" required></textarea>
559
+ <label>Фото*:</label><input type="file" name="photo" accept="image/*" required>
560
+ <button type="submit">Добавить проект</button>
561
+ </form>
562
+ </details>
563
+ <div class="item-list">
564
+ {% for project in projects %}
565
+ <div class="item">
566
+ <p><strong>{{ project.title }}</strong>: {{ project.description | truncate(100) }}</p>
567
+ {% if project.photo %}<div class="current-photo"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/projects/{{ project.photo }}" alt="Current photo"> {{ project.photo }}</div>{% endif %}
568
+ <div class="item-actions">
569
+ <button onclick="toggleEditForm('edit-project-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
570
+ <form method="POST" style="margin:0;"><input type="hidden" name="action" value="delete_project"><input type="hidden" name="index" value="{{ loop.index0 }}"><button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button></form>
571
+ </div>
572
+ <div id="edit-project-{{ loop.index0 }}" class="edit-form-container">
573
+ <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="edit_project"><input type="hidden" name="index" value="{{ loop.index0 }}">
574
+ <label>Название*:</label><input type="text" name="title" value="{{ project.title }}" required>
575
+ <label>Описание*:</label><textarea name="description" rows="3" required>{{ project.description }}</textarea>
576
+ <label>Заменить фото (оставьте пустым, чтобы не менять):</label><input type="file" name="photo" accept="image/*">
577
+ {% if project.photo %}<p class="current-photo">Текущее фото: {{ project.photo }}</p>{% endif %}
578
+ <button type="submit">Сохранить</button>
579
+ </form>
580
+ </div>
581
+ </div>
582
+ {% endfor %}
583
+ </div>
584
+ </div>
585
+
586
+ <div class="section">
587
+ <h2><i class="fas fa-concierge-bell"></i> Услуги "под ключ"</h2>
588
+ <details><summary>Добавить услугу</summary>
589
+ <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="add_service">
590
+ <label>Заголовок*:</label><input type="text" name="title" required>
591
+ <label>Иконка (FontAwesome)*:</label><input type="text" name="icon" placeholder="fas fa-tools" required>
592
+ <label>Описание*:</label><textarea name="description" rows="3" required></textarea>
593
+ <label>Фото (опционально):</label><input type="file" name="photo" accept="image/*">
594
+ <button type="submit">Добавить услугу</button>
595
+ </form>
596
+ </details>
597
+ <div class="item-list">
598
+ {% for service in services %}
599
+ <div class="item">
600
+ <p><i class="{{ service.icon }} fa-fw"></i> <strong>{{ service.title }}</strong>: {{ service.description | truncate(100) }}</p>
601
+ {% if service.photo %}<div class="current-photo"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/services/{{ service.photo }}" alt="Current photo"> {{ service.photo }}</div>{% endif %}
602
+ <div class="item-actions">
603
+ <button onclick="toggleEditForm('edit-service-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
604
+ <form method="POST" style="margin:0;"><input type="hidden" name="action" value="delete_service"><input type="hidden" name="index" value="{{ loop.index0 }}"><button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button></form>
605
+ </div>
606
+ <div id="edit-service-{{ loop.index0 }}" class="edit-form-container">
607
+ <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="edit_service"><input type="hidden" name="index" value="{{ loop.index0 }}">
608
+ <label>Заголовок*:</label><input type="text" name="title" value="{{ service.title }}" required>
609
+ <label>Иконка*:</label><input type="text" name="icon" value="{{ service.icon }}" required>
610
+ <label>Описание*:</label><textarea name="description" rows="3" required>{{ service.description }}</textarea>
611
+ <label>Заменить фото (оставьте пустым, чтобы не менять):</label><input type="file" name="photo" accept="image/*">
612
+ {% if service.photo %}<p class="current-photo">Текущее фото: {{ service.photo }}</p>{% endif %}
613
+ <button type="submit">Сохранить</button>
614
+ </form>
615
+ </div>
616
+ </div>
617
+ {% endfor %}
618
+ </div>
619
+ </div>
620
+
621
+ <div class="section">
622
+ <h2><i class="fas fa-tags"></i> Категории оборудования</h2>
623
+ <details><summary>Добавить категорию</summary>
624
+ <form method="POST"><input type="hidden" name="action" value="add_category"><label>Название категории*:</label><input type="text" name="category_name" required><button type="submit">Добавить</button></form>
625
+ </details>
626
+ {% if categories %}
627
+ <div class="item-list" style="grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));">
628
+ {% for category in categories %}
629
+ <div class="item" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 15px;">
630
+ <span>{{ category }}</span>
631
+ <form method="POST" style="margin: 0;"><input type="hidden" name="action" value="delete_category"><input type="hidden" name="category_name" value="{{ category }}"><button type="submit" class="delete-button" style="margin:0; padding: 5px 10px; font-size: 0.8rem;"><i class="fas fa-trash-alt"></i></button></form>
632
+ </div>
633
+ {% endfor %}
634
+ </div>
635
+ {% else %}
636
+ <p>Категорий пока нет.</p>
637
+ {% endif %}
638
+ </div>
639
+
640
+ <div class="section">
641
+ <h2><i class="fas fa-box-open"></i> Оборудование</h2>
642
+ <details style="margin-top:20px;"><summary>Добавить оборудование</summary>
643
+ <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="add_equipment">
644
+ <label>Название*:</label><input type="text" name="name" required>
645
+ <label>Цена (KGS)*:</label><input type="number" name="price" step="0.01" min="0" required>
646
+ <label>Описание (опционально):</label><textarea name="description" rows="3"></textarea>
647
+ <label>Категория:</label><select name="category"><option value="Без категории">Без категории</option>{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}</select>
648
+ <label>Фото (опционально):</label><input type="file" name="photo" accept="image/*">
649
+ <button type="submit">Добавить</button>
650
+ </form>
651
+ </details>
652
+ <div class="item-list">
653
+ {% for item in equipment %}
654
  <div class="item">
655
+ <p><strong>{{ item.name }}</strong> ({{ item.get('category', 'Без категории') }}) - {{ "%.2f"|format(item.price) }} KGS</p>
656
+ {% if item.get('description') %}<p style="font-size:0.9em; color:#555;">Описание: {{ item.description | truncate(100) }}</p>{% endif %}
657
+ {% if item.photo %}<div class="current-photo"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/equipment/{{ item.photo }}" alt="Current photo"> {{ item.photo }}</div>{% endif %}
658
  <div class="item-actions">
659
+ <button onclick="toggleEditForm('edit-eq-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
660
+ <form method="POST" style="margin:0;"><input type="hidden" name="action" value="delete_equipment"><input type="hidden" name="index" value="{{ loop.index0 }}"><button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button></form>
661
  </div>
662
+ <div id="edit-eq-{{ loop.index0 }}" class="edit-form-container">
663
+ <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="edit_equipment"><input type="hidden" name="index" value="{{ loop.index0 }}">
664
+ <label>Название*:</label><input type="text" name="name" value="{{ item.name }}" required>
665
+ <label>Цена (KGS)*:</label><input type="number" name="price" value="{{ item.price }}" step="0.01" min="0" required>
666
+ <label>Описание (опционально):</label><textarea name="description" rows="3">{{ item.get('description', '') }}</textarea>
667
+ <label>Категория:</label><select name="category"><option value="Без категории" {% if not item.category or item.category == "Без категории" %}selected{% endif %}>Без категории</option>{% for cat in categories %}<option value="{{ cat }}" {% if item.category == cat %}selected{% endif %}>{{ cat }}</option>{% endfor %}</select>
668
+ <label>Заменить фото (оставьте пустым, чтобы не менять):</label><input type="file" name="photo" accept="image/*">
669
+ {% if item.photo %}<p class="current-photo">Текущее фото: {{ item.photo }}</p>{% endif %}
670
+ <button type="submit">Сохранить</button>
671
+ </form>
672
+ </div>
673
+ </div>
674
+ {% endfor %}
675
+ </div>
676
  </div>
677
+ <script>function toggleEditForm(id) { var el = document.getElementById(id); el.style.display = el.style.display === 'block' ? 'none' : 'block'; }</script>
678
+ </body>
679
+ </html>
680
  '''
681
 
 
 
 
 
682
  @app.route('/')
683
  def landing():
684
  data = load_data()
 
 
 
 
 
 
685
  return render_template_string(
686
  LANDING_TEMPLATE,
 
687
  services=data.get('services', []),
688
  equipment=data.get('equipment', []),
689
+ categories=sorted(list(set(item.get('category', 'Без категории') for item in data.get('equipment', [])) - {'Без категории'})),
690
  projects=data.get('projects', []),
691
  repo_id=REPO_ID,
692
  contact_phone=CONTACT_PHONE,
693
  whatsapp_phone=WHATSAPP_PHONE,
694
+ now=datetime.utcnow()
 
695
  )
696
 
697
  @app.route('/admin', methods=['GET', 'POST'])
698
  def admin():
699
  data = load_data()
700
+
 
 
 
 
 
701
  if request.method == 'POST':
702
  action = request.form.get('action')
703
+ logging.info(f"Admin action: {action}")
704
  try:
705
+ if action == 'add_category':
706
+ name = request.form.get('category_name', '').strip()
707
+ if name and name not in data.get('categories', []):
708
+ if 'categories' not in data: data['categories'] = []
709
+ data['categories'].append(name)
710
+ data['categories'] = sorted(list(set(data['categories'])))
711
+ flash(f"Категория '{name}' добавлена.", 'success')
712
+ elif not name:
713
+ flash("Название категории не может быть пустым.", 'error')
714
+ else: flash(f"Категория '{name}' уже существует.", 'warning')
715
+
716
+ elif action == 'delete_category':
717
+ name = request.form.get('category_name')
718
+ if name in data.get('categories', []):
719
+ data['categories'].remove(name)
720
+ for item in data.get('equipment', []):
721
+ if item.get('category') == name:
722
+ item['category'] = 'Без категории'
723
+ flash(f"Категория '{name}' удалена. Оборудование этой категории перемещено в 'Без категории'.", 'success')
724
+ else:
725
+ flash(f"Категория '{name}' не найдена.", 'error')
726
+
727
+
728
+ elif action in ['add_equipment', 'edit_equipment']:
729
+ name = request.form.get('name', '').strip()
730
+ price_str = request.form.get('price')
731
+ category = request.form.get('category')
732
+ description = request.form.get('description', '').strip()
733
+
734
+ if not name:
735
+ flash("Название оборудования обязательно.", 'error')
736
+ return redirect(url_for('admin'))
737
+ try:
738
+ price = round(float(price_str), 2)
739
+ if price < 0:
740
+ flash("Цена не может быть отрицательной.", 'error')
741
+ return redirect(url_for('admin'))
742
+ except (ValueError, TypeError):
743
+ flash("Некорректный формат цены.", 'error')
744
+ return redirect(url_for('admin'))
745
+
746
+ item_data = {'name': name, 'price': price, 'category': category, 'description': description}
747
+ photo_file = request.files.get('photo')
748
 
749
+ if action == 'add_equipment':
750
+ if photo_file and photo_file.filename:
751
+ uploaded_filename = upload_photo_to_hf(photo_file, name, 'equipment')
752
+ if uploaded_filename:
753
+ item_data['photo'] = uploaded_filename
754
+ else:
755
+ flash(f"Фото для '{name}' не было загружено из-за ошибки или отсутствия токена.", 'warning')
756
+ data['equipment'].append(item_data)
757
+ flash(f"Оборудование '{name}' добавлено.", 'success')
758
+
759
+ else:
760
  index = int(request.form.get('index'))
761
+ original_item = data['equipment'][index]
762
+ item_data['photo'] = original_item.get('photo')
763
+
764
+ if photo_file and photo_file.filename:
765
+ delete_photo_from_hf(original_item.get('photo'), 'equipment')
766
+ uploaded_filename = upload_photo_to_hf(photo_file, name, 'equipment')
767
+ if uploaded_filename:
768
+ item_data['photo'] = uploaded_filename
769
  else:
770
+ flash(f"Новое фото для '{name}' не было загружено, старое фото (если было) удалено.", 'warning')
771
+ item_data['photo'] = None # Ensure old photo is cleared if new one failed
772
+
773
+ data['equipment'][index] = item_data
774
+ flash(f"Оборудование '{name}' обновлено.", 'success')
775
+
776
+ elif action == 'delete_equipment':
 
 
777
  index = int(request.form.get('index'))
778
+ item = data['equipment'].pop(index)
779
+ delete_photo_from_hf(item.get('photo'), 'equipment')
780
+ flash(f"Оборудование '{item.get('name')}' удалено.", 'success')
 
 
781
 
782
+ elif action in ['add_service', 'edit_service']:
783
+ title = request.form.get('title', '').strip()
784
+ icon = request.form.get('icon', 'fas fa-tools').strip()
785
+ description = request.form.get('description', '').strip()
786
+
787
+ if not title or not description or not icon:
788
+ flash("Заголовок, иконка и описание услуги обязательны.", 'error')
789
+ return redirect(url_for('admin'))
790
+
791
+ item_data = {'title': title, 'icon': icon, 'description': description}
792
+ photo_file = request.files.get('photo')
793
+
794
+ if action == 'add_service':
795
+ if photo_file and photo_file.filename:
796
+ uploaded_filename = upload_photo_to_hf(photo_file, title, 'services')
797
+ if uploaded_filename:
798
+ item_data['photo'] = uploaded_filename
799
+ else:
800
+ flash(f"Фото для услуги '{title}' не было загружено.", 'warning')
801
+ data['services'].append(item_data)
802
+ flash(f"Услуга '{title}' добавлена.", 'success')
803
+ else:
804
+ index = int(request.form.get('index'))
805
+ original_item = data['services'][index]
806
+ item_data['photo'] = original_item.get('photo')
807
+
808
+ if photo_file and photo_file.filename:
809
+ delete_photo_from_hf(original_item.get('photo'), 'services')
810
+ uploaded_filename = upload_photo_to_hf(photo_file, title, 'services')
811
+ if uploaded_filename:
812
+ item_data['photo'] = uploaded_filename
813
+ else:
814
+ flash(f"Новое фото для услуги '{title}' не было загружено.", 'warning')
815
+ item_data['photo'] = None
816
+ data['services'][index] = item_data
817
+ flash(f"Услуга '{title}' обновлена.", 'success')
818
 
819
+ elif action == 'delete_service':
820
+ index = int(request.form.get('index'))
821
+ item = data['services'].pop(index)
822
+ delete_photo_from_hf(item.get('photo'), 'services')
823
+ flash(f"Услуга '{item.get('title')}' удалена.", 'success')
824
+
825
+ elif action in ['add_project', 'edit_project']:
826
+ title = request.form.get('title', '').strip()
827
+ description = request.form.get('description', '').strip()
828
+ photo_file = request.files.get('photo')
829
+
830
+ if not title or not description:
831
+ flash("Название и описание проекта обязательны.", 'error')
832
+ return redirect(url_for('admin'))
833
+
834
+ item_data = {'title': title, 'description': description}
835
+
836
+ if action == 'add_project':
837
+ if photo_file and photo_file.filename:
838
+ uploaded_filename = upload_photo_to_hf(photo_file, title, 'projects')
839
+ if uploaded_filename:
840
+ item_data['photo'] = uploaded_filename
841
+ data['projects'].append(item_data)
842
+ flash(f"Проект '{title}' добавлен.", 'success')
843
+ else:
844
+ flash(f"Фото для проекта '{title}' не было загружено. Проект не добавлен.", 'error')
845
+ else:
846
+ flash("Фото обязательно для нового проекта.", 'error')
847
+ else:
848
+ index = int(request.form.get('index'))
849
+ original_item = data['projects'][index]
850
+ item_data['photo'] = original_item.get('photo')
851
+
852
+ if photo_file and photo_file.filename:
853
+ delete_photo_from_hf(original_item.get('photo'), 'projects')
854
+ uploaded_filename = upload_photo_to_hf(photo_file, title, 'projects')
855
+ if uploaded_filename:
856
+ item_data['photo'] = uploaded_filename
857
+ else:
858
+ flash(f"Новое фото для проекта '{title}' не было загружено. Проект может остаться без фото или со старым, если оно не было удалено.", 'warning')
859
+ item_data['photo'] = None # Or decide to keep original_item.get('photo') if upload fails
860
+
861
+ if not item_data.get('photo') and not original_item.get('photo'): # If no photo existed and no new one provided/uploaded
862
+ flash(f"Проект '{title}' не имеет фото. Пожалуйста, загрузите фото.", 'warning')
863
+ # Do not save if photo is mandatory for projects
864
+ # return redirect(url_for('admin'))
865
+
866
+ data['projects'][index] = item_data
867
+ flash(f"Проект '{title}' обновлен.", 'success')
868
+
869
+
870
+ elif action == 'delete_project':
871
+ index = int(request.form.get('index'))
872
+ item = data['projects'].pop(index)
873
+ delete_photo_from_hf(item.get('photo'), 'projects')
874
+ flash(f"Проект '{item.get('title')}' удален.", 'success')
875
 
876
  save_data(data)
877
  except Exception as e:
878
+ logging.error(f"Admin action '{action}' failed: {e}", exc_info=True)
879
+ flash(f"Произошла ошибка при выполнении '{action}': {str(e)}", 'error')
880
  return redirect(url_for('admin'))
881
 
882
  return render_template_string(
883
+ ADMIN_TEMPLATE,
884
+ equipment=data.get('equipment', []),
885
+ categories=sorted(data.get('categories', [])),
886
+ services=data.get('services', []),
887
+ projects=data.get('projects', []),
888
+ repo_id=REPO_ID
889
  )
890
 
891
+ def upload_photo_to_hf(photo_storage, item_name, folder):
892
+ if not photo_storage or not photo_storage.filename:
893
+ logging.warning("upload_photo_to_hf: No photo or filename provided.")
894
+ return None
895
+ if not HF_TOKEN_WRITE:
896
+ logging.warning("upload_photo_to_hf: HF_TOKEN_WRITE is not set. Cannot upload photo.")
897
+ flash("Токен для записи на Hugging Face (HF_TOKEN) не установлен. Загрузка фото невозможна.", "error")
898
+ return None
899
+
900
  try:
901
  api = HfApi()
902
+ safe_item_name = secure_filename(item_name.replace(' ', '_'))[:50]
903
+ if not safe_item_name:
904
+ safe_item_name = "untitled"
905
+
906
+ original_ext = os.path.splitext(photo_storage.filename)[1].lower()
907
+ if not original_ext: original_ext = ".jpg" # Default extension
908
+
909
+ photo_filename_in_repo = f"{safe_item_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{original_ext}"
910
 
911
+ path_in_repo = f"{folder}/{photo_filename_in_repo}"
912
+
913
+ photo_storage.stream.seek(0) # Ensure stream is at the beginning
 
914
 
 
 
915
  api.upload_file(
916
+ path_or_fileobj=photo_storage.stream,
917
+ path_in_repo=path_in_repo,
918
+ repo_id=REPO_ID,
919
+ repo_type="dataset",
920
+ token=HF_TOKEN_WRITE,
921
+ commit_message=f"Upload photo {photo_filename_in_repo} to {folder}"
922
  )
923
+ logging.info(f"Successfully uploaded photo {photo_filename_in_repo} to Hugging Face Hub: {folder}/{photo_filename_in_repo}")
924
+ return photo_filename_in_repo
925
  except Exception as e:
926
+ logging.error(f"Error uploading photo {photo_storage.filename} for item '{item_name}' to folder '{folder}': {e}", exc_info=True)
927
+ flash(f"Ошибка загрузки фото '{photo_storage.filename}': {str(e)}", 'error')
 
928
  return None
929
 
930
  def delete_photo_from_hf(photo_filename, folder):
931
+ if not photo_filename or not folder:
932
+ logging.info(f"Skipping deletion of photo: No photo_filename or folder provided. Filename: '{photo_filename}', Folder: '{folder}'")
933
+ return
934
+ if not HF_TOKEN_WRITE:
935
+ logging.warning("HF_TOKEN_WRITE not set. Skipping photo deletion from Hugging Face Hub.")
936
+ return
937
+
938
  try:
939
  api = HfApi()
940
+ path_in_repo_to_delete = f"{folder}/{photo_filename}"
941
+ logging.info(f"Attempting to delete photo {path_in_repo_to_delete} from Hugging Face Hub repo {REPO_ID}")
942
+ api.delete_files(
943
+ repo_id=REPO_ID,
944
+ paths_in_repo=[path_in_repo_to_delete],
945
+ repo_type="dataset",
946
+ token=HF_TOKEN_WRITE,
947
+ commit_message=f"Delete photo {photo_filename} from {folder}"
948
+ )
949
+ logging.info(f"Successfully deleted photo {path_in_repo_to_delete} from Hugging Face Hub.")
950
  except HfHubHTTPError as e:
951
+ if e.response.status_code == 404:
952
+ logging.warning(f"Photo {path_in_repo_to_delete} not found on Hugging Face Hub for deletion (HTTP 404). It might have been already deleted or never uploaded.")
953
+ else:
954
+ logging.error(f"HTTP error deleting photo {path_in_repo_to_delete} from Hugging Face Hub: {e}", exc_info=True)
955
+ # flash(f"Ошибка удаления фото '{photo_filename}' с сервера: HTTP {e.response.status_code}", 'warning') # Flashing here can be noisy
956
+ except Exception as e:
957
+ logging.error(f"Unexpected error deleting photo {path_in_repo_to_delete} from Hugging Face Hub: {e}", exc_info=True)
958
+ # flash(f"Неожиданная ошибка при удалении фото '{photo_filename}': {str(e)}", 'warning')
959
+
960
 
961
  @app.route('/force_upload', methods=['POST'])
962
+ def force_upload():
963
+ upload_db_to_hf()
964
+ flash("Данные синхронизированы (выгружены на сервер).", 'success')
965
+ return redirect(url_for('admin'))
966
+
967
  @app.route('/force_download', methods=['POST'])
968
+ def force_download():
969
+ if download_db_from_hf():
970
+ flash("Данные синхронизированы (загружены с сервера).", 'success')
971
+ else:
972
+ flash("Ошибка при загрузке данных с сервера.", 'error')
973
+ return redirect(url_for('admin'))
974
 
975
  if __name__ == '__main__':
976
+ logging.info("Application starting up...")
977
+ if not os.path.exists(DATA_FILE):
978
+ logging.info(f"{DATA_FILE} not found locally. Attempting initial download.")
979
+ download_db_from_hf()
980
+ else:
981
+ logging.info(f"{DATA_FILE} found locally. Skipping initial download unless forced or file is corrupt.")
982
+
983
+ if HF_TOKEN_WRITE:
984
+ backup_thread = threading.Thread(target=periodic_backup, daemon=True)
985
+ backup_thread.start()
986
+ logging.info("Periodic backup thread started.")
987
+ else:
988
+ logging.warning("HF_TOKEN_WRITE not set, periodic backups to Hugging Face Hub will be disabled.")
989
+
990
  port = int(os.environ.get('PORT', 7860))
991
  app.run(debug=False, host='0.0.0.0', port=port)