Shveiauto commited on
Commit
c36e6ee
·
verified ·
1 Parent(s): 57a846e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +117 -285
app.py CHANGED
@@ -1,5 +1,4 @@
1
 
2
-
3
  from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash
4
  import json
5
  import os
@@ -10,48 +9,34 @@ from datetime import datetime
10
  from huggingface_hub import HfApi, hf_hub_download
11
  from huggingface_hub.utils import RepositoryNotFoundError
12
  from werkzeug.utils import secure_filename
13
- # Импортируем dotenv для загрузки переменных окружения из .env файла
14
  from dotenv import load_dotenv
15
 
16
- # Загружаем переменные окружения из файла .env (если он есть)
17
  load_dotenv()
18
 
19
  app = Flask(__name__)
20
- app.secret_key = 'your_unique_secret_key_soola_cosmetics_67890' # Новый уникальный секретный ключ
21
  DATA_FILE = 'data_soola.json'
22
  USERS_FILE = 'users_soola.json'
23
 
24
- # Список файлов для синхронизации
25
  SYNC_FILES = [DATA_FILE, USERS_FILE]
26
 
27
- # Настройки Hugging Face
28
- # Убедитесь, что REPO_ID соответствует вашему репозиторию на Hugging Face
29
- REPO_ID = "Kgshop/Soola" # Замените на ваш ID, если он другой
30
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
31
- HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") # Может быть тем же, что и HF_TOKEN
32
 
33
- # Адрес магазина
34
- STORE_ADDRESS = "Рынок Дордой, Джунхай, терминал, 38" # Единый адрес
35
 
36
- # Валюта (только KGS)
37
  CURRENCY_CODE = 'KGS'
38
  CURRENCY_NAME = 'Кыргызский сом (с)'
39
 
40
- # Настройка логирования
41
- # Уровни: DEBUG, INFO, WARNING, ERROR, CRITICAL
42
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
43
 
44
- # --- Функции работы с данными и пользователями ---
45
-
46
  def load_data():
47
- """Загрузка данных о товарах и категориях."""
48
  try:
49
- # Попытка скачать актуальные данные перед чтением локальных
50
  download_db_from_hf()
51
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
52
  data = json.load(file)
53
  logging.info(f"Данные успешно загружены из {DATA_FILE}")
54
- # Проверка базовой структуры
55
  if not isinstance(data, dict):
56
  logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.")
57
  return {'products': [], 'categories': []}
@@ -63,13 +48,11 @@ def load_data():
63
  except FileNotFoundError:
64
  logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.")
65
  try:
66
- # download_db_from_hf() # Уже вызывали выше, избегаем повторного вызова при первой ошибке
67
- # Если скачивание не удалось выше, пытаемся просто создать пустые файлы
68
  if not os.path.exists(DATA_FILE):
69
  with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
70
  logging.info(f"Создан пустой файл {DATA_FILE}")
71
  return {'products': [], 'categories': []}
72
- else: # Если файл появился после download_db_from_hf
73
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
74
  data = json.load(file)
75
  logging.info(f"Данные успешно загружены из {DATA_FILE} после попытки скачивания.")
@@ -77,7 +60,7 @@ def load_data():
77
  if 'products' not in data: data['products'] = []
78
  if 'categories' not in data: data['categories'] = []
79
  return data
80
- except (FileNotFoundError, RepositoryNotFoundError) as e: # Ловим ошибку репозитория при скачивании
81
  logging.warning(f"Файл {DATA_FILE} не найден локально и ошибка при доступе к репозиторию HF ({e}). Создание пустой структуры.")
82
  if not os.path.exists(DATA_FILE):
83
  with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
@@ -90,7 +73,6 @@ def load_data():
90
  return {'products': [], 'categories': []}
91
  except json.JSONDecodeError:
92
  logging.error(f"Ошибка декодирования JSON в локальном {DATA_FILE}. Файл может быть поврежден. Возврат пустой структуры.")
93
- # Можно добавить логику восстановления из бэкапа или HF, если нужно
94
  return {'products': [], 'categories': []}
95
  except Exception as e:
96
  logging.error(f"Неизвестная ошибка при загрузке данных ({DATA_FILE}): {e}", exc_info=True)
@@ -98,23 +80,16 @@ def load_data():
98
 
99
 
100
  def save_data(data):
101
- """Сохранение данных о товарах и категориях."""
102
  try:
103
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
104
  json.dump(data, file, ensure_ascii=False, indent=4)
105
  logging.info(f"Данные успешно сохранены в {DATA_FILE}")
106
- # Загрузка на HF после сохранения (можно сделать опциональной)
107
  upload_db_to_hf(specific_file=DATA_FILE)
108
  except Exception as e:
109
  logging.error(f"Ошибка при сохранении данных в {DATA_FILE}: {e}", exc_info=True)
110
- # В реальном приложении можно добавить механизм повторной попытки или уведомления
111
- # raise # Перевыброс исключения может остановить приложение, если не обработан выше
112
 
113
  def load_users():
114
- """Загрузка данных пользователей."""
115
  try:
116
- # Опционально: скачать файл пользователей перед чтением
117
- # download_db_from_hf(specific_file=USERS_FILE) # Раскомментировать, если нужно всегда свежие пользователи
118
  with open(USERS_FILE, 'r', encoding='utf-8') as file:
119
  users = json.load(file)
120
  logging.info(f"Данные пользователей успешно загружены из {USERS_FILE}")
@@ -122,15 +97,13 @@ def load_users():
122
  except FileNotFoundError:
123
  logging.warning(f"Локальный файл {USERS_FILE} не найден. Попытка скачать с HF.")
124
  try:
125
- download_db_from_hf(specific_file=USERS_FILE) # Явный вызов для файла пользователей
126
- # Повторная попытка чтения после скачивания
127
  with open(USERS_FILE, 'r', encoding='utf-8') as file:
128
  users = json.load(file)
129
  logging.info(f"Данные пользователей загружены из {USERS_FILE} после скачивания.")
130
  return users if isinstance(users, dict) else {}
131
  except (FileNotFoundError, RepositoryNotFoundError):
132
  logging.warning(f"Файл {USERS_FILE} не найден локально и в репозитории HF. Создание пустого файла.")
133
- # Создаем пустой файл, если его нет
134
  with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f)
135
  return {}
136
  except json.JSONDecodeError:
@@ -147,22 +120,16 @@ def load_users():
147
  return {}
148
 
149
  def save_users(users):
150
- """Сохранение данных пользователей."""
151
  try:
152
  with open(USERS_FILE, 'w', encoding='utf-8') as file:
153
  json.dump(users, file, ensure_ascii=False, indent=4)
154
  logging.info(f"Данные пользователей успешно сохранены в {USERS_FILE}")
155
- # Загрузка на HF после сохранения
156
  upload_db_to_hf(specific_file=USERS_FILE)
157
  except Exception as e:
158
  logging.error(f"Ошибка при сохранении данных пользователей в {USERS_FILE}: {e}", exc_info=True)
159
 
160
- # --- Функции синхронизации с Hugging Face ---
161
 
162
  def upload_db_to_hf(specific_file=None):
163
- """Загрузка файлов данных на Hugging Face.
164
- Если specific_file указан, загружает только его.
165
- """
166
  if not HF_TOKEN_WRITE:
167
  logging.warning("Переменная окружения HF_TOKEN (для записи) не установлена. Загрузка на Hugging Face пропущена.")
168
  return
@@ -185,7 +152,6 @@ def upload_db_to_hf(specific_file=None):
185
  logging.info(f"Файл {file_name} успешно загружен на Hugging Face.")
186
  except Exception as e:
187
  logging.error(f"Ошибка при загрузке файла {file_name} на Hugging Face: {e}")
188
- # Продолжаем пытаться загрузить другие файлы
189
  else:
190
  logging.warning(f"Файл {file_name} не найден локально, пропуск загрузки.")
191
  logging.info("Загрузка файлов на HF завершена.")
@@ -193,79 +159,58 @@ def upload_db_to_hf(specific_file=None):
193
  logging.error(f"Общая ошибка при инициализации или загрузке на Hugging Face: {e}", exc_info=True)
194
 
195
  def download_db_from_hf(specific_file=None):
196
- """Скачивание файлов данных с Hugging Face.
197
- Если specific_file указан, скачивает только его.
198
- """
199
  if not HF_TOKEN_READ:
200
- # Можно использовать и без токена для публичных репозиториев, но лучше предупредить
201
  logging.warning("Переменная окружения HF_TOKEN_READ не установлена. Попытка скачивания с Hugging Face без токена (может не сработать для приватных репо).")
202
- # Не выходим, пытаемся скачать анонимно
203
 
204
  files_to_download = [specific_file] if specific_file else SYNC_FILES
205
  logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID}...")
206
  downloaded_files_count = 0
207
  try:
208
- # HfApi() не нужен для hf_hub_download, но можно использовать для проверки существования репо
209
- # api = HfApi()
210
- # api.dataset_info(repo_id=REPO_ID, token=HF_TOKEN_READ) # Проверка доступности репо
211
-
212
  for file_name in files_to_download:
213
  try:
214
- # Скачиваем в текущую директорию, перезаписывая существующие файлы
215
  local_path = hf_hub_download(
216
  repo_id=REPO_ID,
217
  filename=file_name,
218
  repo_type="dataset",
219
- token=HF_TOKEN_READ, # Передаем токен, если он есть
220
  local_dir=".",
221
  local_dir_use_symlinks=False,
222
- force_download=True # Принудительно скачивать свежую версию
223
  )
224
  logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.")
225
  downloaded_files_count += 1
226
  except RepositoryNotFoundError:
227
  logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
228
- break # Прерываем цикл, если репозиторий не найден
229
- except Exception as e: # Ловим исключения для каждого файла отдельно
230
- # Проверяем, является ли ошибка 'Not Found' для конкретного файла
231
- # hf_hub_download часто возвращает HTTPError или FileNotFoundError внутри
232
  if "404" in str(e) or isinstance(e, FileNotFoundError):
233
  logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.")
234
  else:
235
- # Логируем другие, возможно, более серьезные ошибки
236
  logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True)
237
  logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.")
238
  except RepositoryNotFoundError:
239
- # Эта ошибка ловится и выше, но может возникнуть при первой проверке репо, если раскомментировать
240
  logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание не выполнено.")
241
  except Exception as e:
242
- # Общая ошибка, если не удалось даже инициализировать Api() или что-то глобальное
243
  logging.error(f"Общая ошибка при попытке скачивания с Hugging Face: {e}", exc_info=True)
244
- # Не прерываем работу приложения, будем использовать локальные файлы, если они есть
245
 
246
 
247
  def periodic_backup():
248
- """Периодическая загрузка данных на HF."""
249
- backup_interval = 1800 # 30 минут
250
  logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.")
251
  while True:
252
  time.sleep(backup_interval)
253
  logging.info("Запуск периодического резервного копирования...")
254
- upload_db_to_hf() # Загружает все SYNC_FILES
255
  logging.info("Периодическое резервное копирование завершено.")
256
 
257
 
258
- # --- Маршруты Flask ---
259
-
260
  @app.route('/')
261
  def catalog():
262
- """Главная страница каталога товаров."""
263
  data = load_data()
264
  products = data.get('products', [])
265
  categories = data.get('categories', [])
266
  is_authenticated = 'user' in session
267
 
