Kgshop commited on
Commit
6154c87
·
verified ·
1 Parent(s): 27da7f3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +310 -847
app.py CHANGED
@@ -1,4 +1,4 @@
1
- from flask import Flask, render_template_string, request, redirect, url_for, flash
2
  import json
3
  import os
4
  import logging
@@ -9,13 +9,16 @@ 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
13
 
14
  load_dotenv()
15
 
16
  app = Flask(__name__)
17
- app.secret_key = 'raina_hvac_secret_key_final_v3'
18
  DATA_FILE = 'data.json'
 
 
 
19
 
20
  SYNC_FILES = [DATA_FILE]
21
 
@@ -36,18 +39,16 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
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,36 +56,27 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
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 requests.exceptions.RequestException as e:
70
- logging.error(f"Network error downloading {file_name}: {e}. Retrying...")
71
  except Exception as e:
72
- logging.error(f"Unexpected error downloading {file_name}: {e}. Retrying...", exc_info=True)
73
- if attempt < retries:
74
- time.sleep(delay)
75
- if not success:
76
- logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
77
- all_successful = False
78
  return all_successful
79
 
80
  def upload_db_to_hf(specific_file=None):
81
- if not HF_TOKEN_WRITE:
82
- logging.warning("HF_TOKEN (for writing) not set. Skipping upload.")
83
- return
84
  try:
85
  api = HfApi()
86
  files_to_upload = [specific_file] if specific_file else SYNC_FILES
87
- logging.info(f"Starting upload of {files_to_upload} to {REPO_ID}...")
88
  for file_name in files_to_upload:
89
  if os.path.exists(file_name):
90
  api.upload_file(
@@ -92,130 +84,32 @@ def upload_db_to_hf(specific_file=None):
92
  repo_type="dataset", token=HF_TOKEN_WRITE,
93
  commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
94
  )
95
- logging.info(f"File {file_name} successfully uploaded.")
96
- else:
97
- logging.warning(f"File {file_name} not found locally, skipping upload.")
98
  except Exception as e:
99
- logging.error(f"Error during Hugging Face upload: {e}", exc_info=True)
100
 
101
  def periodic_backup():
102
- backup_interval = 1800
103
  while True:
104
- time.sleep(backup_interval)
105
- logging.info("Starting periodic backup...")
106
  upload_db_to_hf()
107
- logging.info("Periodic backup finished.")
108
 
109
  def load_data():
110
  default_data = {'equipment': [], 'categories': [], 'services': [], 'projects': []}
111
  try:
112
- with open(DATA_FILE, 'r', encoding='utf-8') as file:
113
- data = json.load(file)
114
- if not isinstance(data, dict):
115
- raise ValueError("Data is not a dictionary")
116
- if 'equipment' not in data: data['equipment'] = []
117
- if 'categories' not in data: data['categories'] = []
118
- if 'services' not in data: data['services'] = []
119
- if 'projects' not in data: data['projects'] = []
120
  return data
121
- except (FileNotFoundError, json.JSONDecodeError, ValueError):
122
- logging.warning(f"Local file {DATA_FILE} not found or corrupt. Attempting download.")
123
- if download_db_from_hf(specific_file=DATA_FILE):
124
- try:
125
- with open(DATA_FILE, 'r', encoding='utf-8') as file:
126
- data = json.load(file)
127
- if not isinstance(data, dict): raise ValueError("Downloaded data is not a dictionary")
128
- if 'equipment' not in data: data['equipment'] = []
129
- if 'categories' not in data: data['categories'] = []
130
- if 'services' not in data: data['services'] = []
131
- if 'projects' not in data: data['projects'] = []
132
- return data
133
- except Exception as e:
134
- logging.error(f"Error loading downloaded data: {e}", exc_info=True)
135
- return default_data
136
  return default_data
137
 
138
  def save_data(data):
139
  try:
140
- if not isinstance(data, dict):
141
- logging.error("Attempted to save invalid data structure. Aborting.")
142
- return
143
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
144
  json.dump(data, file, ensure_ascii=False, indent=4)
145
- logging.info(f"Data saved to {DATA_FILE}")
146
  upload_db_to_hf(specific_file=DATA_FILE)
147
  except Exception as e:
148
  logging.error(f"Error saving data: {e}", exc_info=True)
149
 
150
- def upload_item_photos_to_hf(photo_files, item_name, folder):
151
- if not photo_files or not HF_TOKEN_WRITE:
152
- if photo_files and any(f and f.filename for f in photo_files):
153
- flash("HF_TOKEN (write) не настроен. Фото не загружены.", "warning")
154
- return []
155
-
156
- api = HfApi()
157
- uploaded_photos = []
158
- uploads_dir = 'uploads_temp'
159
- os.makedirs(uploads_dir, exist_ok=True)
160
-
161
- for photo in photo_files:
162
- if photo and photo.filename:
163
- try:
164
- ext = os.path.splitext(photo.filename)[1].lower()
165
- if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
166
- flash(f"Пропущен файл '{photo.filename}': не поддерживаемый формат.", "warning")
167
- continue
168
- safe_name = secure_filename(item_name.replace(' ', '_'))[:50]
169
- photo_filename = f"{folder}_{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
170
- temp_path = os.path.join(uploads_dir, photo_filename)
171
- photo.save(temp_path)
172
-
173
- api.upload_file(
174
- path_or_fileobj=temp_path, path_in_repo=f"{folder}/{photo_filename}",
175
- repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE
176
- )
177
- uploaded_photos.append(photo_filename)
178
- logging.info(f"Uploaded photo {photo_filename} to {folder}")
179
- os.remove(temp_path)
180
- except Exception as e:
181
- logging.error(f"Error uploading photo {photo.filename}: {e}")
182
- flash(f"Ошибка загрузки фото {photo.filename}.", 'error')
183
-
184
- try:
185
- if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
186
- os.rmdir(uploads_dir)
187
- except OSError as e:
188
- logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
189
-
190
- return uploaded_photos
191
-
192
- def delete_item_photos_from_hf(photo_filenames_list, folder):
193
- if not photo_filenames_list or not HF_TOKEN_WRITE:
194
- return
195
-
196
- photos_to_delete = [p for p in photo_filenames_list if p]
197
- if not photos_to_delete:
198
- return
199
-
200
- try:
201
- api = HfApi()
202
- paths_in_repo = [f"{folder}/{p}" for p in photos_to_delete]
203
- logging.info(f"Attempting to delete photos from HF: {paths_in_repo}")
204
- api.delete_files(
205
- repo_id=REPO_ID, paths_in_repo=paths_in_repo,
206
- repo_type="dataset", token=HF_TOKEN_WRITE
207
- )
208
- logging.info(f"Deleted photos from HF: {photos_to_delete}")
209
- except HfHubHTTPError as e:
210
- if e.response.status_code != 404:
211
- logging.error(f"Error deleting photos from HF: {e}")
212
- flash("Не удалось удалить старые фото с сервера.", "warning")
213
- else:
214
- logging.warning(f"Attempted to delete non-existent photos from HF: {photos_to_delete} (404)")
215
- except Exception as e:
216
- logging.error(f"Error deleting photos from HF: {e}")
217
- flash("Не удалось удалить старые фото с сервера.", "warning")
218
-
219
  LANDING_TEMPLATE = '''
220
  <!DOCTYPE html>
221
  <html lang="ru">
@@ -223,10 +117,9 @@ LANDING_TEMPLATE = '''
223
  <meta charset="UTF-8">
