Shveiauto commited on
Commit
bdee531
·
verified ·
1 Parent(s): 6d7a3d0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +298 -267
app.py CHANGED
@@ -1,6 +1,5 @@
1
 
2
-
3
- from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash
4
  import json
5
  import os
6
  import logging
@@ -40,18 +39,15 @@ def load_data():
40
  logging.info(f"Данные успешно загружены из {DATA_FILE}")
41
  if not isinstance(data, dict):
42
  logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.")
43
- data = {'products': [], 'categories': []}
44
-
45
  if 'products' not in data:
46
  data['products'] = []
47
  if 'categories' not in data:
48
  data['categories'] = []
49
-
50
- # Ensure new fields exist with defaults
51
  for product in data['products']:
52
- product.setdefault('is_top', False)
53
  product.setdefault('in_stock', True)
54
-
55
  return data
56
  except FileNotFoundError:
57
  logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.")
@@ -67,9 +63,8 @@ def load_data():
67
  if not isinstance(data, dict): return {'products': [], 'categories': []}
68
  if 'products' not in data: data['products'] = []
69
  if 'categories' not in data: data['categories'] = []
70
- # Ensure new fields exist with defaults after download
71
  for product in data['products']:
72
- product.setdefault('is_top', False)
73
  product.setdefault('in_stock', True)
74
  return data
75
  except (FileNotFoundError, RepositoryNotFoundError) as e:
@@ -93,12 +88,6 @@ def load_data():
93
 
94
  def save_data(data):
95
  try:
96
- # Ensure new fields exist before saving
97
- if 'products' in data:
98
- for product in data['products']:
99
- product.setdefault('is_top', False)
100
- product.setdefault('in_stock', True)
101
-
102
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
103
  json.dump(data, file, ensure_ascii=False, indent=4)
104
  logging.info(f"Данные успешно сохранены в {DATA_FILE}")
@@ -201,7 +190,7 @@ def download_db_from_hf(specific_file=None):
201
  logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
202
  break
203
  except Exception as e:
204
- if "404" in str(e) or isinstance(e, FileNotFoundError) or "EntryNotFound" in str(e):
205
  logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.")
206
  else:
207
  logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True)
@@ -221,6 +210,9 @@ def periodic_backup():
221
  upload_db_to_hf()
222
  logging.info("Периодическое резервное копирование завершено.")
223
 
 
 
 
224
 
225
  @app.route('/')
226
  def catalog():
@@ -229,14 +221,8 @@ def catalog():
229
  categories = data.get('categories', [])
230
  is_authenticated = 'user' in session
231
 
232
- # Filter out products that are not in stock
233
- available_products = [p for p in all_products if p.get('in_stock', True)]
234
-
235
- # Sort products: top products first, then alphabetically
236
- sorted_products = sorted(
237
- available_products,
238
- key=lambda p: (not p.get('is_top', False), p.get('name', '').lower())
239
- )
240
 