268
- # Убираем артефакты {/**/} из HTML шаблона
269
  catalog_html = '''
270
  <!DOCTYPE html>
271
  <html lang="ru">
@@ -277,18 +222,17 @@ def catalog():
277
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
278
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
279
  <style>
280
- /* Общие стили */
281
  * { margin: 0; padding: 0; box-sizing: border-box; }
282
  body { font-family: 'Poppins', sans-serif; background: #f0f9f4; color: #2d332f; line-height: 1.6; transition: background 0.3s, color 0.3s; }
283
  body.dark-mode { background: #1a2b26; color: #c8d8d3; }
284
  .container { max-width: 1300px; margin: 0 auto; padding: 20px; }
285
  .header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #d1e7dd; }
286
  body.dark-mode .header { border-bottom-color: #2c4a41; }
287
- .header h1 { font-size: 1.8rem; font-weight: 600; color: #1C6758; } /* Логотип Soola - Темно-зеленый */
288
  .auth-links { display: flex; gap: 15px; align-items: center; }
289
- .auth-links a { color: #3D8361; text-decoration: none; font-weight: 500; } /* Ссылки - Средне-зеленый */
290
  .auth-links a:hover { text-decoration: underline; }
291
- body.dark-mode .auth-links a { color: #55a683; } /* Ссылки в темной теме */
292
  .auth-links span { font-weight: 500; }
293
  body.dark-mode .auth-links span { color: #b0c8c1;}
294
  .theme-toggle { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #7a8d85; transition: color 0.3s ease; }
@@ -298,19 +242,17 @@ def catalog():
298
  .store-address { padding: 15px; text-align: center; background-color: #ffffff; margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-size: 1rem; color: #44524c; }
299
  body.dark-mode .store-address { background-color: #253f37; color: #b0c8c1; }
300
 
301
- /* Фильтры и поиск */
302
  .filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
303
  .search-container { margin: 20px 0; text-align: center; }
304
  #search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #d1e7dd; border-radius: 25px; outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.05); transition: all 0.3s ease; }
305
  body.dark-mode #search-input { background-color: #253f37; border-color: #2c4a41; color: #c8d8d3; }
306
- #search-input:focus { border-color: #1C6758; box-shadow: 0 0 0 3px rgba(28, 103, 88, 0.2); } /* Фокус - темно-зеленый */
307
- body.dark-mode #search-input:focus { border-color: #3D8361; box-shadow: 0 0 0 3px rgba(61, 131, 97, 0.3); } /* Фокус в темной теме */
308
- .category-filter { padding: 8px 16px; border: 1px solid #d1e7dd; border-radius: 20px; background-color: #fff; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); font-size: 0.9rem; font-weight: 400; color: #1C6758; } /* Цвет текста кнопки фильтра */
309
  body.dark-mode .category-filter { background-color: #253f37; border-color: #2c4a41; color: #97b7ae; }
310
- .category-filter.active, .category-filter:hover { background-color: #1C6758; color: white; border-color: #1C6758; box-shadow: 0 2px 10px rgba(28, 103, 88, 0.3); } /* Активный/ховер фильтр - темно-зеленый */
311
- body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover { background-color: #3D8361; border-color: #3D8361; color: #1a2b26; box-shadow: 0 2px 10px rgba(61, 131, 97, 0.4); } /* Активный/ховер в темной теме */
312
 
313
- /* Сетка товаров */
314
  .products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; padding: 10px; }
315
  @media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
316
  .product { background: #fff; border-radius: 15px; padding: 0; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; height: 100%; border: 1px solid #e1f0e9;}
@@ -323,23 +265,21 @@ def catalog():
323
  .product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
324
  .product h2 { font-size: 1.1rem; font-weight: 600; margin: 0 0 8px 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #2d332f; }
325
  body.dark-mode .product h2 { color: #c8d8d3; }
326
- .product-price { font-size: 1.2rem; color: #1C6758; font-weight: 700; text-align: center; margin: 5px 0; } /* Цена - темно-зеленая */
327
- body.dark-mode .product-price { color: #55a683; } /* Цена в темной теме */
328
  .product-description { font-size: 0.85rem; color: #7a8d85; text-align: center; margin-bottom: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
329
  body.dark-mode .product-description { color: #8aa39a; }
330
  .product-actions { padding: 0 15px 15px 15px; display: flex; flex-direction: column; gap: 8px; }
331
- .product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: #1C6758; color: white; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); text-align: center; text-decoration: none; } /* Кнопка - темно-зеленая */
332
- .product-button:hover { background-color: #164B41; box-shadow: 0 4px 15px rgba(22, 75, 65, 0.4); transform: translateY(-2px); } /* Ховер кнопки - еще темнее зеленый */
333
  .product-button i { margin-right: 5px; }
334
 
335
- /* Стили корзины */
336
- .add-to-cart { background-color: #38a169; } /* Зеленый для корзины (можно оставить) */
337
  .add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
338
- #cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #1C6758; color: white; border: none; border-radius: 50%; width: 55px; height: 55px; font-size: 1.5rem; cursor: pointer; display: none; align-items: center; justify-content: center; box-shadow: 0 4px 15px rgba(28, 103, 88, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; } /* Плавающая кнопка - темно-зеленая */
339
  #cart-button .fa-shopping-cart { margin-right: 0; }
340
- #cart-button span { position: absolute; top: -5px; right: -5px; background-color: #38a169; color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.7rem; font-weight: bold; } /* Счетчик остается зеленым */
341
 
342
- /* Модальные окна */
343
  .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(5px); overflow-y: auto; }
344
  .modal-content { background: #f8fcfb; margin: 5% auto; padding: 25px; border-radius: 15px; width: 90%; max-width: 700px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); animation: slideIn 0.3s ease-out; position: relative; }
345
  body.dark-mode .modal-content { background: #253f37; color: #c8d8d3; }
@@ -348,8 +288,8 @@ def catalog():
348
  .close:hover { color: #333; }
349
  body.dark-mode .close { color: #7a8d85; }
350
  body.dark-mode .close:hover { color: #b0c8c1; }
351
- .modal-content h2 { margin-top: 0; margin-bottom: 20px; color: #1C6758; display: flex; align-items: center; gap: 10px;} /* Заголовок модалки - темно-зеленый */
352
- body.dark-mode .modal-content h2 { color: #55a683; } /* Заголовок модалки в темной теме */
353
  .cart-item { display: grid; grid-template-columns: auto 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #d1e7dd; }
354
  body.dark-mode .cart-item { border-bottom-color: #2c4a41; }
355
  .cart-item:last-child { border-bottom: none; }
@@ -359,7 +299,7 @@ def catalog():
359
  .cart-item-price { font-size: 0.9rem; color: #44524c; }
360
  body.dark-mode .cart-item-price { color: #8aa39a; }
361
  .cart-item-total { font-weight: bold; text-align: right; grid-column: 3; }
362
- .cart-item-remove { grid-column: 4; background:none; border:none; color:#f56565; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; } /* Удаление - красный */
363
  .cart-item-remove:hover { color: #c53030; }
364
  .quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #d1e7dd; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; }
365
  body.dark-mode .quantity-input, body.dark-mode .color-select { background-color: #1a2b26; border-color: #2c4a41; color: #c8d8d3; }
@@ -367,14 +307,13 @@ def catalog():
367
  body.dark-mode .cart-summary { border-top-color: #2c4a41; }
368
  .cart-summary strong { font-size: 1.2rem; }
369
  .cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
370
- .cart-actions .product-button { width: auto; flex-grow: 1; } /* Кнопки растягиваются */
371
- .clear-cart { background-color: #7a8d85; } /* Кнопка очистки - серо-зеленый */
372
  .clear-cart:hover { background-color: #5e6e68; box-shadow: 0 4px 15px rgba(94, 110, 104, 0.4); }
373
- .order-button { background-color: #38a169; } /* Кнопка заказа остается ярко-зеленой */
374
  .order-button:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
375
 
376
- /* Уведомления и сообщения */
377
- .notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: #38a169; color: white; padding: 10px 20px; border-radius: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 1002; opacity: 0; transition: opacity 0.5s ease; font-size: 0.9rem;} /* Уведомление - ярко-зеленое */
378
  .notification.show { opacity: 1;}
379
  .no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; }
380
  body.dark-mode .no-results-message { color: #8aa39a; }
@@ -444,14 +383,12 @@ def catalog():
444
  </div>
445
  </div>
446
  {% endfor %}
447
- {# Сообщение, если нет товаров ПОСЛЕ фильтрации, будет добавлено через JS #}
448
  {% if not products %}
449
  <p class="no-results-message">Товары пока не добавл��ны.</p>
450
  {% endif %}
451
  </div>
452
  </div>
453
 
454
- <!-- Product Modal -->
455
  <div id="productModal" class="modal">
456
  <div class="modal-content">
457
  <span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span>
@@ -459,7 +396,6 @@ def catalog():
459
  </div>
460
  </div>
461
 
462
- <!-- Quantity and Color Modal -->
463
  <div id="quantityModal" class="modal">
464
  <div class="modal-content">
465
  <span class="close" onclick="closeModal('quantityModal')" aria-label="Закрыть">×</span>
@@ -472,7 +408,6 @@ def catalog():
472
  </div>
473
  </div>
474
 
475
- <!-- Cart Modal -->
476
  <div id="cartModal" class="modal">
477
  <div class="modal-content">
478
  <span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span>
@@ -492,18 +427,15 @@ def catalog():
492
  </div>
493
  </div>
494
 
495
- <!-- Cart Floating Button -->
496
  <button id="cart-button" onclick="openCartModal()" aria-label="Открыть корзину">
497
  <i class="fas fa-shopping-cart"></i>
498
  <span id="cart-count">0</span>
499
  </button>
500
 
501
- <!-- Notification Placeholder -->
502
  <div id="notification-placeholder"></div>
503
 
504
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
505
  <script>
506
- // --- Global Variables ---
507
  const products = {{ products|tojson }};
508
  const repoId = '{{ repo_id }}';
509
  const currencyCode = '{{ currency_code }}';
@@ -511,7 +443,6 @@ def catalog():
511
  let selectedProductIndex = null;
512
  let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
513
 
514
- // --- Theme ---
515
  function toggleTheme() {
516
  document.body.classList.toggle('dark-mode');
517
  const icon = document.querySelector('.theme-toggle i');
@@ -530,7 +461,6 @@ def catalog():
530
  }
531
  }
532
 
533
- // --- Auto Login ---
534
  function attemptAutoLogin() {
535
  const storedUser = localStorage.getItem('soolaUser');
536
  if (storedUser && !isAuthenticated) {
@@ -556,7 +486,6 @@ def catalog():
556
  }
557
  }
558
 
559
- // --- Modals ---
560
  function openModal(index) {
561
  loadProductDetails(index);
562
  const modal = document.getElementById('productModal');
@@ -607,12 +536,11 @@ def catalog():
607
  pagination: { el: '.swiper-pagination', clickable: true },
608
  navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
609
  zoom: { maxRatio: 3, containerClass: 'swiper-zoom-container' },
610
- autoplay: { delay: 5000, disableOnInteraction: true, }, // Автопрокрутка
611
  });
612
  }
613
  }
614
 
615
- // --- Cart Logic ---
616
  function openQuantityModal(index) {
617
  if (!isAuthenticated) {
618
  alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
@@ -629,7 +557,7 @@ def catalog():
629
 
630
  const colorSelect = document.getElementById('colorSelect');
631
  const colorLabel = document.querySelector('label[for="colorSelect"]');
632
- colorSelect.innerHTML = ''; // Clear previous options
633
 
634
  const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : [];
635
 
@@ -757,17 +685,16 @@ def catalog():
757
  function removeFromCart(itemId) {
758
  cart = cart.filter(item => item.id !== itemId);
759
  localStorage.setItem('soolaCart', JSON.stringify(cart));
760
- openCartModal(); // Refresh cart modal content
761
- updateCartButton(); // Refresh cart icon
762
  }
763
 
764
  function clearCart() {
765
  if (confirm("Вы уверены, что хотите очистить корзину?")) {
766
  cart = [];
767
  localStorage.removeItem('soolaCart');
768
- openCartModal(); // Update modal to show empty cart message
769
  updateCartButton();
770
- // closeModal('cartModal'); // Можно закрыть или оставить открытой
771
  }
772
  }
773
 
@@ -777,41 +704,37 @@ def catalog():
777
  return;
778
  }
779
  let total = 0;
780
- let orderText = "Новый Заказ от Soola Cosmetics:%0A%0A"; // Заголовок
781
  cart.forEach((item, index) => {
782
  const itemTotal = item.price * item.quantity;
783
  total += itemTotal;
784
  const colorText = item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
785
- // Форматирование строки заказа
786
  orderText += `${index + 1}. ${item.name}${colorText} - ${item.price.toFixed(2)} ${currencyCode} × ${item.quantity} = ${itemTotal.toFixed(2)} ${currencyCode}%0A`;
787
  });
788
- orderText += `%0A*Итого: ${total.toFixed(2)} ${currencyCode}*%0A%0A`; // Итоговая сумма
789
 
790
- // Информация о пользователе, если авторизован
791
  const userInfo = {{ session.get('user_info', {})|tojson }};
792
  if (userInfo && userInfo.login) {
793
- orderText += `Заказчик: ${userInfo.get('first_name', '')} ${userInfo.get('last_name', '')}%0A`;
 
794
  orderText += `Логин: ${userInfo.login}%0A`;
795
- orderText += `Страна: ${userInfo.get('country', 'Не указана')}%0A`;
796
- orderText += `Город: ${userInfo.get('city', 'Не указан')}%0A`;
797
  } else {
798
  orderText += `Заказчик: (Не авторизован)%0A`;
799
  }
 
800
 
801
- // Добавляем текущую дату и время
802
  const now = new Date();
803
- const dateTimeString = now.toLocaleString('ru-RU'); // Формат даты/времени
804
  orderText += `%0AДата заказа: ${dateTimeString}`;
805
 
806
- // *** ИЗМЕНЕНО: Новый номер WhatsApp ***
807
- const whatsappNumber = "996997703090"; // Указываем номер без '+' и пробелов
808
- // Формируем URL для WhatsApp API
809
  const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
810
- // Открываем WhatsApp в новой вкладке
811
  window.open(whatsappUrl, '_blank');
812
  }
813
 
814
- // --- Filtering and Search ---
815
  function filterProducts() {
816
  const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
817
  const activeCategoryButton = document.querySelector('.category-filter.active');
@@ -819,7 +742,6 @@ def catalog():
819
  const grid = document.getElementById('products-grid');
820
  let visibleProducts = 0;
821
 
822
- // Удаляем старое сообщение "не найдено", если оно есть
823
  const existingNoResults = grid.querySelector('.no-results-message');
824
  if (existingNoResults) existingNoResults.remove();
825
 
@@ -839,7 +761,6 @@ def catalog():
839
  }
840
  });
841
 
842
- // Показать сообщение "товары не найдены", если 0 видимых товаров и был поиск/фильтр
843
  if (visibleProducts === 0 && (searchTerm || activeCategory !== 'all')) {
844
  const p = document.createElement('p');
845
  p.className = 'no-results-message';
@@ -863,7 +784,6 @@ def catalog():
863
  });
864
  }
865
 
866
- // --- Notifications ---
867
  function showNotification(message, duration = 3000) {
868
  const placeholder = document.getElementById('notification-placeholder');
869
  if (!placeholder) return;
@@ -873,31 +793,26 @@ def catalog():
873
  notification.textContent = message;
874
  placeholder.appendChild(notification);
875
 
876
- // Trigger transition
877
  setTimeout(() => { notification.classList.add('show'); }, 10);
878
 
879
- // Remove after duration
880
  setTimeout(() => {
881
  notification.classList.remove('show');
882
- setTimeout(() => { notification.remove(); }, 500); // Remove from DOM after fade out
883
  }, duration);
884
  }
885
 
886
- // --- Event Listeners and Initial Setup ---
887
  document.addEventListener('DOMContentLoaded', () => {
888
  applyInitialTheme();
889
- attemptAutoLogin(); // Try auto-login first
890
- updateCartButton(); // Initialize cart button state
891
- setupFilters(); // Setup search and category filters
892
 
893
- // Global click listener for closing modals
894
  window.addEventListener('click', function(event) {
895
  if (event.target.classList.contains('modal')) {
896
  closeModal(event.target.id);
897
  }
898
  });
899
 
900
- // Global keydown listener for closing modals with Escape key
901
  window.addEventListener('keydown', function(event) {
902
  if (event.key === 'Escape') {
903
  document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
@@ -918,14 +833,13 @@ def catalog():
918
  repo_id=REPO_ID,
919
  is_authenticated=is_authenticated,
920
  store_address=STORE_ADDRESS,
921
- session=session, # session доступен в Jinja2 по умолчанию, но передать явно не помешает
922
  currency_code=CURRENCY_CODE
923
  )
924
 
925
 
926
  @app.route('/product/<int:index>')
927
  def product_detail(index):
928
- """Отдает HTML с деталями одного продукта для модального окна."""
929
  data = load_data()
930
  products = data.get('products', [])
931
  is_authenticated = 'user' in session
@@ -935,18 +849,15 @@ def product_detail(index):
935
  logging.warning(f"Попытка доступа к несуществующему продукту с индексом {index}")
936
  return "Товар не найден", 404
937
 
938
- # HTML для деталей продукта с темно-зелеными акцентами
939
  detail_html = '''