224
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
225
  <title>ОсОО "Раина" - Вентиляция и Кондиционирование</title>
226
- <meta name="description" content="Профессиональные услуги по проектированию, монтажу и обслуживанию систем вентиляции и кондиционирования в Кыргызстане. 15 лет опыта, более 1000 проектов.">
227
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
228
  <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" rel="stylesheet">
229
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
230
  <style>
231
  :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); }
232
  * { margin: 0; padding: 0; box-sizing: border-box; scroll-behavior: smooth; }
@@ -239,243 +132,143 @@ LANDING_TEMPLATE = '''
239
  h2::after { content: ''; display: block; width: 80px; height: 4px; background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); margin: 15px auto 0; border-radius: 2px; }
240
  h3 { font-size: clamp(1.2rem, 3vw, 1.5rem); color: var(--primary-color); margin-bottom: 15px; }
241
  p { margin-bottom: 1rem; color: var(--text-muted); }
242
- .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); }
243
  .btn:hover { transform: translateY(-3px) scale(1.05); box-shadow: 0 8px 25px var(--accent-glow); }
244
- .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; }
245
- .header.scrolled { padding: 10px 0; box-shadow: 0 2px 10px rgba(0,0,0,0.3); }
246
  .navbar { display: flex; justify-content: space-between; align-items: center; }
247
  .logo { font-size: clamp(1.5rem, 4vw, 1.8rem); font-weight: 700; color: #fff; text-decoration: none; }
248
  .nav-links { display: flex; gap: 30px; list-style: none; }
249
- .nav-links a { color: var(--text-color); text-decoration: none; font-weight: 600; transition: color 0.3s ease; }
250
- .nav-links a:hover { color: var(--primary-color); }
251
  .menu-toggle { display: none; font-size: 1.5rem; cursor: pointer; border: none; background: none; color: white; }
252
- #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; }
253
  .hero-content { text-align: center; max-width: 800px; margin: 0 auto; }
254
- .hero-content p { font-size: clamp(1rem, 2.5vw, 1.2rem); margin: 30px 0; max-width: 600px; margin-left: auto; margin-right: auto;}
255
  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; }
256
  .about-img { width: 100%; border-radius: 15px; box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
257
- .services-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 30px; }
258
- .service-card, .turnkey-card { background-color: var(--card-bg); padding: 30px; border-radius: 15px; border: 1px solid #2a2a4a; transition: all 0.3s ease; cursor: pointer; }
259
- .service-card:hover, .turnkey-card:hover { transform: translateY(-5px); border-color: var(--primary-color); box-shadow: 0 8px 25px var(--accent-glow); }
260
- .service-card i { font-size: 2.5rem; color: var(--primary-color); margin-bottom: 20px; }
261
- .turnkey-card { padding: 0; display: flex; flex-direction: column; }
262
- .turnkey-img { width: 100%; height: 200px; object-fit: cover; border-radius: 15px 15px 0 0; }
263
- .turnkey-content { padding: 30px; flex-grow: 1;}
 
264
  .equipment-filters { display: flex; justify-content: center; flex-wrap: wrap; gap: 15px; margin-bottom: 40px; }
265
- .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; }
266
  .filter-btn.active, .filter-btn:hover { background-color: var(--primary-color); color: #fff; }
267
- .equipment-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 30px; }
268
- .equipment-card, .project-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; cursor: pointer; }
269
- .equipment-card:hover, .project-card:hover { transform: translateY(-5px); border-color: var(--primary-color); box-shadow: 0 8px 25px var(--accent-glow); }
270
  .equipment-card img { width: 100%; height: 180px; object-fit: contain; margin-bottom: 15px; }
271
- .equipment-card h3 { font-size: 1.2rem; }
272
  .equipment-card .price { font-size: 1.3rem; font-weight: 700; color: #fff; margin: 10px 0; }
273
  .projects-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px; }
274
- .project-card { position: relative; min-height: 300px; display: flex; flex-direction: column; justify-content: flex-end; text-align: left; padding: 0;}
275
- .project-card img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.4s ease; position: absolute; top: 0; left: 0; z-index: 1; }
276
- .project-overlay { position: relative; z-index: 2; background: linear-gradient(to top, rgba(18,18,28,1) 0%, rgba(18,18,28,0) 100%); padding: 40px 20px 20px; }
277
- .project-card h3 { margin-bottom: 5px; font-size: clamp(1.2rem, 3vw, 1.3rem); }
278
- .project-card p { margin-bottom: 0; transition: opacity 0.4s ease; opacity: 0; max-height: 0; overflow: hidden; }
279
- .project-card:hover img { transform: scale(1.05); }
280
- .project-card:hover p { opacity: 1; max-height: 200px; }
281
  #contact { background-color: var(--card-bg); }
282
- .contact-content { text-align: center; }
283
- .contact-info { margin-top: 40px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
284
- .contact-info p { font-size: 1.2rem; margin-bottom: 0; }
285
- .contact-info a { color: var(--primary-color); text-decoration: none; font-weight: 600; }
286
  .footer { text-align: center; padding: 30px 0; background-color: #0d0d14; }
287
- .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); backdrop-filter: blur(5px); overflow-y: auto; }
288
- .modal-content { background: var(--card-bg); margin: 5% auto; padding: 25px; border-radius: 15px; width: 90%; max-width: 700px; box-shadow: 0 10px 30px rgba(0,0,0,0.3); animation: slideIn 0.3s ease-out; position: relative; color: var(--text-color); }
289
- @keyframes slideIn { from { transform: translateY(-50px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
290
- .close { position: absolute; top: 15px; right: 15px; font-size: 1.8rem; color: var(--text-muted); cursor: pointer; transition: color 0.3s; line-height: 1; }
291
- .close:hover { color: #fff; }
292
- .modal-body { padding: 20px 0; }
293
- .modal-body h3 { margin-top: 0; color: #fff; }
294
- .modal-body p { color: var(--text-muted); }
295
- .swiper-container { width: 100%; max-width: 450px; margin: 20px auto; border-radius: 10px; overflow: hidden; border: 1px solid #2a2a4a; }
296
- .swiper-slide { display: flex; justify-content: center; align-items: center; background-color: #0d0d14; }
297
- .swiper-slide img { display: block; width: 100%; height: auto; max-height: 400px; object-fit: contain; }
298
- .swiper-button-next, .swiper-button-prev { color: var(--primary-color) !important; }
299
- .swiper-pagination-bullet { background-color: var(--primary-color) !important; }
300
  @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;} }
301
  @media (max-width: 768px) {
302
- .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); padding-top: 80px; }
303
  .nav-links.active { right: 0; }
304
  .menu-toggle { display: block; z-index: 1001; }
305
- h2 { margin-bottom: 40px; }
306
- .projects-grid { grid-template-columns: 1fr; }
307
  }
308
  </style>
309
  </head>
310
  <body>
311
- <header class="header">
312
- <div class="container navbar">
313
- <a href="#" class="logo">Раина</a>
314
- <ul class="nav-links">
315
- <li><a href="#about" компании</a></li>
316
- <li><a href="#services">Услуги</a></li>
317
- <li><a href="#turnkey">Под ключ</a></li>
318
- <li><a href="#equipment">Оборудование</a></li>
319
- <li><a href="#projects">Проекты</a></li>
320
- <li><a href="#contact">Контакты</a></li>
321
- </ul>
322
- <button class="menu-toggle" aria-label="Открыть меню"><i class="fas fa-bars"></i></button>
323
- </div>
324
- </header>
325
-
326
- <section id="hero">
327
- <div class="container hero-content">
328
- <h1>ОсОО "Раина": Ваш Партнер в Вентиляции и Кондиционировании</h1>
329
- <p>15 лет опыта, более 1000 реализованных проектов. Мы создаем комфорт и здоровье в любом помещении с помощью самых современных климатических систем.</p>
330
- <a href="#contact" class="btn">Получить консультацию</a>
331
- </div>
332
- </section>
333
-
334
- <section id="about">
335
- <div class="container">
336
- <h2>О Нашей Компании</h2>
337
- <div class="grid-2">
338
- <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">
339
- <div>
340
- <h3>Основание и История</h3>
341
- <p>Компания "Раина" была основана в 2009 году. За эти годы мы зарекомендовали себя как надежный партнер, стремящийся к инновациям и совершенству в области климатических решений.</p>
342
- <h3>Наша Миссия</h3>
343
- <p>Наша миссия создание оптимального микроклимата для наших клиентов, обеспечивающего комфорт, здоровье и высокую производительность.</p>
344
- <h3>Профессиональная Команда</h3>
345
- <p>Наша команда состоит из высококвалифицированных инженеров и техников, обладающих глубокими знаниями и опытом в области HVAC.</p>
346
- </div>
347
- </div>
348
- </div>
349
- </section>
350
-
351
- <section id="services">
352
- <div class="container">
353
- <h2>Наши Услуги</h2>
354
- <div class="services-grid">
355
- <div class="service-card"><i class="fas fa-drafting-compass"></i><h3>Проектирование</h3><p>Точные расчеты, 3D-модели и вся необходимая проектная документация.</p></div>
356
- <div class="service-card"><i class="fas fa-tools"></i><h3>Монтаж</h3><p>Профессиональная установка всех типов систем HVAC, от бытовых до промышленных.</p></div>
357
- <div class="service-card"><i class="fas fa-headset"></i><h3>Сервис 24/7</h3><p>Плановое обслуживание и оперативный аварийный ремонт в любое время.</p></div>
358
- <div class="service-card"><i class="fas fa-sync-alt"></i><h3>Модернизация</h3><p>Повышение энергоэффективности и снижение расходов на эксплуатацию.</p></div>
359
- </div>
360
- </div>
361
- </section>
362
-
363
- <section id="turnkey" style="background-color: var(--card-bg);">
364
- <div class="container">
365
- <h2>Услуги "под ключ"</h2>
366
- {% if services %}
367
- <div class="services-grid">
368
- {% for service in services %}
369
- <div class="turnkey-card" onclick="openModal('service', {{ loop.index0 }})">
370
- {% if service.photo %}
371
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/services/{{ service.photo }}" alt="{{ service.title }}" class="turnkey-img">
372
- {% endif %}
373
- <div class="turnkey-content">
374
- <h3><i class="{{ service.icon }} fa-fw" style="margin-right: 8px; color: var(--primary-color);"></i>{{ service.title }}</h3>
375
- <p>{{ service.description[:100] }}{% if service.description|length > 100 %}...{% endif %}</p>
376
- </div>
377
- </div>
378
- {% endfor %}
379
- </div>
380
- {% else %}
381
- <p style="text-align: center;">Информация об услугах "под ключ" скоро появится на сайте.</p>
382
- {% endif %}
383
- </div>
384
- </section>
385
-
386
- <section id="equipment">
387
- <div class="container">
388
- <h2>Наше Оборудование</h2>
389
- {% if equipment %}
390
- <div class="equipment-filters">
391
- <button class="filter-btn active" data-filter="all">Все</button>
392
- {% for category in categories %}
393
- <button class="filter-btn" data-filter="{{ category }}">{{ category }}</button>
394
- {% endfor %}
395
- </div>
396
- <div class="equipment-grid">
397
- {% for item in equipment %}
398
- <div class="equipment-card" data-category="{{ item.get('category', 'all') }}" onclick="openModal('equipment', {{ loop.index0 }})">
399
- {% if item.photos %}
400
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/equipment/{{ item.photos[0] }}" alt="{{ item.name }}">
401
- {% else %}
402
- <img src="https://via.placeholder.com/250x180.png?text=No+Image" alt="No Image">
403
- {% endif %}
404
- <h3>{{ item.name }}</h3>
405
- <p class="price">{{ "%.2f"|format(item.price) }} KGS</p>
406
- <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте, интересует оборудование: {{ item.name }}" target="_blank" class="btn" style="padding: 8px 20px; font-size: 0.9rem; margin-top: 15px;" onclick="event.stopPropagation();">Запросить</a>
407
- </div>
408
- {% endfor %}
409
- </div>
410
- {% else %}
411
- <p style="text-align: center;">Каталог оборудования скоро будет доступен.</p>
412
- {% endif %}
413
- </div>
414
- </section>
415
-
416
- <section id="projects">
417
- <div class="container">
418
- <h2>Реализованные Проекты</h2>
419
- {% if projects %}
420
- <div class="projects-grid">
421
- {% for project in projects %}
422
- <div class="project-card" onclick="openModal('project', {{ loop.index0 }})">
423
- {% if project.photo %}
424
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/projects/{{ project.photo }}" alt="{{ project.title }}">
425
- {% endif %}
426
- <div class="project-overlay">
427
- <h3>{{ project.title }}</h3>
428
- <p>{{ project.description[:150] }}{% if project.description|length > 150 %}...{% endif %}</p>
429
- </div>
430
- </div>
431
- {% endfor %}
432
- </div>
433
- {% else %}
434
- <p style="text-align: center;">Информация о реализованных проектах скоро появится на сайте.</p>
435
- {% endif %}
436
- </div>
437
- </section>
438
-
439
- <section id="contact">
440
- <div class="container contact-content">
441
- <h2>Контакты</h2>
442
- <p>Готовы стать вашим надежным партнером в создании идеального климата.</p>
443
- <div class="contact-info">
444
- <p><strong>Свяжитесь с нами:</strong> <a href="tel:{{ contact_phone }}">{{ contact_phone }}</a></p>
445
- <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте, я хотел(а) бы получить консультацию по вашим услугам." target="_blank" class="btn"><i class="fab fa-whatsapp"></i> Написать в WhatsApp</a>
446
- </div>
447
- <div style="margin-top: 40px; font-size: 0.9rem; color: var(--text-muted);">
448
- <p><strong>Реквизиты:</strong> ОсОО «Раина», ИНН: 00812202110194, ОКПО: 31290279</p>
449
- </div>
450
- </div>
451
- </section>
452
 
453
- <footer class="footer">
454
- <p>© {{ now.year }} ОсОО "Раина". Все права защищены.</p>
455
- </footer>
 
 
 
456
 
457
- <div id="detailModal" class="modal">
458
- <div class="modal-content">
459
- <span class="close" onclick="closeModal()" aria-label="Закрыть">×</span>
460
- <div id="modalBody" class="modal-body"><p style="text-align:center; padding: 40px;">Загрузка...</p></div>
461
- </div>
462
- </div>
 
 
 
463
 
464
- <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
465
- <script>
 
 
 
 
 
 
 
 
 
 
466
  document.addEventListener('DOMContentLoaded', function() {
467
- const header = document.querySelector('.header');
468
  const menuToggle = document.querySelector('.menu-toggle');
469
  const navLinks = document.querySelector('.nav-links');
470
- const detailModal = document.getElementById('detailModal');
471
- const modalBody = document.getElementById('modalBody');
472
-
473
- window.addEventListener('scroll', () => { header.classList.toggle('scrolled', window.scrollY > 50); });
474
- menuToggle.addEventListener('click', () => { navLinks.classList.toggle('active'); });
475
- document.querySelectorAll('.nav-links a').forEach(link => {
476
- link.addEventListener('click', () => { navLinks.classList.remove('active'); });
477
- });
478
-
479
  const filterContainer = document.querySelector('.equipment-filters');
480
  if (filterContainer) {
481
  filterContainer.addEventListener('click', (e) => {
@@ -484,335 +277,120 @@ LANDING_TEMPLATE = '''
484
  e.target.classList.add('active');