241
  catalog_html = '''
242
  <!DOCTYPE html>
@@ -245,6 +231,7 @@ def catalog():
245
  <meta charset="UTF-8">
246
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
247
  <title>Soola Cosmetics - Каталог</title>
 
248
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
249
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
250
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
@@ -285,10 +272,9 @@ def catalog():
285
  body.dark-mode .product { background: #253f37; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #2c4a41; }
286
  .product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
287
  body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
288
- .product-image { width: 100%; aspect-ratio: 1 / 1; background-color: #fff; border-radius: 10px 10px 0 0; overflow: hidden; display: flex; justify-content: center; align-items: center; margin-bottom: 0; position: relative; }
289
  .product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; }
290
  .product-image img:hover { transform: scale(1.08); }
291
- .top-product-badge { position: absolute; top: 10px; left: 10px; background-color: #ffc107; color: #333; padding: 3px 8px; font-size: 0.75rem; font-weight: bold; border-radius: 5px; z-index: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
292
  .product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
293
  .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; }
294
  body.dark-mode .product h2 { color: #c8d8d3; }
@@ -300,6 +286,7 @@ def catalog():
300
  .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; }
301
  .product-button:hover { background-color: #164B41; box-shadow: 0 4px 15px rgba(22, 75, 65, 0.4); transform: translateY(-2px); }
302
  .product-button i { margin-right: 5px; }
 
303
 
304
  .add-to-cart { background-color: #38a169; }
305
  .add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
@@ -358,7 +345,7 @@ def catalog():
358
  <a href="{{ url_for('login') }}">Войти</a>
359
  {% endif %}
360
  </div>
361
- <button class="theme-toggle" aria-label="Переключить тему">
362
  <i class="fas fa-moon"></i>
363
  </button>
364
  </div>
@@ -378,14 +365,12 @@ def catalog():
378
 
379
  <div class="products-grid" id="products-grid">
380
  {% for product in products %}
381
- <div class="product"
382
  data-name="{{ product['name']|lower }}"
383
  data-description="{{ product.get('description', '')|lower }}"
384
- data-category="{{ product.get('category', 'Без категории') }}">
 
385
  <div class="product-image">
386
- {% if product.get('is_top') %}
387
- <span class="top-product-badge" title="Популярный товар"><i class="fas fa-star"></i> Топ</span>
388
- {% endif %}
389
  {% if product.get('photos') and product['photos']|length > 0 %}
390
  <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
391
  alt="{{ product['name'] }}"
@@ -395,7 +380,7 @@ def catalog():
395
  {% endif %}
396
  </div>
397
  <div class="product-info">
398
- <h2>{{ product['name'] }}</h2>
399
  {% if is_authenticated %}
400
  <div class="product-price">{{ "%.2f"|format(product['price']) }} {{ currency_code }}</div>
401
  {% else %}
@@ -404,9 +389,9 @@ def catalog():
404
  <p class="product-description">{{ product.get('description', '')[:50] }}{% if product.get('description', '')|length > 50 %}...{% endif %}</p>
405
  </div>
406
  <div class="product-actions">
407
- <button class="product-button" data-action="open-details" data-index="{{ loop.index0 }}">Подробнее</button>
408
  {% if is_authenticated %}
409
- <button class="product-button add-to-cart" data-action="open-quantity" data-index="{{ loop.index0 }}">
410
  <i class="fas fa-cart-plus"></i> В корзину
411
  </button>
412
  {% endif %}
@@ -414,8 +399,9 @@ def catalog():
414
  </div>
415
  {% endfor %}
416
  {% if not products %}
417
- <p class="no-results-message">Товары пока не добавлены или нет в наличии.</p>
418
  {% endif %}
 
419
  </div>
420
  </div>
421
 
@@ -466,13 +452,15 @@ def catalog():
466
 
467
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
468
  <script>
469
- const products = {{ products|tojson }};
 
470
  const repoId = '{{ repo_id }}';
471
  const currencyCode = '{{ currency_code }}';
472
  const isAuthenticated = {{ is_authenticated|tojson }};
473
  let selectedProductIndex = null;
474
  let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
475
 
 
476
  function toggleTheme() {
477
  document.body.classList.toggle('dark-mode');
478
  const icon = document.querySelector('.theme-toggle i');
@@ -488,10 +476,13 @@ def catalog():
488
  document.body.classList.add('dark-mode');
489
  const icon = document.querySelector('.theme-toggle i');
490
  if (icon) icon.classList.replace('fa-moon', 'fa-sun');
 
 
 
491
  }
492
  }
493
 
494
- function attemptAutoLogin() {
495
  const storedUser = localStorage.getItem('soolaUser');
496
  if (storedUser && !isAuthenticated) {
497
  console.log('Attempting auto-login for:', storedUser);
@@ -506,18 +497,19 @@ def catalog():
506
  window.location.reload();
507
  } else {
508
  response.text().then(text => console.log(`Auto-login failed: ${response.status} ${text}`));
509
- localStorage.removeItem('soolaUser');
510
  }
511
  })
512
  .catch(error => {
513
  console.error('Auto-login fetch error:', error);
514
- localStorage.removeItem('soolaUser');
515
  });
516
  }
517
  }
518
 
519
- function openModal(index) {
520
- loadProductDetails(index);
 
521
  const modal = document.getElementById('productModal');
522
  if (modal) {
523
  modal.style.display = "block";
@@ -530,6 +522,7 @@ def catalog():
530
  if (modal) {
531
  modal.style.display = "none";
532
  }
 
533
  const anyModalOpen = document.querySelector('.modal[style*="display: block"]');
534
  if (!anyModalOpen) {
535
  document.body.style.overflow = 'auto';
@@ -540,7 +533,7 @@ def catalog():
540
  const modalContent = document.getElementById('modalContent');
541
  if (!modalContent) return;
542
  modalContent.innerHTML = '<p style="text-align:center; padding: 40px;">Загрузка...</p>';
543
- // The index passed is relative to the filtered/sorted list shown on the page
544
  fetch('/product/' + index)
545
  .then(response => {
546
  if (!response.ok) throw new Error(`Ошибка ${response.status}: ${response.statusText}`);
@@ -562,7 +555,7 @@ def catalog():
562
  new Swiper(swiperContainer, {
563
  slidesPerView: 1,
564
  spaceBetween: 20,
565
- loop: true,
566
  grabCursor: true,
567
  pagination: { el: '.swiper-pagination', clickable: true },
568
  navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
@@ -572,17 +565,17 @@ def catalog():
572
  }
573
  }
574
 
575
- function openQuantityModal(index) {
576
  if (!isAuthenticated) {
577
  alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
578
  window.location.href = '/login';
579
  return;
580
  }
581
- selectedProductIndex = index;
582
- // Use the 'products' array available in JS, which is already filtered/sorted
583
- const product = products[index];
584
  if (!product) {
585
- console.error("Product not found for index:", index);
586
  alert("Ошибка: товар не найден.");
587
  return;
588
  }
@@ -603,6 +596,11 @@ def catalog():
603
  colorSelect.style.display = 'block';
604
  if(colorLabel) colorLabel.style.display = 'block';
605
  } else {
 
 
 
 
 
606
  colorSelect.style.display = 'none';
607
  if(colorLabel) colorLabel.style.display = 'none';
608
  }
@@ -615,12 +613,14 @@ def catalog():
615
  }
616
  }
617
 
 
618
  function confirmAddToCart() {
619
  if (selectedProductIndex === null) return;
620
 
621
  const quantityInput = document.getElementById('quantityInput');
622
  const quantity = parseInt(quantityInput.value);
623
  const colorSelect = document.getElementById('colorSelect');
 
624
  const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
625
 
626
  if (isNaN(quantity) || quantity <= 0) {
@@ -629,20 +629,21 @@ def catalog():
629
  return;
630
  }
631
 
632
- const product = products[selectedProductIndex];
633
  if (!product) {
634
  alert("Ошибка добавления: товар не найден.");
635
  return;
636
  }
637
 
638
- const cartItemId = `${product.name}-${color}`;
 
639
  const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
640
 
641
  if (existingItemIndex > -1) {
642
  cart[existingItemIndex].quantity += quantity;
643
  } else {
644
  cart.push({
645
- id: cartItemId,
646
  name: product.name,
647
  price: product.price,
648
  photo: product.photos && product.photos.length > 0 ? product.photos[0] : null,
@@ -655,8 +656,10 @@ def catalog():
655
  closeModal('quantityModal');
656
  updateCartButton();
657
  showNotification(`${product.name} добавлен в корзину!`);
 
658
  }
659
 
 
660
  function updateCartButton() {
661
  const cartCountElement = document.getElementById('cart-count');
662
  const cartButton = document.getElementById('cart-button');
@@ -665,7 +668,7 @@ def catalog():
665
  let totalItems = 0;
666
  cart.forEach(item => { totalItems += item.quantity; });
667
 
668
- if (totalItems > 0) {
669
  cartCountElement.textContent = totalItems;
670
  cartButton.style.display = 'flex';
671
  } else {
@@ -675,6 +678,9 @@ def catalog():
675
  }
676
 
677
  function openCartModal() {
 
 
 
678
  const cartContent = document.getElementById('cartContent');
679
  const cartTotalElement = document.getElementById('cartTotal');
680
  if (!cartContent || !cartTotalElement) return;
@@ -717,16 +723,16 @@ def catalog():
717
  function removeFromCart(itemId) {
718
  cart = cart.filter(item => item.id !== itemId);
719
  localStorage.setItem('soolaCart', JSON.stringify(cart));
720
- openCartModal();
721
- updateCartButton();
722
  }
723
 
724
  function clearCart() {
725
  if (confirm("Вы уверены, что хотите очистить корзину?")) {
726
  cart = [];
727
  localStorage.removeItem('soolaCart');
728
- openCartModal();
729
- updateCartButton();
730
  }
731
  }
732
 
@@ -751,7 +757,11 @@ def catalog():
751
  orderText += `*Итого: ${total.toFixed(2)} ${currencyCode}*\n\n`;
752
  orderText += "--- Заказчик ---\n";
753
 
754
- const userInfo = {{ session.get('user_info', {})|tojson }};
 
 
 
 
755
  if (userInfo && userInfo.login) {
756
  orderText += `Имя: ${userInfo.first_name || ''} ${userInfo.last_name || ''}\n`;
757
  orderText += `Логин: ${userInfo.login}\n`;
@@ -766,23 +776,20 @@ def catalog():
766
  const dateTimeString = now.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'medium'});
767
  orderText += `\nДата заказа: ${dateTimeString}`;
768
 
769
- const whatsappNumber = "996997703090";
770
  const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
771
  window.open(whatsappUrl, '_blank');
772
  }
773
 
774
- function filterProducts() {
775
  const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
776
  const activeCategoryButton = document.querySelector('.category-filter.active');
777
  const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
778
  const grid = document.getElementById('products-grid');
 
779
  let visibleProducts = 0;
780
 
781
- const existingNoResults = grid.querySelector('.no-results-message');
782
- if (existingNoResults) existingNoResults.remove();
783
-
784
- // Iterate over the products displayed (which are already filtered by stock)
785
- document.querySelectorAll('.products-grid .product').forEach(productElement => {
786
  const name = productElement.getAttribute('data-name');
787
  const description = productElement.getAttribute('data-description');
788
  const category = productElement.getAttribute('data-category');
@@ -791,31 +798,29 @@ def catalog():
791
  const matchesCategory = activeCategory === 'all' || category === activeCategory;
792
 
793
  if (matchesSearch && matchesCategory) {
794
- productElement.style.display = 'flex';
795
  visibleProducts++;
796
  } else {
797
  productElement.style.display = 'none';
798
  }
799
  });
800
 
 
801
  if (visibleProducts === 0 && (searchTerm || activeCategory !== 'all')) {
802
- const p = document.createElement('p');
803
- p.className = 'no-results-message';
804
- p.textContent = 'По вашему запросу товары не найдены.';
805
- grid.appendChild(p);
806
- } else if (products.length === 0 && !grid.querySelector('.no-results-message')) {
807
- const p = document.createElement('p');
808
- p.className = 'no-results-message';
809
- p.textContent = 'Товары пока не добавлены или нет в наличии.';
810
- grid.appendChild(p);
811
  }
812
- }
 
813
 
814
  function setupFilters() {
815
  const searchInput = document.getElementById('search-input');
816
  const categoryFilters = document.querySelectorAll('.category-filter');
817
 
818
- if(searchInput) searchInput.addEventListener('input', filterProducts);
 
 
819
 
820
  categoryFilters.forEach(filter => {
821
  filter.addEventListener('click', function() {
@@ -828,69 +833,52 @@ def catalog():
828
 
829
  function showNotification(message, duration = 3000) {
830
  const placeholder = document.getElementById('notification-placeholder');
831
- if (!placeholder) return;
 
 
 
 
 
 
832
 
833
  const notification = document.createElement('div');
834
  notification.className = 'notification';
835
  notification.textContent = message;
836
  placeholder.appendChild(notification);
837
 
838
- setTimeout(() => { notification.classList.add('show'); }, 10);
 
 
 
 
839
 
 
840
  setTimeout(() => {
841
  notification.classList.remove('show');
842
- setTimeout(() => { notification.remove(); }, 500);
 
843
  }, duration);
844
  }
845
 
 
846
  document.addEventListener('DOMContentLoaded', () => {
847
- applyInitialTheme();
848
- attemptAutoLogin();
849
- updateCartButton();
850
- setupFilters();
851
- filterProducts(); // Initial filter application
852
-
853
- // Attach theme toggle event listener
854
- const themeToggleButton = document.querySelector('.theme-toggle');
855
- if (themeToggleButton) {
856
- themeToggleButton.addEventListener('click', toggleTheme);
857
- }
858
-
859
- // Attach product action listeners using event delegation
860
- const productsGrid = document.getElementById('products-grid');
861
- if (productsGrid) {
862
- productsGrid.addEventListener('click', function(event) {
863
- const button = event.target.closest('button[data-action]');
864
- if (!button) return;
865
-
866
- const action = button.dataset.action;
867
- const index = parseInt(button.dataset.index, 10);
868
-
869
- if (isNaN(index)) {
870
- console.error("Invalid index found on button:", button);
871
- return;
872
- }
873
-
874
- if (action === 'open-details') {
875
- openModal(index);
876
- } else if (action === 'open-quantity') {
877
- if (isAuthenticated) {
878
- openQuantityModal(index);
879
- } else {
880
- alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
881
- window.location.href = '/login';
882
- }
883
- }
884
- });
885
- }
886
 
 
 
 
 
887
 
 
888
  window.addEventListener('click', function(event) {
889
  if (event.target.classList.contains('modal')) {
890
  closeModal(event.target.id);
891
  }
892
  });
893
 
 
894
  window.addEventListener('keydown', function(event) {
895
  if (event.key === 'Escape') {
896
  document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
@@ -898,6 +886,9 @@ def catalog():
898
  });
899
  }
900
  });
 
 
 
901
  });
902
 
903
  </script>
@@ -906,7 +897,7 @@ def catalog():
906
  '''
