Kgshop commited on
Commit
0c1ad78
·
verified ·
1 Parent(s): a69332c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +526 -189
app.py CHANGED
@@ -29,22 +29,61 @@ def load_data():
29
  data = json.load(file)
30
  logging.info("Данные успешно загружены из JSON")
31
  if not isinstance(data, dict) or 'products' not in data or 'categories' not in data:
32
- return {'products': [], 'categories': [] if not isinstance(data, list) else data}
 
 
 
 
 
 
 
 
 
 
 
33
  return data
34
  except FileNotFoundError:
35
- logging.warning("Локальный файл базы данных не найден после скачивания.")
36
  return {'products': [], 'categories': []}
37
  except json.JSONDecodeError:
38
  logging.error("Ошибка: Невозможно декодировать JSON файл.")
39
  return {'products': [], 'categories': []}
40
  except RepositoryNotFoundError:
41
- logging.error("Репозиторий не найден. Создание локальной базы данных.")
42
- return {'products': [], 'categories': []}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  except Exception as e:
44
  logging.error(f"Произошла ошибка при загрузке данных: {e}")
45
  return {'products': [], 'categories': []}
46
 
 
47
  def save_data(data):
 
 
 
 
 
 
 
 
 
 
 
48
  try:
49
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
50
  json.dump(data, file, ensure_ascii=False, indent=4)
@@ -52,9 +91,16 @@ def save_data(data):
52
  upload_db_to_hf()
53
  except Exception as e:
54
  logging.error(f"Ошибка при сохранении данных: {e}")
55
- raise
 
56
 
57
  def upload_db_to_hf():
 
 
 
 
 
 
58
  try:
59
  api = HfApi()