940
- {# Используем Jinja комментарий #}
941
  <div style="padding: 10px;">
942
- <h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #1C6758;">{{ product['name'] }}</h2> {# Темно-зеленый заголовок #}
943
- {# Swiper Slider for Photos #}
944
  <div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
945
  <div class="swiper-wrapper">
946
  {% if product.get('photos') and product['photos']|length > 0 %}
947
  {% for photo in product['photos'] %}
948
  <div class="swiper-slide" style="display: flex; justify-content: center; align-items: center; padding: 10px;">
949
- <div class="swiper-zoom-container"> {# Контейнер для зума #}
950
  <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
951
  alt="{{ product['name'] }} - фото {{ loop.index }}"
952
  style="max-width: 100%; max-height: 400px; object-fit: contain; display: block; margin: auto; cursor: grab;">
@@ -959,26 +870,23 @@ def product_detail(index):
959
  </div>
960
  {% endif %}
961
  </div>
962
- {# Элементы управления Swiper (показываем только если фото больше 1) #}
963
  {% if product.get('photos') and product['photos']|length > 1 %}
964
  <div class="swiper-pagination" style="position: relative; bottom: 5px;"></div>
965
- <div class="swiper-button-next" style="color: #1C6758;"></div> {# Темно-зеленые стрелки #}
966
- <div class="swiper-button-prev" style="color: #1C6758;"></div> {# Темно-зеленые стрелки #}
967
  {% endif %}
968
  </div>
969
 
970
- {# Product Details #}
971
  <div style="margin-top: 20px; font-size: 1rem; line-height: 1.7;">
972
  <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
973
  {% if is_authenticated %}
974
- <p style="font-size: 1.2rem; font-weight: bold; color: #1C6758;"><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p> {# Темно-зеленая цена #}
975
  {% else %}
976
- <p><strong>Цена:</strong> <a href="{{ url_for('login') }}" style="color: #3D8361; text-decoration: underline;">Доступна после входа</a></p> {# Средне-зеленая ссылка #}
977
  {% endif %}
978
- {# Используем safe фильтр для рендеринга <br> тегов из описания #}
979
- <p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\n', '<br>')|safe }}</p>
980
  {% set colors = product.get('colors', []) %}
981
- {% if colors and colors|select('ne', '')|list|length > 0 %} {# Проверяем, что список не пуст и не содержит только пустые строки #}
982
  <p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
983
  {% endif %}
984
  </div>
@@ -992,9 +900,6 @@ def product_detail(index):
992
  currency_code=CURRENCY_CODE
993
  )
994
 
995
- # --- Маршруты аутентификации ---
996
-
997
- # Шаблон для страницы входа с темно-зелеными акцентами
998
  LOGIN_TEMPLATE = '''
999
  <!DOCTYPE html>
1000
  <html lang="ru">
@@ -1004,16 +909,16 @@ LOGIN_TEMPLATE = '''
1004
  <title>Вход - Soola Cosmetics</title>
1005
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
1006
  <style>
1007
- body { font-family: 'Poppins', sans-serif; background: linear-gradient(135deg, #d1e7dd, #e9f5f0); display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; } /* Светло-зеленый градиент */
1008
  .container { max-width: 400px; width: 100%; background: #fff; padding: 30px 40px; border-radius: 15px; box-shadow: 0 5px 20px rgba(0,0,0,0.1); text-align: center; }
1009
- h2 { color: #1C6758; margin-bottom: 25px; font-weight: 600; } /* Темно-зеленый заголовок */
1010
  label { display: block; text-align: left; margin: 15px 0 5px; font-weight: 500; color: #44524c; }
1011
  input[type="text"], input[type="password"] { width: 100%; padding: 12px; margin-bottom: 15px; border: 1px solid #c4d9d1; border-radius: 8px; box-sizing: border-box; font-size: 1rem; }
1012
- input:focus { border-color: #1C6758; outline: none; box-shadow: 0 0 0 2px rgba(28, 103, 88, 0.2); } /* Фокус - темно-зеленый */
1013
- button { width: 100%; padding: 12px; background-color: #1C6758; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; font-weight: 600; transition: background-color 0.3s ease; margin-top: 10px; } /* Кнопка - темно-зеленая */
1014
- button:hover { background-color: #164B41; } /* Ховер кнопки - темнее */
1015
- .error { color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; padding: 10px; border-radius: 8px; margin-bottom: 15px; font-size: 0.9rem; text-align: left;} /* Ошибка - красный */
1016
- .back-link { display: inline-block; margin-top: 20px; color: #3D8361; text-decoration: none; font-size: 0.9rem; } /* Ссылка назад - средне-зеленая */
1017
  .back-link:hover { text-decoration: underline; }
1018
  </style>
1019
  </head>
@@ -1038,7 +943,6 @@ LOGIN_TEMPLATE = '''
1038
 
1039
  @app.route('/login', methods=['GET', 'POST'])
1040
  def login():
1041
- """Страница входа пользователя."""
1042
  if request.method == 'POST':
1043
  login = request.form.get('login')
1044
  password = request.form.get('password')
@@ -1047,11 +951,6 @@ def login():
1047
 
1048
  users = load_users()
1049
 
1050
- # Важно: В реальном приложении используйте хеширование паролей!
1051
- # Пример с хешированием (нужна библиотека passlib):
1052
- # from passlib.hash import pbkdf2_sha256
1053
- # if login in users and pbkdf2_sha256.verify(password, users[login].get('password_hash')):
1054
- # Либо простая проверка для демонстрации:
1055
  if login in users and users[login].get('password') == password:
1056
  user_info = users[login]
1057
  session['user'] = login
@@ -1078,13 +977,11 @@ def login():
1078
  error_message = "Неверный логин или пароль."
1079
  return render_template_string(LOGIN_TEMPLATE, error=error_message), 401
1080
 
1081
- # GET запрос
1082
  return render_template_string(LOGIN_TEMPLATE, error=None)
1083
 
1084
 
1085
  @app.route('/auto_login', methods=['POST'])
1086
  def auto_login():
1087
- """Попытка автоматического входа по логину из localStorage."""
1088
  data = request.get_json()
1089
  if not data or 'login' not in data:
1090
  logging.warning("Запрос auto_login без данных или логина.")
@@ -1097,8 +994,6 @@ def auto_login():
1097
 
1098
  users = load_users()
1099
  if login in users:
1100
- # Пароль не проверяем при авто-логине, т.к. он не передается
1101
- # Доверяем только наличию логина в localStorage (умеренная безопасность)
1102
  user_info = users[login]
1103
  session['user'] = login
1104
  session['user_info'] = {
@@ -1112,19 +1007,15 @@ def auto_login():
1112
  return "OK", 200
1113
  else:
1114
  logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
1115
- # Важно: Не возвращаем 401 или 404, чтобы не раскрывать существование пользователей
1116
- # Просто возвращаем ошибку, чтобы JS удалил невалидный логин из localStorage
1117
- return "Ошибка авто-входа", 400 # Или 403 Forbidden
1118
 
1119
  @app.route('/logout')
1120
  def logout():
1121
- """Выход пользователя из системы."""
1122
  logged_out_user = session.get('user')
1123
  session.pop('user', None)
1124
  session.pop('user_info', None)
1125
  if logged_out_user:
1126
  logging.info(f"Пользователь {logged_out_user} вышел из системы.")
1127
- # Ответ с JS для очистки localStorage и редиректом
1128
  logout_response_html = '''
1129
  <!DOCTYPE html><html><head><title>Выход...</title></head><body>
1130
  <script>
@@ -1136,8 +1027,6 @@ def logout():
1136
  '''
1137
  return logout_response_html
1138
 
1139
- # --- Админ-панель ---
1140
- # Шаблон админ-панели с темно-зелеными акцентами
1141
  ADMIN_TEMPLATE = '''
1142
  <!DOCTYPE html>
1143
  <html lang="ru">
@@ -1148,28 +1037,28 @@ ADMIN_TEMPLATE = '''
1148
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
1149
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
1150
  <style>
1151
- body { font-family: 'Poppins', sans-serif; background-color: #e9f5f0; color: #2d332f; padding: 20px; line-height: 1.6; } /* Светло-зеленый фон */
1152
  .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); }
1153
  .header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #d1e7dd; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
1154
- h1, h2, h3 { font-weight: 600; color: #1C6758; margin-bottom: 15px; } /* Темно-зеленые заголовки */
1155
  h1 { font-size: 1.8rem; }
1156
  h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
1157
- h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; } /* Темнее зеленый для подзаголовков */
1158
  .section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; }
1159
  form { margin-bottom: 20px; }
1160
  label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;}
1161
  input[type="text"], input[type="number"], input[type="password"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #c4d9d1; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; }
1162
- input:focus, textarea:focus, select:focus { border-color: #1C6758; outline: none; box-shadow: 0 0 0 2px rgba(28, 103, 88, 0.1); } /* Фокус - темно-зеленый */
1163
  textarea { min-height: 80px; resize: vertical; }
1164
  input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;}
1165
  input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;}
1166
- button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #1C6758; color: white; font-weight: 500; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 0.95rem; display: inline-flex; align-items: center; gap: 5px; text-decoration: none; line-height: 1.2;} /* Кнопка - темно-зеленая */
1167
- button:hover, .button:hover { background-color: #164B41; } /* Ховер кнопки - темнее */
1168
  button:active, .button:active { transform: scale(0.98); }
1169
  button[type="submit"] { min-width: 120px; justify-content: center; }
1170
- .delete-button { background-color: #f56565; } /* Удаление - красный */
1171
  .delete-button:hover { background-color: #e53e3e; }
1172
- .add-button { background-color: #38a169; } /* Добавить - ярко-зеленый */
1173
  .add-button:hover { background-color: #2f855a; }
1174
  .item-list { display: grid; gap: 20px; }
1175
  .item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #e1f0e9; }
@@ -1177,44 +1066,40 @@ ADMIN_TEMPLATE = '''
1177
  .item strong { color: #2d332f; }
1178
  .item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
1179
  .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
1180
- /* Кнопка редактирования - основной темно-зеленый */
1181
  .item-actions button:not(.delete-button) { background-color: #1C6758; }
1182
  .item-actions button:not(.delete-button):hover { background-color: #164B41; }
1183
- .edit-form-container { margin-top: 15px; padding: 20px; background: #f0f9f4; border: 1px dashed #c4d9d1; border-radius: 6px; display: none; /* Скрыто по умолчанию */ }
1184
  details { background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; margin-bottom: 20px; }
1185
- details > summary { cursor: pointer; font-weight: 600; color: #164B41; display: block; padding: 15px; border-bottom: 1px solid #d1e7dd; list-style: none; /* Убрать стандартный маркер */ position: relative; } /* Заголовок details - темнее зеленый */
1186
- details > summary::after { content: '\\f078'; /* FontAwesome chevron-down */ font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: #1C6758; } /* Стрелка - темно-зеленая */
1187
  details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
1188
  details[open] > summary { border-bottom: 1px solid #d1e7dd; }
1189
  details .form-content { padding: 20px; }
1190
  .color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
1191
  .color-input-group input { flex-grow: 1; margin: 0; }
1192
- .remove-color-btn { background-color: #f56565; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; } /* Удалить цвет - красный */
1193
  .remove-color-btn:hover { background-color: #e53e3e; }
1194
- /* Кнопка "Добавить поле цвета" - синяя для контраста */
1195
  .add-color-btn { background-color: #63b3ed; }
1196
  .add-color-btn:hover { background-color: #4299e1; }
1197
  .photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #d1e7dd; object-fit: cover;}
1198
  .sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
1199
- /* Кнопка "Скачать с HF" - более мягкий цвет, например, серый */
1200
  .download-hf-button { background-color: #7a8d85; }
1201
  .download-hf-button:hover { background-color: #5e6e68; }
1202
  .flex-container { display: flex; flex-wrap: wrap; gap: 20px; }
1203
- .flex-item { flex: 1; min-width: 350px; /* Минимальная ширина колонки */ }
1204
  .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;}
1205
- .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;} /* Успех - зеленый */
1206
- .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;} /* Ошибка - красный */
1207
- .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; } /* Предупреждение - желтый */
1208
  </style>
1209
  </head>
1210
  <body>
1211
  <div class="container">
1212
  <div class="header">
1213
  <h1><i class="fas fa-tools"></i> Админ-панель Soola Cosmetics</h1>
1214
- <a href="{{ url_for('catalog') }}" class="button" style="background-color: #3D8361;"><i class="fas fa-store"></i> Перейти в каталог</a> {# Средне-зеленая кнопка каталога #}
1215
  </div>
1216
 
1217
- {# Сообщения об успехе/ошибке #}
1218
  {% with messages = get_flashed_messages(with_categories=true) %}
1219
  {% if messages %}
1220
  {% for category, message in messages %}
@@ -1227,10 +1112,10 @@ ADMIN_TEMPLATE = '''
1227
  <h2><i class="fas fa-sync-alt"></i> Синхронизация с Hugging Face</h2>
1228
  <div class="sync-buttons">
1229
  <form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно загрузить локальные данные на сервер? Это перезапишет данные на сервере.');">
1230
- <button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить на HF</button> {# Основная темно-зеленая кнопка #}
1231
  </form>
1232
  <form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
1233
- <button type="submit" class="button download-hf-button" title="Скачать файлы с Hugging Face (перезапишет локальные)"><i class="fas fa-download"></i> Скачать с HF</button> {# Серая кнопка #}
1234
  </form>
1235
  </div>
1236
  <p style="font-size: 0.85rem; color: #5e6e68;">Резервное копирование на Hugging Face происходит автоматически каждые 30 м��нут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
@@ -1238,7 +1123,7 @@ ADMIN_TEMPLATE = '''
1238
 
1239
 
1240
  <div class="flex-container">
1241
- <div class="flex-item"> {# Колонка Категории #}
1242
  <div class="section">
1243
  <h2><i class="fas fa-tags"></i> Управление категориями</h2>
1244
  <details>
@@ -1248,7 +1133,7 @@ ADMIN_TEMPLATE = '''
1248
  <input type="hidden" name="action" value="add_category">
1249
  <label for="add_category_name">Название новой категории:</label>
1250
  <input type="text" id="add_category_name" name="category_name" required>
1251
- <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button> {# Ярко-зеленая кнопка #}
1252
  </form>
1253
  </div>
1254
  </details>
@@ -1262,7 +1147,7 @@ ADMIN_TEMPLATE = '''
1262
  <form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить категорию \'{{ category }}\'? Товары этой категории будут помечены как \'Без категории\'.');">
1263
  <input type="hidden" name="action" value="delete_category">
1264
  <input type="hidden" name="category_name" value="{{ category }}">
1265
- <button type="submit" class="delete-button" style="padding: 5px 10px; font-size: 0.8rem; margin: 0;"><i class="fas fa-trash-alt"></i></button> {# Красная кнопка #}
1266
  </form>
1267
  </div>
1268
  {% endfor %}
@@ -1273,7 +1158,7 @@ ADMIN_TEMPLATE = '''
1273
  </div>
1274
  </div>
1275
 
1276
- <div class="flex-item"> {# Колонка Пользователи #}
1277
  <div class="section">
1278
  <h2><i class="fas fa-users"></i> Управление пользователями</h2>
1279
  <details>
@@ -1294,7 +1179,7 @@ ADMIN_TEMPLATE = '''
1294
  <input type="text" id="country" name="country">
1295
  <label for="city">Город:</label>
1296
  <input type="text" id="city" name="city">
1297
- <button type="submit" class="add-button"><i class="fas fa-save"></i> Сохранить пользователя</button> {# Ярко-зеленая кнопка #}
1298
  </form>
1299
  </div>
1300
  </details>
@@ -1311,9 +1196,8 @@ ADMIN_TEMPLATE = '''
1311
  <form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя \'{{ login }}\'?');">
1312
  <input type="hidden" name="action" value="delete_user">
1313
  <input type="hidden" name="login" value="{{ login }}">
1314
- <button type="submit" class="delete-button"><i class="fas fa-user-slash"></i> Удалить</button> {# Красная кнопка #}
1315
  </form>
1316
- {# Можно добавить кнопку редактирования пользователя, если нужно #}
1317
  </div>
1318
  </div>
1319
  {% endfor %}
@@ -1326,7 +1210,7 @@ ADMIN_TEMPLATE = '''
1326
  </div>
1327
 
1328
 
1329
- <div class="section"> {# Секция Товары #}
1330
  <h2><i class="fas fa-box-open"></i> Управление товарами</h2>
1331
  <details>
1332
  <summary><i class="fas fa-plus-circle"></i> Добавить но��ый товар</summary>
@@ -1352,12 +1236,12 @@ ADMIN_TEMPLATE = '''
1352
  <div id="add-color-inputs">
1353
  <div class="color-input-group">
1354
  <input type="text" name="colors" placeholder="Например: Розовый">
1355
- <button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button> {# Красная кнопка #}
1356
  </div>
1357
  </div>
1358
- <button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button> {# Синяя кнопка #}
1359
  <br>
1360
- <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button> {# Ярко-зеленая кнопка #}
1361
  </form>
1362
  </div>
1363
  </details>
@@ -1368,7 +1252,6 @@ ADMIN_TEMPLATE = '''
1368
  {% for product in products %}
1369
  <div class="item">
1370
  <div style="display: flex; gap: 15px; align-items: flex-start;">
1371
- {# Превью первого фото #}
1372
  <div class="photo-preview" style="flex-shrink: 0;">
1373
  {% if product.get('photos') %}
1374
  <a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" target="_blank" title="Посмотреть первое фото">
@@ -1378,9 +1261,8 @@ ADMIN_TEMPLATE = '''
1378
  <img src="https://via.placeholder.com/70x70.png?text=N/A" alt="Нет фото">
1379
  {% endif %}
1380
  </div>
1381
- {# Информация о товаре #}
1382
  <div style="flex-grow: 1;">
1383
- <h3 style="margin-top: 0; margin-bottom: 5px; color: #2d332f;">{{ product['name'] }}</h3> {# Цвет текста заголовка #}
1384
  <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
1385
  <p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
1386
  <p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
@@ -1393,15 +1275,14 @@ ADMIN_TEMPLATE = '''
1393
  </div>
1394
 
1395
  <div class="item-actions">
1396
- <button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button> {# Основная темно-зеленая кнопка #}
1397
  <form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
1398
  <input type="hidden" name="action" value="delete_product">
1399
  <input type="hidden" name="index" value="{{ loop.index0 }}">
1400
- <button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button> {# Красная кнопка #}
1401
  </form>
1402
  </div>
1403
 
1404
- {# Форма редактирования (скрыта по умолчанию) #}
1405
  <div id="edit-form-{{ loop.index0 }}" class="edit-form-container">
1406
  <h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
1407
  <form method="POST" enctype="multipart/form-data">
@@ -1435,24 +1316,23 @@ ADMIN_TEMPLATE = '''
1435
  {% set current_colors = product.get('colors', []) %}
1436
  {% if current_colors and current_colors|select('ne', '')|list|length > 0 %}
1437
  {% for color in current_colors %}
1438
- {% if color.strip() %} {# Отображаем только не пустые #}
1439
  <div class="color-input-group">
1440
  <input type="text" name="colors" value="{{ color }}">
1441
- <button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button> {# Красная кнопка #}
1442
  </div>
1443
  {% endif %}
1444
  {% endfor %}
1445
  {% else %}
1446
- {# Добавляем одно пустое поле, если цветов нет #}
1447
  <div class="color-input-group">
1448
  <input type="text" name="colors" placeholder="Например: Красный">
1449
- <button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button> {# Красная кнопка #}
1450
  </div>
1451
  {% endif %}
1452
  </div>
1453
- <button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')"><i class="fas fa-palette"></i> Добавить поле для цвета</button> {# Синяя кнопка #}
1454
  <br>
1455
- <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button> {# Ярко-зеленая кнопка #}
1456
  </form>
1457
  </div>
1458
  </div>
@@ -1483,7 +1363,6 @@ ADMIN_TEMPLATE = '''
1483
  <button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
1484
  `;
1485
  container.appendChild(newInputGroup);
1486
- // Установить фокус на новый инпут
1487
  const newInput = newInputGroup.querySelector('input[name="colors"]');
1488
  if (newInput) {
1489
  newInput.focus();
@@ -1492,15 +1371,10 @@ ADMIN_TEMPLATE = '''
1492
  }
1493
 
1494
  function removeColorInput(button) {
1495
- // Ищем ближайший родительский элемент с классом 'color-input-group'
1496
  const group = button.closest('.color-input-group');
1497
  if (group) {
1498
  const container = group.parentNode;
1499
  group.remove();
1500
- // Опционально: если удалили последний, можно добавить новый пустой
1501
- // if (container && container.children.length === 0) {
1502
- // addColorInput(container.id);
1503
- // }
1504
  } else {
1505
  console.warn("Не удалось найти родительский .color-input-group для кнопки удаления");
1506
  }
@@ -1512,14 +1386,9 @@ ADMIN_TEMPLATE = '''
1512
 
1513
  @app.route('/admin', methods=['GET', 'POST'])
1514
  def admin():
1515
- """Админ-панель для управления товарами, категориями и пользователями."""
1516
- # Здесь должна быть проверка прав администратора!
1517
- # Пример: if session.get('user') != 'admin_login': return "Доступ запрещен", 403
1518
- # Для простоты пока опускаем (В ПРОДАВКШЕНЕ ОБЯЗАТЕЛЬНО ДОБАВИТЬ!)
1519
- if not session.get('user'): # Простейшая проверка - залогинен ли хоть кто-то
1520
  flash("Требуется вход для доступа к админ-панели.", 'warning')
1521
  return redirect(url_for('login'))
1522
- # TODO: Добавить более строгую проверку роли администратора, если пользователей много
1523
 
1524
  data = load_data()
1525
  products = data.get('products', [])
@@ -1535,7 +1404,7 @@ def admin():
1535
  category_name = request.form.get('category_name', '').strip()
1536
  if category_name and category_name not in categories:
1537
  categories.append(category_name)
1538
- categories.sort() # Сортируем категории
1539
  save_data(data)
1540
  logging.info(f"Категория '{category_name}' добавлена.")
1541
  flash(f"Категория '{category_name}' успешно добавлена.", 'success')
@@ -1550,7 +1419,6 @@ def admin():
1550
  category_to_delete = request.form.get('category_name')
1551
  if category_to_delete and category_to_delete in categories:
1552
  categories.remove(category_to_delete)
1553
- # Обновляем товары
1554
  updated_count = 0
1555
  for product in products:
1556
  if product.get('category') == category_to_delete:
@@ -1574,7 +1442,7 @@ def admin():
1574
 
1575
  if not name or not price_str:
1576
  flash("Название и цена товара обязательны.", 'error')
1577
- return redirect(url_for('admin')) # Прерываем выполнение
1578
 
1579
  try:
1580
  price = round(float(price_str), 2)
@@ -1597,7 +1465,6 @@ def admin():
1597
  break
1598
  if photo and photo.filename:
1599
  try:
1600
- # Создаем уникальное имя файла
1601
  ext = os.path.splitext(photo.filename)[1]
1602
  photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
1603
  temp_path = os.path.join(uploads_dir, photo_filename)
@@ -1605,7 +1472,7 @@ def admin():
1605
  logging.info(f"Загрузка фото {photo_filename} на HF для товара {name}...")
1606
  api.upload_file(
1607
  path_or_fileobj=temp_path,
1608
- path_in_repo=f"photos/{photo_filename}", # Путь в репозитории
1609
  repo_id=REPO_ID,
1610
  repo_type="dataset",
1611
  token=HF_TOKEN_WRITE,
@@ -1620,7 +1487,6 @@ def admin():
1620
  flash(f"Ошибка при загрузке фото {photo.filename}.", 'error')
1621
  elif photo and not photo.filename:
1622
  logging.warning("Получен пустой объект файла фото при добавлении товара.")
1623
- # Удаляем временную папку, если она пуста
1624
  try:
1625
  if not os.listdir(uploads_dir):
1626
  os.rmdir(uploads_dir)
@@ -1634,7 +1500,6 @@ def admin():
1634
  'photos': photos_list, 'colors': colors
1635
  }
1636
  products.append(new_product)
1637
- # Сортируем продукты по имени после добавления
1638
  products.sort(key=lambda x: x.get('name', '').lower())
1639
  save_data(data)
1640
  logging.info(f"Товар '{name}' добавлен.")
@@ -1655,7 +1520,6 @@ def admin():
1655
  flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
1656
  return redirect(url_for('admin'))
1657
 
1658
- # Обновляем поля
1659
  product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
1660
  price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.')
1661
  product_to_edit['description'] = request.form.get('description', product_to_edit['description']).strip()
@@ -1671,7 +1535,6 @@ def admin():
1671
  logging.warning(f"Неверный формат цены '{price_str}' при редактировании товара {original_name}. Цена не изменена.")
1672
  flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
1673
 
1674
- # Обработка новых фото (замена старых)
1675
  photos_files = request.files.getlist('photos')
1676
  if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
1677
  uploads_dir = 'uploads_temp'
@@ -1703,18 +1566,14 @@ def admin():
1703
  except Exception as e:
1704
  logging.error(f"Ошибка загрузки нового фото {photo.filename}: {e}", exc_info=True)
1705
  flash(f"Ошибка при загрузке нового фото {photo.filename}.", 'error')
1706
- # Удаляем временную папку, если она пуста
1707
  try:
1708
  if not os.listdir(uploads_dir):
1709
  os.rmdir(uploads_dir)
1710
  except OSError as e:
1711
  logging.warning(f"Не удалось удалить временную папку {uploads_dir}: {e}")
1712
 
1713
- # Если были успешно загружены новые фото, заменяем старый список
1714
  if new_photos_list:
1715
  logging.info(f"Список фото для товара {product_to_edit['name']} обновлен.")
1716
- # TODO: Удалить старые фото с HF? Это сложнее, требует хранения списка старых фото.
1717
- # ----- Начало: Опциональное удаление старых фото ----
1718
  old_photos = product_to_edit.get('photos', [])
1719
  if old_photos:
1720
  logging.info(f"Попытка удаления старых фото: {old_photos}")
@@ -1730,13 +1589,11 @@ def admin():
1730
  except Exception as e:
1731
  logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True)
1732
  flash("Не удалось удалить старые фотографии с сервера.", "warning")
1733
- # ----- Конец: Опциональное удаление старых фото -----
1734
  product_to_edit['photos'] = new_photos_list
1735
  flash("Фотографии товара успешно обновлены.", "success")
1736
  elif uploaded_count == 0 and any(f.filename for f in photos_files):
1737
  flash("Не удалось загрузить новые фотографии.", "error")
1738
 
1739
- # Сортируем продукты по имени после редактирования
1740
  products.sort(key=lambda x: x.get('name', '').lower())
1741
  save_data(data)
1742
  logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}'.")
@@ -1754,7 +1611,6 @@ def admin():
1754
  deleted_product = products.pop(index)
1755
  product_name = deleted_product.get('name', 'N/A')
1756
 
1757
- # ----- Начало: Удаление фото с HF при удалении товара ----
1758
  photos_to_delete = deleted_product.get('photos', [])
1759
  if photos_to_delete and HF_TOKEN_WRITE:
1760
  logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}")
@@ -1771,7 +1627,6 @@ def admin():
1771
  except Exception as e:
1772
  logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True)
1773
  flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
1774
- # ----- Конец: Удаление фото с HF при удалении товара ----
1775
 
1776
  save_data(data)
1777
  logging.info(f"Товар '{product_name}' (индекс {index}) удален.")
@@ -1795,12 +1650,8 @@ def admin():
1795
  flash(f"Пользователь с логином '{login}' уже существует.", 'error')
1796
  return redirect(url_for('admin'))
1797
 
1798
- # Важно: Хранить пароли в открытом виде НЕБЕЗОПАСНО!
1799
- # Используйте хеширование для реальных приложений.
1800
- # from passlib.hash import pbkdf2_sha256
1801
- # password_hash = pbkdf2_sha256.hash(password)
1802
  users[login] = {
1803
- 'password': password, # Замените на 'password_hash': password_hash
1804
  'first_name': first_name, 'last_name': last_name,
1805
  'country': country, 'city': city
1806
  }
@@ -1823,36 +1674,28 @@ def admin():
1823
  logging.warning(f"Получено неизвестное действие в админ-панели: {action}")
1824
  flash(f"Неизвестное действие: {action}", 'warning')
1825
 
1826
- # После обработки POST-запроса делаем редирект, чтобы избежать повторной отправки формы
1827
  return redirect(url_for('admin'))
1828
 
1829
  except Exception as e:
1830
  logging.error(f"Ошибка при обработке действия '{action}' в админ-панели: {e}", exc_info=True)
1831
  flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
1832
- return redirect(url_for('admin')) # Редирект даже при ошибке
1833
 
1834
- # GET запрос для отображения админ-панели
1835
- # Сортируем продукты для отображения
1836
  products.sort(key=lambda x: x.get('name', '').lower())
1837
- # Сортируем категории
1838
  categories.sort()
1839
- # Сортируем пользователей по логину
1840
  sorted_users = dict(sorted(users.items()))
1841
 
1842
  return render_template_string(
1843
- ADMIN_TEMPLATE, # Используем переменную с шаблоном
1844
  products=products,
1845
  categories=categories,
1846
- users=sorted_users, # Передаем отсортированных пользователей
1847
  repo_id=REPO_ID,
1848
  currency_code=CURRENCY_CODE
1849
  )
1850
 
1851
- # --- Маршруты для принудительной синхронизации ---
1852
-
1853
  @app.route('/force_upload', methods=['POST'])
1854
  def force_upload():
1855
- # TODO: Добавить проверку прав администратора
1856
  if not session.get('user'):
1857
  flash("Требуется вход для выполнения этого действия.", 'warning')
1858
  return redirect(url_for('login'))
@@ -1868,7 +1711,6 @@ def force_upload():
1868
 
1869
  @app.route('/force_download', methods=['POST'])
1870
  def force_download():
1871
- # TODO: Добавить проверку прав администратора
1872
  if not session.get('user'):
1873
  flash("Требуется вход для выполнения этого действия.", 'warning')
1874
  return redirect(url_for('login'))
@@ -1883,14 +1725,10 @@ def force_download():
1883
  return redirect(url_for('admin'))
1884
 
1885
 
1886
- # --- Запуск приложения ---
1887
-
1888
  if __name__ == '__main__':
1889
- # Попытка загрузить/создать файлы данных при старте
1890
  load_data()
1891
  load_users()
1892
 
1893
- # Запуск потока для периодического бэкапа
1894
  if HF_TOKEN_WRITE:
1895
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1896
  backup_thread.start()
@@ -1898,12 +1736,6 @@ if __name__ == '__main__':
1898
  else:
1899
  logging.warning("Периодическое резервное копирование НЕ будет запущено (HF_TOKEN или HF_TOKEN_WRITE не установлена).")
1900
 
1901
- # Запуск Flask приложения
1902
- port = int(os.environ.get('PORT', 7860)) # Порт по умолчанию 7860, можно изменить через переменную окружения PORT
1903
  logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
1904
- # debug=False для продакшена! Установите в True только для локальной разработки.
1905
- # Использование app.run() подходит только для разработки.
1906
- # Для продакшена используйте WSGI сервер, например, Gunicorn или Waitress.
1907
- # Пример с Gunicorn: gunicorn --bind 0.0.0.0:7860 app:app
1908
- # Пример с Waitress: waitress-serve --host 0.0.0.0 --port 7860 app:app
1909
  app.run(debug=False, host='0.0.0.0', port=port)
 
1
 
 
2
  from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash
3
  import json
4
  import os
 
9
  from huggingface_hub import HfApi, hf_hub_download
10
  from huggingface_hub.utils import RepositoryNotFoundError
11
  from werkzeug.utils import secure_filename
 
12
  from dotenv import load_dotenv
13
 
 
14
  load_dotenv()
15
 
16
  app = Flask(__name__)
17
+ app.secret_key = 'your_unique_secret_key_soola_cosmetics_67890'
18
  DATA_FILE = 'data_soola.json'
19
  USERS_FILE = 'users_soola.json'
20
 
 
21
  SYNC_FILES = [DATA_FILE, USERS_FILE]
22
 
23
+ REPO_ID = "Kgshop/Soola"
 
 
24
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
25
+ HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
26
 
27
+ STORE_ADDRESS = "Рынок Дордой, Джунхай, терминал, 38"
 
28
 
 
29
  CURRENCY_CODE = 'KGS'
30
  CURRENCY_NAME = 'Кыргызский сом (с)'
31
 
 
 
32
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
33
 
 
 
34
  def load_data():
 
35
  try:
 
36
  download_db_from_hf()
37
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
38
  data = json.load(file)
39
  logging.info(f"Данные успешно загружены из {DATA_FILE}")
 
40
  if not isinstance(data, dict):
41
  logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.")
42
  return {'products': [], 'categories': []}
 
48
  except FileNotFoundError:
49
  logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.")
50
  try:
 
 
51
  if not os.path.exists(DATA_FILE):
52
  with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
53
  logging.info(f"Создан пустой файл {DATA_FILE}")
54
  return {'products': [], 'categories': []}
55
+ else:
56
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
57
  data = json.load(file)
58
  logging.info(f"Данные успешно загружены из {DATA_FILE} после попытки скачивания.")
 
60
  if 'products' not in data: data['products'] = []
61
  if 'categories' not in data: data['categories'] = []
62
  return data
63
+ except (FileNotFoundError, RepositoryNotFoundError) as e:
64
  logging.warning(f"Файл {DATA_FILE} не найден локально и ошибка при доступе к репозиторию HF ({e}). Создание пустой структуры.")
65
  if not os.path.exists(DATA_FILE):
66
  with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
 
73
  return {'products': [], 'categories': []}
74
  except json.JSONDecodeError:
75
  logging.error(f"Ошибка декодирования JSON в локальном {DATA_FILE}. Файл может быть поврежден. Возврат пустой структуры.")
 
76
  return {'products': [], 'categories': []}
77
  except Exception as e:
78
  logging.error(f"Неизвестная ошибка при загрузке данных ({DATA_FILE}): {e}", exc_info=True)
 
80
 
81
 
82
  def save_data(data):
 
83
  try:
84
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
85
  json.dump(data, file, ensure_ascii=False, indent=4)
86
  logging.info(f"Данные успешно сохранены в {DATA_FILE}")
 
87
  upload_db_to_hf(specific_file=DATA_FILE)
88
  except Exception as e:
89
  logging.error(f"Ошибка при сохранении данных в {DATA_FILE}: {e}", exc_info=True)
 
 
90
 
91
  def load_users():
 
92
  try:
 
 
93
  with open(USERS_FILE, 'r', encoding='utf-8') as file:
94
  users = json.load(file)
95
  logging.info(f"Данные пользователей успешно загружены из {USERS_FILE}")
 
97
  except FileNotFoundError:
98
  logging.warning(f"Локальный файл {USERS_FILE} не найден. Попытка скачать с HF.")
99
  try:
100
+ download_db_from_hf(specific_file=USERS_FILE)
 
101
  with open(USERS_FILE, 'r', encoding='utf-8') as file:
102
  users = json.load(file)
103
  logging.info(f"Данные пользователей загружены из {USERS_FILE} после скачивания.")
104
  return users if isinstance(users, dict) else {}
105
  except (FileNotFoundError, RepositoryNotFoundError):
106
  logging.warning(f"Файл {USERS_FILE} не найден локально и в репозитории HF. Создание пустого файла.")
 
107
  with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f)
108
  return {}
109
  except json.JSONDecodeError:
 
120
  return {}
121
 
122
  def save_users(users):
 
123
  try:
124
  with open(USERS_FILE, 'w', encoding='utf-8') as file:
125
  json.dump(users, file, ensure_ascii=False, indent=4)
126
  logging.info(f"Данные пользователей успешно сохранены в {USERS_FILE}")
 
127
  upload_db_to_hf(specific_file=USERS_FILE)
128
  except Exception as e:
129
  logging.error(f"Ошибка при сохранении данных пользователей в {USERS_FILE}: {e}", exc_info=True)
130
 
 
131
 
132
  def upload_db_to_hf(specific_file=None):
 
 
 
133
  if not HF_TOKEN_WRITE:
134
  logging.warning("Переменная окружения HF_TOKEN (для записи) не установлена. Загрузка на Hugging Face пропущена.")
135
  return
 
152
  logging.info(f"Файл {file_name} успешно загружен на Hugging Face.")
153
  except Exception as e:
154
  logging.error(f"Ошибка при загрузке файла {file_name} на Hugging Face: {e}")
 
155
  else:
156
  logging.warning(f"Файл {file_name} не найден локально, пропуск загрузки.")
157
  logging.info("Загрузка файлов на HF завершена.")
 
159
  logging.error(f"Общая ошибка при инициализации или загрузке на Hugging Face: {e}", exc_info=True)
160
 
161
  def download_db_from_hf(specific_file=None):
 
 
 
162
  if not HF_TOKEN_READ:
 
163
  logging.warning("Переменная окружения HF_TOKEN_READ не установлена. Попытка скачивания с Hugging Face без токена (может не сработать для приватных репо).")
 
164
 
165
  files_to_download = [specific_file] if specific_file else SYNC_FILES
166
  logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID}...")
167
  downloaded_files_count = 0
168
  try:
 
 
 
 
169
  for file_name in files_to_download:
170
  try:
 
171
  local_path = hf_hub_download(
172
  repo_id=REPO_ID,
173
  filename=file_name,
174
  repo_type="dataset",
175
+ token=HF_TOKEN_READ,
176
  local_dir=".",
177
  local_dir_use_symlinks=False,
178
+ force_download=True
179
  )
180
  logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.")
181
  downloaded_files_count += 1
182
  except RepositoryNotFoundError:
183
  logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
184
+ break
185
+ except Exception as e:
 
 
186
  if "404" in str(e) or isinstance(e, FileNotFoundError):
187
  logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.")
188
  else:
 
189
  logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True)
190
  logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.")
191
  except RepositoryNotFoundError:
 
192
  logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание не выполнено.")
193
  except Exception as e:
 
194
  logging.error(f"Общая ошибка при попытке скачивания с Hugging Face: {e}", exc_info=True)
 
195
 
196
 
197
  def periodic_backup():
198
+ backup_interval = 1800
 
199
  logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.")
200
  while True:
201
  time.sleep(backup_interval)
202
  logging.info("Запуск периодического резервного копирования...")
203
+ upload_db_to_hf()
204
  logging.info("Периодическое резервное копирование завершено.")
205
 
206
 
 
 
207
  @app.route('/')
208
  def catalog():
 
209
  data = load_data()
210
  products = data.get('products', [])
211
  categories = data.get('categories', [])
212
  is_authenticated = 'user' in session
213
 
 
214
  catalog_html = '''
215
  <!DOCTYPE html>
216
  <html lang="ru">
 
222
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
223
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
224
  <style>
 
225
  * { margin: 0; padding: 0; box-sizing: border-box; }
226
  body { font-family: 'Poppins', sans-serif; background: #f0f9f4; color: #2d332f; line-height: 1.6; transition: background 0.3s, color 0.3s; }
227
  body.dark-mode { background: #1a2b26; color: #c8d8d3; }
228
  .container { max-width: 1300px; margin: 0 auto; padding: 20px; }
229
  .header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #d1e7dd; }
230
  body.dark-mode .header { border-bottom-color: #2c4a41; }
231
+ .header h1 { font-size: 1.8rem; font-weight: 600; color: #1C6758; }
232
  .auth-links { display: flex; gap: 15px; align-items: center; }
233
+ .auth-links a { color: #3D8361; text-decoration: none; font-weight: 500; }
234
  .auth-links a:hover { text-decoration: underline; }
235
+ body.dark-mode .auth-links a { color: #55a683; }
236
  .auth-links span { font-weight: 500; }
237
  body.dark-mode .auth-links span { color: #b0c8c1;}
238
  .theme-toggle { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #7a8d85; transition: color 0.3s ease; }
 
242
  .store-address { padding: 15px; text-align: center; background-color: #ffffff; margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-size: 1rem; color: #44524c; }
243
  body.dark-mode .store-address { background-color: #253f37; color: #b0c8c1; }
244
 
 
245
  .filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
246
  .search-container { margin: 20px 0; text-align: center; }
247
  #search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #d1e7dd; border-radius: 25px; outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.05); transition: all 0.3s ease; }
248
  body.dark-mode #search-input { background-color: #253f37; border-color: #2c4a41; color: #c8d8d3; }
249
+ #search-input:focus { border-color: #1C6758; box-shadow: 0 0 0 3px rgba(28, 103, 88, 0.2); }
250
+ body.dark-mode #search-input:focus { border-color: #3D8361; box-shadow: 0 0 0 3px rgba(61, 131, 97, 0.3); }
251
+ .category-filter { padding: 8px 16px; border: 1px solid #d1e7dd; border-radius: 20px; background-color: #fff; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); font-size: 0.9rem; font-weight: 400; color: #1C6758; }
252
  body.dark-mode .category-filter { background-color: #253f37; border-color: #2c4a41; color: #97b7ae; }
253
+ .category-filter.active, .category-filter:hover { background-color: #1C6758; color: white; border-color: #1C6758; box-shadow: 0 2px 10px rgba(28, 103, 88, 0.3); }
254
+ body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover { background-color: #3D8361; border-color: #3D8361; color: #1a2b26; box-shadow: 0 2px 10px rgba(61, 131, 97, 0.4); }
255
 
 
256
  .products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; padding: 10px; }
257
  @media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
258
  .product { background: #fff; border-radius: 15px; padding: 0; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; height: 100%; border: 1px solid #e1f0e9;}
 
265
  .product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
266
  .product h2 { font-size: 1.1rem; font-weight: 600; margin: 0 0 8px 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #2d332f; }
267
  body.dark-mode .product h2 { color: #c8d8d3; }
268
+ .product-price { font-size: 1.2rem; color: #1C6758; font-weight: 700; text-align: center; margin: 5px 0; }
269
+ body.dark-mode .product-price { color: #55a683; }
270
  .product-description { font-size: 0.85rem; color: #7a8d85; text-align: center; margin-bottom: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
271
  body.dark-mode .product-description { color: #8aa39a; }
272
  .product-actions { padding: 0 15px 15px 15px; display: flex; flex-direction: column; gap: 8px; }
273
+ .product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: #1C6758; color: white; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); text-align: center; text-decoration: none; }
274
+ .product-button:hover { background-color: #164B41; box-shadow: 0 4px 15px rgba(22, 75, 65, 0.4); transform: translateY(-2px); }
275
  .product-button i { margin-right: 5px; }
276
 
277
+ .add-to-cart { background-color: #38a169; }
 
278
  .add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
279
+ #cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #1C6758; color: white; border: none; border-radius: 50%; width: 55px; height: 55px; font-size: 1.5rem; cursor: pointer; display: none; align-items: center; justify-content: center; box-shadow: 0 4px 15px rgba(28, 103, 88, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; }
280
  #cart-button .fa-shopping-cart { margin-right: 0; }
281
+ #cart-button span { position: absolute; top: -5px; right: -5px; background-color: #38a169; color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.7rem; font-weight: bold; }
282
 
 
283
  .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(5px); overflow-y: auto; }
284
  .modal-content { background: #f8fcfb; margin: 5% auto; padding: 25px; border-radius: 15px; width: 90%; max-width: 700px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); animation: slideIn 0.3s ease-out; position: relative; }
285
  body.dark-mode .modal-content { background: #253f37; color: #c8d8d3; }
 
288
  .close:hover { color: #333; }
289
  body.dark-mode .close { color: #7a8d85; }
290
  body.dark-mode .close:hover { color: #b0c8c1; }
291
+ .modal-content h2 { margin-top: 0; margin-bottom: 20px; color: #1C6758; display: flex; align-items: center; gap: 10px;}
292
+ body.dark-mode .modal-content h2 { color: #55a683; }
293
  .cart-item { display: grid; grid-template-columns: auto 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #d1e7dd; }
294
  body.dark-mode .cart-item { border-bottom-color: #2c4a41; }
295
  .cart-item:last-child { border-bottom: none; }
 
299
  .cart-item-price { font-size: 0.9rem; color: #44524c; }
300
  body.dark-mode .cart-item-price { color: #8aa39a; }
301
  .cart-item-total { font-weight: bold; text-align: right; grid-column: 3; }
302
+ .cart-item-remove { grid-column: 4; background:none; border:none; color:#f56565; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; }
303
  .cart-item-remove:hover { color: #c53030; }
304
  .quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #d1e7dd; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; }
305
  body.dark-mode .quantity-input, body.dark-mode .color-select { background-color: #1a2b26; border-color: #2c4a41; color: #c8d8d3; }
 
307
  body.dark-mode .cart-summary { border-top-color: #2c4a41; }
308
  .cart-summary strong { font-size: 1.2rem; }
309
  .cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
310
+ .cart-actions .product-button { width: auto; flex-grow: 1; }
311
+ .clear-cart { background-color: #7a8d85; }
312
  .clear-cart:hover { background-color: #5e6e68; box-shadow: 0 4px 15px rgba(94, 110, 104, 0.4); }
313
+ .order-button { background-color: #38a169; }
314
  .order-button:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
315
 
316
+ .notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: #38a169; color: white; padding: 10px 20px; border-radius: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 1002; opacity: 0; transition: opacity 0.5s ease; font-size: 0.9rem;}
 
317
  .notification.show { opacity: 1;}
318
  .no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; }
319
  body.dark-mode .no-results-message { color: #8aa39a; }
 
383
  </div>
384
  </div>
385
  {% endfor %}
 
386
  {% if not products %}
387
  <p class="no-results-message">Товары пока не добавл��ны.</p>
388
  {% endif %}
389
  </div>
390
  </div>
391
 
 
392
  <div id="productModal" class="modal">
393
  <div class="modal-content">
394
  <span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span>
 
396
  </div>
397
  </div>
398
 
 
399
  <div id="quantityModal" class="modal">
400
  <div class="modal-content">
401
  <span class="close" onclick="closeModal('quantityModal')" aria-label="Закрыть">×</span>
 
408
  </div>
409
  </div>
410
 
 
411
  <div id="cartModal" class="modal">
412
  <div class="modal-content">
413
  <span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span>
 
427
  </div>
428
  </div>
429
 
 
430
  <button id="cart-button" onclick="openCartModal()" aria-label="Открыть корзину">
431
  <i class="fas fa-shopping-cart"></i>
432
  <span id="cart-count">0</span>
433
  </button>
434
 
 
435
  <div id="notification-placeholder"></div>
436
 
437
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
438
  <script>
 
439
  const products = {{ products|tojson }};
440
  const repoId = '{{ repo_id }}';
441
  const currencyCode = '{{ currency_code }}';
 
443
  let selectedProductIndex = null;
444
  let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
445
 
 
446
  function toggleTheme() {
447
  document.body.classList.toggle('dark-mode');
448
  const icon = document.querySelector('.theme-toggle i');
 
461
  }
462
  }
463
 
 
464
  function attemptAutoLogin() {
465
  const storedUser = localStorage.getItem('soolaUser');
466
  if (storedUser && !isAuthenticated) {
 
486
  }
487
  }
488
 
 
489
  function openModal(index) {
490
  loadProductDetails(index);
491
  const modal = document.getElementById('productModal');
 
536
  pagination: { el: '.swiper-pagination', clickable: true },
537
  navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
538
  zoom: { maxRatio: 3, containerClass: 'swiper-zoom-container' },
539
+ autoplay: { delay: 5000, disableOnInteraction: true, },
540
  });
541
  }
542
  }
543
 
 
544
  function openQuantityModal(index) {
545
  if (!isAuthenticated) {
546
  alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
 
557
 
558
  const colorSelect = document.getElementById('colorSelect');
559
  const colorLabel = document.querySelector('label[for="colorSelect"]');
560
+ colorSelect.innerHTML = '';
561
 
562
  const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : [];
563
 
 
685
  function removeFromCart(itemId) {
686
  cart = cart.filter(item => item.id !== itemId);
687
  localStorage.setItem('soolaCart', JSON.stringify(cart));
688
+ openCartModal();
689
+ updateCartButton();
690
  }
691
 
692
  function clearCart() {
693
  if (confirm("Вы уверены, что хотите очистить корзину?")) {
694
  cart = [];
695
  localStorage.removeItem('soolaCart');
696
+ openCartModal();
697
  updateCartButton();
 
698
  }
699
  }
700
 
 
704
  return;
705
  }
706
  let total = 0;
707
+ let orderText = "Новый Заказ от Soola Cosmetics:%0A%0A";
708
  cart.forEach((item, index) => {
709
  const itemTotal = item.price * item.quantity;
710
  total += itemTotal;
711
  const colorText = item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
 
712
  orderText += `${index + 1}. ${item.name}${colorText} - ${item.price.toFixed(2)} ${currencyCode} × ${item.quantity} = ${itemTotal.toFixed(2)} ${currencyCode}%0A`;
713
  });
714
+ orderText += `%0A*Итого: ${total.toFixed(2)} ${currencyCode}*%0A%0A`;
715
 
716
+ // --- FIX STARTS HERE ---
717
  const userInfo = {{ session.get('user_info', {})|tojson }};
718
  if (userInfo && userInfo.login) {
719
+ // Use direct property access or || for defaults
720
+ orderText += `Заказчик: ${userInfo.first_name || ''} ${userInfo.last_name || ''}%0A`;
721
  orderText += `Логин: ${userInfo.login}%0A`;
722
+ orderText += `Страна: ${userInfo.country || 'Не указана'}%0A`;
723
+ orderText += `Город: ${userInfo.city || 'Не указан'}%0A`;
724
  } else {
725
  orderText += `Заказчик: (Не авторизован)%0A`;
726
  }
727
+ // --- FIX ENDS HERE ---
728
 
 
729
  const now = new Date();
730
+ const dateTimeString = now.toLocaleString('ru-RU');
731
  orderText += `%0AДата заказа: ${dateTimeString}`;
732
 
733
+ const whatsappNumber = "996997703090";
 
 
734
  const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
 
735
  window.open(whatsappUrl, '_blank');
736
  }
737
 
 
738
  function filterProducts() {
739
  const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
740
  const activeCategoryButton = document.querySelector('.category-filter.active');
 
742
  const grid = document.getElementById('products-grid');
743
  let visibleProducts = 0;
744
 
 
745
  const existingNoResults = grid.querySelector('.no-results-message');
746
  if (existingNoResults) existingNoResults.remove();
747
 
 
761
  }
762
  });
763
 
 
764
  if (visibleProducts === 0 && (searchTerm || activeCategory !== 'all')) {
765
  const p = document.createElement('p');
766
  p.className = 'no-results-message';
 
784
  });
785
  }
786
 
 
787
  function showNotification(message, duration = 3000) {
788
  const placeholder = document.getElementById('notification-placeholder');
789
  if (!placeholder) return;
 
793
  notification.textContent = message;
794
  placeholder.appendChild(notification);
795
 
 
796
  setTimeout(() => { notification.classList.add('show'); }, 10);
797
 
 
798
  setTimeout(() => {
799
  notification.classList.remove('show');
800
+ setTimeout(() => { notification.remove(); }, 500);
801
  }, duration);
802
  }
803
 
 
804
  document.addEventListener('DOMContentLoaded', () => {
805
  applyInitialTheme();
806
+ attemptAutoLogin();
807
+ updateCartButton();
808
+ setupFilters();
809
 
 
810
  window.addEventListener('click', function(event) {
811
  if (event.target.classList.contains('modal')) {
812
  closeModal(event.target.id);
813
  }
814
  });
815
 
 
816
  window.addEventListener('keydown', function(event) {
817
  if (event.key === 'Escape') {
818
  document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
 
833
  repo_id=REPO_ID,
834
  is_authenticated=is_authenticated,
835
  store_address=STORE_ADDRESS,
836
+ session=session,
837
  currency_code=CURRENCY_CODE
838
  )
839
 
840
 
841
  @app.route('/product/<int:index>')
842
  def product_detail(index):
 
843
  data = load_data()
844
  products = data.get('products', [])
845
  is_authenticated = 'user' in session
 
849
  logging.warning(f"Попытка доступа к несуществующему продукту с индексом {index}")
850
  return "Товар не найден", 404
851
 
 
852
  detail_html = '''
 
853
  <div style="padding: 10px;">
854
+ <h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #1C6758;">{{ product['name'] }}</h2>
 
855
  <div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
856
  <div class="swiper-wrapper">
857
  {% if product.get('photos') and product['photos']|length > 0 %}
858
  {% for photo in product['photos'] %}
859
  <div class="swiper-slide" style="display: flex; justify-content: center; align-items: center; padding: 10px;">
860
+ <div class="swiper-zoom-container">
861
  <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
862
  alt="{{ product['name'] }} - фото {{ loop.index }}"
863
  style="max-width: 100%; max-height: 400px; object-fit: contain; display: block; margin: auto; cursor: grab;">
 
870
  </div>
871
  {% endif %}
872
  </div>
 
873
  {% if product.get('photos') and product['photos']|length > 1 %}
874
  <div class="swiper-pagination" style="position: relative; bottom: 5px;"></div>
875
+ <div class="swiper-button-next" style="color: #1C6758;"></div>
876
+ <div class="swiper-button-prev" style="color: #1C6758;"></div>
877
  {% endif %}
878
  </div>
879
 
 
880
  <div style="margin-top: 20px; font-size: 1rem; line-height: 1.7;">
881
  <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
882
  {% if is_authenticated %}
883
+ <p style="font-size: 1.2rem; font-weight: bold; color: #1C6758;"><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
884
  {% else %}
885
+ <p><strong>Цена:</strong> <a href="{{ url_for('login') }}" style="color: #3D8361; text-decoration: underline;">Доступна после входа</a></p>
886
  {% endif %}
887
+ <p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p>
 
888
  {% set colors = product.get('colors', []) %}
889
+ {% if colors and colors|select('ne', '')|list|length > 0 %}
890
  <p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
891
  {% endif %}
892
  </div>
 
900
  currency_code=CURRENCY_CODE
901
  )
902
 
 
 
 
903
  LOGIN_TEMPLATE = '''
904
  <!DOCTYPE html>
905
  <html lang="ru">
 
909
  <title>Вход - Soola Cosmetics</title>
910
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
911
  <style>
912
+ body { font-family: 'Poppins', sans-serif; background: linear-gradient(135deg, #d1e7dd, #e9f5f0); display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
913
  .container { max-width: 400px; width: 100%; background: #fff; padding: 30px 40px; border-radius: 15px; box-shadow: 0 5px 20px rgba(0,0,0,0.1); text-align: center; }
914
+ h2 { color: #1C6758; margin-bottom: 25px; font-weight: 600; }
915
  label { display: block; text-align: left; margin: 15px 0 5px; font-weight: 500; color: #44524c; }
916
  input[type="text"], input[type="password"] { width: 100%; padding: 12px; margin-bottom: 15px; border: 1px solid #c4d9d1; border-radius: 8px; box-sizing: border-box; font-size: 1rem; }
917
+ input:focus { border-color: #1C6758; outline: none; box-shadow: 0 0 0 2px rgba(28, 103, 88, 0.2); }
918
+ button { width: 100%; padding: 12px; background-color: #1C6758; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; font-weight: 600; transition: background-color 0.3s ease; margin-top: 10px; }
919
+ button:hover { background-color: #164B41; }
920
+ .error { color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; padding: 10px; border-radius: 8px; margin-bottom: 15px; font-size: 0.9rem; text-align: left;}
921
+ .back-link { display: inline-block; margin-top: 20px; color: #3D8361; text-decoration: none; font-size: 0.9rem; }
922
  .back-link:hover { text-decoration: underline; }
923
  </style>
924
  </head>
 
943
 
944
  @app.route('/login', methods=['GET', 'POST'])
945
  def login():
 
946
  if request.method == 'POST':
947
  login = request.form.get('login')
948
  password = request.form.get('password')
 
951
 
952
  users = load_users()
953
 
 
 
 
 
 
954
  if login in users and users[login].get('password') == password:
955
  user_info = users[login]
956
  session['user'] = login
 
977
  error_message = "Неверный логин или пароль."
978
  return render_template_string(LOGIN_TEMPLATE, error=error_message), 401
979
 
 
980
  return render_template_string(LOGIN_TEMPLATE, error=None)
981
 
982
 
983
  @app.route('/auto_login', methods=['POST'])
984
  def auto_login():
 
985
  data = request.get_json()
986
  if not data or 'login' not in data:
987
  logging.warning("Запрос auto_login без данных или логина.")
 
994
 
995
  users = load_users()
996
  if login in users:
 
 
997
  user_info = users[login]
998
  session['user'] = login
999
  session['user_info'] = {
 
1007
  return "OK", 200
1008
  else:
1009
  logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
1010
+ return "Ошибка авто-входа", 400
 
 
1011
 
1012
  @app.route('/logout')
1013
  def logout():
 
1014
  logged_out_user = session.get('user')
1015
  session.pop('user', None)
1016
  session.pop('user_info', None)
1017
  if logged_out_user:
1018
  logging.info(f"Пользователь {logged_out_user} вышел из системы.")
 
1019
  logout_response_html = '''
1020
  <!DOCTYPE html><html><head><title>Выход...</title></head><body>
1021
  <script>
 
1027
  '''
1028
  return logout_response_html
1029
 
 
 
1030
  ADMIN_TEMPLATE = '''
1031
  <!DOCTYPE html>
1032
  <html lang="ru">
 
1037
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
1038
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
1039
  <style>
1040
+ body { font-family: 'Poppins', sans-serif; background-color: #e9f5f0; color: #2d332f; padding: 20px; line-height: 1.6; }
1041
  .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); }
1042
  .header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #d1e7dd; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
1043
+ h1, h2, h3 { font-weight: 600; color: #1C6758; margin-bottom: 15px; }
1044
  h1 { font-size: 1.8rem; }
1045
  h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
1046
+ h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; }
1047
  .section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; }
1048
  form { margin-bottom: 20px; }
1049
  label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;}
1050
  input[type="text"], input[type="number"], input[type="password"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #c4d9d1; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; }
1051
+ input:focus, textarea:focus, select:focus { border-color: #1C6758; outline: none; box-shadow: 0 0 0 2px rgba(28, 103, 88, 0.1); }
1052
  textarea { min-height: 80px; resize: vertical; }
1053
  input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;}
1054
  input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;}
1055
+ button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #1C6758; color: white; font-weight: 500; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 0.95rem; display: inline-flex; align-items: center; gap: 5px; text-decoration: none; line-height: 1.2;}
1056
+ button:hover, .button:hover { background-color: #164B41; }
1057
  button:active, .button:active { transform: scale(0.98); }
1058
  button[type="submit"] { min-width: 120px; justify-content: center; }
1059
+ .delete-button { background-color: #f56565; }
1060
  .delete-button:hover { background-color: #e53e3e; }
1061
+ .add-button { background-color: #38a169; }
1062
  .add-button:hover { background-color: #2f855a; }
1063
  .item-list { display: grid; gap: 20px; }
1064
  .item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #e1f0e9; }
 
1066
  .item strong { color: #2d332f; }
1067
  .item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
1068
  .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
 
1069
  .item-actions button:not(.delete-button) { background-color: #1C6758; }
1070
  .item-actions button:not(.delete-button):hover { background-color: #164B41; }
1071
+ .edit-form-container { margin-top: 15px; padding: 20px; background: #f0f9f4; border: 1px dashed #c4d9d1; border-radius: 6px; display: none; }
1072
  details { background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; margin-bottom: 20px; }
1073
+ details > summary { cursor: pointer; font-weight: 600; color: #164B41; display: block; padding: 15px; border-bottom: 1px solid #d1e7dd; list-style: none; position: relative; }
1074
+ 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; color: #1C6758; }
1075
  details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
1076
  details[open] > summary { border-bottom: 1px solid #d1e7dd; }
1077
  details .form-content { padding: 20px; }
1078
  .color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
1079
  .color-input-group input { flex-grow: 1; margin: 0; }
1080
+ .remove-color-btn { background-color: #f56565; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; }
1081
  .remove-color-btn:hover { background-color: #e53e3e; }
 
1082
  .add-color-btn { background-color: #63b3ed; }
1083
  .add-color-btn:hover { background-color: #4299e1; }
1084
  .photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #d1e7dd; object-fit: cover;}
1085
  .sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
 
1086
  .download-hf-button { background-color: #7a8d85; }
1087
  .download-hf-button:hover { background-color: #5e6e68; }
1088
  .flex-container { display: flex; flex-wrap: wrap; gap: 20px; }
1089
+ .flex-item { flex: 1; min-width: 350px; }
1090
  .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;}
1091
+ .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
1092
+ .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
1093
+ .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
1094
  </style>
1095
  </head>
1096
  <body>
1097
  <div class="container">
1098
  <div class="header">
1099
  <h1><i class="fas fa-tools"></i> Админ-панель Soola Cosmetics</h1>
1100
+ <a href="{{ url_for('catalog') }}" class="button" style="background-color: #3D8361;"><i class="fas fa-store"></i> Перейти в каталог</a>
1101
  </div>
1102
 
 
1103
  {% with messages = get_flashed_messages(with_categories=true) %}
1104
  {% if messages %}
1105
  {% for category, message in messages %}
 
1112
  <h2><i class="fas fa-sync-alt"></i> Синхронизация с Hugging Face</h2>
1113
  <div class="sync-buttons">
1114
  <form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно загрузить локальные данные на сервер? Это перезапишет данные на сервере.');">
1115
+ <button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить на HF</button>
1116
  </form>
1117
  <form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
1118
+ <button type="submit" class="button download-hf-button" title="Скачать файлы с Hugging Face (перезапишет локальные)"><i class="fas fa-download"></i> Скачать с HF</button>
1119
  </form>
1120
  </div>
1121
  <p style="font-size: 0.85rem; color: #5e6e68;">Резервное копирование на Hugging Face происходит автоматически каждые 30 м��нут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
 
1123
 
1124
 
1125
  <div class="flex-container">
1126
+ <div class="flex-item">
1127
  <div class="section">
1128
  <h2><i class="fas fa-tags"></i> Управление категориями</h2>
1129
  <details>
 
1133
  <input type="hidden" name="action" value="add_category">
1134
  <label for="add_category_name">Название новой категории:</label>
1135
  <input type="text" id="add_category_name" name="category_name" required>
1136
+ <button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button>
1137
  </form>
1138
  </div>
1139
  </details>
 
1147
  <form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить категорию \'{{ category }}\'? Товары этой категории будут помечены как \'Без категории\'.');">
1148
  <input type="hidden" name="action" value="delete_category">
1149
  <input type="hidden" name="category_name" value="{{ category }}">
1150
+ <button type="submit" class="delete-button" style="padding: 5px 10px; font-size: 0.8rem; margin: 0;"><i class="fas fa-trash-alt"></i></button>
1151
  </form>
1152
  </div>
1153
  {% endfor %}
 
1158
  </div>
1159
  </div>
1160
 
1161
+ <div class="flex-item">
1162
  <div class="section">
1163
  <h2><i class="fas fa-users"></i> Управление пользователями</h2>
1164
  <details>
 
1179
  <input type="text" id="country" name="country">
1180
  <label for="city">Город:</label>
1181
  <input type="text" id="city" name="city">
1182
+ <button type="submit" class="add-button"><i class="fas fa-save"></i> Сохранить пользователя</button>
1183
  </form>
1184
  </div>
1185
  </details>
 
1196
  <form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя \'{{ login }}\'?');">
1197
  <input type="hidden" name="action" value="delete_user">
1198
  <input type="hidden" name="login" value="{{ login }}">
1199
+ <button type="submit" class="delete-button"><i class="fas fa-user-slash"></i> Удалить</button>
1200
  </form>
 
1201
  </div>
1202
  </div>
1203
  {% endfor %}
 
1210
  </div>
1211
 
1212
 
1213
+ <div class="section">
1214
  <h2><i class="fas fa-box-open"></i> Управление товарами</h2>
1215
  <details>
1216
  <summary><i class="fas fa-plus-circle"></i> Добавить но��ый товар</summary>
 
1236
  <div id="add-color-inputs">
1237
  <div class="color-input-group">
1238
  <input type="text" name="colors" placeholder="Например: Розовый">
1239
+ <button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
1240
  </div>
1241
  </div>
1242
+ <button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button>
1243
  <br>
1244
+ <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button>
1245
  </form>
1246
  </div>
1247
  </details>
 
1252
  {% for product in products %}
1253
  <div class="item">
1254
  <div style="display: flex; gap: 15px; align-items: flex-start;">
 
1255
  <div class="photo-preview" style="flex-shrink: 0;">
1256
  {% if product.get('photos') %}
1257
  <a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" target="_blank" title="Посмотреть первое фото">
 
1261
  <img src="https://via.placeholder.com/70x70.png?text=N/A" alt="Нет фото">
1262
  {% endif %}
1263
  </div>
 
1264
  <div style="flex-grow: 1;">
1265
+ <h3 style="margin-top: 0; margin-bottom: 5px; color: #2d332f;">{{ product['name'] }}</h3>
1266
  <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
1267
  <p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
1268
  <p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
 
1275
  </div>
1276
 
1277
  <div class="item-actions">
1278
+ <button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
1279
  <form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
1280
  <input type="hidden" name="action" value="delete_product">
1281
  <input type="hidden" name="index" value="{{ loop.index0 }}">
1282
+ <button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
1283
  </form>
1284
  </div>
1285
 
 
1286
  <div id="edit-form-{{ loop.index0 }}" class="edit-form-container">
1287
  <h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
1288
  <form method="POST" enctype="multipart/form-data">
 
1316
  {% set current_colors = product.get('colors', []) %}
1317
  {% if current_colors and current_colors|select('ne', '')|list|length > 0 %}
1318
  {% for color in current_colors %}
1319
+ {% if color.strip() %}
1320
  <div class="color-input-group">
1321
  <input type="text" name="colors" value="{{ color }}">
1322
+ <button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
1323
  </div>
1324
  {% endif %}
1325
  {% endfor %}
1326
  {% else %}
 
1327
  <div class="color-input-group">
1328
  <input type="text" name="colors" placeholder="Например: Красный">
1329
+ <button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
1330
  </div>
1331
  {% endif %}
1332
  </div>
1333
+ <button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')"><i class="fas fa-palette"></i> Добавить поле для цвета</button>
1334
  <br>
1335
+ <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button>
1336
  </form>
1337
  </div>
1338
  </div>
 
1363
  <button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
1364
  `;
1365
  container.appendChild(newInputGroup);
 
1366
  const newInput = newInputGroup.querySelector('input[name="colors"]');
1367
  if (newInput) {
1368
  newInput.focus();
 
1371
  }
1372
 
1373
  function removeColorInput(button) {
 
1374
  const group = button.closest('.color-input-group');
1375
  if (group) {
1376
  const container = group.parentNode;
1377
  group.remove();
 
 
 
 
1378
  } else {
1379
  console.warn("Не удалось найти родительский .color-input-group для кнопки удаления");
1380
  }
 
1386
 
1387
  @app.route('/admin', methods=['GET', 'POST'])
1388
  def admin():
1389
+ if not session.get('user'):
 
 
 
 
1390
  flash("Требуется вход для доступа к админ-панели.", 'warning')
1391
  return redirect(url_for('login'))
 
1392
 
1393
  data = load_data()
1394
  products = data.get('products', [])
 
1404
  category_name = request.form.get('category_name', '').strip()
1405
  if category_name and category_name not in categories:
1406
  categories.append(category_name)
1407
+ categories.sort()
1408
  save_data(data)
1409
  logging.info(f"Категория '{category_name}' добавлена.")
1410
  flash(f"Категория '{category_name}' успешно добавлена.", 'success')
 
1419
  category_to_delete = request.form.get('category_name')
1420
  if category_to_delete and category_to_delete in categories:
1421
  categories.remove(category_to_delete)
 
1422
  updated_count = 0
1423
  for product in products:
1424
  if product.get('category') == category_to_delete:
 
1442
 
1443
  if not name or not price_str:
1444
  flash("Название и цена товара обязательны.", 'error')
1445
+ return redirect(url_for('admin'))
1446
 
1447
  try:
1448
  price = round(float(price_str), 2)
 
1465
  break
1466
  if photo and photo.filename:
1467
  try:
 
1468
  ext = os.path.splitext(photo.filename)[1]
1469
  photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
1470
  temp_path = os.path.join(uploads_dir, photo_filename)
 
1472
  logging.info(f"Загрузка фото {photo_filename} на HF для товара {name}...")
1473
  api.upload_file(
1474
  path_or_fileobj=temp_path,
1475
+ path_in_repo=f"photos/{photo_filename}",
1476
  repo_id=REPO_ID,
1477
  repo_type="dataset",
1478
  token=HF_TOKEN_WRITE,
 
1487
  flash(f"Ошибка при загрузке фото {photo.filename}.", 'error')
1488
  elif photo and not photo.filename:
1489
  logging.warning("Получен пустой объект файла фото при добавлении товара.")
 
1490
  try:
1491
  if not os.listdir(uploads_dir):
1492
  os.rmdir(uploads_dir)
 
1500
  'photos': photos_list, 'colors': colors
1501
  }
1502
  products.append(new_product)
 
1503
  products.sort(key=lambda x: x.get('name', '').lower())
1504
  save_data(data)
1505
  logging.info(f"Товар '{name}' добавлен.")
 
1520
  flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
1521
  return redirect(url_for('admin'))
1522
 
 
1523
  product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
1524
  price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.')
1525
  product_to_edit['description'] = request.form.get('description', product_to_edit['description']).strip()
 
1535
  logging.warning(f"Неверный формат цены '{price_str}' при редактировании товара {original_name}. Цена не изменена.")
1536
  flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
1537
 
 
1538
  photos_files = request.files.getlist('photos')
1539
  if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
1540
  uploads_dir = 'uploads_temp'
 
1566
  except Exception as e:
1567
  logging.error(f"Ошибка загрузки нового фото {photo.filename}: {e}", exc_info=True)
1568
  flash(f"Ошибка при загрузке нового фото {photo.filename}.", 'error')
 
1569
  try:
1570
  if not os.listdir(uploads_dir):
1571
  os.rmdir(uploads_dir)
1572
  except OSError as e:
1573
  logging.warning(f"Не удалось удалить временную папку {uploads_dir}: {e}")
1574
 
 
1575
  if new_photos_list:
1576
  logging.info(f"Список фото для товара {product_to_edit['name']} обновлен.")
 
 
1577
  old_photos = product_to_edit.get('photos', [])
1578
  if old_photos:
1579
  logging.info(f"Попытка удаления старых фото: {old_photos}")
 
1589
  except Exception as e:
1590
  logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True)
1591
  flash("Не удалось удалить старые фотографии с сервера.", "warning")
 
1592
  product_to_edit['photos'] = new_photos_list
1593
  flash("Фотографии товара успешно обновлены.", "success")
1594
  elif uploaded_count == 0 and any(f.filename for f in photos_files):
1595
  flash("Не удалось загрузить новые фотографии.", "error")
1596
 
 
1597
  products.sort(key=lambda x: x.get('name', '').lower())
1598
  save_data(data)
1599
  logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}'.")
 
1611
  deleted_product = products.pop(index)
1612
  product_name = deleted_product.get('name', 'N/A')
1613
 
 
1614
  photos_to_delete = deleted_product.get('photos', [])
1615
  if photos_to_delete and HF_TOKEN_WRITE:
1616
  logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}")
 
1627
  except Exception as e:
1628
  logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True)
1629
  flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
 
1630
 
1631
  save_data(data)
1632
  logging.info(f"Товар '{product_name}' (индекс {index}) удален.")
 
1650
  flash(f"Пользователь с логином '{login}' уже существует.", 'error')
1651
  return redirect(url_for('admin'))
1652
 
 
 
 
 
1653
  users[login] = {
1654
+ 'password': password,
1655
  'first_name': first_name, 'last_name': last_name,
1656
  'country': country, 'city': city
1657
  }
 
1674
  logging.warning(f"Получено неизвестное действие в админ-панели: {action}")
1675
  flash(f"Неизвестное действие: {action}", 'warning')
1676
 
 
1677
  return redirect(url_for('admin'))
1678
 
1679
  except Exception as e:
1680
  logging.error(f"Ошибка при обработке действия '{action}' в админ-панели: {e}", exc_info=True)
1681
  flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
1682
+ return redirect(url_for('admin'))
1683
 
 
 
1684
  products.sort(key=lambda x: x.get('name', '').lower())
 
1685
  categories.sort()
 
1686
  sorted_users = dict(sorted(users.items()))
1687
 
1688
  return render_template_string(
1689
+ ADMIN_TEMPLATE,
1690
  products=products,
1691
  categories=categories,
1692
+ users=sorted_users,
1693
  repo_id=REPO_ID,
1694
  currency_code=CURRENCY_CODE
1695
  )
1696
 
 
 
1697
  @app.route('/force_upload', methods=['POST'])
1698
  def force_upload():
 
1699
  if not session.get('user'):
1700
  flash("Требуется вход для выполнения этого действия.", 'warning')
1701
  return redirect(url_for('login'))
 
1711
 
1712
  @app.route('/force_download', methods=['POST'])
1713
  def force_download():
 
1714
  if not session.get('user'):
1715
  flash("Требуется вход для выполнения этого действия.", 'warning')
1716
  return redirect(url_for('login'))
 
1725
  return redirect(url_for('admin'))
1726
 
1727
 
 
 
1728
  if __name__ == '__main__':
 
1729
  load_data()
1730
  load_users()
1731
 
 
1732
  if HF_TOKEN_WRITE:
1733
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1734
  backup_thread.start()
 
1736
  else:
1737
  logging.warning("Периодическое резервное копирование НЕ будет запущено (HF_TOKEN или HF_TOKEN_WRITE не установлена).")
1738
 
1739
+ port = int(os.environ.get('PORT', 7860))
 
1740
  logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
 
 
 
 
 
1741
  app.run(debug=False, host='0.0.0.0', port=port)