485
  const filter = e.target.dataset.filter;
486
  document.querySelectorAll('.equipment-card').forEach(card => {
487
- card.style.display = (filter === 'all' || card.dataset.category === filter) ? 'flex' : 'none'; // Use flex for equipment card display
488
  });
489
  });
 
 
490
  }
491
-
492
- window.openModal = function(type, index) {
493
- modalBody.innerHTML = '<p style="text-align:center; padding: 40px;">Загрузка...</p>';
494
- detailModal.style.display = 'block';
495
- document.body.style.overflow = 'hidden';
496
-
497
- fetch(`/details/${type}/${index}`)
498
- .then(response => {
499
- if (!response.ok) throw new Error(`Ошибка ${response.status}`);
500
- return response.text();
501
- })
502
- .then(html => {
503
- modalBody.innerHTML = html;
504
- if (type === 'equipment' || type === 'project') {
505
- initializeSwiper();
506
- }
507
- })
508
- .catch(error => {
509
- console.error('Ошибка загрузки деталей:', error);
510
- modalBody.innerHTML = `<p style="color: #e74c3c; text-align:center; padding: 40px;">Не удалось загрузить информацию. ${error.message}</p>`;
511
- });
512
- }
513
-
514
- window.closeModal = function() {
515
- detailModal.style.display = 'none';
516
- document.body.style.overflow = 'auto';
517
- modalBody.innerHTML = '';
518
- }
519
-
520
- window.addEventListener('click', function(event) {
521
- if (event.target === detailModal) {
522
- closeModal();
523
- }
524
- });
525
-
526
- window.addEventListener('keydown', function(event) {
527
- if (event.key === 'Escape' && detailModal.style.display === 'block') {
528
- closeModal();
529
- }
530
- });
531
-
532
- function initializeSwiper() {
533
- const swiperContainer = document.querySelector('#modalBody .swiper-container');
534
- if (swiperContainer) {
535
- const swiper = new Swiper(swiperContainer, {
536
- slidesPerView: 1,
537
- spaceBetween: 20,
538
- loop: document.querySelectorAll('#modalBody .swiper-slide').length > 1,
539
- grabCursor: true,
540
- pagination: { el: '.swiper-pagination', clickable: true },
541
- navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
542
- autoplay: { delay: 5000, disableOnInteraction: true, },
543
- });
544
- }
545
- }
546
-
547
  });
