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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +823 -234
app.py CHANGED
@@ -9,14 +9,13 @@ from huggingface_hub import HfApi, hf_hub_download
9
  from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
10
  from werkzeug.utils import secure_filename
11
  from dotenv import load_dotenv
12
- import shutil
13
 
14
  load_dotenv()
15
 
16
  app = Flask(__name__)
17
- app.secret_key = 'raina_hvac_secret_key_v3_modals'
18
  DATA_FILE = 'data.json'
19
- UPLOADS_DIR = 'uploads_temp'
20
 
21
  SYNC_FILES = [DATA_FILE]
22
 
@@ -34,24 +33,29 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(
34
 
35
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
36
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
37
- logging.warning("HF tokens not set. Download might fail for private repos.")
38
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
39
  files_to_download = [specific_file] if specific_file else SYNC_FILES
 
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
  hf_hub_download(
46
  repo_id=REPO_ID, filename=file_name, repo_type="dataset", token=token_to_use,
47
  local_dir=".", local_dir_use_symlinks=False, force_download=True, resume_download=False
48
  )
 
49
  success = True
50
  break
51
  except RepositoryNotFoundError:
 
52
  return False
53
  except HfHubHTTPError as e:
54
  if e.response.status_code == 404:
 
55
  if not os.path.exists(file_name):
56
  try:
57
  with open(file_name, 'w', encoding='utf-8') as f:
@@ -62,20 +66,25 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
62
  break
63
  else:
64
  logging.error(f"HTTP error downloading {file_name}: {e}. Retrying...")
 
 
65
  except Exception as e:
66
  logging.error(f"Unexpected error downloading {file_name}: {e}. Retrying...", exc_info=True)
67
  if attempt < retries:
68
  time.sleep(delay)
69
  if not success:
 
70
  all_successful = False
71
  return all_successful
72
 
73
  def upload_db_to_hf(specific_file=None):
74
  if not HF_TOKEN_WRITE:
 
75
  return
76
  try:
77
  api = HfApi()
78
  files_to_upload = [specific_file] if specific_file else SYNC_FILES
 
79
  for file_name in files_to_upload:
80
  if os.path.exists(file_name):
81
  api.upload_file(
@@ -83,36 +92,130 @@ def upload_db_to_hf(specific_file=None):
83
  repo_type="dataset", token=HF_TOKEN_WRITE,
84
  commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
85
  )
 
 
 
86
  except Exception as e:
87
  logging.error(f"Error during Hugging Face upload: {e}", exc_info=True)
88
 
89
  def periodic_backup():
 
90
  while True:
91
- time.sleep(1800)
 
92
  upload_db_to_hf()
 
93
 
94
  def load_data():
95
  default_data = {'equipment': [], 'categories': [], 'services': [], 'projects': []}
96
  try:
97
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
98
  data = json.load(file)
99
- if not isinstance(data, dict): raise ValueError("Data is not a dictionary")
100
- for key in default_data:
101
- if key not in data: data[key] = []
 
 
 
102
  return data
103
  except (FileNotFoundError, json.JSONDecodeError, ValueError):
 
104
  if download_db_from_hf(specific_file=DATA_FILE):
105
- return load_data()
 
 
 
 
 
 
 
 
 
 
 
106
  return default_data
107
 
108
  def save_data(data):
109
  try:
 
 
 
110
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
111
  json.dump(data, file, ensure_ascii=False, indent=4)
 
112
  upload_db_to_hf(specific_file=DATA_FILE)
113
  except Exception as e:
114
  logging.error(f"Error saving data: {e}", exc_info=True)
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  LANDING_TEMPLATE = '''
117
  <!DOCTYPE html>
118
  <html lang="ru">
@@ -120,7 +223,7 @@ LANDING_TEMPLATE = '''
120
  <meta charset="UTF-8">
121
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
122
  <title>ОсОО "Раина" - Вентиляция и Кондиционирование</title>
123
- <meta name="description" content="Профессиональные услуги по проектированию, монтажу и обслуживанию систем вентиляции и кондиционирования в Кыргызстане. 15 лет опыта.">
124
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
125
  <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" rel="stylesheet">
126
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
@@ -136,10 +239,8 @@ LANDING_TEMPLATE = '''
136
  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; }
137
  h3 { font-size: clamp(1.2rem, 3vw, 1.5rem); color: var(--primary-color); margin-bottom: 15px; }
138
  p { margin-bottom: 1rem; color: var(--text-muted); }
139
- .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); border: none; cursor: pointer;}
140
  .btn:hover { transform: translateY(-3px) scale(1.05); box-shadow: 0 8px 25px var(--accent-glow); }
141
- .btn-outline { background: transparent; border: 2px solid var(--primary-color); color: var(--primary-color); padding: 10px 24px; box-shadow: none; }
142
- .btn-outline:hover { background: var(--primary-color); color: #fff; box-shadow: 0 4px 15px var(--accent-glow); }
143
  .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; }
144
  .header.scrolled { padding: 10px 0; box-shadow: 0 2px 10px rgba(0,0,0,0.3); }
145
  .navbar { display: flex; justify-content: space-between; align-items: center; }
@@ -154,35 +255,51 @@ LANDING_TEMPLATE = '''
154
  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; }