60
  api.upload_file(
@@ -70,6 +116,10 @@ def upload_db_to_hf():
70
  logging.error(f"Ошибка при загрузке резервной копии: {e}")
71
 
72
  def download_db_from_hf():
 
 
 
 
73
  try:
74
  hf_hub_download(
75
  repo_id=REPO_ID,
@@ -77,26 +127,32 @@ def download_db_from_hf():
77
  repo_type="dataset",
78
  token=HF_TOKEN_READ,
79
  local_dir=".",
80
- local_dir_use_symlinks=False
 
81
  )
82
  logging.info("JSON база успешно скачана из Hugging Face.")
83
  except RepositoryNotFoundError as e:
84
  logging.error(f"Репозиторий не найден: {e}")
85
- raise
86
  except Exception as e:
 
87
  logging.error(f"Ошибка при скачивании JSON базы: {e}")
88
- raise
 
89
 
90
  def periodic_backup():
91
  while True:
92
- upload_db_to_hf()
93
  time.sleep(800)
 
 
 
94
 
95
  @app.route('/')
96
  def catalog():
97
  data = load_data()
98
- products = sorted(data['products'], key=lambda x: x.get('added_at', ''), reverse=True)
99
- categories = data['categories']
 
100
 
101
  catalog_html = '''
102
  <!DOCTYPE html>
@@ -207,10 +263,26 @@ def catalog():
207
  }
208
  .products-grid {
209
  display: grid;
210
- grid-template-columns: repeat(2, minmax(200px, 1fr));
211
- gap: 15px;
212
  padding: 10px;
213
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  .product {
215
  background: var(--light-text);
216
  border-radius: 15px;
@@ -218,6 +290,9 @@ def catalog():
218
  box-shadow: 0 4px 15px var(--shadow-color);
219
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
220
  overflow: hidden;
 
 
 
221
  }
222
  .product:hover {
223
  transform: translateY(-5px) scale(1.02);
@@ -225,13 +300,14 @@ def catalog():
225
  }
226
  .product-image {
227
  width: 100%;
228
- aspect-ratio: 1;
229
  background-color: #fff;
230
  border-radius: 10px;
231
  overflow: hidden;
232
  display: flex;
233
  justify-content: center;
234
  align-items: center;
 
235
  }
236
  .product-image img {
237
  max-width: 100%;
@@ -245,11 +321,15 @@ def catalog():
245
  .product h2 {
246
  font-size: 1rem;
247
  font-weight: 600;
248
- margin: 10px 0;
249
  text-align: center;
250
- white-space: nowrap;
251
  overflow: hidden;
252
- text-overflow: ellipsis;
 
 
 
 
253
  }
254
  .product-price {
255
  font-size: 1.1rem;
@@ -266,6 +346,13 @@ def catalog():
266
  overflow: hidden;
267
  text-overflow: ellipsis;
268
  white-space: nowrap;
 
 
 
 
 
 
 
269
  }
270
  .product-button {
271
  display: block;
@@ -279,7 +366,6 @@ def catalog():
279
  font-weight: 500;
280
  cursor: pointer;
281
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
282
- margin: 5px 0;
283
  text-align: center;
284
  text-decoration: none;
285
  }
@@ -311,6 +397,8 @@ def catalog():
311
  box-shadow: 0 4px 15px rgba(255, 167, 38, 0.4);
312
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
313
  z-index: 1000;
 
 
314
  }
315
  #cart-button:hover {
316
  background-color: var(--hover-primary-color);
@@ -335,17 +423,19 @@ def catalog():
335
  max-width: 700px;
336
  box-shadow: 0 10px 30px rgba(0,0,0,0.2);
337
  animation: slideIn 0.3s ease-out;
338
- max-height: 85vh;
339
- overflow-y: auto;
340
- display: flex;
341
- flex-direction: column;
 
 
 
 
 
342
  }
343
  #cartContent {
344
- max-height: 60vh;
345
- overflow-y: auto;
346
- margin-bottom: 15px;
347
- padding-right: 10px;
348
- flex-grow: 1;
349
  }
350
  @keyframes slideIn {
351
  from { transform: translateY(-50px); opacity: 0; }
@@ -357,7 +447,8 @@ def catalog():
357
  color: #718096;
358
  cursor: pointer;
359
  transition: color 0.3s;
360
- align-self: flex-end;
 
361
  }
362
  .close:hover {
363
  color: var(--primary-color);
@@ -368,13 +459,32 @@ def catalog():
368
  align-items: center;
369
  padding: 15px 0;
370
  border-bottom: 1px solid var(--secondary-color);
 
 
 
 
 
 
 
371
  }
372
  .cart-item img {
373
  width: 50px;
374
  height: 50px;
375
  object-fit: contain;
376
  border-radius: 8px;
377
- margin-right: 15px;
 
 
 
 
 
 
 
 
 
 
 
 
378
  }
379
  .quantity-input, .color-select {
380
  width: 100%;
@@ -400,15 +510,19 @@ def catalog():
400
  box-shadow: 0 4px 15px rgba(255, 160, 0, 0.4);
401
  }
402
  .modal-footer {
403
- margin-top: auto;
404
  padding-top: 15px;
405
  text-align: right;
406
  border-top: 1px solid var(--secondary-color);
 
 
 
 
407
  }
 
 
 
408
  @media (max-width: 768px) {
409
- .products-grid {
410
- grid-template-columns: repeat(2, minmax(150px, 1fr));
411
- }
412
  .header h1 {
413
  font-size: 1.2rem;
414
  }
@@ -444,12 +558,15 @@ def catalog():
444
  #cartContent {
445
  max-height: 65vh;
446
  }
 
 
 
 
 
 
 
447
  }
448
  @media (max-width: 480px) {
449
- .products-grid {
450
- grid-template-columns: 1fr;
451
- gap: 10px;
452
- }
453
  .header {
454
  flex-direction: column;
455
  align-items: center;
@@ -466,6 +583,14 @@ def catalog():
466
  .category-filter {
467
  padding: 5px 10px;
468
  }
 
 
 
 
 
 
 
 
469
  }
470
  </style>
471
  </head>
@@ -489,7 +614,7 @@ def catalog():
489
  <div class="product"
490
  data-name="{{ product['name']|lower }}"
491
  data-description="{{ product['description']|lower }}"
492
- data-category="{{ product.get('category', 'Без категории') }}">
493
  {% if product.get('photos') and product['photos']|length > 0 %}
494
  <div class="product-image">
495
  <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
@@ -500,8 +625,10 @@ def catalog():
500
  <h2>{{ product['name'] }}</h2>
501
  <div class="product-price">{{ product['price'] }} с</div>
502
  <p class="product-description">{{ product['description'][:50] }}{% if product['description']|length > 50 %}...{% endif %}</p>
503
- <button class="product-button" onclick="openModal({{ loop.index0 }})">Подробнее</button>
504
- <button class="product-button add-to-cart" onclick="openQuantityModal({{ loop.index0 }})">В корзину</button>
 
 
505
  </div>
506
  {% endfor %}
507
  </div>
@@ -520,7 +647,9 @@ def catalog():
520
  <h2>Укажите количество и цвет</h2>
521
  <input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
522
  <select id="colorSelect" class="color-select"></select>
523
- <button class="product-button" onclick="confirmAddToCart()">Добавить</button>
 
 
524
  </div>
525
  </div>
526
 
@@ -531,8 +660,10 @@ def catalog():
531
  <div id="cartContent"></div>
532
  <div class="modal-footer">
533
  <strong>Итого: <span id="cartTotal">0</span> с</strong>
534
- <button class="product-button clear-cart" onclick="clearCart()">Очистить</button>
535
- <button class="product-button order-button" onclick="orderViaWhatsApp()">Заказать</button>
 
 
536
  </div>
537
  </div>
538
  </div>
@@ -543,10 +674,12 @@ def catalog():
543
  <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.3/dist/umd/popper.min.js"></script>
544
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
545
  <script>
 
546
  const products = {{ products|tojson }};
547
  let selectedProductIndex = null;
548
 
549
  function openModal(index) {
 
550
  loadProductDetails(index);
551
  document.getElementById('productModal').style.display = "block";
552
  }
@@ -556,45 +689,63 @@ def catalog():
556
  }
557
 
558
  function loadProductDetails(index) {
 
559
  fetch('/product/' + index)
560
- .then(response => response.text())
 
 
 
 
 
561
  .then(data => {
562
  document.getElementById('modalContent').innerHTML = data;
563
  initializeSwiper();
564
  })
565
- .catch(error => console.error('Ошибка:', error));
 
 
 
566
  }
567
 
568
  function initializeSwiper() {
569
- new Swiper('.swiper-container', {
 
 
 
 
 
570
  slidesPerView: 1,
571
  spaceBetween: 20,
572
- loop: true,
573
  grabCursor: true,
574
  pagination: { el: '.swiper-pagination', clickable: true },
575
  navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
576
- zoom: { maxRatio: 3 }
 
 
577
  });
578
  }
579
 
580
  function openQuantityModal(index) {
581
  selectedProductIndex = index;
 
582
  const product = products[index];
 
 
 
 
 
583
  const colorSelect = document.getElementById('colorSelect');
584
  colorSelect.innerHTML = '';
585
- if (product.colors && product.colors.length > 0) {
586
- product.colors.forEach(color => {
587
- const option = document.createElement('option');
588
- option.value = color;
589
- option.text = color;
590
- colorSelect.appendChild(option);
591
- });
592
- } else {
593
  const option = document.createElement('option');
594
- option.value = 'Нет цвета';
595
- option.text = 'Нет цвета';
596
  colorSelect.appendChild(option);
597
- }
 
598
  document.getElementById('quantityModal').style.display = 'block';
599
  document.getElementById('quantityInput').value = 1;
600
  }
@@ -608,15 +759,23 @@ def catalog():
608
  return;
609
  }
610
  let cart = JSON.parse(localStorage.getItem('cart') || '[]');
 
611
  const product = products[selectedProductIndex];
612
- const cartItemId = `${product.name}-${color}`;
613
- const existingItem = cart.find(item => item.id === cartItemId);
 
 
 
614
 
615
- if (existingItem) {
616
- existingItem.quantity += quantity;
 
 
 
 
617
  } else {
618
  cart.push({
619
- id: cartItemId,
620
  name: product.name,
621
  price: product.price,
622
  photo: product.photos && product.photos.length > 0 ? product.photos[0] : '',
@@ -628,14 +787,13 @@ def catalog():
628
  localStorage.setItem('cart', JSON.stringify(cart));
629
  closeModal('quantityModal');
630
  updateCartButton();
 
631
  }
632
 
633
  function updateCartButton() {
634
  const cart = JSON.parse(localStorage.getItem('cart') || '[]');
635
  const cartButton = document.getElementById('cart-button');
636
  cartButton.style.display = cart.length > 0 ? 'flex' : 'none';
637
- cartButton.style.alignItems = 'center';
638
- cartButton.style.justifyContent = 'center';
639
  }
640
 
641
  function openCartModal() {
@@ -644,24 +802,29 @@ def catalog():
644
  let total = 0;
645
 
646
  cartContent.innerHTML = cart.length === 0 ? '<p>Корзина пуста</p>' : cart.map(item => {
647
- const itemTotal = item.price * item.quantity;
 
 
 
648
  total += itemTotal;
649
  return `
650
  <div class="cart-item">
651
- <div style="display: flex; align-items: center;">
652
- ${item.photo ? `<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/${item.photo}" alt="${item.name}">` : ''}
653
- <div>
654
  <strong>${item.name}</strong>
655
- <p>${item.price} с × ${item.quantity} (Цвет: ${item.color})</p>
656
  </div>
657
  </div>
658
- <span>${itemTotal} с</span>
659
  </div>
660
  `;
661
  }).join('');
662
 
663
- document.getElementById('cartTotal').textContent = total;
664
  document.getElementById('cartModal').style.display = 'block';
 
 
665
  }
666
 
667
  function orderViaWhatsApp() {
@@ -671,14 +834,22 @@ def catalog():
671
  return;
672
  }
673
  let total = 0;
674
- let orderText = "Заказ:%0A";
675
  cart.forEach((item, index) => {
676
- const itemTotal = item.price * item.quantity;
 
 
677
  total += itemTotal;
678
- orderText += `${index + 1}. ${item.name} - ${item.price} с × ${item.quantity} (Цвет: ${item.color})%0A`;
 
 
 
 
679
  });
680
- orderText += `Итого: ${total} с`;
681
- window.open(`https://api.whatsapp.com/send?phone=996704667878&text=${orderText}`, '_blank');
 
 
682
  }
683
 
684
  function clearCart() {
@@ -687,10 +858,14 @@ def catalog():
687
  updateCartButton();
688
  }
689
 
 
690
  window.onclick = function(event) {
691
- if (event.target.classList.contains('modal')) {
692
- event.target.style.display = "none";
693
- }
 
 
 
694
  }
695
 
696
  document.getElementById('search-input').addEventListener('input', filterProducts);
@@ -703,19 +878,52 @@ def catalog():
703
  });
704
 
705
  function filterProducts() {
706
- const searchTerm = document.getElementById('search-input').value.toLowerCase();
707
- const activeCategory = document.querySelector('.category-filter.active').dataset.category;
708
- document.querySelectorAll('.product').forEach(product => {
709
- const name = product.getAttribute('data-name');
710
- const description = product.getAttribute('data-description');
711
- const category = product.getAttribute('data-category');
712
- const matchesSearch = name.includes(searchTerm) || description.includes(searchTerm);
 
 
 
 
 
713
  const matchesCategory = activeCategory === 'all' || category === activeCategory;
714
- product.style.display = matchesSearch && matchesCategory ? 'block' : 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
  });
 
716
  }
717
 
 
718
  updateCartButton();
 
 
719
  </script>
720
  </body>
721
  </html>
@@ -725,42 +933,51 @@ def catalog():
725
  @app.route('/product/<int:index>')
726
  def product_detail(index):
727
  data = load_data()
728
- products = data['products']
 
729
  try:
 
730
  product = products[index]
731
  except IndexError:
 
732
  return "Продукт не найден", 404
 
 
 
733
 
734
  detail_html = '''
735
- <div class="container" style="padding: 20px; font-family: 'Poppins', sans-serif; color: #333;">
736
- <h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 20px; color: #FFA726;">{{ product['name'] }}</h2>
737
- <div class="swiper-container" style="max-width: 400px; margin: 0 auto 20px; --swiper-navigation-color: #FFA726; --swiper-pagination-color: #FFA726;">
738
  <div class="swiper-wrapper">
739
- {% if product.get('photos') and product['photos']|length > 0 %}
740
- {% for photo in product['photos'] %}
741
- <div class="swiper-slide" style="background-color: #fff; display: flex; justify-content: center; align-items: center; border-radius: 10px;">
742
- <div class="swiper-zoom-container">
743
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
744
- alt="{{ product['name'] }}"
745
- style="max-width: 100%; max-height: 350px; object-fit: contain;">
 
 
746
  </div>
747
- </div>
748
- {% endfor %}
749
  {% else %}
750
- <div class="swiper-slide" style="background-color: #eee; display: flex; justify-content: center; align-items: center; border-radius: 10px; height: 350px;">
751
- <span style="color: #999;">Нет изображения</span>
752
- </div>
753
  {% endif %}
754
  </div>
 
755
  <div class="swiper-pagination"></div>
756
  <div class="swiper-button-next"></div>
757
  <div class="swiper-button-prev"></div>
 
758
  </div>
759
  <div style="background: #FFF8E1; padding: 15px; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
760
  <p style="margin-bottom: 10px;"><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
761
  <p style="margin-bottom: 10px;"><strong>Цена:</strong> <span style="color: #D84315; font-weight: bold;">{{ product['price'] }} с</span></p>
762
- <p style="margin-bottom: 10px;"><strong>Описание:</strong> {{ product['description'] }}</p>
763
- <p><strong>Доступные цвета:</strong> {{ product.get('colors', ['Нет цветов'])|join(', ') if product.get('colors') else 'Нет цветов' }}</p>
764
  </div>
765
  </div>
766
  '''
@@ -770,44 +987,66 @@ def product_detail(index):
770
  @app.route('/admin', methods=['GET', 'POST'])
771
  def admin():
772
  data = load_data()
773
- products = data['products']
774
- categories = data['categories']
775
 
776
  if request.method == 'POST':
777
  action = request.form.get('action')
778
 
779
  if action == 'add_category':
780
- category_name = request.form.get('category_name')
781
  if category_name and category_name not in categories:
782
  categories.append(category_name)
783
- save_data(data)
784
  return redirect(url_for('admin'))
785
- return "Ошибка: Категория уже существует или не указано название", 400
 
 
 
786
 
787
  elif action == 'delete_category':
788
- category_index = int(request.form.get('category_index'))
789
- deleted_category = categories.pop(category_index)
790
- for product in products:
791
- if product.get('category') == deleted_category:
792
- product['category'] = 'Без категории'
793
- save_data(data)
794
- return redirect(url_for('admin'))
 
 
 
 
 
 
 
795
 
796
  elif action == 'add':
797
- name = request.form.get('name')
798
- price = request.form.get('price')
799
- description = request.form.get('description')
800
  category = request.form.get('category')
801
  photos_files = request.files.getlist('photos')
802
  colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
803
  photos_list = []
804
 
 
 
 
 
 
 
 
 
 
 
805
  if photos_files:
 
 
806
  for photo in photos_files[:10]:
807
  if photo and photo.filename:
808
- photo_filename = secure_filename(f"{int(time.time())}_{photo.filename}")
809
- uploads_dir = 'uploads'
810
- os.makedirs(uploads_dir, exist_ok=True)
811
  temp_path = os.path.join(uploads_dir, photo_filename)
812
  try:
813
  photo.save(temp_path)
@@ -821,6 +1060,7 @@ def admin():
821
  commit_message=f"Добавлено фото для товара {name}"
822
  )
823
  photos_list.append(photo_filename)
 
824
  except Exception as e:
825
  logging.error(f"Ошибка при загрузке фото {photo_filename}: {e}")
826
  finally:
@@ -831,14 +1071,6 @@ def admin():
831
  logging.error(f"Ошибка при удалении временного файла {temp_path}: {e}")
832
 
833
 
834
- if not name or not price or not description:
835
- return "Ошибка: Заполните все обязательные поля", 400
836
-
837
- try:
838
- price = float(price.replace(',', '.'))
839
- except ValueError:
840
- return "Ошибка: Неверный формат цены", 400
841
-
842
  new_product = {
843
  'name': name,
844
  'price': price,
@@ -846,43 +1078,49 @@ def admin():
846
  'category': category if category in categories else 'Без категории',
847
  'photos': photos_list,
848
  'colors': colors,
849
- 'added_at': datetime.now().isoformat()
850
  }
851
  products.append(new_product)
852
- save_data(data)
853
  return redirect(url_for('admin'))
854
 
855
  elif action == 'edit':
856
  try:
857
  index = int(request.form.get('index'))
858
  if not 0 <= index < len(products):
859
- return "Ошибка: Неверный индекс товара", 400
860
  except (ValueError, TypeError):
861
- return "Ошибка: Неверный индекс товара", 400
862
 
863
- name = request.form.get('name')
864
- price = request.form.get('price')
865
- description = request.form.get('description')
866
  category = request.form.get('category')
867
  photos_files = request.files.getlist('photos')
868
  colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
869
 
870
- if not name or not price or not description:
871
- return "Ошибка: Заполните все обязательные поля", 400
872
 
873
  try:
874
- price_float = float(price.replace(',', '.'))
 
 
875
  except ValueError:
876
- return "Ошибка: Неверный формат цены", 400
877
 
 
878
 
879
- if photos_files and any(photo.filename for photo in photos_files):
 
880
  new_photos_list = []
 
 
 
881
  for photo in photos_files[:10]:
882
  if photo and photo.filename:
883
- photo_filename = secure_filename(f"{int(time.time())}_{photo.filename}")
884
- uploads_dir = 'uploads'
885
- os.makedirs(uploads_dir, exist_ok=True)
886
  temp_path = os.path.join(uploads_dir, photo_filename)
887
  try:
888
  photo.save(temp_path)
@@ -896,6 +1134,7 @@ def admin():
896
  commit_message=f"Обновлено фото для товара {name}"
897
  )
898
  new_photos_list.append(photo_filename)
 
899
  except Exception as e:
900
  logging.error(f"Ошибка при загрузке фото {photo_filename} при редактировании: {e}")
901
  finally:
@@ -905,28 +1144,39 @@ def admin():
905
  except OSError as e:
906
  logging.error(f"Ошибка при удалении временного файла {temp_path} при редактировании: {e}")
907
 
908
-
909
- products[index]['photos'] = new_photos_list
910
-
911
  products[index]['name'] = name
912
  products[index]['price'] = price_float
913
  products[index]['description'] = description
914
  products[index]['category'] = category if category in categories else 'Без категории'
 
915
  products[index]['colors'] = colors
 
 
916
 
917
- save_data(data)
918
  return redirect(url_for('admin'))
919
 
920
  elif action == 'delete':
921
  try:
922
  index = int(request.form.get('index'))
923
  if not 0 <= index < len(products):
924
- return "Ошибка: Неверный индекс товара", 400
 
 
 
 
 
 
 
 
 
 
 
925
 
926
  del products[index]
927
- save_data(data)
928
  except (ValueError, TypeError):
929
- return "Ошибка: Неверный индекс товара", 400
930
  except Exception as e:
931
  logging.error(f"Ошибка при удалении товара: {e}")
932
  return "Ошибка при удалении товара", 500
@@ -1032,6 +1282,7 @@ def admin():
1032
  textarea {
1033
  resize: vertical;
1034
  min-height: 80px;
 
1035
  }
1036
  input[type="file"] {
1037
  padding: 10px;
@@ -1111,12 +1362,15 @@ def admin():
1111
  margin: 0;
1112
  color: var(--text-color);
1113
  font-weight: 500;
 
 
1114
  }
1115
  .category-item form {
1116
  background: none;
1117
  padding: 0;
1118
  box-shadow: none;
1119
  margin: 0;
 
1120
  }
1121
  .category-item button {
1122
  margin: 0;
@@ -1131,10 +1385,16 @@ def admin():
1131
  margin-bottom: 8px;
1132
  font-size: 0.9rem;
1133
  line-height: 1.5;
 
1134
  }
1135
  .product-item p strong {
1136
  color: #555;
 
 
1137
  }
 
 
 
1138
  .product-item .product-photos {
1139
  display: flex;
1140
  flex-wrap: wrap;
@@ -1163,6 +1423,13 @@ def admin():
1163
  .product-item summary:hover {
1164
  color: var(--hover-primary-color);
1165
  }
 
 
 
 
 
 
 
1166
  .edit-form {
1167
  margin-top: 15px;
1168
  padding: 20px;
@@ -1189,11 +1456,17 @@ def admin():
1189
  font-size: 0.8rem;
1190
  background-color: #ddd;
1191
  color: #333;
 
1192
  border-radius: 50%;
1193
  margin-top: 0;
1194
  min-width: 25px;
1195
  height: 25px;
1196
- line-height: 1;
 
 
 
 
 
1197
  }
1198
  .color-input-group .remove-color-btn:hover {
1199
  background-color: #ccc;
@@ -1204,18 +1477,36 @@ def admin():
1204
  .button-group {
1205
  margin-top: 20px;
1206
  display: flex;
 
1207
  gap: 10px;
1208
  }
1209
- .button-group form {
 
1210
  background: none;
1211
  padding: 0;
1212
  margin: 0;
1213
  box-shadow: none;
 
1214
  }
1215
- .button-group button {
 
1216
  margin-top: 0;
1217
  }
1218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1219
  @media (max-width: 768px) {
1220
  .product-list, .category-list {
1221
  grid-template-columns: 1fr;
@@ -1231,6 +1522,16 @@ def admin():
1231
  padding: 20px;
1232
  }
1233
  }
 
 
 
 
 
 
 
 
 
 
1234
 
1235
  </style>
1236
  </head>
@@ -1248,7 +1549,7 @@ def admin():
1248
  <input type="text" id="add-name" name="name" required>
1249
 
1250
  <label for="add-price">Цена (с):</label>
1251
- <input type="number" id="add-price" name="price" step="0.01" required placeholder="Например: 150.50">
1252
 
1253
  <label for="add-description">Описание:</label>
1254
  <textarea id="add-description" name="description" rows="4" required></textarea>
@@ -1261,8 +1562,8 @@ def admin():
1261
  {% endfor %}
1262
  </select>
1263
 
1264
- <label for="add-photos">Фотографии (до 10):</label>
1265
- <input type="file" id="add-photos" name="photos" accept="image/*" multiple>
1266
 
1267
  <label>Доступные цвета:</label>
1268
  <div id="add-color-inputs" class="color-input-container">
@@ -1289,7 +1590,7 @@ def admin():
1289
  {% for category in categories %}
1290
  <div class="category-item">
1291
  <h3>{{ category }}</h3>
1292
- <form method="POST" style="display: inline;">
1293
  <input type="hidden" name="action" value="delete_category">
1294
  <input type="hidden" name="category_index" value="{{ loop.index0 }}">
1295
  <button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
@@ -1312,13 +1613,15 @@ def admin():
1312
 
1313
  <h2>Список товаров</h2>
1314
  <div class="product-list">
1315
- {% for product in products %}
 
1316
  <div class="product-item">
1317
  <h3>{{ product['name'] }}</h3>
1318
  <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
1319
  <p><strong>Цена:</strong> {{ product['price'] }} с</p>
1320
- <p><strong>Описание:</strong> {{ product['description'] }}</p>
1321
  <p><strong>Цвета:</strong> {{ product.get('colors', [])|join(', ') if product.get('colors') else 'Нет цветов' }}</p>
 
1322
  {% if product.get('photos') and product['photos']|length > 0 %}
1323
  <div class="product-photos">
1324
  {% for photo in product['photos'] %}
@@ -1327,9 +1630,11 @@ def admin():
1327
  loading="lazy">
1328
  {% endfor %}
1329
  </div>
 
 
1330
  {% endif %}
1331
  <div class="button-group">
1332
- <details style="margin-top: 0;">
1333
  <summary><i class="fas fa-edit"></i> Редактировать</summary>
1334
  <form method="POST" enctype="multipart/form-data" class="edit-form">
1335
  <input type="hidden" name="action" value="edit">
@@ -1337,7 +1642,7 @@ def admin():
1337
  <label for="edit-name-{{ loop.index0 }}">Название:</label>
1338
  <input type="text" id="edit-name-{{ loop.index0 }}" name="name" value="{{ product['name'] }}" required>
1339
  <label for="edit-price-{{ loop.index0 }}">Цена (с):</label>
1340
- <input type="number" id="edit-price-{{ loop.index0 }}" name="price" step="0.01" value="{{ product['price'] }}" required>
1341
  <label for="edit-description-{{ loop.index0 }}">Описание:</label>
1342
  <textarea id="edit-description-{{ loop.index0 }}" name="description" rows="4" required>{{ product['description'] }}</textarea>
1343
  <label for="edit-category-{{ loop.index0 }}">Категория:</label>
@@ -1348,7 +1653,7 @@ def admin():
1348
  {% endfor %}
1349
  </select>
1350
  <label for="edit-photos-{{ loop.index0 }}">Заменить фотографии (до 10):</label>
1351
- <input type="file" id="edit-photos-{{ loop.index0 }}" name="photos" accept="image/*" multiple>
1352
  <label>Доступные цвета:</label>
1353
  <div id="edit-color-inputs-{{ loop.index0 }}" class="color-input-container">
1354
  {% set colors = product.get('colors', []) %}
@@ -1370,10 +1675,10 @@ def admin():
1370
  <button type="submit"><i class="fas fa-save"></i> Сохранить изменения</button>
1371
  </form>
1372
  </details>
1373
- <form method="POST">
1374
  <input type="hidden" name="action" value="delete">
1375
- <input type="hidden" name="index" value="{{ loop.index0 }}">
1376
- <button type="submit" class="delete-button" onclick="return confirm('Вы уверены, что хотите удалить этот товар?');"><i class="fas fa-trash-alt"></i> Удалить товар</button>
1377
  </form>
1378
  </div>
1379
  </div>
@@ -1385,6 +1690,7 @@ def admin():
1385
  <script>
1386
  function addColorInput(containerId) {
1387
  const container = document.getElementById(containerId);
 
1388
  const newInputGroup = document.createElement('div');
1389
  newInputGroup.className = 'color-input-group';
1390
  newInputGroup.innerHTML = `
@@ -1392,22 +1698,30 @@ def admin():
1392
  <button type="button" class="remove-color-btn" onclick="removeColorInput(this)">X</button>
1393
  `;
1394
 
1395
- container.querySelectorAll('.remove-color-btn').forEach(btn => btn.style.display = 'inline-block');
1396
  container.appendChild(newInputGroup);
1397
  }
1398
 
1399
  function removeColorInput(button) {
1400
- const container = button.closest('.color-input-container');
1401
- button.closest('.color-input-group').remove();
 
 
 
1402
 
1403
  const remainingGroups = container.querySelectorAll('.color-input-group');
1404
  if (remainingGroups.length === 1) {
1405
  const lastRemoveBtn = remainingGroups[0].querySelector('.remove-color-btn');
1406
  if(lastRemoveBtn) lastRemoveBtn.style.display = 'none';
1407
- }
1408
-
1409
- if (remainingGroups.length === 0) {
1410
  addColorInput(container.id);
 
 
 
 
 
 
1411
  }
1412
  }
1413
 
@@ -1417,18 +1731,36 @@ def admin():
1417
  if (groups.length === 1) {
1418
  const removeBtn = groups[0].querySelector('.remove-color-btn');
1419
  if (removeBtn) removeBtn.style.display = 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1420
  }
1421
  });
1422
  </script>
1423
  </body>
1424
  </html>
1425
  '''
 
 
1426
  return render_template_string(admin_html, products=products, categories=categories, repo_id=REPO_ID)
1427
 
1428
  @app.route('/backup', methods=['POST'])
1429
  def backup():
1430
  try:
1431
- upload_db_to_hf()
 
1432
  return "Резервная копия успешно создана и загружена на Hugging Face.", 200
1433
  except Exception as e:
1434
  logging.error(f"Ошибка при ручном создании резервной копии: {e}")
@@ -1439,9 +1771,8 @@ def backup():
1439
  def download():
1440
  try:
1441
  download_db_from_hf()
1442
-
1443
-
1444
- return "Актуальная база данных успешно скачана из Hugging Face.", 200
1445
  except RepositoryNotFoundError:
1446
  return "Ошибка: Репозиторий Hugging Face не найден.", 404
1447
  except Exception as e:
@@ -1450,22 +1781,28 @@ def download():
1450
 
1451
 
1452
  if __name__ == '__main__':
1453
-
1454
  os.makedirs('uploads', exist_ok=True)
1455
 
 
 
 
 
 
 
 
1456
 
1457
- backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1458
- backup_thread.start()
1459
-
1460
-
1461
  try:
1462
  load_data()
1463
  except Exception as e:
 
1464
 
1465
- logging.warning(f"Не удалось первоначально загрузить/скачать базу данных: {e}. Приложение запустится с пустыми данными.")
1466
-
1467
-
 
 
 
1468
 
1469
- app.run(debug=False, host='0.0.0.0', port=int(os.environ.get("PORT", 7860)))
1470
 
1471
  # --- END OF FILE app.py ---
 
29
  data = json.load(file)
30
  logging.info("Данные успешно загружены из JSON")
31
  if not isinstance(data, dict) or 'products' not in data or 'categories' not in data:
32
+ logging.warning("Структура JSON неверна, инициализация...")
33
+ # Handle case where data is a list (old format) or dict is missing keys
34
+ if isinstance(data, list):
35
+ return {'products': [], 'categories': data} # Assume list is categories if not dict
36
+ else:
37
+ return {'products': [], 'categories': []}
38
+ # Ensure categories is always a list
39
+ if 'categories' not in data or not isinstance(data['categories'], list):
40
+ data['categories'] = []
41
+ # Ensure products is always a list
42
+ if 'products' not in data or not isinstance(data['products'], list):
43
+ data['products'] = []
44
  return data
45
  except FileNotFoundError:
46
+ logging.warning("Локальный файл базы данных не найден.")
47
  return {'products': [], 'categories': []}
48
  except json.JSONDecodeError:
49
  logging.error("Ошибка: Невозможно декодировать JSON файл.")
50
  return {'products': [], 'categories': []}
51
  except RepositoryNotFoundError:
52
+ logging.warning("Репозиторий HF не найден. Используется/создается локальная база.")
53
+ # Attempt to load local file even if repo not found, or return empty
54
+ try:
55
+ with open(DATA_FILE, 'r', encoding='utf-8') as file:
56
+ data = json.load(file)
57
+ if not isinstance(data, dict) or 'products' not in data or 'categories' not in data:
58
+ if isinstance(data, list):
59
+ return {'products': [], 'categories': data}
60
+ else:
61
+ return {'products': [], 'categories': []}
62
+ if 'categories' not in data or not isinstance(data['categories'], list):
63
+ data['categories'] = []
64
+ if 'products' not in data or not isinstance(data['products'], list):
65
+ data['products'] = []
66
+ return data
67
+ except (FileNotFoundError, json.JSONDecodeError):
68
+ logging.warning("Локальный файл также не найден или поврежден.")
69
+ return {'products': [], 'categories': []}
70
  except Exception as e:
71
  logging.error(f"Произошла ошибка при загрузке данных: {e}")
72
  return {'products': [], 'categories': []}
73
 
74
+
75
  def save_data(data):
76
+ # Ensure data integrity before saving
77
+ if not isinstance(data, dict):
78
+ logging.error("Попытка сохранить данные не в формате словаря. Сохранение отменено.")
79
+ return
80
+ if 'products' not in data or not isinstance(data['products'], list):
81
+ data['products'] = []
82
+ logging.warning("Ключ 'products' отсутствует или не является списком. Инициализирован как пустой список.")
83
+ if 'categories' not in data or not isinstance(data['categories'], list):
84
+ data['categories'] = []
85
+ logging.warning("Ключ 'categories' отсутствует или не является списком. Инициализирован как пустой список.")
86
+
87
  try:
88
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
89
  json.dump(data, file, ensure_ascii=False, indent=4)
 
91
  upload_db_to_hf()
92
  except Exception as e:
93
  logging.error(f"Ошибка при сохранении данных: {e}")
94
+ # Do not raise here to potentially allow app to continue running
95
+ # Consider more robust error handling if needed
96
 
97
  def upload_db_to_hf():
98
+ if not HF_TOKEN_WRITE:
99
+ logging.warning("HF_TOKEN_WRITE не установлен. Загрузка на Hugging Face пропущена.")
100
+ return
101
+ if not os.path.exists(DATA_FILE):
102
+ logging.warning(f"Файл {DATA_FILE} не найден для загрузки на Hugging Face.")
103
+ return
104
  try:
105
  api = HfApi()
106
  api.upload_file(
 
116
  logging.error(f"Ошибка при загрузке резервной копии: {e}")
117
 
118
  def download_db_from_hf():
119
+ if not HF_TOKEN_READ:
120
+ logging.warning("HF_TOKEN_READ не установлен. Скачивание с Hugging Face пропущено.")
121
+ # Try to load local file instead of raising immediately
122
+ return
123
  try:
124
  hf_hub_download(
125
  repo_id=REPO_ID,
 
127
  repo_type="dataset",
128
  token=HF_TOKEN_READ,
129
  local_dir=".",
130
+ local_dir_use_symlinks=False,
131
+ force_download=True # Ensure fresh copy
132
  )
133
  logging.info("JSON база успешно скачана из Hugging Face.")
134
  except RepositoryNotFoundError as e:
135
  logging.error(f"Репозиторий не найден: {e}")
136
+ raise # Re-raise to be caught by load_data
137
  except Exception as e:
138
+ # Handle other potential HF download errors (e.g., connection issues)
139
  logging.error(f"Ошибка при скачивании JSON базы: {e}")
140
+ # Do not raise here, let load_data try local file
141
+
142
 
143
  def periodic_backup():
144
  while True:
 
145
  time.sleep(800)
146
+ logging.info("Запуск периодического резервного копирования...")
147
+ upload_db_to_hf()
148
+
149
 
150
  @app.route('/')
151
  def catalog():
152
  data = load_data()
153
+ # Sort products for display
154
+ products = sorted(data.get('products', []), key=lambda x: x.get('added_at', ''), reverse=True)
155
+ categories = data.get('categories', [])
156
 
157
  catalog_html = '''
158
  <!DOCTYPE html>
 
263
  }
264
  .products-grid {
265
  display: grid;
266
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); /* More responsive */
267
+ gap: 20px; /* Slightly larger gap */
268
  padding: 10px;
269
  }
270
+ @media (min-width: 600px) {
271
+ .products-grid {
272
+ grid-template-columns: repeat(2, minmax(200px, 1fr));
273
+ }
274
+ }
275
+ @media (min-width: 900px) {
276
+ .products-grid {
277
+ grid-template-columns: repeat(3, minmax(200px, 1fr));
278
+ }
279
+ }
280
+ @media (min-width: 1200px) {
281
+ .products-grid {
282
+ grid-template-columns: repeat(4, minmax(200px, 1fr));
283
+ }
284
+ }
285
+
286
  .product {
287
  background: var(--light-text);
288
  border-radius: 15px;
 
290
  box-shadow: 0 4px 15px var(--shadow-color);
291
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
292
  overflow: hidden;
293
+ display: flex;
294
+ flex-direction: column; /* Stack elements vertically */
295
+ height: 100%; /* Make products fill grid cell height */
296
  }
297
  .product:hover {
298
  transform: translateY(-5px) scale(1.02);
 
300
  }
301
  .product-image {
302
  width: 100%;
303
+ aspect-ratio: 1 / 1.1; /* Slightly taller aspect ratio */
304
  background-color: #fff;
305
  border-radius: 10px;
306
  overflow: hidden;
307
  display: flex;
308
  justify-content: center;
309
  align-items: center;
310
+ margin-bottom: 10px; /* Space below image */
311
  }
312
  .product-image img {
313
  max-width: 100%;
 
321
  .product h2 {
322
  font-size: 1rem;
323
  font-weight: 600;
324
+ margin: 10px 0 5px 0; /* Adjust margins */
325
  text-align: center;
326
+ white-space: normal; /* Allow wrapping */
327
  overflow: hidden;
328
+ /* Limit title lines if desired
329
+ display: -webkit-box;
330
+ -webkit-line-clamp: 2;
331
+ -webkit-box-orient: vertical;
332
+ */
333
  }
334
  .product-price {
335
  font-size: 1.1rem;
 
346
  overflow: hidden;
347
  text-overflow: ellipsis;
348
  white-space: nowrap;
349
+ flex-grow: 1; /* Allow description to take space if needed */
350
+ }
351
+ .product-buttons {
352
+ margin-top: auto; /* Push buttons to bottom */
353
+ display: flex;
354
+ flex-direction: column; /* Stack buttons */
355
+ gap: 5px; /* Space between buttons */
356
  }
357
  .product-button {
358
  display: block;
 
366
  font-weight: 500;
367
  cursor: pointer;
368
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
 
369
  text-align: center;
370
  text-decoration: none;
371
  }
 
397
  box-shadow: 0 4px 15px rgba(255, 167, 38, 0.4);
398
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
399
  z-index: 1000;
400
+ align-items: center; /* Center icon */
401
+ justify-content: center; /* Center icon */
402
  }
403
  #cart-button:hover {
404
  background-color: var(--hover-primary-color);
 
423
  max-width: 700px;
424
  box-shadow: 0 10px 30px rgba(0,0,0,0.2);
425
  animation: slideIn 0.3s ease-out;
426
+ max-height: 85vh; /* Limit height */
427
+ overflow-y: auto; /* Enable scrolling */
428
+ display: flex; /* Use flexbox for layout */
429
+ flex-direction: column; /* Stack content vertically */
430
+ }
431
+ #modalContent, #cartContent {
432
+ overflow-y: auto; /* Allow content inside to scroll if needed */
433
+ flex-grow: 1; /* Allow content to take available space */
434
+ padding-right: 10px; /* Space for scrollbar */
435
  }
436
  #cartContent {
437
+ max-height: 60vh; /* Specific max height for cart items */
438
+ margin-bottom: 15px;
 
 
 
439
  }
440
  @keyframes slideIn {
441
  from { transform: translateY(-50px); opacity: 0; }
 
447
  color: #718096;
448
  cursor: pointer;
449
  transition: color 0.3s;
450
+ align-self: flex-end; /* Position close button top right */
451
+ margin-bottom: 10px; /* Space below close button */
452
  }
453
  .close:hover {
454
  color: var(--primary-color);
 
459
  align-items: center;
460
  padding: 15px 0;
461
  border-bottom: 1px solid var(--secondary-color);
462
+ gap: 10px; /* Add gap between elements */
463
+ }
464
+ .cart-item-details {
465
+ display: flex;
466
+ align-items: center;
467
+ gap: 15px;
468
+ flex-grow: 1; /* Allow details to take space */
469
  }
470
  .cart-item img {
471
  width: 50px;
472
  height: 50px;
473
  object-fit: contain;
474
  border-radius: 8px;
475
+ flex-shrink: 0; /* Prevent image shrinking */
476
+ }
477
+ .cart-item-info {
478
+ font-size: 0.9rem; /* Adjust font size */
479
+ }
480
+ .cart-item-info strong {
481
+ display: block; /* Name on its own line */
482
+ margin-bottom: 3px;
483
+ }
484
+ .cart-item-total {
485
+ font-weight: bold;
486
+ white-space: nowrap; /* Prevent price breaking */
487
+ flex-shrink: 0;
488
  }
489
  .quantity-input, .color-select {
490
  width: 100%;
 
510
  box-shadow: 0 4px 15px rgba(255, 160, 0, 0.4);
511
  }
512
  .modal-footer {
513
+ margin-top: auto; /* Push footer to bottom */
514
  padding-top: 15px;
515
  text-align: right;
516
  border-top: 1px solid var(--secondary-color);
517
+ display: flex; /* Use flexbox for footer buttons */
518
+ justify-content: space-between; /* Space out total and buttons */
519
+ align-items: center; /* Align items vertically */
520
+ gap: 10px;
521
  }
522
+ .modal-footer strong {
523
+ margin-right: auto; /* Push total to the left */
524
+ }
525
  @media (max-width: 768px) {
 
 
 
526
  .header h1 {
527
  font-size: 1.2rem;
528
  }
 
558
  #cartContent {
559
  max-height: 65vh;
560
  }
561
+ .cart-item {
562
+ flex-wrap: wrap; /* Allow wrapping on small screens */
563
+ }
564
+ .cart-item-details {
565
+ flex-basis: 100%; /* Take full width */
566
+ margin-bottom: 5px; /* Space below details */
567
+ }
568
  }
569
  @media (max-width: 480px) {
 
 
 
 
570
  .header {
571
  flex-direction: column;
572
  align-items: center;
 
583
  .category-filter {
584
  padding: 5px 10px;
585
  }
586
+ .modal-footer {
587
+ flex-direction: column;
588
+ align-items: flex-end; /* Align buttons right */
589
+ }
590
+ .modal-footer strong {
591
+ margin-bottom: 10px; /* Space below total */
592
+ align-self: flex-start; /* Align total left */
593
+ }
594
  }
595
  </style>
596
  </head>
 
614
  <div class="product"
615
  data-name="{{ product['name']|lower }}"
616
  data-description="{{ product['description']|lower }}"
617
+ data-category="{{ product.get('category', 'Без категории')|lower }}">
618
  {% if product.get('photos') and product['photos']|length > 0 %}
619
  <div class="product-image">
620
  <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
 
625
  <h2>{{ product['name'] }}</h2>
626
  <div class="product-price">{{ product['price'] }} с</div>
627
  <p class="product-description">{{ product['description'][:50] }}{% if product['description']|length > 50 %}...{% endif %}</p>
628
+ <div class="product-buttons">
629
+ <button class="product-button" onclick="openModal({{ loop.index0 }})">Подробнее</button>
630
+ <button class="product-button add-to-cart" onclick="openQuantityModal({{ loop.index0 }})">В корзину</button>
631
+ </div>
632
  </div>
633
  {% endfor %}
634
  </div>
 
647
  <h2>Укажите количество и цвет</h2>
648
  <input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
649
  <select id="colorSelect" class="color-select"></select>
650
+ <div class="modal-footer" style="border-top: none; padding-top: 10px;"> <!-- Re-use footer style for button -->
651
+ <button class="product-button" style="margin-top: 0;" onclick="confirmAddToCart()">Добавить</button>
652
+ </div>
653
  </div>
654
  </div>
655
 
 
660
  <div id="cartContent"></div>
661
  <div class="modal-footer">
662
  <strong>Итого: <span id="cartTotal">0</span> с</strong>
663
+ <div>
664
+ <button class="product-button clear-cart" onclick="clearCart()">Очистить</button>
665
+ <button class="product-button order-button" onclick="orderViaWhatsApp()">Заказать</button>
666
+ </div>
667
  </div>
668
  </div>
669
  </div>
 
674
  <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.3/dist/umd/popper.min.js"></script>
675
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
676
  <script>
677
+ // Use the same sorted product list on the client as generated by the server
678
  const products = {{ products|tojson }};
679
  let selectedProductIndex = null;
680
 
681
  function openModal(index) {
682
+ // Fetch details using the index relative to the currently displayed (sorted) list
683
  loadProductDetails(index);
684
  document.getElementById('productModal').style.display = "block";
685
  }
 
689
  }
690
 
691
  function loadProductDetails(index) {
692
+ // Send the index to the server endpoint
693
  fetch('/product/' + index)
694
+ .then(response => {
695
+ if (!response.ok) {
696
+ throw new Error('Network response was not ok ' + response.statusText);
697
+ }
698
+ return response.text();
699
+ })
700
  .then(data => {
701
  document.getElementById('modalContent').innerHTML = data;
702
  initializeSwiper();
703
  })
704
+ .catch(error => {
705
+ console.error('Ошибка загрузки деталей продукта:', error);
706
+ document.getElementById('modalContent').innerHTML = '<p>Не удалось загрузить детали продукта.</p>';
707
+ });
708
  }
709
 
710
  function initializeSwiper() {
711
+ // Destroy previous Swiper instance if it exists
712
+ if (document.querySelector('.swiper-container') && document.querySelector('.swiper-container').swiper) {
713
+ document.querySelector('.swiper-container').swiper.destroy(true, true);
714
+ }
715
+ // Initialize new Swiper
716
+ new Swiper('.swiper-container', {
717
  slidesPerView: 1,
718
  spaceBetween: 20,
719
+ loop: true, // Consider disabling loop if only 1 image
720
  grabCursor: true,
721
  pagination: { el: '.swiper-pagination', clickable: true },
722
  navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
723
+ zoom: { maxRatio: 3 },
724
+ observer: true, // Re-init on DOM changes
725
+ observeParents: true // Re-init on parent DOM changes
726
  });
727
  }
728
 
729
  function openQuantityModal(index) {
730
  selectedProductIndex = index;
731
+ // Use the client-side sorted 'products' array
732
  const product = products[index];
733
+ if (!product) {
734
+ console.error("Product not found at index:", index);
735
+ alert("Ошибка: товар не найден.");
736
+ return;
737
+ }
738
  const colorSelect = document.getElementById('colorSelect');
739
  colorSelect.innerHTML = '';
740
+ const availableColors = product.colors && product.colors.length > 0 ? product.colors : ['Нет информации'];
741
+
742
+ availableColors.forEach(color => {
 
 
 
 
 
743
  const option = document.createElement('option');
744
+ option.value = color;
745
+ option.text = color;
746
  colorSelect.appendChild(option);
747
+ });
748
+
749
  document.getElementById('quantityModal').style.display = 'block';
750
  document.getElementById('quantityInput').value = 1;
751
  }
 
759
  return;
760
  }
761
  let cart = JSON.parse(localStorage.getItem('cart') || '[]');
762
+ // Use the client-side sorted 'products' array
763
  const product = products[selectedProductIndex];
764
+ if (!product) {
765
+ console.error("Product not found at index:", selectedProductIndex);
766
+ alert("Ошибка: товар не найден при добавлении в корзину.");
767
+ return;
768
+ }
769
 
770
+ // Use a unique ID combining product name and color
771
+ const cartItemId = `${product.name}-${color}`; // Assuming name is unique enough or use a real ID if available
772
+ const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
773
+
774
+ if (existingItemIndex > -1) {
775
+ cart[existingItemIndex].quantity += quantity;
776
  } else {
777
  cart.push({
778
+ id: cartItemId, // Use a unique ID
779
  name: product.name,
780
  price: product.price,
781
  photo: product.photos && product.photos.length > 0 ? product.photos[0] : '',
 
787
  localStorage.setItem('cart', JSON.stringify(cart));
788
  closeModal('quantityModal');
789
  updateCartButton();
790
+ openCartModal(); // Optionally open cart after adding
791
  }
792
 
793
  function updateCartButton() {
794
  const cart = JSON.parse(localStorage.getItem('cart') || '[]');
795
  const cartButton = document.getElementById('cart-button');
796
  cartButton.style.display = cart.length > 0 ? 'flex' : 'none';
 
 
797
  }
798
 
799
  function openCartModal() {
 
802
  let total = 0;
803
 
804
  cartContent.innerHTML = cart.length === 0 ? '<p>Корзина пуста</p>' : cart.map(item => {
805
+ // Ensure price and quantity are numbers
806
+ const price = parseFloat(item.price) || 0;
807
+ const quantity = parseInt(item.quantity) || 0;
808
+ const itemTotal = price * quantity;
809
  total += itemTotal;
810
  return `
811
  <div class="cart-item">
812
+ <div class="cart-item-details">
813
+ ${item.photo ? `<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/${item.photo}" alt="${item.name}">` : '<div style="width:50px; height: 50px; background: #eee; border-radius: 8px;"></div>'}
814
+ <div class="cart-item-info">
815
  <strong>${item.name}</strong>
816
+ <span>${price.toFixed(1)} с × ${quantity} (Цвет: ${item.color})</span>
817
  </div>
818
  </div>
819
+ <span class="cart-item-total">${itemTotal.toFixed(1)} с</span>
820
  </div>
821
  `;
822
  }).join('');
823
 
824
+ document.getElementById('cartTotal').textContent = total.toFixed(1);
825
  document.getElementById('cartModal').style.display = 'block';
826
+ // Scroll cart content to top when opened
827
+ cartContent.scrollTop = 0;
828
  }
829
 
830
  function orderViaWhatsApp() {
 
834
  return;
835
  }
836
  let total = 0;
837
+ let orderText = "Здравствуйте! Хочу сделать заказ:%0A%0A";
838
  cart.forEach((item, index) => {
839
+ const price = parseFloat(item.price) || 0;
840
+ const quantity = parseInt(item.quantity) || 0;
841
+ const itemTotal = price * quantity;
842
  total += itemTotal;
843
+ orderText += `${index + 1}. ${item.name}%0A`;
844
+ orderText += ` Цвет: ${item.color}%0A`;
845
+ orderText += ` Кол-во: ${quantity}%0A`;
846
+ orderText += ` Цена: ${price.toFixed(1)} с%0A`;
847
+ orderText += ` Сумма: ${itemTotal.toFixed(1)} с%0A%0A`;
848
  });
849
+ orderText += `*Итого: ${total.toFixed(1)} с*`;
850
+
851
+ const whatsappUrl = `https://api.whatsapp.com/send?phone=996704667878&text=${orderText}`;
852
+ window.open(whatsappUrl, '_blank');
853
  }
854
 
855
  function clearCart() {
 
858
  updateCartButton();
859
  }
860
 
861
+ // Close modal if clicking outside content
862
  window.onclick = function(event) {
863
+ const modals = document.querySelectorAll('.modal');
864
+ modals.forEach(modal => {
865
+ if (event.target == modal) {
866
+ modal.style.display = "none";
867
+ }
868
+ });
869
  }
870
 
871
  document.getElementById('search-input').addEventListener('input', filterProducts);
 
878
  });
879
 
880
  function filterProducts() {
881
+ const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
882
+ const activeCategory = document.querySelector('.category-filter.active').dataset.category.toLowerCase();
883
+ const grid = document.getElementById('products-grid');
884
+ // Instead of hiding/showing, store matched elements and re-render grid
885
+ // This ensures elements are in the correct order if search/filter changes
886
+ const matchedProducts = [];
887
+ products.forEach((product, index) => { // Iterate over the original client-side sorted array
888
+ const name = product.name.toLowerCase();
889
+ const description = product.description.toLowerCase();
890
+ const category = product.category ? product.category.toLowerCase() : 'без категории';
891
+
892
+ const matchesSearch = searchTerm === '' || name.includes(searchTerm) || description.includes(searchTerm);
893
  const matchesCategory = activeCategory === 'all' || category === activeCategory;
894
+
895
+ if (matchesSearch && matchesCategory) {
896
+ // Store the original index along with the product data if needed later,
897
+ // or just recreate the HTML element directly.
898
+ // Recreating is simpler here.
899
+ matchedProducts.push(`
900
+ <div class="product"
901
+ data-name="${name}"
902
+ data-description="${description}"
903
+ data-category="${category}">
904
+ ${product.photos && product.photos.length > 0 ? `
905
+ <div class="product-image">
906
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/${product.photos[0]}"
907
+ alt="${product.name}"
908
+ loading="lazy">
909
+ </div>` : ''}
910
+ <h2>${product.name}</h2>
911
+ <div class="product-price">${product.price} с</div>
912
+ <p class="product-description">${product.description.substring(0, 50)}${product.description.length > 50 ? '...' : ''}</p>
913
+ <div class="product-buttons">
914
+ <button class="product-button" onclick="openModal(${index})">Подробнее</button>
915
+ <button class="product-button add-to-cart" onclick="openQuantityModal(${index})">В корзину</button>
916
+ </div>
917
+ </div>`);
918
+ }
919
  });
920
+ grid.innerHTML = matchedProducts.join(''); // Replace grid content
921
  }
922
 
923
+ // Initial setup
924
  updateCartButton();
925
+ filterProducts(); // Apply initial filter (e.g., "All categories")
926
+
927
  </script>
928
  </body>
929
  </html>
 
933
  @app.route('/product/<int:index>')
934
  def product_detail(index):
935
  data = load_data()
936
+ # Sort products exactly as in the catalog view to match the index
937
+ products = sorted(data.get('products', []), key=lambda x: x.get('added_at', ''), reverse=True)
938
  try:
939
+ # Access the product using the index from the sorted list
940
  product = products[index]
941
  except IndexError:
942
+ logging.error(f"Product index {index} out of range after sorting.")
943
  return "Продукт не найден", 404
944
+ except Exception as e:
945
+ logging.error(f"Error retrieving product at index {index}: {e}")
946
+ return "Ошибка при загрузке продукта", 500
947
 
948
  detail_html = '''
949
+ <div style="font-family: 'Poppins', sans-serif; color: #333;">
950
+ <h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 20px; color: #FFA726; text-align: center;">{{ product['name'] }}</h2>
951
+ <div class="swiper-container" style="max-width: 400px; margin: 0 auto 20px; --swiper-navigation-color: #FFA726; --swiper-pagination-color: #FFA726; border-radius: 10px; overflow: hidden;">
952
  <div class="swiper-wrapper">
953
+ {% set photos = product.get('photos', []) %}
954
+ {% if photos and photos|length > 0 %}
955
+ {% for photo in photos %}
956
+ <div class="swiper-slide" style="background-color: #fff; display: flex; justify-content: center; align-items: center;">
957
+ <div class="swiper-zoom-container">
958
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
959
+ alt="{{ product['name'] }}"
960
+ style="max-width: 100%; max-height: 350px; object-fit: contain; display: block;">
961
+ </div>
962
  </div>
963
+ {% endfor %}
 
964
  {% else %}
965
+ <div class="swiper-slide" style="background-color: #eee; display: flex; justify-content: center; align-items: center; height: 350px;">
966
+ <span style="color: #999;">Нет изображения</span>
967
+ </div>
968
  {% endif %}
969
  </div>
970
+ {% if photos and photos|length > 1 %} <!-- Show nav/pagination only if multiple photos -->
971
  <div class="swiper-pagination"></div>
972
  <div class="swiper-button-next"></div>
973
  <div class="swiper-button-prev"></div>
974
+ {% endif %}
975
  </div>
976
  <div style="background: #FFF8E1; padding: 15px; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
977
  <p style="margin-bottom: 10px;"><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
978
  <p style="margin-bottom: 10px;"><strong>Цена:</strong> <span style="color: #D84315; font-weight: bold;">{{ product['price'] }} с</span></p>
979
+ <p style="margin-bottom: 10px; white-space: pre-wrap;"><strong>Описание:</strong> {{ product['description'] }}</p>
980
+ <p><strong>Доступные цвета:</strong> {{ product.get('colors', ['Нет информации'])|join(', ') if product.get('colors') else 'Нет информации' }}</p>
981
  </div>
982
  </div>
983
  '''
 
987
  @app.route('/admin', methods=['GET', 'POST'])
988
  def admin():
989
  data = load_data()
990
+ products = data.get('products', []) # Use .get for safety
991
+ categories = data.get('categories', [])
992
 
993
  if request.method == 'POST':
994
  action = request.form.get('action')
995
 
996
  if action == 'add_category':
997
+ category_name = request.form.get('category_name', '').strip()
998
  if category_name and category_name not in categories:
999
  categories.append(category_name)
1000
+ save_data({'products': products, 'categories': categories})
1001
  return redirect(url_for('admin'))
1002
+ elif category_name in categories:
1003
+ return "Ошибка: Категория с таким названием уже существует.", 400
1004
+ else:
1005
+ return "Ошибка: Название категории не может быть пустым.", 400
1006
 
1007
  elif action == 'delete_category':
1008
+ try:
1009
+ category_index = int(request.form.get('category_index'))
1010
+ if 0 <= category_index < len(categories):
1011
+ deleted_category = categories.pop(category_index)
1012
+ # Update products that used this category
1013
+ for product in products:
1014
+ if product.get('category') == deleted_category:
1015
+ product['category'] = 'Без категории'
1016
+ save_data({'products': products, 'categories': categories})
1017
+ return redirect(url_for('admin'))
1018
+ else:
1019
+ return "Ошибка: Неверный индекс категории.", 400
1020
+ except (ValueError, TypeError, IndexError):
1021
+ return "Ошибка: Неверный индекс категории.", 400
1022
 
1023
  elif action == 'add':
1024
+ name = request.form.get('name', '').strip()
1025
+ price_str = request.form.get('price', '').strip()
1026
+ description = request.form.get('description', '').strip()
1027
  category = request.form.get('category')
1028
  photos_files = request.files.getlist('photos')
1029
  colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1030
  photos_list = []
1031
 
1032
+ if not name or not price_str or not description:
1033
+ return "Ошибка: Заполните все обязательные поля (Название, Цена, Описание).", 400
1034
+
1035
+ try:
1036
+ price = float(price_str.replace(',', '.'))
1037
+ if price < 0:
1038
+ return "Ошибка: Цена не может быть отрицательной.", 400
1039
+ except ValueError:
1040
+ return "Ошибка: Неверный формат цены. Используйте число (например, 150 или 150.50).", 400
1041
+
1042
  if photos_files:
1043
+ uploads_dir = 'uploads'
1044
+ os.makedirs(uploads_dir, exist_ok=True)
1045
  for photo in photos_files[:10]:
1046
  if photo and photo.filename:
1047
+ # Sanitize filename and make unique
1048
+ base, ext = os.path.splitext(secure_filename(photo.filename))
1049
+ photo_filename = f"{base}_{int(time.time())}{ext}"
1050
  temp_path = os.path.join(uploads_dir, photo_filename)
1051
  try:
1052
  photo.save(temp_path)
 
1060
  commit_message=f"Добавлено фото для товара {name}"
1061
  )
1062
  photos_list.append(photo_filename)
1063
+ logging.info(f"Загружено фото: photos/{photo_filename}")
1064
  except Exception as e:
1065
  logging.error(f"Ошибка при загрузке фото {photo_filename}: {e}")
1066
  finally:
 
1071
  logging.error(f"Ошибка при удалении временного файла {temp_path}: {e}")
1072
 
1073
 
 
 
 
 
 
 
 
 
1074
  new_product = {
1075
  'name': name,
1076
  'price': price,
 
1078
  'category': category if category in categories else 'Без категории',
1079
  'photos': photos_list,
1080
  'colors': colors,
1081
+ 'added_at': datetime.now().isoformat() # Add timestamp
1082
  }
1083
  products.append(new_product)
1084
+ save_data({'products': products, 'categories': categories})
1085
  return redirect(url_for('admin'))
1086
 
1087
  elif action == 'edit':
1088
  try:
1089
  index = int(request.form.get('index'))
1090
  if not 0 <= index < len(products):
1091
+ return "Ошибка: Неверный индекс товара для редактирования.", 400
1092
  except (ValueError, TypeError):
1093
+ return "Ошибка: Неверный индекс товара.", 400
1094
 
1095
+ name = request.form.get('name', '').strip()
1096
+ price_str = request.form.get('price', '').strip()
1097
+ description = request.form.get('description', '').strip()
1098
  category = request.form.get('category')
1099
  photos_files = request.files.getlist('photos')
1100
  colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1101
 
1102
+ if not name or not price_str or not description:
1103
+ return "Ошибка: Заполните все обязательные поля (Название, Цена, Описание).", 400
1104
 
1105
  try:
1106
+ price_float = float(price_str.replace(',', '.'))
1107
+ if price_float < 0:
1108
+ return "Ошибка: Цена не может быть отрицательной.", 400
1109
  except ValueError:
1110
+ return "Ошибка: Неверный формат цены. Используйте число (например, 150 или 150.50).", 400
1111
 
1112
+ new_photos_list = products[index].get('photos', []) # Start with existing photos
1113
 
1114
+ if photos_files and any(f.filename for f in photos_files):
1115
+ # If new photos are uploaded, replace the old list
1116
  new_photos_list = []
1117
+ # TODO: Optionally delete old photos from HF here (requires tracking filenames)
1118
+ uploads_dir = 'uploads'
1119
+ os.makedirs(uploads_dir, exist_ok=True)
1120
  for photo in photos_files[:10]:
1121
  if photo and photo.filename:
1122
+ base, ext = os.path.splitext(secure_filename(photo.filename))
1123
+ photo_filename = f"{base}_{int(time.time())}{ext}"
 
1124
  temp_path = os.path.join(uploads_dir, photo_filename)
1125
  try:
1126
  photo.save(temp_path)
 
1134
  commit_message=f"Обновлено фото для товара {name}"
1135
  )
1136
  new_photos_list.append(photo_filename)
1137
+ logging.info(f"Загружено (обновление) фото: photos/{photo_filename}")
1138
  except Exception as e:
1139
  logging.error(f"Ошибка при загрузке фото {photo_filename} при редактировании: {e}")
1140
  finally:
 
1144
  except OSError as e:
1145
  logging.error(f"Ошибка при удалении временного файла {temp_path} при редактировании: {e}")
1146
 
 
 
 
1147
  products[index]['name'] = name
1148
  products[index]['price'] = price_float
1149
  products[index]['description'] = description
1150
  products[index]['category'] = category if category in categories else 'Без категории'
1151
+ products[index]['photos'] = new_photos_list # Assign potentially updated list
1152
  products[index]['colors'] = colors
1153
+ # Keep original 'added_at' if it exists, otherwise set it? Might not be needed.
1154
+ # products[index]['added_at'] = products[index].get('added_at', datetime.now().isoformat())
1155
 
1156
+ save_data({'products': products, 'categories': categories})
1157
  return redirect(url_for('admin'))
1158
 
1159
  elif action == 'delete':
1160
  try:
1161
  index = int(request.form.get('index'))
1162
  if not 0 <= index < len(products):
1163
+ return "Ошибка: Неверный индекс товара для удаления.", 400
1164
+
1165
+ # TODO: Optionally delete photos from HF here
1166
+ # photos_to_delete = products[index].get('photos', [])
1167
+ # if photos_to_delete:
1168
+ # api = HfApi()
1169
+ # try:
1170
+ # api.delete_files(repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
1171
+ # paths_in_repo=[f"photos/{p}" for p in photos_to_delete])
1172
+ # logging.info(f"Удалены фото для товара {products[index]['name']}: {photos_to_delete}")
1173
+ # except Exception as e:
1174
+ # logging.error(f"Ошибка при удалении фото с HF: {e}")
1175
 
1176
  del products[index]
1177
+ save_data({'products': products, 'categories': categories})
1178
  except (ValueError, TypeError):
1179
+ return "Ошибка: Неверный индекс товара.", 400
1180
  except Exception as e:
1181
  logging.error(f"Ошибка при удалении товара: {e}")
1182
  return "Ошибка при удалении товара", 500
 
1282
  textarea {
1283
  resize: vertical;
1284
  min-height: 80px;
1285
+ white-space: pre-wrap; /* Preserve line breaks */
1286
  }
1287
  input[type="file"] {
1288
  padding: 10px;
 
1362
  margin: 0;
1363
  color: var(--text-color);
1364
  font-weight: 500;
1365
+ word-break: break-all; /* Prevent long category names from overflowing */
1366
+ margin-right: 15px;
1367
  }
1368
  .category-item form {
1369
  background: none;
1370
  padding: 0;
1371
  box-shadow: none;
1372
  margin: 0;
1373
+ flex-shrink: 0; /* Prevent button from shrinking */
1374
  }
1375
  .category-item button {
1376
  margin: 0;
 
1385
  margin-bottom: 8px;
1386
  font-size: 0.9rem;
1387
  line-height: 1.5;
1388
+ word-break: break-word; /* Allow long words to break */
1389
  }
1390
  .product-item p strong {
1391
  color: #555;
1392
+ display: inline-block; /* Ensure strong tag doesn't cause weird wrapping */
1393
+ margin-right: 5px;
1394
  }
1395
+ .product-item p:has(> strong)::before { /* Add spacing before content after strong tag if needed */
1396
+ /* content: ' '; */
1397
+ }
1398
  .product-item .product-photos {
1399
  display: flex;
1400
  flex-wrap: wrap;
 
1423
  .product-item summary:hover {
1424
  color: var(--hover-primary-color);
1425
  }
1426
+ .product-item summary::marker { /* Style the arrow */
1427
+ color: var(--primary-color);
1428
+ }
1429
+ .product-item details[open] > summary { /* Style when open */
1430
+ margin-bottom: 10px;
1431
+ }
1432
+
1433
  .edit-form {
1434
  margin-top: 15px;
1435
  padding: 20px;
 
1456
  font-size: 0.8rem;
1457
  background-color: #ddd;
1458
  color: #333;
1459
+ border: none; /* Remove border */
1460
  border-radius: 50%;
1461
  margin-top: 0;
1462
  min-width: 25px;
1463
  height: 25px;
1464
+ line-height: 1; /* Center 'X' vertically */
1465
+ cursor: pointer;
1466
+ display: flex; /* Center 'X' */
1467
+ align-items: center;
1468
+ justify-content: center;
1469
+ flex-shrink: 0; /* Prevent shrinking */
1470
  }
1471
  .color-input-group .remove-color-btn:hover {
1472
  background-color: #ccc;
 
1477
  .button-group {
1478
  margin-top: 20px;
1479
  display: flex;
1480
+ flex-wrap: wrap; /* Allow buttons to wrap */
1481
  gap: 10px;
1482
  }
1483
+ .button-group form,
1484
+ .button-group details { /* Apply to both forms and details */
1485
  background: none;
1486
  padding: 0;
1487
  margin: 0;
1488
  box-shadow: none;
1489
+ display: flex; /* Align button/summary */
1490
  }
1491
+ .button-group button,
1492
+ .button-group summary { /* Reset margin for buttons/summary in group */
1493
  margin-top: 0;
1494
  }
1495
 
1496
+ /* Make icon buttons more visually appealing */
1497
+ button > i.fas {
1498
+ margin-right: 8px;
1499
+ }
1500
+ .add-color-btn i.fas,
1501
+ .delete-button i.fas {
1502
+ margin-right: 5px; /* Smaller margin for smaller buttons */
1503
+ }
1504
+ .edit-form button[type="submit"] i.fas,
1505
+ details > summary i.fas {
1506
+ margin-right: 8px;
1507
+ }
1508
+
1509
+
1510
  @media (max-width: 768px) {
1511
  .product-list, .category-list {
1512
  grid-template-columns: 1fr;
 
1522
  padding: 20px;
1523
  }
1524
  }
1525
+ @media (max-width: 480px) {
1526
+ .category-item {
1527
+ flex-direction: column; /* Stack category name and button */
1528
+ align-items: flex-start; /* Align left */
1529
+ gap: 10px;
1530
+ }
1531
+ .category-item form {
1532
+ align-self: flex-end; /* Move button to the right */
1533
+ }
1534
+ }
1535
 
1536
  </style>
1537
  </head>
 
1549
  <input type="text" id="add-name" name="name" required>
1550
 
1551
  <label for="add-price">Цена (с):</label>
1552
+ <input type="text" inputmode="decimal" id="add-price" name="price" required placeholder="Например: 150.50">
1553
 
1554
  <label for="add-description">Описание:</label>
1555
  <textarea id="add-description" name="description" rows="4" required></textarea>
 
1562
  {% endfor %}
1563
  </select>
1564
 
1565
+ <label for="add-photos">Фотографии (до 10, JPG/PNG/WEBP):</label>
1566
+ <input type="file" id="add-photos" name="photos" accept="image/jpeg, image/png, image/webp" multiple>
1567
 
1568
  <label>Доступные цвета:</label>
1569
  <div id="add-color-inputs" class="color-input-container">
 
1590
  {% for category in categories %}
1591
  <div class="category-item">
1592
  <h3>{{ category }}</h3>
1593
+ <form method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить категорию \'{{ category }}\'? Товары этой категории станут \'Без категории\'.');">
1594
  <input type="hidden" name="action" value="delete_category">
1595
  <input type="hidden" name="category_index" value="{{ loop.index0 }}">
1596
  <button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
 
1613
 
1614
  <h2>Список товаров</h2>
1615
  <div class="product-list">
1616
+ {% set sorted_admin_products = products|sort(attribute='added_at', reverse=True) %}
1617
+ {% for product in sorted_admin_products %}
1618
  <div class="product-item">
1619
  <h3>{{ product['name'] }}</h3>
1620
  <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
1621
  <p><strong>Цена:</strong> {{ product['price'] }} с</p>
1622
+ <p style="white-space: pre-wrap;"><strong>Описание:</strong> {{ product['description'] }}</p>
1623
  <p><strong>Цвета:</strong> {{ product.get('colors', [])|join(', ') if product.get('colors') else 'Нет цветов' }}</p>
1624
+ <p><small><strong>Добавлено:</strong> {{ product.get('added_at', 'N/A') }}</small></p>
1625
  {% if product.get('photos') and product['photos']|length > 0 %}
1626
  <div class="product-photos">
1627
  {% for photo in product['photos'] %}
 
1630
  loading="lazy">
1631
  {% endfor %}
1632
  </div>
1633
+ {% else %}
1634
+ <p><small>Нет фотографий</small></p>
1635
  {% endif %}
1636
  <div class="button-group">
1637
+ <details>
1638
  <summary><i class="fas fa-edit"></i> Редактировать</summary>
1639
  <form method="POST" enctype="multipart/form-data" class="edit-form">
1640
  <input type="hidden" name="action" value="edit">
 
1642
  <label for="edit-name-{{ loop.index0 }}">Название:</label>
1643
  <input type="text" id="edit-name-{{ loop.index0 }}" name="name" value="{{ product['name'] }}" required>
1644
  <label for="edit-price-{{ loop.index0 }}">Цена (с):</label>
1645
+ <input type="text" inputmode="decimal" id="edit-price-{{ loop.index0 }}" name="price" value="{{ product['price'] }}" required>
1646
  <label for="edit-description-{{ loop.index0 }}">Описание:</label>
1647
  <textarea id="edit-description-{{ loop.index0 }}" name="description" rows="4" required>{{ product['description'] }}</textarea>
1648
  <label for="edit-category-{{ loop.index0 }}">Категория:</label>
 
1653
  {% endfor %}
1654
  </select>
1655
  <label for="edit-photos-{{ loop.index0 }}">Заменить фотографии (до 10):</label>
1656
+ <input type="file" id="edit-photos-{{ loop.index0 }}" name="photos" accept="image/jpeg, image/png, image/webp" multiple>
1657
  <label>Доступные цвета:</label>
1658
  <div id="edit-color-inputs-{{ loop.index0 }}" class="color-input-container">
1659
  {% set colors = product.get('colors', []) %}
 
1675
  <button type="submit"><i class="fas fa-save"></i> Сохранить изменения</button>
1676
  </form>
1677
  </details>
1678
+ <form method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
1679
  <input type="hidden" name="action" value="delete">
1680
+ <input type="hidden" name="index" value="{{ loop.index0 }}">
1681
+ <button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить товар</button>
1682
  </form>
1683
  </div>
1684
  </div>
 
1690
  <script>
1691
  function addColorInput(containerId) {
1692
  const container = document.getElementById(containerId);
1693
+ if (!container) return;
1694
  const newInputGroup = document.createElement('div');
1695
  newInputGroup.className = 'color-input-group';
1696
  newInputGroup.innerHTML = `
 
1698
  <button type="button" class="remove-color-btn" onclick="removeColorInput(this)">X</button>
1699
  `;
1700
 
1701
+ container.querySelectorAll('.remove-color-btn').forEach(btn => btn.style.display = 'inline-flex'); // Use inline-flex for centering
1702
  container.appendChild(newInputGroup);
1703
  }
1704
 
1705
  function removeColorInput(button) {
1706
+ const inputGroup = button.closest('.color-input-group');
1707
+ const container = button.closest('.color-input-container');
1708
+ if (!inputGroup || !container) return;
1709
+
1710
+ inputGroup.remove();
1711
 
1712
  const remainingGroups = container.querySelectorAll('.color-input-group');
1713
  if (remainingGroups.length === 1) {
1714
  const lastRemoveBtn = remainingGroups[0].querySelector('.remove-color-btn');
1715
  if(lastRemoveBtn) lastRemoveBtn.style.display = 'none';
1716
+ } else if (remainingGroups.length === 0) {
1717
+ // If all are removed, add a new empty one back
 
1718
  addColorInput(container.id);
1719
+ // And hide its remove button immediately
1720
+ const firstGroup = container.querySelector('.color-input-group');
1721
+ if (firstGroup) {
1722
+ const firstRemoveBtn = firstGroup.querySelector('.remove-color-btn');
1723
+ if (firstRemoveBtn) firstRemoveBtn.style.display = 'none';
1724
+ }
1725
  }
1726
  }
1727
 
 
1731
  if (groups.length === 1) {
1732
  const removeBtn = groups[0].querySelector('.remove-color-btn');
1733
  if (removeBtn) removeBtn.style.display = 'none';
1734
+ } else if (groups.length === 0) {
1735
+ // If initialized with zero groups (e.g., editing product with no colors)
1736
+ // Add one and hide its button
1737
+ addColorInput(container.id);
1738
+ const firstGroup = container.querySelector('.color-input-group');
1739
+ if (firstGroup) {
1740
+ const firstRemoveBtn = firstGroup.querySelector('.remove-color-btn');
1741
+ if (firstRemoveBtn) firstRemoveBtn.style.display = 'none';
1742
+ }
1743
+ } else {
1744
+ // Ensure all buttons are visible if more than one exists initially
1745
+ groups.forEach(group => {
1746
+ const removeBtn = group.querySelector('.remove-color-btn');
1747
+ if(removeBtn) removeBtn.style.display = 'inline-flex';
1748
+ });
1749
  }
1750
  });
1751
  </script>
1752
  </body>
1753
  </html>
1754
  '''
1755
+ # Pass the unsorted products list to the admin template
1756
+ # Sorting is handled within the template itself if needed for display there
1757
  return render_template_string(admin_html, products=products, categories=categories, repo_id=REPO_ID)
1758
 
1759
  @app.route('/backup', methods=['POST'])
1760
  def backup():
1761
  try:
1762
+ save_data(load_data()) # Ensure latest data is saved before uploading
1763
+ # upload_db_to_hf() is called inside save_data
1764
  return "Резервная копия успешно создана и загружена на Hugging Face.", 200
1765
  except Exception as e:
1766
  logging.error(f"Ошибка при ручном создании резервной копии: {e}")
 
1771
  def download():
1772
  try:
1773
  download_db_from_hf()
1774
+ load_data() # Reload data into memory after download
1775
+ return "Актуальная база данных успешно скачана из Hugging Face и загружена в приложение.", 200
 
1776
  except RepositoryNotFoundError:
1777
  return "Ошибка: Репозиторий Hugging Face не найден.", 404
1778
  except Exception as e:
 
1781
 
1782
 
1783
  if __name__ == '__main__':
 
1784
  os.makedirs('uploads', exist_ok=True)
1785
 
1786
+ # Start background backup thread only if HF token is available
1787
+ if HF_TOKEN_WRITE:
1788
+ backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1789
+ backup_thread.start()
1790
+ logging.info("Поток периодического резервного копирования запущен.")
1791
+ else:
1792
+ logging.warning("HF_TOKEN_WRITE не установлен, периодическое резервное копирование отключено.")
1793
 
1794
+ # Initial data load attempt
 
 
 
1795
  try:
1796
  load_data()
1797
  except Exception as e:
1798
+ logging.warning(f"Не удалось первоначально загрузить/скачать базу данных: {e}. Приложение запустится с пустыми/старыми локальными данными.")
1799
 
1800
+ port = int(os.environ.get("PORT", 7860))
1801
+ logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
1802
+ # Use 'waitress' for production instead of Flask's development server
1803
+ # from waitress import serve
1804
+ # serve(app, host='0.0.0.0', port=port)
1805
+ app.run(debug=False, host='0.0.0.0', port=port)
1806
 
 
1807
 
1808
  # --- END OF FILE app.py ---