548
  </script>
549
  </body>
550
  </html>
551
  '''
552
 
553
- EQUIPMENT_DETAIL_TEMPLATE = '''
554
- <div style="padding: 10px;">
555
- <h3 style="text-align: center; margin-bottom: 15px;">{{ item.name }}</h3>
556
- {% if item.photos and item.photos|length > 0 %}
557
- <div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden;">
558
- <div class="swiper-wrapper">
559
- {% for photo in item.photos %}
560
- <div class="swiper-slide">
561
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/equipment/{{ photo }}" alt="{{ item.name }} - фото {{ loop.index }}">
562
- </div>
563
- {% endfor %}
564
- </div>
565
- {% if item.photos|length > 1 %}
566
- <div class="swiper-pagination"></div>
567
- <div class="swiper-button-next"></div>
568
- <div class="swiper-button-prev"></div>
569
- {% endif %}
570
- </div>
571
- {% endif %}
572
- <p><strong>Категория:</strong> {{ item.get('category', 'Без категории') }}</p>
573
- <p><strong>Цена:</strong> {{ "%.2f"|format(item.price) }} KGS</p>
574
- {% if item.description %}
575
- <p><strong>Описание:</strong><br>{{ item.description|replace('\\n', '<br>')|safe }}</p>
576
- {% endif %}
577
- <div style="text-align: center; margin-top: 20px;">
578
- <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте, интересует оборудование: {{ item.name }}" target="_blank" class="btn"><i class="fab fa-whatsapp"></i> Запросить в WhatsApp</a>
579
- </div>
580
- </div>
581
- '''
582
-
583
- SERVICE_DETAIL_TEMPLATE = '''
584
- <div style="padding: 10px; text-align: center;">
585
- <h3 style="margin-bottom: 15px;"><i class="{{ item.icon }} fa-fw" style="margin-right: 8px; color: var(--primary-color);"></i>{{ item.title }}</h3>
586
- {% if item.photo %}
587
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/services/{{ item.photo }}" alt="{{ item.title }}" style="max-width: 100%; max-height: 300px; object-fit: contain; margin-bottom: 20px; border-radius: 8px; border: 1px solid #2a2a4a;">
588
- {% endif %}
589
- <p style="text-align: left;">{{ item.description|replace('\\n', '<br>')|safe }}</p>
590
- <div style="text-align: center; margin-top: 20px;">
591
- <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте, интересует услуга '%s'." target="_blank" class="btn">{{ item.title }}</a>
592
- </div>
593
- </div>
594
- ''' % "{{ item.title }}" # escape jinja for whatsapp link
595
-
596
- PROJECT_DETAIL_TEMPLATE = '''
597
- <div style="padding: 10px; text-align: center;">
598
- <h3 style="margin-bottom: 15px;">{{ item.title }}</h3>
599
- {% if item.photo %}
600
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/projects/{{ item.photo }}" alt="{{ item.title }}" style="max-width: 100%; max-height: 400px; object-fit: contain; margin-bottom: 20px; border-radius: 8px; border: 1px solid #2a2a4a;">
601
- {% endif %}
602
- <p style="text-align: left;">{{ item.description|replace('\\n', '<br>')|safe }}</p>
603
- </div>
604
- '''
605
-
606
-
607
  ADMIN_TEMPLATE = '''