155
  .about-img { width: 100%; border-radius: 15px; box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
156
  .services-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 30px; }
157
- .card { background-color: var(--card-bg); border-radius: 15px; border: 1px solid #2a2a4a; transition: all 0.3s ease; display: flex; flex-direction: column; }
158
- .card:hover { transform: translateY(-5px); border-color: var(--primary-color); box-shadow: 0 8px 25px var(--accent-glow); }
159
- .card-img { width: 100%; height: 200px; object-fit: cover; border-radius: 15px 15px 0 0; }
160
- .card-content { padding: 30px; flex-grow: 1; text-align: center; }
161
- .card-content p { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
 
162
  .equipment-filters { display: flex; justify-content: center; flex-wrap: wrap; gap: 15px; margin-bottom: 40px; }
163
  .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; }
164
  .filter-btn.active, .filter-btn:hover { background-color: var(--primary-color); color: #fff; }
 
 
 
 
 
 
165
  .projects-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px; }
166
- .project-card { position: relative; border-radius: 15px; overflow: hidden; min-height: 400px; background-color: var(--card-bg); cursor: pointer; }
167
- .project-card img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.4s ease; }
168
- .project-overlay { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(to top, rgba(18,18,28,1) 0%, rgba(18,18,28,0) 100%); padding: 40px 20px 20px; }
 
 
169
  .project-card:hover img { transform: scale(1.05); }
 
 
170
  .contact-content { text-align: center; }