907
  return render_template_string(
908
  catalog_html,
909
- products=sorted_products, # Pass sorted and filtered products
910
  categories=categories,
911
  repo_id=REPO_ID,
912
  is_authenticated=is_authenticated,
@@ -919,31 +910,25 @@ def catalog():
919
  @app.route('/product/<int:index>')
920
  def product_detail(index):
921
  data = load_data()
922
- all_products = data.get('products', [])
923
  is_authenticated = 'user' in session
924
-
925
- # Apply the same filtering and sorting as the catalog to ensure the index is correct
926
- available_products = [p for p in all_products if p.get('in_stock', True)]
927
- sorted_products = sorted(
928
- available_products,
929
- key=lambda p: (not p.get('is_top', False), p.get('name', '').lower())
930
- )
931
-
932
  try:
933
- product = sorted_products[index]
 
 
 
 
 
934
  except IndexError:
935
- logging.warning(f"Попытка доступа к несуществующему или недоступному продукту с индексом {index} (относительно видимого списка)")
936
- return "Товар не найден или недоступен", 404
937
 
938
  detail_html = '''
939
  <div style="padding: 10px;">
940
  <h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #1C6758;">{{ product['name'] }}</h2>
941
  {% if not product.get('in_stock', True) %}
942
- <p style="text-align: center; color: #f56565; font-weight: bold; margin-bottom: 15px;">Нет в наличии</p>
943
  {% endif %}
944
- {% if product.get('is_top') %}
945
- <p style="text-align: center; color: #ffc107; font-weight: bold; margin-bottom: 15px;"><i class="fas fa-star"></i> Популярный товар</p>
946
- {% endif %}
947
  <div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
948
  <div class="swiper-wrapper">
949
  {% if product.get('photos') and product['photos']|length > 0 %}
@@ -982,11 +967,20 @@ def product_detail(index):
982
  <p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
983
  {% endif %}
984
  </div>
 
 
 
 
 
 
 
985
  </div>
986
  '''
 
987
  return render_template_string(
988
  detail_html,
989
  product=product,
 
990
  repo_id=REPO_ID,
991
  is_authenticated=is_authenticated,
992
  currency_code=CURRENCY_CODE
@@ -999,6 +993,7 @@ LOGIN_TEMPLATE = '''
999
  <meta charset="UTF-8">
1000
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1001
  <title>Вход - Soola Cosmetics</title>
 
1002
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
1003
  <style>
1004
  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; }
@@ -1056,7 +1051,7 @@ def login():
1056
  }
1057
  logging.info(f"Пользователь {login} успешно вошел в систему.")
1058
  login_response_html = f'''
1059
- <!DOCTYPE html><html><head><title>Перенаправление...</title></head><body>
1060
  <script>
1061
  try {{ localStorage.setItem('soolaUser', '{login}'); }} catch (e) {{ console.error("Ошибка сохранения в localStorage:", e); }}
1062
  window.location.href = "{url_for('catalog')}";
@@ -1101,7 +1096,8 @@ def auto_login():
1101
  return "OK", 200
1102
  else:
1103
  logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
1104
- return "Ошибка авто-входа", 400
 
1105
 
1106
  @app.route('/logout')
1107
  def logout():
@@ -1111,7 +1107,7 @@ def logout():
1111
  if logged_out_user:
1112
  logging.info(f"Пользователь {logged_out_user} вышел из системы.")
1113
  logout_response_html = '''
1114
- <!DOCTYPE html><html><head><title>Выход...</title></head><body>
1115
  <script>
1116
  try { localStorage.removeItem('soolaUser'); } catch (e) { console.error("Ошибка удаления из localStorage:", e); }
1117
  window.location.href = "/";
@@ -1128,16 +1124,18 @@ ADMIN_TEMPLATE = '''
1128
  <meta charset="UTF-8">
1129
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1130
  <title>Админ-панель - Soola Cosmetics</title>
 
1131
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
1132
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
1133
  <style>
1134
  body { font-family: 'Poppins', sans-serif; background-color: #e9f5f0; color: #2d332f; padding: 20px; line-height: 1.6; }
1135
  .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); }
1136
  .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;}
1137
- h1, h2, h3 { font-weight: 600; color: #1C6758; margin-bottom: 15px; }
1138
  h1 { font-size: 1.8rem; }
1139
  h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
1140
  h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; }
 
1141
  .section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; }
1142
  form { margin-bottom: 20px; }
1143
  label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;}
@@ -1146,8 +1144,6 @@ ADMIN_TEMPLATE = '''
1146
  textarea { min-height: 80px; resize: vertical; }
1147
  input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;}
1148
  input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;}
1149
- input[type="checkbox"] { margin-right: 5px; width: auto; vertical-align: middle;}
1150
- .checkbox-label { display: inline-block; margin-top: 10px; margin-bottom: 5px; font-weight: 400; color: #2d332f; }
1151
  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;}
1152
  button:hover, .button:hover { background-color: #164B41; }
1153
  button:active, .button:active { transform: scale(0.98); }
@@ -1160,12 +1156,7 @@ ADMIN_TEMPLATE = '''
1160
  .item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #e1f0e9; }