608
  <!DOCTYPE html>
609
  <html lang="ru">
610
  <head>
611
- <meta charset="UTF-8">
612
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
613
  <title>Админ-панель - Раина</title>
614
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
615
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
616
  <style>
617
- body { font-family: 'Poppins', sans-serif; background-color: #f4f7f9; color: #333; padding: 20px; line-height: 1.6; }
618
- .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); }
619
- .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;}
620
- h1, h2, h3 { font-weight: 600; color: #6a0dad; margin-bottom: 15px; }
621
- h1 { font-size: 1.8rem; }
622
- h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
623
  .section { margin-bottom: 30px; padding: 20px; background-color: #fafafa; border: 1px solid #e9e9e9; border-radius: 8px; }
624
- form { margin-bottom: 20px; }
625
- label { font-weight: 500; margin-top: 10px; display: block; color: #555; font-size: 0.9rem;}
626
- 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; }
627
- input[type="file"] { padding: 8px; cursor: pointer; border: 1px solid #ddd; margin-top: 5px;}
628
- 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; display: inline-flex; align-items: center; gap: 5px;}
629
- button:hover, .button:hover { background-color: #8e44ad; }
630
- .delete-button { background-color: #e74c3c; }
631
- .delete-button:hover { background-color: #c0392b; }
632
- .item-list { display: grid; gap: 20px; }
633
- .item { background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #eee; }
634
- .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
635
- .edit-form-container { margin-top: 15px; padding: 20px; background: #fdf9ff; border: 1px dashed #ddd; border-radius: 6px; display: none; }
636
- details { background-color: #fafafa; border: 1px solid #e9e9e9; border-radius: 8px; margin-bottom: 20px; }
637
- details > summary { cursor: pointer; font-weight: 600; color: #8e44ad; display: block; padding: 15px; position: relative; list-style: none; }
638
- details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; }
639
- details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
640
- .photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; object-fit: cover;}
641
- .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; }
642
  .message.success { background-color: #d4edda; color: #155724; }
643
  .message.error { background-color: #f8d7da; color: #721c24; }
644
- .message.warning { background-color: #fff3cd; color: #856404; }
645
  </style>
646
  </head>
647
  <body>
648
  <div class="container">
649
- <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>
650
  {% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}{% for category, message in messages %}<div class="message {{ category }}">{{ message }}</div>{% endfor %}{% endif %}{% endwith %}
651
-
652
- <div class="section">
653
- <h2><i class="fas fa-sync-alt"></i> Синхронизация</h2>
654
- <form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно загрузить локальные данные на сервер?');"><button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить БД</button></form>
655
- <form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера?');"><button type="submit" class="button" title="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button></form>
656
  </div>
657
 
658
- <div class="section">
659
- <h2><i class="fas fa-star"></i> Реализованные проекты</h2>
660
- <details><summary>Добавить проект</summary>
661
- <div class="form-content">
662
- <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="add_project">
663
- <label>Название*:</label><input type="text" name="title" required>
664
- <label>Описание*:</label><textarea name="description" rows="3" required></textarea>
665
- <label>Фото*:</label><input type="file" name="photo" accept="image/*" required>
666
- <button type="submit"><i class="fas fa-save"></i> Добавить проект</button>
667
- </form>
668
- </div>
669
- </details>
670
- <div class="item-list">
671
- {% for project in projects %}
672
- <div class="item">
673
- <div style="display: flex; gap: 15px; align-items: flex-start;">
674
- <div class="photo-preview" style="flex-shrink: 0;">
675
- {% if project.photo %}
676
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/projects/{{ project.photo }}" alt="Фото">
677
- {% endif %}
678
- </div>
679
- <div style="flex-grow: 1;">
680
- <p><strong>{{ project.title }}</strong></p>
681
- <p>{{ project.description[:100] }}{% if project.description|length > 100 %}...{% endif %}</p>
682
- </div>
683
- </div>
684
- <div class="item-actions">
685
- <button onclick="toggleEditForm('edit-project-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
686
- <form method="POST" style="margin:0;" onsubmit="return confirm('Удалить проект \'{{ project.title }}\'?');"><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>
687
- </div>
688
- <div id="edit-project-{{ loop.index0 }}" class="edit-form-container">
689
- <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="edit_project"><input type="hidden" name="index" value="{{ loop.index0 }}">
690
- <label>Название*:</label><input type="text" name="title" value="{{ project.title }}" required>
691
- <label>Описание*:</label><textarea name="description" rows="3" required>{{ project.description }}</textarea>
692
- <label>Заменить фото:</label><input type="file" name="photo" accept="image/*">
693
- <button type="submit"><i class="fas fa-save"></i> Сохранить</button>
694
- </form>
695
- </div>
696
- </div>
697
- {% endfor %}
698
- </div>
699
- </div>
700
-
701
- <div class="section">
702
- <h2><i class="fas fa-concierge-bell"></i> Услуги "под ключ"</h2>
703
- <details><summary>Добавить услугу</summary>
704
- <div class="form-content">
705
- <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="add_service">
706
- <label>Заголовок*:</label><input type="text" name="title" required>
707
- <label>Иконка (FontAwesome)*:</label><input type="text" name="icon" placeholder="fas fa-tools" required>
708
- <label>Описание*:</label><textarea name="description" rows="3" required></textarea>
709
- <label>Фото:</label><input type="file" name="photo" accept="image/*">
710
- <button type="submit"><i class="fas fa-save"></i> Добавить услугу</button>
711
- </form>
712
- </div>
713
- </details>
714
- <div class="item-list">
715
- {% for service in services %}
716
- <div class="item">
717
- <div style="display: flex; gap: 15px; align-items: flex-start;">
718
- <div class="photo-preview" style="flex-shrink: 0;">
719
- {% if service.photo %}
720
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/services/{{ service.photo }}" alt="Фото">
721
- {% endif %}
722
- </div>
723
- <div style="flex-grow: 1;">
724
- <p><i class="{{ service.icon }} fa-fw"></i> <strong>{{ service.title }}</strong></p>
725
- <p>{{ service.description[:100] }}{% if service.description|length > 100 %}...{% endif %}</p>
726
- </div>
727
- </div>
728
- <div class="item-actions">
729
- <button onclick="toggleEditForm('edit-service-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
730
- <form method="POST" style="margin:0;" onsubmit="return confirm('Удалить услугу \'{{ service.title }}\'?');"><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>
731
- </div>
732
- <div id="edit-service-{{ loop.index0 }}" class="edit-form-container">
733
- <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="edit_service"><input type="hidden" name="index" value="{{ loop.index0 }}">
734
- <label>Заголовок*:</label><input type="text" name="title" value="{{ service.title }}" required>
735
- <label>Иконка*:</label><input type="text" name="icon" value="{{ service.icon }}" required>
736
- <label>Описание*:</label><textarea name="description" rows="3" required>{{ service.description }}</textarea>
737
- <label>Заменить фото:</label><input type="file" name="photo" accept="image/*">
738
- <button type="submit"><i class="fas fa-save"></i> Сохранить</button>
739
- </form>
740
- </div>
741
- </div>
742
- {% endfor %}
743
- </div>
744
- </div>
745
-
746
- <div class="section">
747
- <h2><i class="fas fa-box-open"></i> Оборудование</h2>
748
- <details><summary>Добавить категорию</summary>
749
- <div class="form-content">
750
- <form method="POST"><input type="hidden" name="action" value="add_category"><label>Название:</label><input type="text" name="category_name" required><button type="submit"><i class="fas fa-plus"></i> Добавить</button></form>
751
- </div>
752
- </details>
753
- <div class="item-list">
754
- {% for category in categories %}
755
- <div class="item" style="display: flex; justify-content: space-between; align-items: center;">
756
- <span>{{ category }}</span>
757
- <form method="POST" style="margin: 0;" onsubmit="return confirm('Удалить категорию \'{{ category }}\'?');"><input type="hidden" name="action" value="delete_category"><input type="hidden" name="category_name" value="{{ category }}"><button type="submit" class="delete-button" style="margin:0;"><i class="fas fa-trash-alt"></i></button></form>
758
- </div>
759
- {% endfor %}
760
- </div>
761
-
762
- <details style="margin-top:20px;"><summary>Добавить оборудование</summary>
763
- <div class="form-content">
764
- <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="add_equipment">
765
- <label>Название*:</label><input type="text" name="name" required>
766
- <label>Цена (KGS)*:</label><input type="number" name="price" step="0.01" min="0" required>
767
- <label>Категория:</label><select name="category"><option value="Без категории">Без категории</option>{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}</select>
768
- <label>Фотографии (до 10 шт.):</label><input type="file" name="photos" accept="image/*" multiple>
769
- <button type="submit"><i class="fas fa-save"></i> Добавить</button>
770
- </form>
771
- </div>
772
- </details>
773
- <div class="item-list">
774
- {% for item in equipment %}
775
  <div class="item">
776
- <div style="display: flex; gap: 15px; align-items: flex-start;">
777
- <div class="photo-preview" style="flex-shrink: 0;">
778
- {% if item.photos %}
779
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/equipment/{{ item.photos[0] }}" alt="Фото">
780
- {% else %}
781
- <img src="https://via.placeholder.com/70x70.png?text=N/A" alt="Нет фото">
782
- {% endif %}
783
- </div>
784
- <div style="flex-grow: 1;">
785
- <p><strong>{{ item.name }}</strong> ({{ item.category }})</p>
786
- <p>Цена: {{ "%.2f"|format(item.price) }} KGS</p>
787
- </div>
788
- </div>
789
  <div class="item-actions">
790
- <button onclick="toggleEditForm('edit-eq-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
791
- <form method="POST" style="margin:0;" onsubmit="return confirm('Удалить оборудование \'{{ item.name }}\'?');"><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>
792
- </div>
793
- <div id="edit-eq-{{ loop.index0 }}" class="edit-form-container">
794
- <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="edit_equipment"><input type="hidden" name="index" value="{{ loop.index0 }}">
795
- <label>Название*:</label><input type="text" name="name" value="{{ item.name }}" required>
796
- <label>Цена (KGS)*:</label><input type="number" name="price" value="{{ item.price }}" step="0.01" min="0" required>
797
- <label>Категория:</label><select name="category">{% for cat in categories %}<option value="{{ cat }}" {% if item.category == cat %}selected{% endif %}>{{ cat }}</option>{% endfor %}</select>
798
- <label>Заменить фотографии (до 10 шт.):</label><input type="file" name="photos" accept="image/*" multiple>
799
- <button type="submit"><i class="fas fa-save"></i> Сохранить</button>
800
- </form>
801
  </div>
802
- </div>
803
- {% endfor %}
804
- </div>
 
 
 
 
805
  </div>
806
- <script>function toggleEditForm(id) { document.getElementById(id).style.display = document.getElementById(id).style.display === 'block' ? 'none' : 'block'; }</script>
807
- </body>
808
- </html>
809
  '''
810
 
 
 
 
 
811
  @app.route('/')
812
  def landing():
813
  data = load_data()
 
 
 
 
 
 
814
  return render_template_string(
815
  LANDING_TEMPLATE,
 
816
  services=data.get('services', []),
817
  equipment=data.get('equipment', []),
818
  categories=sorted(data.get('categories', [])),
@@ -820,253 +398,138 @@ def landing():
820
  repo_id=REPO_ID,
821
  contact_phone=CONTACT_PHONE,
822
  whatsapp_phone=WHATSAPP_PHONE,
823
- now=datetime.utcnow()
 
824
  )
825
 
826
  @app.route('/admin', methods=['GET', 'POST'])
827
  def admin():
828
  data = load_data()
829
-
 
 
 
 
 
830
  if request.method == 'POST':
831
  action = request.form.get('action')
832
- logging.info(f"Admin action: {action}")
833
  try:
834
- if action == 'add_category':
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835
  name = request.form.get('category_name', '').strip()
836
- if name and name not in data['categories']:
837
- data['categories'].append(name)
838
- flash(f"Категория '{name}' добавлена.", 'success')
839
  else: flash("Категория уже существует или пуста.", 'error')
840
 
841
  elif action == 'delete_category':
842
  name = request.form.get('category_name')
843
- if name in data['categories']:
844
- data['categories'].remove(name)
845
- flash(f"Категория '{name}' удалена.", 'success')
846
-
847
- elif action == 'add_equipment':
848
- name = request.form.get('name', '').strip()
849
- price = round(float(request.form.get('price', 0)), 2)
850
- category = request.form.get('category')
851
- photos = request.files.getlist('photos')
852
- if not name or price <= 0:
853
- flash("Название и цена обязательны.", 'error')
854
- return redirect(url_for('admin'))
855
- item_data = {'name': name, 'price': price, 'category': category}
856
- uploaded_photos = upload_item_photos_to_hf(photos, name, 'equipment')
857
- if uploaded_photos:
858
- item_data['photos'] = uploaded_photos
859
- data['equipment'].append(item_data)
860
- flash(f"Оборудование '{name}' добавлено.", 'success')
861
-
862
- elif action == 'edit_equipment':
863
- index = int(request.form.get('index'))
864
- if not (0 <= index < len(data.get('equipment', []))):
865
- flash("Ошибка: неверный индекс оборудования.", "error")
866
- return redirect(url_for('admin'))
867
- original_item = data['equipment'][index]
868
-
869
- name = request.form.get('name', original_item.get('name', '')).strip()
870
- price = round(float(request.form.get('price', original_item.get('price', 0))), 2)
871
- category = request.form.get('category', original_item.get('category'))
872
- photos = request.files.getlist('photos')
873
-
874
- item_data = {'name': name, 'price': price, 'category': category}
875
-
876
- if photos and any(f.filename for f in photos):
877
- uploaded_photos = upload_item_photos_to_hf(photos, name, 'equipment')
878
- if uploaded_photos:
879
- delete_item_photos_from_hf(original_item.get('photos', []), 'equipment')
880
- item_data['photos'] = uploaded_photos
881
- else:
882
- item_data['photos'] = original_item.get('photos') # Keep old photos if new upload failed
883
- flash("Загрузка новых фото не удалась.", "warning")
884
- else:
885
- item_data['photos'] = original_item.get('photos') # Keep old photos if no new files selected
886
-
887
- data['equipment'][index] = item_data
888
- flash(f"Оборудование '{name}' обновлено.", 'success')
889
-
890
- elif action == 'delete_equipment':
891
- index = int(request.form.get('index'))
892
- if not (0 <= index < len(data.get('equipment', []))):
893
- flash("Ошибка: неверный индекс оборудования.", "error")
894
- return redirect(url_for('admin'))
895
- item = data['equipment'].pop(index)
896
- delete_item_photos_from_hf(item.get('photos', []), 'equipment')
897
- flash(f"Оборудование '{item.get('name')}' удалено.", 'success')
898
-
899
- elif action == 'add_service':
900
- title = request.form.get('title', '').strip()
901
- icon = request.form.get('icon', 'fas fa-check').strip()
902
- description = request.form.get('description', '').strip()
903
- photo_file = request.files.get('photo')
904
- if not all([title, icon, description]):
905
- flash("Все поля услуги обязательны.", 'error')
906
- return redirect(url_for('admin'))
907
- item_data = {'title': title, 'icon': icon, 'description': description}
908
- uploaded_photos = upload_item_photos_to_hf([photo_file], title, 'services')
909
- if uploaded_photos:
910
- item_data['photo'] = uploaded_photos[0]
911
- data['services'].append(item_data)
912
- flash(f"Услуга '{title}' добавлена.", 'success')
913
-
914
- elif action == 'edit_service':
915
- index = int(request.form.get('index'))
916
- if not (0 <= index < len(data.get('services', []))):
917
- flash("Ошибка: неверный индекс услуги.", "error")
918
- return redirect(url_for('admin'))
919
- original_item = data['services'][index]
920
-
921
- title = request.form.get('title', original_item.get('title', '')).strip()
922
- icon = request.form.get('icon', original_item.get('icon', 'fas fa-check')).strip()
923
- description = request.form.get('description', original_item.get('description', '')).strip()
924
- photo_file = request.files.get('photo')
925
-
926
- item_data = {'title': title, 'icon': icon, 'description': description}
927
-
928
- if photo_file and photo_file.filename:
929
- uploaded_photos = upload_item_photos_to_hf([photo_file], title, 'services')
930
- if uploaded_photos:
931
- delete_item_photos_from_hf([original_item.get('photo')], 'services')
932
- item_data['photo'] = uploaded_photos[0]
933
- else:
934
- item_data['photo'] = original_item.get('photo') # Keep old photo if new upload failed
935
- flash("Загрузка нового фото не удалась.", "warning")
936
- else:
937
- item_data['photo'] = original_item.get('photo') # Keep old photo if no new file selected
938
-
939
- data['services'][index] = item_data
940
- flash(f"Услуга '{title}' обновлена.", 'success')
941
-
942
- elif action == 'delete_service':
943
- index = int(request.form.get('index'))
944
- if not (0 <= index < len(data.get('services', []))):
945
- flash("Ошибка: неверный индекс услуги.", "error")
946
- return redirect(url_for('admin'))
947
- item = data['services'].pop(index)
948
- delete_item_photos_from_hf([item.get('photo')], 'services')
949
- flash(f"Услуга '{item.get('title')}' удалена.", 'success')
950
-
951
- elif action == 'add_project':
952
- title = request.form.get('title', '').strip()
953
- description = request.form.get('description', '').strip()
954
- photo_file = request.files.get('photo')
955
- if not all([title, description]) or not (photo_file and photo_file.filename):
956
- flash("Название, описание и фото обязательны для нового проекта.", 'error')
957
- return redirect(url_for('admin'))
958
- item_data = {'title': title, 'description': description}
959
- uploaded_photos = upload_item_photos_to_hf([photo_file], title, 'projects')
960
- if uploaded_photos:
961
- item_data['photo'] = uploaded_photos[0]
962
- data['projects'].append(item_data)
963
- flash(f"Проект '{title}' добавлен.", 'success')
964
- else:
965
- flash("Не удалось загрузить фото для проекта.", "error")
966
-
967
-
968
- elif action == 'edit_project':
969
- index = int(request.form.get('index'))
970
- if not (0 <= index < len(data.get('projects', []))):
971
- flash("Ошибка: неверный индекс проекта.", "error")
972
- return redirect(url_for('admin'))
973
- original_item = data['projects'][index]
974
-
975
- title = request.form.get('title', original_item.get('title', '')).strip()
976
- description = request.form.get('description', original_item.get('description', '')).strip()
977
- photo_file = request.files.get('photo')
978
-
979
- item_data = {'title': title, 'description': description}
980
-
981
- if photo_file and photo_file.filename:
982
- uploaded_photos = upload_item_photos_to_hf([photo_file], title, 'projects')
983
- if uploaded_photos:
984
- delete_item_photos_from_hf([original_item.get('photo')], 'projects')
985
- item_data['photo'] = uploaded_photos[0]
986
- else:
987
- item_data['photo'] = original_item.get('photo') # Keep old photo if new upload failed
988
- flash("Загрузка нового фото не удалась.", "warning")
989
- else:
990
- item_data['photo'] = original_item.get('photo') # Keep old photo if no new file selected
991
-
992
- data['projects'][index] = item_data
993
- flash(f"Проект '{title}' обновлен.", 'success')
994
-
995
- elif action == 'delete_project':
996
- index = int(request.form.get('index'))
997
- if not (0 <= index < len(data.get('projects', []))):
998
- flash("Ошибка: неверный индекс проекта.", "error")
999
- return redirect(url_for('admin'))
1000
- item = data['projects'].pop(index)
1001
- delete_item_photos_from_hf([item.get('photo')], 'projects')
1002
- flash(f"Проект '{item.get('title')}' удален.", 'success')
1003
-
1004
- else:
1005
- flash(f"Неизвестное действие: {action}", 'warning')
1006
 
1007
  save_data(data)
1008
- return redirect(url_for('admin'))
1009
  except Exception as e:
1010
- logging.error(f"Admin action '{action}' failed: {e}", exc_info=True)
1011
- flash(f"Произошла ошибка: {e}", 'error')
1012
- return redirect(url_for('admin'))
1013
 
1014
  return render_template_string(
1015
- ADMIN_TEMPLATE,
1016
- equipment=data.get('equipment', []),
1017
- categories=sorted(data.get('categories', [])),
1018
- services=data.get('services', []),
1019
- projects=data.get('projects', [])
1020
  )
1021
 
1022
- @app.route('/details/<item_type>/<int:index>')
1023
- def item_detail_modal(item_type, index):
1024
- data = load_data()
1025
- item = None
1026
- template = None
1027
- item_list = []
1028
-
1029
- if item_type == 'equipment':
1030
- item_list = data.get('equipment', [])
1031
- template = EQUIPMENT_DETAIL_TEMPLATE
1032
- elif item_type == 'service':
1033
- item_list = data.get('services', [])
1034
- template = SERVICE_DETAIL_TEMPLATE
1035
- elif item_type == 'project':
1036
- item_list = data.get('projects', [])
1037
- template = PROJECT_DETAIL_TEMPLATE
1038
- else:
1039
- return "Неизвестный тип объекта.", 404
1040
-
1041
  try:
1042
- item = item_list[index]
1043
- except IndexError:
1044
- return "Объект не найден.", 404
1045
-
1046
- return render_template_string(
1047
- template,
1048
- item=item,
1049
- repo_id=REPO_ID,
1050
- whatsapp_phone=WHATSAPP_PHONE
1051
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1052
 
 
 
 
 
 
 
 
 
1053
 
1054
  @app.route('/force_upload', methods=['POST'])
1055
- def force_upload():
1056
- upload_db_to_hf()
1057
- flash("Данные загружены на сервер.", 'success')
1058
- return redirect(url_for('admin'))
1059
-
1060
  @app.route('/force_download', methods=['POST'])
1061
- def force_download():
1062
- download_db_from_hf()
1063
- flash("Данные скачаны с сервера.", 'success')
1064
- return redirect(url_for('admin'))
1065
 
1066
  if __name__ == '__main__':
1067
- logging.info("Application starting up...")
1068
  download_db_from_hf()
1069
- if HF_TOKEN_WRITE:
1070
- threading.Thread(target=periodic_backup, daemon=True).start()
1071
  port = int(os.environ.get('PORT', 7860))
1072
  app.run(debug=False, host='0.0.0.0', port=port)
 
1
+ from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify
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 # Хотя 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
  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
  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
  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)
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  LANDING_TEMPLATE = '''
114
  <!DOCTYPE html>
115
  <html lang="ru">
 
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; }
 
132
  h2::after { content: ''; display: block; width: 80px; height: 4px; background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); margin: 15px auto 0; border-radius: 2px; }
133
  h3 { font-size: clamp(1.2rem, 3vw, 1.5rem); color: var(--primary-color); margin-bottom: 15px; }
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) => {
 
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>
289
  </html>
290
  '''
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  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', [])),
 
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)