171
  .contact-info { margin-top: 40px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
 
 
172
  .footer { text-align: center; padding: 30px 0; background-color: #0d0d14; }
173
- .modal { display: none; position: fixed; z-index: 2000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(5px); }
174
- .modal-content { position: relative; background: var(--dark-bg); margin: 5% auto; padding: 30px; border: 1px solid var(--primary-color); border-radius: 15px; width: 90%; max-width: 800px; box-shadow: 0 0 40px var(--accent-glow); animation: slideIn 0.4s ease-out; }
175
  @keyframes slideIn { from { transform: translateY(-50px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
176
- .close-btn { position: absolute; top: 15px; right: 20px; color: #fff; font-size: 2rem; font-weight: bold; cursor: pointer; transition: color 0.3s; }
177
- .close-btn:hover { color: var(--primary-color); }
178
- .swiper { width: 100%; height: 400px; margin-bottom: 25px; border-radius: 10px; background-color: #000; }
179
- .swiper-slide { text-align: center; font-size: 18px; background: #000; display: flex; justify-content: center; align-items: center; }
180
- .swiper-slide img { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
181
- .swiper-button-next, .swiper-button-prev { color: var(--primary-color); }
182
- .swiper-pagination-bullet-active { background: var(--primary-color); }
 
 
 
183
  @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;} }
184
  @media (max-width: 768px) {
185
- .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); }
186
  .nav-links.active { right: 0; }
187
  .menu-toggle { display: block; z-index: 1001; }
188
  h2 { margin-bottom: 40px; }
@@ -191,35 +308,174 @@ LANDING_TEMPLATE = '''
191
  </style>
192
  </head>
193
  <body>
194
- <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="#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>
195
- <section id="hero"><div class="container hero-content"><h1>Ваш Партнер в Вентиляции и Кондиционировании</h1><p>15 лет опыта, тысячи реализованных проектов. Мы создаем комфорт и здоровье в любом помещении с помощью самых современных климатических систем.</p><a href="#contact" class="btn">Получить консультацию</a></div></section>
196
- <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&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>
197
- <section id="services"><div class="container"><h2>Услуги "под ключ"</h2>
198
- {% if services %}<div class="services-grid">
199
- {% for service in services %}<div class="card"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/services/{{ service.photos[0] if service.photos else 'placeholder.jpg' }}" alt="{{ service.title }}" class="card-img"><div class="card-content"><h3><i class="{{ service.icon }} fa-fw"></i> {{ service.title }}</h3><p>{{ service.description }}</p><button class="btn btn-outline" onclick="openModal('service', {{loop.index0}})">Подробнее</button></div></div>{% endfor %}
200
- </div>{% else %}<p style="text-align: center;">Информация об услугах скоро появится.</p>{% endif %}</div></section>
201
- <section id="equipment"><div class="container"><h2>Наше Оборудование</h2>
202
- {% if equipment %}<div class="equipment-filters"><button class="filter-btn active" data-filter="all">Все</button>{% for category in categories %}<button class="filter-btn" data-filter="{{ category }}">{{ category }}</button>{% endfor %}</div><div class="services-grid">
203
- {% for item in equipment %}<div class="card" data-category="{{ item.get('category', 'all') }}"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/equipment/{{ item.photos[0] if item.photos else 'placeholder.jpg' }}" alt="{{ item.name }}" class="card-img"><div class="card-content"><h3>{{ item.name }}</h3><p style="font-size: 1.3rem; font-weight: 700; color: #fff;">{{ "%.2f"|format(item.price) }} KGS</p><button class="btn btn-outline" onclick="openModal('equipment', {{loop.index0}})">Подробнее</button></div></div>{% endfor %}
204
- </div>{% else %}<p style="text-align: center;">Каталог оборудования скоро будет доступен.</p>{% endif %}</div></section>
205
- <section id="projects"><div class="container"><h2>Реализованные Проекты</h2>
206
- {% if projects %}<div class="projects-grid">
207
- {% for project in projects %}<div class="project-card" onclick="openModal('project', {{loop.index0}})"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/projects/{{ project.photos[0] if project.photos else 'placeholder.jpg' }}" alt="{{ project.title }}"><div class="project-overlay"><h3>{{ project.title }}</h3></div></div>{% endfor %}
208
- </div>{% else %}<p style="text-align: center;">Информация о проектах скоро появится.</p>{% endif %}</div></section>
209
- <section id="contact"><div class="container contact-content"><h2>Контакты</h2><p>Готовы стать вашим надежным партнером в создании идеального климата.</p><div class="contact-info"><p><a href="tel:{{ contact_phone }}">{{ contact_phone }}</a></p><a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}" target="_blank" class="btn"><i class="fab fa-whatsapp"></i> Написать в WhatsApp</a></div></div></section>
210
- <footer class="footer"><p>© {{ now.year }} ОсОО "Раина". Все права защищены.</p></footer>
211
- <div id="detailModal" class="modal"><div id="modalContent" class="modal-content"></div></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
213
  <script>
214
  document.addEventListener('DOMContentLoaded', function() {
215
  const header = document.querySelector('.header');
216
  const menuToggle = document.querySelector('.menu-toggle');
217
  const navLinks = document.querySelector('.nav-links');
218
- const modal = document.getElementById('detailModal');
219
- const modalContent = document.getElementById('modalContent');
 
220
  window.addEventListener('scroll', () => { header.classList.toggle('scrolled', window.scrollY > 50); });
221
  menuToggle.addEventListener('click', () => { navLinks.classList.toggle('active'); });
222
- document.querySelectorAll('.nav-links a').forEach(link => { link.addEventListener('click', () => { navLinks.classList.remove('active'); }); });
 
 
 
223
  const filterContainer = document.querySelector('.equipment-filters');
224
  if (filterContainer) {
225
  filterContainer.addEventListener('click', (e) => {
@@ -227,143 +483,344 @@ LANDING_TEMPLATE = '''
227
  filterContainer.querySelector('.active').classList.remove('active');
228
  e.target.classList.add('active');
229
  const filter = e.target.dataset.filter;
230
- document.querySelectorAll('#equipment .card').forEach(card => { card.style.display = (filter === 'all' || card.dataset.category === filter) ? 'flex' : 'none'; });
 
 
231
  });
232
  }
233
- window.openModal = (type, index) => {
234
- fetch(`/details/${type}/${index}`).then(res => res.text()).then(html => {
235
- modalContent.innerHTML = html;
236
- modal.style.display = 'block';
237
- document.body.style.overflow = 'hidden';
238
- const swiper = modal.querySelector('.swiper');
239
- if(swiper) new Swiper(swiper, { loop: true, pagination: { el: '.swiper-pagination', clickable: true }, navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' } });
240
- });
241
- };
242
- window.closeModal = () => {
243
- modal.style.display = 'none';
244
- modalContent.innerHTML = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  document.body.style.overflow = 'auto';
246
- };
247
- modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
248
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display === 'block') closeModal(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  });
250
  </script>
251
  </body>
252
  </html>
253
  '''
254
 
255
- DETAIL_MODAL_TEMPLATE = '''
256
- <span class="close-btn" onclick="closeModal()">×</span>
257
- {% if item.photos and item.photos|length > 0 %}
258
- <div class="swiper">
259
- <div class="swiper-wrapper">
260
- {% for photo in item.photos %}
261
- <div class="swiper-slide"><img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/{{ item_type }}/{{ photo }}" alt="Фото"></div>
262
- {% endfor %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  </div>
264
- {% if item.photos|length > 1 %}
265
- <div class="swiper-pagination"></div>
266
- <div class="swiper-button-prev"></div>
267
- <div class="swiper-button-next"></div>
268
- {% endif %}
269
- </div>
270
- {% endif %}
271
- <h3>{% if item.icon %}<i class="{{ item.icon }} fa-fw"></i> {% endif %}{{ item.title or item.name }}</h3>
272
- {% if item.price %}
273
- <p style="font-size: 1.5rem; font-weight: 700; color: #fff;">{{ "%.2f"|format(item.price) }} KGS</p>
274
- {% endif %}
275
- <p style="white-space: pre-wrap;">{{ item.description }}</p>
276
- <a href="https://api.whatsapp.com/send?phone={{ whatsapp_phone }}&text=Здравствуйте, интересует: {{ item.title or item.name }}" target="_blank" class="btn" style="margin-top: 20px;"><i class="fab fa-whatsapp"></i> Обсудить в WhatsApp</a>
277
  '''
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  ADMIN_TEMPLATE = '''
280
  <!DOCTYPE html>
281
  <html lang="ru">
282
  <head>
283
- <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Админ-панель</title>
 
 
 
284
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
285
  <style>
286
- body { font-family: sans-serif; background-color: #f4f7f9; color: #333; padding: 20px; }
287
- .container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; }
288
- h1, h2 { color: #6a0dad; }
 
 
 
289
  .section { margin-bottom: 30px; padding: 20px; background-color: #fafafa; border: 1px solid #e9e9e9; border-radius: 8px; }
290
- form { margin-bottom: 1rem; }
291
- label { font-weight: 500; margin-top: 10px; display: block; }
292
- input, textarea, select { width: 100%; padding: 10px; margin-top: 5px; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; }
293
- button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #9b59b6; color: white; cursor: pointer; text-decoration: none; display: inline-block; margin-top: 10px; }
 
294
  button:hover, .button:hover { background-color: #8e44ad; }
295
- .delete-button { background-color: #e74c3c; } .delete-button:hover { background-color: #c0392b; }
296
- .item { background: #fff; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 10px; }
297
- .item-actions { margin-top: 10px; display: flex; gap: 10px; }
298
- .edit-form { display: none; margin-top: 15px; padding: 15px; background: #fdf9ff; border: 1px dashed #ddd; }
299
- .message { padding: 10px; border-radius: 6px; margin-bottom: 15px; }
 
 
 
 
 
 
 
300
  .message.success { background-color: #d4edda; color: #155724; }
301
  .message.error { background-color: #f8d7da; color: #721c24; }
 
302
  </style>
303
  </head>
304
  <body>
305
- <div class="container">
306
- <div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #eee; padding-bottom:1rem; margin-bottom:1rem;"><h1>Админ-панель</h1><a href="/" class="button">На сайт</a></div>
307
- {% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}{% for category, message in messages %}<div class="message {{ category }}">{{ message }}</div>{% endfor %}{% endif %}{% endwith %}
308
- <div class="section"><h2>Синхронизация</h2><form method="POST" action="{{ url_for('force_upload') }}" style="display:inline;"><button type="submit">Загрузить на сервер</button></form><form method="POST" action="{{ url_for('force_download') }}" style="display:inline;"><button type="submit">Скачать с сервера</button></form></div>
309
-
310
- {% set sections = [
311
- ('projects', 'Проекты', ['title', 'description', 'photos']),
312
- ('services', 'Услуги', ['title', 'icon', 'description', 'photos']),
313
- ('equipment', 'Оборудование', ['name', 'price', 'category', 'photos'])
314
- ] %}
315
- {% for key, title, fields in sections %}
316
- <div class="section"><h2>{{ title }}</h2><button onclick="toggleForm('add-{{key}}')">Добавить</button><div id="add-{{key}}" style="display:none;">
317
- <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="add_{{key}}">
318
- {% for field in fields %}<label>{{field}}:</label>
319
- {% if field == 'photos' %}<input type="file" name="photos" accept="image/*" multiple>
320
- {% elif field == 'description' %}<textarea name="{{field}}"></textarea>
321
- {% elif field == 'category' %}<select name="category">{% for cat in categories %}<option value="{{cat}}">{{cat}}</option>{% endfor %}</select>
322
- {% else %}<input type="{{'number' if field=='price' else 'text'}}" name="{{field}}" {{'step=0.01' if field=='price'}}>
323
- {% endif %}
324
- {% endfor %}<button type="submit">Сохранить</button>
325
- </form></div>
326
- {% for item in data[key] %}
327
- <div class="item"><strong>{{ item.title or item.name }}</strong>
328
- <div class="item-actions"><button onclick="toggleForm('edit-{{key}}-{{loop.index0}}')">Редактировать</button><form method="POST" style="margin:0;"><input type="hidden" name="action" value="delete_{{key}}"><input type="hidden" name="index" value="{{loop.index0}}"><button type="submit" class="delete-button">Удалить</button></form></div>
329
- <div id="edit-{{key}}-{{loop.index0}}" class="edit-form">
330
- <form method="POST" enctype="multipart/form-data"><input type="hidden" name="action" value="edit_{{key}}"><input type="hidden" name="index" value="{{loop.index0}}">
331
- {% for field in fields %}<label>{{field}}:</label>
332
- {% if field == 'photos' %}<input type="file" name="photos" accept="image/*" multiple>
333
- {% elif field == 'description' %}<textarea name="{{field}}">{{item[field]}}</textarea>
334
- {% elif field == 'category' %}<select name="category">{% for cat in categories %}<option value="{{cat}}" {%if item[field]==cat%}selected{%endif%}>{{cat}}</option>{% endfor %}</select>
335
- {% else %}<input type="{{'number' if field=='price' else 'text'}}" name="{{field}}" value="{{item[field]}}" {{'step=0.01' if field=='price'}}>
336
- {% endif %}
337
- {% endfor %}<button type="submit">Сохранить</button>
338
- </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  </div>
340
  </div>
341
- {% endfor %}
342
- </div>
343
- {% endfor %}
344
- <div class="section"><h2>Категории оборудования</h2><button onclick="toggleForm('add-category')">Добавить</button><div id="add-category" style="display:none;"><form method="POST"><input type="hidden" name="action" value="add_category"><label>Название:</label><input type="text" name="name" required><button type="submit">Добавить</button></form></div>
345
- {% for cat in categories %}<div class="item" style="display:flex;justify-content:space-between;">{{cat}}<form method="POST" style="margin:0;"><input type="hidden" name="action" value="delete_category"><input type="hidden" name="name" value="{{cat}}"><button class="delete-button" type="submit">Удалить</button></form></div>{% endfor %}
346
- </div>
347
- </div>
348
- <script>function toggleForm(id){const el=document.getElementById(id);el.style.display=el.style.display==='block'?'none':'block';}</script>
349
- </body></html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  '''
351
 
352
- @app.route('/details/<item_type>/<int:index>')
353
- def get_details(item_type, index):
354
  data = load_data()
355
- item_list = data.get(item_type[:-1] + 's' if item_type[-1] != 's' else item_type)
356
- if not item_list or index >= len(item_list):
357
- return "Элемент не найден", 404
358
-
359
- item = item_list[index]
360
-
361
  return render_template_string(
362
- DETAIL_MODAL_TEMPLATE,
363
- item=item,
364
- item_type=item_type[:-1] + 's' if item_type[-1] != 's' else item_type,
 
 
365
  repo_id=REPO_ID,
366
- whatsapp_phone=WHATSAPP_PHONE
 
 
367
  )
368
 
369
  @app.route('/admin', methods=['GET', 'POST'])
@@ -372,110 +829,242 @@ def admin():
372
 
373
  if request.method == 'POST':
374
  action = request.form.get('action')
 
375
  try:
376
- entity_map = {
377
- 'project': ('projects', ['title', 'description', 'photos']),
378
- 'service': ('services', ['title', 'icon', 'description', 'photos']),
379
- 'equipment': ('equipment', ['name', 'price', 'category', 'photos']),
380
- }
381
-
382
- action_type, _, entity_name = action.partition('_')
383
-
384
- if entity_name in entity_map:
385
- data_key, fields = entity_map[entity_name]
386
-
387
- if action_type in ['add', 'edit']:
388
- item_data = {f: request.form.get(f) for f in fields if f != 'photos'}
389
- if 'price' in item_data: item_data['price'] = float(item_data['price'] or 0)
390
-
391
- photos = request.files.getlist('photos')
392
- uploaded_photos = upload_photos_to_hf(photos, item_data.get('title') or item_data.get('name'), data_key)
393
-
394
- if action_type == 'add':
395
- item_data['photos'] = uploaded_photos
396
- data[data_key].append(item_data)
397
- flash(f'"{item_data.get("title") or item_data.get("name")}" добавлен.', 'success')
398
- else:
399
- index = int(request.form.get('index'))
400
- original_item = data[data_key][index]
401
- if uploaded_photos:
402
- delete_photos_from_hf(original_item.get('photos', []), data_key)
403
- item_data['photos'] = uploaded_photos
404
- else:
405
- item_data['photos'] = original_item.get('photos', [])
406
- data[data_key][index] = item_data
407
- flash(f'"{item_data.get("title") or item_data.get("name")}" обновлен.', 'success')
408
-
409
- elif action_type == 'delete':
410
- index = int(request.form.get('index'))
411
- item = data[data_key].pop(index)
412
- delete_photos_from_hf(item.get('photos', []), data_key)
413
- flash(f'"{item.get("title") or item.get("name")}" удален.', 'success')
414
-
415
- elif action == 'add_category':
416
- name = request.form.get('name', '').strip()
417
  if name and name not in data['categories']:
418
  data['categories'].append(name)
419
  flash(f"Категория '{name}' добавлена.", 'success')
 
420
 
421
  elif action == 'delete_category':
422
- name = request.form.get('name')
423
  if name in data['categories']:
424
  data['categories'].remove(name)
425
  flash(f"Категория '{name}' удалена.", 'success')
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  save_data(data)
 
428
  except Exception as e:
429
  logging.error(f"Admin action '{action}' failed: {e}", exc_info=True)
430
  flash(f"Произошла ошибка: {e}", 'error')
431
- return redirect(url_for('admin'))
432
 
433
- return render_template_string(ADMIN_TEMPLATE, data=data, categories=sorted(data.get('categories', [])))
 
 
 
 
 
 
434
 
435
- def upload_photos_to_hf(photo_files, item_name, folder):
436
- if not photo_files or not HF_TOKEN_WRITE: return []
437
- os.makedirs(UPLOADS_DIR, exist_ok=True)
438
- api = HfApi()
439
- uploaded_photos = []
440
- for photo in photo_files:
441
- if photo and photo.filename:
442
- try:
443
- safe_name = secure_filename(item_name.replace(' ', '_'))[:50]
444
- ext = os.path.splitext(photo.filename)[1].lower()
445
- temp_filename = f"{safe_name}_{datetime.now().strftime('%f')}{ext}"
446
- temp_path = os.path.join(UPLOADS_DIR, temp_filename)
447
- photo.save(temp_path)
448
 
449
- repo_path = f"{folder}/{temp_filename}"
450
- api.upload_file(path_or_fileobj=temp_path, path_in_repo=repo_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
451
- uploaded_photos.append(temp_filename)
452
- except Exception as e:
453
- logging.error(f"Error uploading {photo.filename}: {e}")
454
- finally:
455
- if os.path.exists(temp_path): os.remove(temp_path)
456
- return uploaded_photos
 
 
 
457
 
458
- def delete_photos_from_hf(photo_list, folder):
459
- if not photo_list or not HF_TOKEN_WRITE: return
460
- api = HfApi()
461
- paths_in_repo = [f"{folder}/{p}" for p in photo_list if p]
462
- if not paths_in_repo: return
463
  try:
464
- api.delete_files(repo_id=REPO_ID, paths_in_repo=paths_in_repo, repo_type="dataset", token=HF_TOKEN_WRITE)
465
- except HfHubHTTPError as e:
466
- if e.response.status_code != 404: logging.error(f"Error deleting photos: {e}")
467
- except Exception as e:
468
- logging.error(f"Error deleting photos: {e}")
 
 
 
 
 
 
469
 
470
  @app.route('/force_upload', methods=['POST'])
471
  def force_upload():
472
- upload_db_to_hf(); flash("Данные загружены.", 'success'); return redirect(url_for('admin'))
 
 
473
 
474
  @app.route('/force_download', methods=['POST'])
475
  def force_download():
476
- download_db_from_hf(); flash("Данные скачаны.", 'success'); return redirect(url_for('admin'))
 
 
477
 
478
  if __name__ == '__main__':
 
479
  download_db_from_hf()
480
  if HF_TOKEN_WRITE:
481
  threading.Thread(target=periodic_backup, daemon=True).start()
 
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
 
 
33
 
34
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
35
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
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:
54
+ logging.error(f"Repository {REPO_ID} not found. Download cancelled.")
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:
 
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
  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
  <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">
 
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; }
 
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; }
 
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) => {
 
483
  filterContainer.querySelector('.active').classList.remove('active');
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', [])),
819
+ projects=data.get('projects', []),
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'])
 
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()