1161
  .item p { margin: 5px 0; font-size: 0.9rem; color: #44524c; }
1162
  .item strong { color: #2d332f; }
1163
- .item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
1164
- .item-status { display: flex; gap: 15px; flex-wrap: wrap; font-size: 0.85rem; margin-top: 8px;}
1165
- .status-badge { padding: 2px 8px; border-radius: 10px; font-weight: 500; }
1166
- .status-top { background-color: #ffecb3; color: #6d4c41; border: 1px solid #ffe082;}
1167
- .status-instock { background-color: #c8e6c9; color: #1b5e20; border: 1px solid #a5d6a7;}
1168
- .status-outofstock { background-color: #ffcdd2; color: #b71c1c; border: 1px solid #ef9a9a;}
1169
  .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
1170
  .item-actions button:not(.delete-button) { background-color: #1C6758; }
1171
  .item-actions button:not(.delete-button):hover { background-color: #164B41; }
@@ -1192,8 +1183,11 @@ ADMIN_TEMPLATE = '''
1192
  .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
1193
  .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
1194
  .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
1195
- .form-row { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 10px;}
1196
- .form-row > div { flex: 1; min-width: 150px;}
 
 
 
1197
  </style>
1198
  </head>
1199
  <body>
@@ -1338,16 +1332,6 @@ ADMIN_TEMPLATE = '''
1338
  </select>
1339
  <label for="add_photos">Фотографии (до 10 шт.):</label>
1340
  <input type="file" id="add_photos" name="photos" accept="image/*" multiple>
1341
- <div class="form-row">
1342
- <div>
1343
- <input type="checkbox" id="add_is_top" name="is_top">
1344
- <label for="add_is_top" class="checkbox-label"> <i class="fas fa-star"></i> Топ товар (в начале каталога)</label>
1345
- </div>
1346
- <div>
1347
- <input type="checkbox" id="add_in_stock" name="in_stock" checked>
1348
- <label for="add_in_stock" class="checkbox-label"> <i class="fas fa-check-circle"></i> В наличии (отображать в каталоге)</label>
1349
- </div>
1350
- </div>
1351
  <label>Цвета/Варианты (оставьте пустым, если нет):</label>
1352
  <div id="add-color-inputs">
1353
  <div class="color-input-group">
@@ -1356,6 +1340,16 @@ ADMIN_TEMPLATE = '''
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>
@@ -1363,9 +1357,9 @@ ADMIN_TEMPLATE = '''
1363
  </details>
1364
 
1365
  <h3>Список товаров:</h3>
1366
- {% if all_products %}
1367
  <div class="item-list">
1368
- {% for product in all_products %}
1369
  <div class="item">
1370
  <div style="display: flex; gap: 15px; align-items: flex-start;">
1371
  <div class="photo-preview" style="flex-shrink: 0;">
@@ -1378,24 +1372,22 @@ ADMIN_TEMPLATE = '''
1378
  {% endif %}
1379
  </div>
1380
  <div style="flex-grow: 1;">
1381
- <h3 style="margin-top: 0; margin-bottom: 5px; color: #2d332f;">{{ product['name'] }}</h3>
 
 
 
 
 
 
 
 
1382
  <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
1383
  <p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
1384
  <p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
1385
  {% set colors = product.get('colors', []) %}
1386
  <p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}</p>
1387
- <div class="item-status">
1388
- {% if product.get('is_top') %}
1389
- <span class="status-badge status-top"><i class="fas fa-star"></i> Топ товар</span>
1390
- {% endif %}
1391
- {% if product.get('in_stock', True) %}
1392
- <span class="status-badge status-instock"><i class="fas fa-check-circle"></i> В наличии</span>
1393
- {% else %}
1394
- <span class="status-badge status-outofstock"><i class="fas fa-times-circle"></i> Нет в наличии</span>
1395
- {% endif %}
1396
- </div>
1397
  {% if product.get('photos') and product['photos']|length > 1 %}
1398
- <p style="font-size: 0.8rem; color: #5e6e68; margin-top: 5px;">(Всего фото: {{ product['photos']|length }})</p>
1399
  {% endif %}
1400
  </div>
1401
  </div>
@@ -1404,7 +1396,7 @@ ADMIN_TEMPLATE = '''
1404
  <button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
1405
  <form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
1406
  <input type="hidden" name="action" value="delete_product">
1407
- <input type="hidden" name="original_index" value="{{ loop.index0 }}">
1408
  <button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
1409
  </form>
1410
  </div>
@@ -1413,7 +1405,7 @@ ADMIN_TEMPLATE = '''
1413
  <h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
1414
  <form method="POST" enctype="multipart/form-data">
1415
  <input type="hidden" name="action" value="edit_product">
1416
- <input type="hidden" name="original_index" value="{{ loop.index0 }}">
1417
  <label>Название *:</label>
1418
  <input type="text" name="name" value="{{ product['name'] }}" required>
1419
  <label>Цена ({{ currency_code }}) *:</label>
@@ -1437,16 +1429,6 @@ ADMIN_TEMPLATE = '''
1437
  {% endfor %}
1438
  </div>
1439
  {% endif %}
1440
- <div class="form-row">
1441
- <div>
1442
- <input type="checkbox" id="edit_is_top_{{ loop.index0 }}" name="is_top" {% if product.get('is_top') %}checked{% endif %}>
1443
- <label for="edit_is_top_{{ loop.index0 }}" class="checkbox-label"> <i class="fas fa-star"></i> Топ товар</label>
1444
- </div>
1445
- <div>
1446
- <input type="checkbox" id="edit_in_stock_{{ loop.index0 }}" name="in_stock" {% if product.get('in_stock', True) %}checked{% endif %}>
1447
- <label for="edit_in_stock_{{ loop.index0 }}" class="checkbox-label"> <i class="fas fa-check-circle"></i> В наличии</label>
1448
- </div>
1449
- </div>
1450
  <label>Цвета/Варианты:</label>
1451
  <div id="edit-color-inputs-{{ loop.index0 }}">
1452
  {% set current_colors = product.get('colors', []) %}
@@ -1467,6 +1449,16 @@ ADMIN_TEMPLATE = '''
1467
  {% endif %}
1468
  </div>
1469
  <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>
 
 
 
 
 
 
 
 
 
 
1470
  <br>
1471
  <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button>
1472
  </form>
@@ -1509,15 +1501,16 @@ ADMIN_TEMPLATE = '''
1509
  function removeColorInput(button) {
1510
  const group = button.closest('.color-input-group');
1511
  if (group) {
1512
- const container = group.parentNode;
1513
- // Prevent removing the last input field in the add form
1514
- const isAddForm = container.id === 'add-color-inputs';
1515
- const siblingCount = container.querySelectorAll('.color-input-group').length;
1516
- if (isAddForm && siblingCount <= 1) {
1517
- group.querySelector('input[name="colors"]').value = ''; // Clear instead of removing
1518
- return;
1519
- }
1520
- group.remove();
 
1521
  } else {
1522
  console.warn("Не удалось найти родительский .color-input-group для кнопки удаления");
1523
  }
@@ -1529,16 +1522,17 @@ ADMIN_TEMPLATE = '''
1529
 
1530
  @app.route('/admin', methods=['GET', 'POST'])
1531
  def admin():
 
 
 
 
 
 
1532
  data = load_data()
1533
- # Admin sees ALL products, regardless of stock status
1534
- all_products = data.get('products', [])
1535
  categories = data.get('categories', [])
1536
  users = load_users()
1537
 
1538
- # Sort products alphabetically for consistent admin display
1539
- all_products.sort(key=lambda x: x.get('name', '').lower())
1540
-
1541
-
1542
  if request.method == 'POST':
1543
  action = request.form.get('action')
1544
  logging.info(f"Admin action received: {action}")
@@ -1564,8 +1558,7 @@ def admin():
1564
  if category_to_delete and category_to_delete in categories:
1565
  categories.remove(category_to_delete)
1566
  updated_count = 0
1567
- # Use all_products here as data['products'] is the source
1568
- for product in data['products']:
1569
  if product.get('category') == category_to_delete:
1570
  product['category'] = 'Без категории'
1571
  updated_count += 1
@@ -1584,8 +1577,8 @@ def admin():
1584
  category = request.form.get('category')
1585
  photos_files = request.files.getlist('photos')
1586
  colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1587
- is_top = request.form.get('is_top') == 'on'
1588
- in_stock = request.form.get('in_stock') == 'on'
1589
 
1590
  if not name or not price_str:
1591
  flash("Название и цена товара обязательны.", 'error')
@@ -1599,7 +1592,7 @@ def admin():
1599
  return redirect(url_for('admin'))
1600
 
1601
  photos_list = []
1602
- if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
1603
  uploads_dir = 'uploads_temp'
1604
  os.makedirs(uploads_dir, exist_ok=True)
1605
  api = HfApi()
@@ -1612,7 +1605,7 @@ def admin():
1612
  break
1613
  if photo and photo.filename:
1614
  try:
1615
- ext = os.path.splitext(photo.filename)[1].lower()
1616
  photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
1617
  temp_path = os.path.join(uploads_dir, photo_filename)
1618
  photo.save(temp_path)
@@ -1635,7 +1628,7 @@ def admin():
1635
  elif photo and not photo.filename:
1636
  logging.warning("Получен пустой объект файла фото при добавлении товара.")
1637
  try:
1638
- # Attempt to remove the temp dir only if it's empty
1639
  if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
1640
  os.rmdir(uploads_dir)
1641
  except OSError as e:
@@ -1646,29 +1639,28 @@ def admin():
1646
  'name': name, 'price': price, 'description': description,
1647
  'category': category if category in categories else 'Без категории',
1648
  'photos': photos_list, 'colors': colors,
1649
- 'is_top': is_top, 'in_stock': in_stock
1650
  }
1651
- # Append to the source list in data
1652
- data['products'].append(new_product)
1653
- # No need to sort here, sorting happens when loading or displaying
1654
  save_data(data)
1655
- logging.info(f"Товар '{name}' добавлен.")
1656
  flash(f"Товар '{name}' успешно добавлен.", 'success')
1657
 
1658
  elif action == 'edit_product':
1659
- index_str = request.form.get('original_index') # Use original_index from hidden input
1660
  if index_str is None:
1661
  flash("Ошибка редактирования: индекс товара не передан.", 'error')
1662
  return redirect(url_for('admin'))
1663
 
1664
  try:
1665
- # Index refers to the position in the ORIGINAL all_products list from data
1666
  index = int(index_str)
1667
- if not (0 <= index < len(data['products'])): raise IndexError("Индекс вне диапазона")
1668
- product_to_edit = data['products'][index]
1669
  original_name = product_to_edit.get('name', 'N/A')
1670
- except (ValueError, IndexError) as e:
1671
- flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'. {e}", 'error')
1672
  return redirect(url_for('admin'))
1673
 
1674
  product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
@@ -1677,8 +1669,8 @@ def admin():
1677
  category = request.form.get('category')
1678
  product_to_edit['category'] = category if category in categories else 'Без категории'
1679
  product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1680
- product_to_edit['is_top'] = request.form.get('is_top') == 'on'
1681
- product_to_edit['in_stock'] = request.form.get('in_stock') == 'on'
1682
 
1683
  try:
1684
  price = round(float(price_str), 2)
@@ -1704,7 +1696,7 @@ def admin():
1704
  break
1705
  if photo and photo.filename:
1706
  try:
1707
- ext = os.path.splitext(photo.filename)[1].lower()
1708
  photo_filename = secure_filename(f"{product_to_edit['name'].replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
1709
  temp_path = os.path.join(uploads_dir, photo_filename)
1710
  photo.save(temp_path)
@@ -1731,39 +1723,52 @@ def admin():
1731
  if old_photos:
1732
  logging.info(f"Попытка удаления старых фото: {old_photos}")
1733
  try:
1734
- api.delete_files(
1735
- repo_id=REPO_ID,
1736
- paths_in_repo=[f"photos/{p}" for p in old_photos],
1737
- repo_type="dataset",
1738
- token=HF_TOKEN_WRITE,
1739
- commit_message=f"Delete old photos for product {product_to_edit['name']}"
1740
- )
1741
- logging.info(f"Старые фото для товара {product_to_edit['name']} удалены с HF.")
 
 
 
 
 
 
 
 
1742
  except Exception as e:
1743
- # Log error but don't stop the update process
1744
- logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True)
1745
- flash("Не удалось удалить старые фотографии с сервера.", "warning")
 
 
 
 
 
1746
  product_to_edit['photos'] = new_photos_list
1747
  flash("Фотографии товара успешно обновлены.", "success")
1748
  elif uploaded_count == 0 and any(f.filename for f in photos_files):
1749
  flash("Не удалось загрузить новые фотографии.", "error")
1750
 
1751
- # No sorting needed here, save directly
 
1752
  save_data(data)
1753
- logging.info(f"Товар '{original_name}' (исходный индекс {index}) обновлен на '{product_to_edit['name']}'.")
1754
  flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
1755
 
1756
 
1757
  elif action == 'delete_product':
1758
- index_str = request.form.get('original_index') # Use original_index
1759
  if index_str is None:
1760
  flash("Ошибка удаления: индекс товара не передан.", 'error')
1761
  return redirect(url_for('admin'))
1762
  try:
1763
  index = int(index_str)
1764
- # Index refers to the position in the ORIGINAL all_products list from data
1765
- if not (0 <= index < len(data['products'])): raise IndexError("Индекс вне диапазона")
1766
- deleted_product = data['products'].pop(index) # Remove from the source list
1767
  product_name = deleted_product.get('name', 'N/A')
1768
 
1769
  photos_to_delete = deleted_product.get('photos', [])
@@ -1771,23 +1776,31 @@ def admin():
1771
  logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}")
1772
  try:
1773
  api = HfApi()
1774
- api.delete_files(
1775
- repo_id=REPO_ID,
1776
- paths_in_repo=[f"photos/{p}" for p in photos_to_delete],
1777
- repo_type="dataset",
1778
- token=HF_TOKEN_WRITE,
1779
- commit_message=f"Delete photos for deleted product {product_name}"
1780
- )
1781
- logging.info(f"Фото товара '{product_name}' удалены с HF.")
 
 
 
 
1782
  except Exception as e:
1783
- logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True)
1784
- flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
 
 
 
 
1785
 
1786
  save_data(data)
1787
- logging.info(f"Товар '{product_name}' (исходный индекс {index}) удален.")
1788
  flash(f"Товар '{product_name}' удален.", 'success')
1789
- except (ValueError, IndexError) as e:
1790
- flash(f"Ошибка удаления: неверный индекс товара '{index_str}'. {e}", 'error')
1791
 
1792
 
1793
  elif action == 'add_user':
@@ -1838,18 +1851,14 @@ def admin():
1838
  flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
1839
  return redirect(url_for('admin'))
1840
 
1841
- # Prepare data for rendering the admin page again after potential POST redirect or GET
1842
- data = load_data() # Reload data in case it was modified
1843
- all_products = data.get('products', [])
1844
- all_products.sort(key=lambda x: x.get('name', '').lower()) # Sort for display
1845
- categories = data.get('categories', [])
1846
  categories.sort()
1847
- users = load_users()
1848
  sorted_users = dict(sorted(users.items()))
1849
 
1850
  return render_template_string(
1851
  ADMIN_TEMPLATE,
1852
- all_products=all_products, # Pass all products to admin template
1853
  categories=categories,
1854
  users=sorted_users,
1855
  repo_id=REPO_ID,
@@ -1858,6 +1867,10 @@ def admin():
1858
 
1859
  @app.route('/force_upload', methods=['POST'])
1860
  def force_upload():
 
 
 
 
1861
  logging.info("Запущена принудительная загрузка данных на Hugging Face...")
1862
  try:
1863
  upload_db_to_hf()
@@ -1869,10 +1882,18 @@ def force_upload():
1869
 
1870
  @app.route('/force_download', methods=['POST'])
1871
  def force_download():
 
 
 
 
1872
  logging.info("Запущено принудительное скачивание данных с Hugging Face...")
1873
  try:
1874
  download_db_from_hf()
1875
- flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success')
 
 
 
 
1876
  except Exception as e:
1877
  logging.error(f"Ошибка при принудительном скачивании: {e}", exc_info=True)
1878
  flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
@@ -1880,7 +1901,21 @@ def force_download():
1880
 
1881
 
1882
  if __name__ == '__main__':
1883
- # Initial load on startup
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1884
  load_data()
1885
  load_users()
1886
 
@@ -1893,10 +1928,6 @@ if __name__ == '__main__':
1893
 
1894
  port = int(os.environ.get('PORT', 7860))
1895
  logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
1896
- # Use Waitress or Gunicorn for production instead of app.run(debug=...)
1897
- # Example with Waitress (install waitress first: pip install waitress)
1898
- # from waitress import serve
1899
- # serve(app, host='0.0.0.0', port=port)
1900
- # For development:
1901
  app.run(debug=False, host='0.0.0.0', port=port)
1902
 
 
1
 
2
+ from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash, abort
 
3
  import json
4
  import os
5
  import logging
 
39
  logging.info(f"Данные успешно загружены из {DATA_FILE}")
40
  if not isinstance(data, dict):
41
  logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.")
42
+ return {'products': [], 'categories': []}
 
43
  if 'products' not in data:
44
  data['products'] = []
45
  if 'categories' not in data:
46
  data['categories'] = []
47
+ # Ensure default values for new fields if missing
 
48
  for product in data['products']:
49
+ product.setdefault('is_featured', False)
50
  product.setdefault('in_stock', True)
 
51
  return data
52
  except FileNotFoundError:
53
  logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.")
 
63
  if not isinstance(data, dict): return {'products': [], 'categories': []}
64
  if 'products' not in data: data['products'] = []
65
  if 'categories' not in data: data['categories'] = []
 
66
  for product in data['products']:
67
+ product.setdefault('is_featured', False)
68
  product.setdefault('in_stock', True)
69
  return data
70
  except (FileNotFoundError, RepositoryNotFoundError) as e:
 
88
 
89
  def save_data(data):
90
  try:
 
 
 
 
 
 
91
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
92
  json.dump(data, file, ensure_ascii=False, indent=4)
93
  logging.info(f"Данные успешно сохранены в {DATA_FILE}")
 
190
  logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
191
  break
192
  except Exception as e:
193
+ if "404" in str(e) or isinstance(e, FileNotFoundError):
194
  logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.")
195
  else:
196
  logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True)
 
210
  upload_db_to_hf()
211
  logging.info("Периодическое резервное копирование завершено.")
212
 
213
+ @app.route('/favicon.ico')
214
+ def favicon():
215
+ return send_file('static/favicon.ico', mimetype='image/vnd.microsoft.icon', max_age=0) # Use max_age=0 for debugging
216
 
217
  @app.route('/')
218
  def catalog():
 
221
  categories = data.get('categories', [])
222
  is_authenticated = 'user' in session
223
 
224
+ products_in_stock = [p for p in all_products if p.get('in_stock', True)]
225
+ products_in_stock.sort(key=lambda p: (not p.get('is_featured', False), p.get('name', '').lower()))
 
 
 
 
 
 
226
 
227
  catalog_html = '''
228
  <!DOCTYPE html>
 
231
  <meta charset="UTF-8">
232
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
233
  <title>Soola Cosmetics - Каталог</title>
234
+ <link rel="icon" href="/favicon.ico">
235
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
236
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
237
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
 
272
  body.dark-mode .product { background: #253f37; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #2c4a41; }
273
  .product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
274
  body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
275
+ .product-image { width: 100%; aspect-ratio: 1 / 1; background-color: #fff; border-radius: 10px 10px 0 0; overflow: hidden; display: flex; justify-content: center; align-items: center; margin-bottom: 0; }
276
  .product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; }
277
  .product-image img:hover { transform: scale(1.08); }
 
278
  .product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
279
  .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; }
280
  body.dark-mode .product h2 { color: #c8d8d3; }
 
286
  .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; }
287
  .product-button:hover { background-color: #164B41; box-shadow: 0 4px 15px rgba(22, 75, 65, 0.4); transform: translateY(-2px); }
288
  .product-button i { margin-right: 5px; }
289
+ .product.featured { border-left: 5px solid #ffc107; } /* Style for featured */
290
 
291
  .add-to-cart { background-color: #38a169; }
292
  .add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
 
345
  <a href="{{ url_for('login') }}">Войти</a>
346
  {% endif %}
347
  </div>
348
+ <button class="theme-toggle" onclick="toggleTheme()" aria-label="Переключить тему">
349
  <i class="fas fa-moon"></i>
350
  </button>
351
  </div>
 
365
 
366
  <div class="products-grid" id="products-grid">
367
  {% for product in products %}
368
+ <div class="product {% if product.get('is_featured') %}featured{% endif %}"
369
  data-name="{{ product['name']|lower }}"
370
  data-description="{{ product.get('description', '')|lower }}"
371
+ data-category="{{ product.get('category', 'Без категории') }}"
372
+ data-id="{{ loop.index0 }}"> {# Use original index for mapping #}
373
  <div class="product-image">
 
 
 
374
  {% if product.get('photos') and product['photos']|length > 0 %}
375
  <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
376
  alt="{{ product['name'] }}"
 
380
  {% endif %}
381
  </div>
382
  <div class="product-info">
383
+ <h2>{{ product['name'] }} {% if product.get('is_featured') %}<i class="fas fa-star" style="color: #ffc107; font-size: 0.8em;" title="Популярный товар"></i>{% endif %}</h2>
384
  {% if is_authenticated %}
385
  <div class="product-price">{{ "%.2f"|format(product['price']) }} {{ currency_code }}</div>
386
  {% else %}
 
389
  <p class="product-description">{{ product.get('description', '')[:50] }}{% if product.get('description', '')|length > 50 %}...{% endif %}</p>
390
  </div>
391
  <div class="product-actions">
392
+ <button class="product-button" onclick="openModal({{ loop.index0 }})">Подробнее</button>
393
  {% if is_authenticated %}
394
+ <button class="product-button add-to-cart" onclick="openQuantityModal({{ loop.index0 }})">
395
  <i class="fas fa-cart-plus"></i> В корзину
396
  </button>
397
  {% endif %}
 
399
  </div>
400
  {% endfor %}
401
  {% if not products %}
402
+ <p class="no-results-message">Товары пока не добавлены или не соответствуют фильтру.</p>
403
  {% endif %}
404
+ <p id="no-results-placeholder" class="no-results-message" style="display: none;">По вашему запросу товары не найдены.</p>
405
  </div>
406
  </div>
407
 
 
452
 
453
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
454
  <script>
455
+ // Make sure product data is available globally AFTER DOM is loaded or handled correctly
456
+ let products = [];
457
  const repoId = '{{ repo_id }}';
458
  const currencyCode = '{{ currency_code }}';
459
  const isAuthenticated = {{ is_authenticated|tojson }};
460
  let selectedProductIndex = null;
461
  let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
462
 
463
+ // --- Function Definitions ---
464
  function toggleTheme() {
465
  document.body.classList.toggle('dark-mode');
466
  const icon = document.querySelector('.theme-toggle i');
 
476
  document.body.classList.add('dark-mode');
477
  const icon = document.querySelector('.theme-toggle i');
478
  if (icon) icon.classList.replace('fa-moon', 'fa-sun');
479
+ } else {
480
+ const icon = document.querySelector('.theme-toggle i');
481
+ if (icon) icon.classList.replace('fa-sun', 'fa-moon');
482
  }
483
  }
484
 
485
+ function attemptAutoLogin() {
486
  const storedUser = localStorage.getItem('soolaUser');
487
  if (storedUser && !isAuthenticated) {
488
  console.log('Attempting auto-login for:', storedUser);
 
497
  window.location.reload();
498
  } else {
499
  response.text().then(text => console.log(`Auto-login failed: ${response.status} ${text}`));
500
+ localStorage.removeItem('soolaUser'); // Remove invalid stored user
501
  }
502
  })
503
  .catch(error => {
504
  console.error('Auto-login fetch error:', error);
505
+ localStorage.removeItem('soolaUser'); // Remove on error too
506
  });
507
  }
508
  }
509
 
510
+ function openModal(productIndex) {
511
+ // Fetch details using the original index
512
+ loadProductDetails(productIndex);
513
  const modal = document.getElementById('productModal');
514
  if (modal) {
515
  modal.style.display = "block";
 
522
  if (modal) {
523
  modal.style.display = "none";
524
  }
525
+ // Check if ANY modal is still open before re-enabling scroll
526
  const anyModalOpen = document.querySelector('.modal[style*="display: block"]');
527
  if (!anyModalOpen) {
528
  document.body.style.overflow = 'auto';
 
533
  const modalContent = document.getElementById('modalContent');
534
  if (!modalContent) return;
535
  modalContent.innerHTML = '<p style="text-align:center; padding: 40px;">Загрузка...</p>';
536
+ // Use the original index passed from the backend
537
  fetch('/product/' + index)
538
  .then(response => {
539
  if (!response.ok) throw new Error(`Ошибка ${response.status}: ${response.statusText}`);
 
555
  new Swiper(swiperContainer, {
556
  slidesPerView: 1,
557
  spaceBetween: 20,
558
+ loop: true, // Looping might cause issues if only 1 image
559
  grabCursor: true,
560
  pagination: { el: '.swiper-pagination', clickable: true },
561
  navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
 
565
  }
566
  }
567
 
568
+ function openQuantityModal(productIndex) {
569
  if (!isAuthenticated) {
570
  alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
571
  window.location.href = '/login';
572
  return;
573
  }
574
+ selectedProductIndex = productIndex; // Store the original index
575
+ const product = products[selectedProductIndex]; // Access using original index
576
+
577
  if (!product) {
578
+ console.error("Product not found for index:", productIndex);
579
  alert("Ошибка: товар не найден.");
580
  return;
581
  }
 
596
  colorSelect.style.display = 'block';
597
  if(colorLabel) colorLabel.style.display = 'block';
598
  } else {
599
+ // Add a default "N/A" option if no colors, but hide it
600
+ const option = document.createElement('option');
601
+ option.value = 'N/A';
602
+ option.text = 'N/A';
603
+ colorSelect.appendChild(option);
604
  colorSelect.style.display = 'none';
605
  if(colorLabel) colorLabel.style.display = 'none';
606
  }
 
613
  }
614
  }
615
 
616
+
617
  function confirmAddToCart() {
618
  if (selectedProductIndex === null) return;
619
 
620
  const quantityInput = document.getElementById('quantityInput');
621
  const quantity = parseInt(quantityInput.value);
622
  const colorSelect = document.getElementById('colorSelect');
623
+ // Use selected value if visible, otherwise 'N/A'
624
  const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A';
625
 
626
  if (isNaN(quantity) || quantity <= 0) {
 
629
  return;
630
  }
631
 
632
+ const product = products[selectedProductIndex]; // Access using original index
633
  if (!product) {
634
  alert("Ошибка добавления: товар не найден.");
635
  return;
636
  }
637
 
638
+ // Use product index and color to make ID unique even if names are same
639
+ const cartItemId = `product-${selectedProductIndex}-${color}`;
640
  const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
641
 
642
  if (existingItemIndex > -1) {
643
  cart[existingItemIndex].quantity += quantity;
644
  } else {
645
  cart.push({
646
+ id: cartItemId, // Use unique ID
647
  name: product.name,
648
  price: product.price,
649
  photo: product.photos && product.photos.length > 0 ? product.photos[0] : null,
 
656
  closeModal('quantityModal');
657
  updateCartButton();
658
  showNotification(`${product.name} добавлен в корзину!`);
659
+ selectedProductIndex = null; // Reset selected index
660
  }
661
 
662
+
663
  function updateCartButton() {
664
  const cartCountElement = document.getElementById('cart-count');
665
  const cartButton = document.getElementById('cart-button');
 
668
  let totalItems = 0;
669
  cart.forEach(item => { totalItems += item.quantity; });
670
 
671
+ if (totalItems > 0 && isAuthenticated) { // Show only if logged in and items exist
672
  cartCountElement.textContent = totalItems;
673
  cartButton.style.display = 'flex';
674
  } else {
 
678
  }
679
 
680
  function openCartModal() {
681
+ if (!isAuthenticated) { // Should not happen if button isn't shown, but double-check
682
+ return;
683
+ }
684
  const cartContent = document.getElementById('cartContent');
685
  const cartTotalElement = document.getElementById('cartTotal');
686
  if (!cartContent || !cartTotalElement) return;
 
723
  function removeFromCart(itemId) {
724
  cart = cart.filter(item => item.id !== itemId);
725
  localStorage.setItem('soolaCart', JSON.stringify(cart));
726
+ openCartModal(); // Refresh cart view
727
+ updateCartButton(); // Update bubble count
728
  }
729
 
730
  function clearCart() {
731
  if (confirm("Вы уверены, что хотите очистить корзину?")) {
732
  cart = [];
733
  localStorage.removeItem('soolaCart');
734
+ openCartModal(); // Refresh cart view
735
+ updateCartButton(); // Update bubble count
736
  }
737
  }
738
 
 
757
  orderText += `*Итого: ${total.toFixed(2)} ${currencyCode}*\n\n`;
758
  orderText += "--- Заказчик ---\n";
759
 
760
+ // Fetch current user info directly if possible, or use session data if available client-side
761
+ // Assuming session data is passed correctly and available via `isAuthenticated` check
762
+ // The Python template passes session data, but it's safer to get it fresh if needed, though not standard for client-side JS
763
+ // We rely on the `session['user_info']` passed during render
764
+ const userInfo = {{ session.get('user_info', {})|tojson }}; // Use data injected by Jinja
765
  if (userInfo && userInfo.login) {
766
  orderText += `Имя: ${userInfo.first_name || ''} ${userInfo.last_name || ''}\n`;
767
  orderText += `Логин: ${userInfo.login}\n`;
 
776
  const dateTimeString = now.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'medium'});
777
  orderText += `\nДата заказа: ${dateTimeString}`;
778
 
779
+ const whatsappNumber = "996997703090"; // Replace if needed
780
  const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
781
  window.open(whatsappUrl, '_blank');
782
  }
783
 
784
+ function filterProducts() {
785
  const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
786
  const activeCategoryButton = document.querySelector('.category-filter.active');
787
  const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all';
788
  const grid = document.getElementById('products-grid');
789
+ const noResultsPlaceholder = document.getElementById('no-results-placeholder');
790
  let visibleProducts = 0;
791
 
792
+ grid.querySelectorAll('.product').forEach(productElement => {
 
 
 
 
793
  const name = productElement.getAttribute('data-name');
794
  const description = productElement.getAttribute('data-description');
795
  const category = productElement.getAttribute('data-category');
 
798
  const matchesCategory = activeCategory === 'all' || category === activeCategory;
799
 
800
  if (matchesSearch && matchesCategory) {
801
+ productElement.style.display = 'flex'; // Use flex as defined in CSS
802
  visibleProducts++;
803
  } else {
804
  productElement.style.display = 'none';
805
  }
806
  });
807
 
808
+ // Show/hide the "no results" message specifically for filtering
809
  if (visibleProducts === 0 && (searchTerm || activeCategory !== 'all')) {
810
+ if (noResultsPlaceholder) noResultsPlaceholder.style.display = 'block';
811
+ } else {
812
+ if (noResultsPlaceholder) noResultsPlaceholder.style.display = 'none';
 
 
 
 
 
 
813
  }
814
+ }
815
+
816
 
817
  function setupFilters() {
818
  const searchInput = document.getElementById('search-input');
819
  const categoryFilters = document.querySelectorAll('.category-filter');
820
 
821
+ if(searchInput) {
822
+ searchInput.addEventListener('input', filterProducts);
823
+ }
824
 
825
  categoryFilters.forEach(filter => {
826
  filter.addEventListener('click', function() {
 
833
 
834
  function showNotification(message, duration = 3000) {
835
  const placeholder = document.getElementById('notification-placeholder');
836
+ if (!placeholder) {
837
+ // Create placeholder if it doesn't exist
838
+ const newPlaceholder = document.createElement('div');
839
+ newPlaceholder.id = 'notification-placeholder';
840
+ document.body.appendChild(newPlaceholder);
841
+ placeholder = newPlaceholder;
842
+ }
843
 
844
  const notification = document.createElement('div');
845
  notification.className = 'notification';
846
  notification.textContent = message;
847
  placeholder.appendChild(notification);
848
 
849
+ // Trigger reflow to enable transition
850
+ void notification.offsetWidth;
851
+
852
+ // Add 'show' class to fade in
853
+ notification.classList.add('show');
854
 
855
+ // Set timer to remove notification
856
  setTimeout(() => {
857
  notification.classList.remove('show');
858
+ // Remove element after fade out transition ends
859
+ notification.addEventListener('transitionend', () => notification.remove());
860
  }, duration);
861
  }
862
 
863
+ // --- Initialization ---
864
  document.addEventListener('DOMContentLoaded', () => {
865
+ // Load product data into the global variable AFTER DOM is ready
866
+ // The data is injected directly into the script tag by Jinja
867
+ products = {{ products|tojson }};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
868
 
869
+ applyInitialTheme();
870
+ attemptAutoLogin(); // Attempt auto-login if needed
871
+ updateCartButton(); // Initial cart button state
872
+ setupFilters(); // Set up search and category filters
873
 
874
+ // Close modal on background click
875
  window.addEventListener('click', function(event) {
876
  if (event.target.classList.contains('modal')) {
877
  closeModal(event.target.id);
878
  }
879
  });
880
 
881
+ // Close modal on Escape key
882
  window.addEventListener('keydown', function(event) {
883
  if (event.key === 'Escape') {
884
  document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
 
886
  });
887
  }
888
  });
889
+
890
+ // Initial filter application in case the page loads with filters active (e.g., back button)
891
+ filterProducts();
892
  });
893
 
894
  </script>
 
897
  '''
898
  return render_template_string(
899
  catalog_html,
900
+ products=products_in_stock, # Pass filtered & sorted list
901
  categories=categories,
902
  repo_id=REPO_ID,
903
  is_authenticated=is_authenticated,
 
910
  @app.route('/product/<int:index>')
911
  def product_detail(index):
912
  data = load_data()
913
+ products = data.get('products', [])
914
  is_authenticated = 'user' in session
 
 
 
 
 
 
 
 
915
  try:
916
+ # Use the index directly as passed from the catalog
917
+ product = products[index]
918
+ if not product.get('in_stock', True):
919
+ # Optionally, decide if out-of-stock items can be viewed directly
920
+ # For now, let's allow viewing but maybe add a note later
921
+ pass
922
  except IndexError:
923
+ logging.warning(f"Попытка доступа к несуществующему продукту с индексом {index}")
924
+ abort(404, "Товар не найден") # Use abort for standard 404 page
925
 
926
  detail_html = '''
927
  <div style="padding: 10px;">
928
  <h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #1C6758;">{{ product['name'] }}</h2>
929
  {% if not product.get('in_stock', True) %}
930
+ <p style="text-align: center; color: red; font-weight: bold; margin-bottom: 15px;">Нет в наличии</p>
931
  {% endif %}
 
 
 
932
  <div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
933
  <div class="swiper-wrapper">
934
  {% if product.get('photos') and product['photos']|length > 0 %}
 
967
  <p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
968
  {% endif %}
969
  </div>
970
+ {% if is_authenticated and product.get('in_stock', True) %}
971
+ <div style="text-align: center; margin-top: 20px;">
972
+ <button class="product-button add-to-cart" onclick="closeModal('productModal'); openQuantityModal({{ index }});">
973
+ <i class="fas fa-cart-plus"></i> В корзину
974
+ </button>
975
+ </div>
976
+ {% endif %}
977
  </div>
978
  '''
979
+ # Pass the original index back to the template for the add-to-cart button
980
  return render_template_string(
981
  detail_html,
982
  product=product,
983
+ index=index,
984
  repo_id=REPO_ID,
985
  is_authenticated=is_authenticated,
986
  currency_code=CURRENCY_CODE
 
993
  <meta charset="UTF-8">
994
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
995
  <title>Вход - Soola Cosmetics</title>
996
+ <link rel="icon" href="/favicon.ico">
997
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
998
  <style>
999
  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; }
 
1051
  }
1052
  logging.info(f"Пользователь {login} успешно вошел в систему.")
1053
  login_response_html = f'''
1054
+ <!DOCTYPE html><html><head><title>Перенаправление...</title><link rel="icon" href="/favicon.ico"></head><body>
1055
  <script>
1056
  try {{ localStorage.setItem('soolaUser', '{login}'); }} catch (e) {{ console.error("Ошибка сохранения в localStorage:", e); }}
1057
  window.location.href = "{url_for('catalog')}";
 
1096
  return "OK", 200
1097
  else:
1098
  logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
1099
+ # Clear invalid user from localStorage on client side via response header or message
1100
+ return "Ошибка авто-входа", 400 # 400 or 401 might be appropriate
1101
 
1102
  @app.route('/logout')
1103
  def logout():
 
1107
  if logged_out_user:
1108
  logging.info(f"Пользователь {logged_out_user} вышел из системы.")
1109
  logout_response_html = '''
1110
+ <!DOCTYPE html><html><head><title>Выход...</title><link rel="icon" href="/favicon.ico"></head><body>
1111
  <script>
1112
  try { localStorage.removeItem('soolaUser'); } catch (e) { console.error("Ошибка удаления из localStorage:", e); }
1113
  window.location.href = "/";
 
1124
  <meta charset="UTF-8">
1125
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1126
  <title>Админ-панель - Soola Cosmetics</title>
1127
+ <link rel="icon" href="/favicon.ico">
1128
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
1129
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
1130
  <style>
1131
  body { font-family: 'Poppins', sans-serif; background-color: #e9f5f0; color: #2d332f; padding: 20px; line-height: 1.6; }
1132
  .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); }
1133
  .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;}
1134
+ h1, h2, h3, h4 { font-weight: 600; color: #1C6758; margin-bottom: 15px; }
1135
  h1 { font-size: 1.8rem; }
1136
  h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
1137
  h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; }
1138
+ h4 { font-size: 1.1rem; margin-bottom: 10px; color: #1C6758;}
1139
  .section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; }
1140
  form { margin-bottom: 20px; }
1141
  label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;}
 
1144
  textarea { min-height: 80px; resize: vertical; }
1145
  input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;}
1146
  input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;}
 
 
1147
  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;}
1148
  button:hover, .button:hover { background-color: #164B41; }
1149
  button:active, .button:active { transform: scale(0.98); }
 
1156
  .item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #e1f0e9; }
1157
  .item p { margin: 5px 0; font-size: 0.9rem; color: #44524c; }
1158
  .item strong { color: #2d332f; }
1159
+ .item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; display: block; } /* Ensure block for ellipsis */
 
 
 
 
 
1160
  .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
1161
  .item-actions button:not(.delete-button) { background-color: #1C6758; }
1162
  .item-actions button:not(.delete-button):hover { background-color: #164B41; }
 
1183
  .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
1184
  .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
1185
  .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
1186
+ .checkbox-label { display: flex; align-items: center; gap: 5px; margin-top: 10px; }
1187
+ .checkbox-label input[type="checkbox"] { width: auto; margin-top: 0; }
1188
+ .status-indicator { font-weight: bold; font-size: 0.85rem; margin-left: 10px; padding: 3px 8px; border-radius: 4px; }
1189
+ .status-featured { color: #856404; background-color: #fff3cd; }
1190
+ .status-outofstock { color: #721c24; background-color: #f8d7da; }
1191
  </style>
1192
  </head>
1193
  <body>
 
1332
  </select>
1333
  <label for="add_photos">Фотографии (до 10 шт.):</label>
1334
  <input type="file" id="add_photos" name="photos" accept="image/*" multiple>
 
 
 
 
 
 
 
 
 
 
1335
  <label>Цвета/Варианты (оставьте пустым, если нет):</label>
1336
  <div id="add-color-inputs">
1337
  <div class="color-input-group">
 
1340
  </div>
1341
  </div>
1342
  <button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button>
1343
+
1344
+ <label class="checkbox-label">
1345
+ <input type="checkbox" id="add_in_stock" name="in_stock" value="true" checked>
1346
+ <span>В наличии? (Снимите галочку, чтобы скрыть товар из каталога)</span>
1347
+ </label>
1348
+ <label class="checkbox-label">
1349
+ <input type="checkbox" id="add_is_featured" name="is_featured" value="true">
1350
+ <span><i class="fas fa-star"></i> Показывать в топе? (Отмеченные товары будут первыми в списке)</span>
1351
+ </label>
1352
+
1353
  <br>
1354
  <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button>
1355
  </form>
 
1357
  </details>
1358
 
1359
  <h3>Список товаров:</h3>
1360
+ {% if products %}
1361
  <div class="item-list">
1362
+ {% for product in products %}
1363
  <div class="item">
1364
  <div style="display: flex; gap: 15px; align-items: flex-start;">
1365
  <div class="photo-preview" style="flex-shrink: 0;">
 
1372
  {% endif %}
1373
  </div>
1374
  <div style="flex-grow: 1;">
1375
+ <h3 style="margin-top: 0; margin-bottom: 5px; color: #2d332f;">
1376
+ {{ product['name'] }}
1377
+ {% if product.get('is_featured') %}
1378
+ <span class="status-indicator status-featured"><i class="fas fa-star"></i> В топе</span>
1379
+ {% endif %}
1380
+ {% if not product.get('in_stock', True) %}
1381
+ <span class="status-indicator status-outofstock"><i class="fas fa-times-circle"></i> Нет в наличии</span>
1382
+ {% endif %}
1383
+ </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>
1387
  {% set colors = product.get('colors', []) %}
1388
  <p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}</p>
 
 
 
 
 
 
 
 
 
 
1389
  {% if product.get('photos') and product['photos']|length > 1 %}
1390
+ <p style="font-size: 0.8rem; color: #5e6e68;">(Всего фото: {{ product['photos']|length }})</p>
1391
  {% endif %}
1392
  </div>
1393
  </div>
 
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>
 
1405
  <h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
1406
  <form method="POST" enctype="multipart/form-data">
1407
  <input type="hidden" name="action" value="edit_product">
1408
+ <input type="hidden" name="index" value="{{ loop.index0 }}">
1409
  <label>Название *:</label>
1410
  <input type="text" name="name" value="{{ product['name'] }}" required>
1411
  <label>Цена ({{ currency_code }}) *:</label>
 
1429
  {% endfor %}
1430
  </div>
1431
  {% endif %}
 
 
 
 
 
 
 
 
 
 
1432
  <label>Цвета/Варианты:</label>
1433
  <div id="edit-color-inputs-{{ loop.index0 }}">
1434
  {% set current_colors = product.get('colors', []) %}
 
1449
  {% endif %}
1450
  </div>
1451
  <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>
1452
+
1453
+ <label class="checkbox-label">
1454
+ <input type="checkbox" name="in_stock" value="true" {% if product.get('in_stock', True) %}checked{% endif %}>
1455
+ <span>В наличии?</span>
1456
+ </label>
1457
+ <label class="checkbox-label">
1458
+ <input type="checkbox" name="is_featured" value="true" {% if product.get('is_featured', False) %}checked{% endif %}>
1459
+ <span><i class="fas fa-star"></i> Показывать в топе?</span>
1460
+ </label>
1461
+
1462
  <br>
1463
  <button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button>
1464
  </form>
 
1501
  function removeColorInput(button) {
1502
  const group = button.closest('.color-input-group');
1503
  if (group) {
1504
+ // Only remove if there's more than one input or if it's empty
1505
+ const container = group.parentNode;
1506
+ const inputs = container.querySelectorAll('.color-input-group');
1507
+ // Keep at least one input field available unless explicitly cleared later
1508
+ //if (inputs.length > 1) {
1509
+ group.remove();
1510
+ //} else {
1511
+ // const inputField = group.querySelector('input[name="colors"]');
1512
+ // if (inputField) inputField.value = ''; // Clear the last one instead of removing
1513
+ //}
1514
  } else {
1515
  console.warn("Не удалось найти родительский .color-input-group для кнопки удаления");
1516
  }
 
1522
 
1523
  @app.route('/admin', methods=['GET', 'POST'])
1524
  def admin():
1525
+ # Authentication Check - Basic example, replace with proper roles if needed
1526
+ if 'user' not in session:
1527
+ flash("Доступ запрещен. Пожалуйста, войдите.", "error")
1528
+ return redirect(url_for('login'))
1529
+ # Add role check here if implementing roles later
1530
+
1531
  data = load_data()
1532
+ products = data.get('products', [])
 
1533
  categories = data.get('categories', [])
1534
  users = load_users()
1535
 
 
 
 
 
1536
  if request.method == 'POST':
1537
  action = request.form.get('action')
1538
  logging.info(f"Admin action received: {action}")
 
1558
  if category_to_delete and category_to_delete in categories:
1559
  categories.remove(category_to_delete)
1560
  updated_count = 0
1561
+ for product in products:
 
1562
  if product.get('category') == category_to_delete:
1563
  product['category'] = 'Без категории'
1564
  updated_count += 1
 
1577
  category = request.form.get('category')
1578
  photos_files = request.files.getlist('photos')
1579
  colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1580
+ in_stock = request.form.get('in_stock') == 'true'
1581
+ is_featured = request.form.get('is_featured') == 'true'
1582
 
1583
  if not name or not price_str:
1584
  flash("Название и цена товара обязательны.", 'error')
 
1592
  return redirect(url_for('admin'))
1593
 
1594
  photos_list = []
1595
+ if photos_files and HF_TOKEN_WRITE:
1596
  uploads_dir = 'uploads_temp'
1597
  os.makedirs(uploads_dir, exist_ok=True)
1598
  api = HfApi()
 
1605
  break
1606
  if photo and photo.filename:
1607
  try:
1608
+ ext = os.path.splitext(photo.filename)[1]
1609
  photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
1610
  temp_path = os.path.join(uploads_dir, photo_filename)
1611
  photo.save(temp_path)
 
1628
  elif photo and not photo.filename:
1629
  logging.warning("Получен пустой объект файла фото при добавлении товара.")
1630
  try:
1631
+ # Cleanup only if directory exists and is empty
1632
  if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
1633
  os.rmdir(uploads_dir)
1634
  except OSError as e:
 
1639
  'name': name, 'price': price, 'description': description,
1640
  'category': category if category in categories else 'Без категории',
1641
  'photos': photos_list, 'colors': colors,
1642
+ 'in_stock': in_stock, 'is_featured': is_featured
1643
  }
1644
+ products.append(new_product)
1645
+ # products.sort(key=lambda x: x.get('name', '').lower()) # Keep insertion order or sort later? Let's sort.
1646
+ products.sort(key=lambda p: (not p.get('is_featured', False), p.get('name', '').lower()))
1647
  save_data(data)
1648
+ logging.info(f"Товар '{name}' добавлен (In Stock: {in_stock}, Featured: {is_featured}).")
1649
  flash(f"Товар '{name}' успешно добавлен.", 'success')
1650
 
1651
  elif action == 'edit_product':
1652
+ index_str = request.form.get('index')
1653
  if index_str is None:
1654
  flash("Ошибка редактирования: индекс товара не передан.", 'error')
1655
  return redirect(url_for('admin'))
1656
 
1657
  try:
 
1658
  index = int(index_str)
1659
+ if not (0 <= index < len(products)): raise IndexError("Индекс вне диапазона")
1660
+ product_to_edit = products[index]
1661
  original_name = product_to_edit.get('name', 'N/A')
1662
+ except (ValueError, IndexError):
1663
+ flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
1664
  return redirect(url_for('admin'))
1665
 
1666
  product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
 
1669
  category = request.form.get('category')
1670
  product_to_edit['category'] = category if category in categories else 'Без категории'
1671
  product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
1672
+ product_to_edit['in_stock'] = request.form.get('in_stock') == 'true'
1673
+ product_to_edit['is_featured'] = request.form.get('is_featured') == 'true'
1674
 
1675
  try:
1676
  price = round(float(price_str), 2)
 
1696
  break
1697
  if photo and photo.filename:
1698
  try:
1699
+ ext = os.path.splitext(photo.filename)[1]
1700
  photo_filename = secure_filename(f"{product_to_edit['name'].replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
1701
  temp_path = os.path.join(uploads_dir, photo_filename)
1702
  photo.save(temp_path)
 
1723
  if old_photos:
1724
  logging.info(f"Попытка удаления старых фото: {old_photos}")
1725
  try:
1726
+ api = HfApi()
1727
+ paths_to_delete = [f"photos/{p}" for p in old_photos if p] # Ensure no empty strings
1728
+ if paths_to_delete:
1729
+ api.delete_files(
1730
+ repo_id=REPO_ID,
1731
+ paths_in_repo=paths_to_delete,
1732
+ repo_type="dataset",
1733
+ token=HF_TOKEN_WRITE,
1734
+ commit_message=f"Delete old photos for product {product_to_edit['name']}",
1735
+ # ignore_patterns=None, # Ensure it tries to delete
1736
+ # delete_patterns=None
1737
+ )
1738
+ logging.info(f"Старые фото для товара {product_to_edit['name']} удалены с HF.")
1739
+ else:
1740
+ logging.info("Нет старых фото для удаления с HF.")
1741
+
1742
  except Exception as e:
1743
+ # Handle cases where files might not exist on HF already
1744
+ if "404" in str(e) or "not found on repo" in str(e).lower():
1745
+ logging.warning(f"Некоторые старые фото не найдены на HF при попытке удаления: {e}")
1746
+ flash("Некоторые ста��ые фото не были найдены на сервере для удаления.", "warning")
1747
+ else:
1748
+ logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True)
1749
+ flash("Не удалось удалить старые фотографии с сервера.", "warning")
1750
+
1751
  product_to_edit['photos'] = new_photos_list
1752
  flash("Фотографии товара успешно обновлены.", "success")
1753
  elif uploaded_count == 0 and any(f.filename for f in photos_files):
1754
  flash("Не удалось загрузить новые фотографии.", "error")
1755
 
1756
+ # products.sort(key=lambda x: x.get('name', '').lower()) # Re-sort after edit
1757
+ products.sort(key=lambda p: (not p.get('is_featured', False), p.get('name', '').lower()))
1758
  save_data(data)
1759
+ logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}' (In Stock: {product_to_edit['in_stock']}, Featured: {product_to_edit['is_featured']}).")
1760
  flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
1761
 
1762
 
1763
  elif action == 'delete_product':
1764
+ index_str = request.form.get('index')
1765
  if index_str is None:
1766
  flash("Ошибка удаления: индекс товара не передан.", 'error')
1767
  return redirect(url_for('admin'))
1768
  try:
1769
  index = int(index_str)
1770
+ if not (0 <= index < len(products)): raise IndexError("Индекс вне диапазона")
1771
+ deleted_product = products.pop(index)
 
1772
  product_name = deleted_product.get('name', 'N/A')
1773
 
1774
  photos_to_delete = deleted_product.get('photos', [])
 
1776
  logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}")
1777
  try:
1778
  api = HfApi()
1779
+ paths_to_delete = [f"photos/{p}" for p in photos_to_delete if p]
1780
+ if paths_to_delete:
1781
+ api.delete_files(
1782
+ repo_id=REPO_ID,
1783
+ paths_in_repo=paths_to_delete,
1784
+ repo_type="dataset",
1785
+ token=HF_TOKEN_WRITE,
1786
+ commit_message=f"Delete photos for deleted product {product_name}"
1787
+ )
1788
+ logging.info(f"Фото товара '{product_name}' удалены с HF.")
1789
+ else:
1790
+ logging.info("Нет фото для удаления с HF.")
1791
  except Exception as e:
1792
+ if "404" in str(e) or "not found on repo" in str(e).lower():
1793
+ logging.warning(f"Некоторые фото не найдены на HF при удалении товара '{product_name}': {e}")
1794
+ flash(f"Фото для удаленного товара '{product_name}' не найдены на сервере.", "warning")
1795
+ else:
1796
+ logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True)
1797
+ flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
1798
 
1799
  save_data(data)
1800
+ logging.info(f"Товар '{product_name}' (индекс {index}) удален.")
1801
  flash(f"Товар '{product_name}' удален.", 'success')
1802
+ except (ValueError, IndexError):
1803
+ flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.", 'error')
1804
 
1805
 
1806
  elif action == 'add_user':
 
1851
  flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
1852
  return redirect(url_for('admin'))
1853
 
1854
+ # Sort products for display in admin panel as well
1855
+ products.sort(key=lambda p: (not p.get('is_featured', False), p.get('name', '').lower()))
 
 
 
1856
  categories.sort()
 
1857
  sorted_users = dict(sorted(users.items()))
1858
 
1859
  return render_template_string(
1860
  ADMIN_TEMPLATE,
1861
+ products=products, # Pass the full list to admin
1862
  categories=categories,
1863
  users=sorted_users,
1864
  repo_id=REPO_ID,
 
1867
 
1868
  @app.route('/force_upload', methods=['POST'])
1869
  def force_upload():
1870
+ # Add auth check
1871
+ if 'user' not in session:
1872
+ flash("Доступ запрещен.", "error")
1873
+ return redirect(url_for('login'))
1874
  logging.info("Запущена принудительная загрузка данных на Hugging Face...")
1875
  try:
1876
  upload_db_to_hf()
 
1882
 
1883
  @app.route('/force_download', methods=['POST'])
1884
  def force_download():
1885
+ # Add auth check
1886
+ if 'user' not in session:
1887
+ flash("Доступ запрещен.", "error")
1888
+ return redirect(url_for('login'))
1889
  logging.info("Запущено принудительное скачивание данных с Hugging Face...")
1890
  try:
1891
  download_db_from_hf()
1892
+ # Reload data in memory after download
1893
+ global data, users
1894
+ data = load_data()
1895
+ users = load_users()
1896
+ flash("Данные успешно скачаны с Hugging Face. Локальные файлы и данные в памяти обновлены.", 'success')
1897
  except Exception as e:
1898
  logging.error(f"Ошибка при принудительном скачивании: {e}", exc_info=True)
1899
  flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
 
1901
 
1902
 
1903
  if __name__ == '__main__':
1904
+ # Ensure static folder exists for favicon
1905
+ if not os.path.exists('static'):
1906
+ os.makedirs('static')
1907
+ # Create a dummy favicon if it doesn't exist to avoid initial 404s
1908
+ favicon_path = 'static/favicon.ico'
1909
+ if not os.path.exists(favicon_path):
1910
+ try:
1911
+ # Create a minimal ICO file (1x1 pixel transparent)
1912
+ with open(favicon_path, 'wb') as f:
1913
+ f.write(b'\x00\x00\x01\x00\x01\x00\x01\x01\x00\x00\x01\x00\x18\x00\x1c\x00\x00\x00\x16\x00\x00\x00(\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00')
1914
+ logging.info(f"Создан пустой файл {favicon_path}")
1915
+ except Exception as e:
1916
+ logging.warning(f"Не удалось создать пустой favicon.ico: {e}")
1917
+
1918
+
1919
  load_data()
1920
  load_users()
1921
 
 
1928
 
1929
  port = int(os.environ.get('PORT', 7860))
1930
  logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
1931
+ # Set debug=False for production/deployment
 
 
 
 
1932
  app.run(debug=False, host='0.0.0.0', port=port)
1933