Kgshop commited on
Commit
899fceb
·
verified ·
1 Parent(s): fa52192

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +331 -1083
app.py CHANGED
@@ -1,1112 +1,360 @@
1
- from flask import Flask, render_template_string, request, redirect, url_for
2
  import json
3
- import os
4
- import logging
5
- import threading
6
- import time
7
- from datetime import datetime
8
- from huggingface_hub import HfApi, hf_hub_download
9
- from huggingface_hub.utils import RepositoryNotFoundError
10
- from werkzeug.utils import secure_filename
11
 
12
  app = Flask(__name__)
13
- DATA_FILE = 'data_zzirix.json'
14
 
15
- # Настройки Hugging Face
16
- REPO_ID = "Kgshop/Clients2"
17
- HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
18
- HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
19
 
20
- # Ссылка на логотип
21
- LOGO_URL = "https://cdn-avatars.huggingface.co/v1/production/uploads/67b22aaeae9b6a59f1cfb849/NQvBksXzJItYt6hfFjyaB.jpeg"
 
 
 
22
 
23
- # Настройка логирования
24
- logging.basicConfig(level=logging.DEBUG)
25
 
26
- def load_data():
 
27
  try:
28
- download_db_from_hf()
29
- with open(DATA_FILE, 'r', encoding='utf-8') as file:
30
- data = json.load(file)
31
- logging.info("Данные успешно загружены из JSON")
32
- if not isinstance(data, dict) or 'products' not in data or 'categories' not in data:
33
- return {'products': [], 'categories': [] if not isinstance(data, list) else data}
34
- return data
35
- except FileNotFoundError:
36
- logging.warning("Локальный файл базы данных не найден после скачивания.")
37
- return {'products': [], 'categories': []}
38
- except json.JSONDecodeError:
39
- logging.error("Ошибка: Невозможно декодировать JSON файл.")
40
- return {'products': [], 'categories': []}
41
- except RepositoryNotFoundError:
42
- logging.error("Репозиторий не найден. Создание локальной базы данных.")
43
- return {'products': [], 'categories': []}
44
- except Exception as e:
45
- logging.error(f"Произошла ошибка при загрузке данных: {e}")
46
- return {'products': [], 'categories': []}
47
-
48
- def save_data(data):
49
  try:
50
- with open(DATA_FILE, 'w', encoding='utf-8') as file:
51
- json.dump(data, file, ensure_ascii=False, indent=4)
52
- logging.info("Данные успешно сохранены в JSON")
53
- upload_db_to_hf()
54
- except Exception as e:
55
- logging.error(f"Ошибка при сохранении данных: {e}")
56
- raise
57
-
58
- def upload_db_to_hf():
 
 
 
59
  try:
60
- api = HfApi()
61
- api.upload_file(
62
- path_or_fileobj=DATA_FILE,
63
- path_in_repo=DATA_FILE,
64
- repo_id=REPO_ID,
65
- repo_type="dataset",
66
- token=HF_TOKEN_WRITE,
67
- commit_message=f"Автоматическое резервное копирование базы данных {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
68
  )
69
- logging.info("Резервная копия JSON базы успешно загружена на Hugging Face.")
70
  except Exception as e:
71
- logging.error(f"Ошибка при загрузке резервной копии: {e}")
 
72
 
73
- def download_db_from_hf():
74
- try:
75
- hf_hub_download(
76
- repo_id=REPO_ID,
77
- filename=DATA_FILE,
78
- repo_type="dataset",
79
- token=HF_TOKEN_READ,
80
- local_dir=".",
81
- local_dir_use_symlinks=False
82
- )
83
- logging.info("JSON база успешно скачана из Hugging Face.")
84
- except RepositoryNotFoundError as e:
85
- logging.error(f"Репозиторий не найден: {e}")
86
- raise
87
- except Exception as e:
88
- logging.error(f"Ошибка при скачивании JSON базы: {e}")
89
- raise
90
 
91
- def periodic_backup():
92
- while True:
93
- upload_db_to_hf()
94
- time.sleep(800)
95
 
96
- @app.route('/')
97
- def catalog():
98
- data = load_data()
99
- products = data['products']
100
- categories = data['categories']
101
-
102
- catalog_html = '''
103
- <!DOCTYPE html>
104
- <html lang="ru">
105
- <head>
106
- <meta charset="UTF-8">
107
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
108
- <title>Канцтовары оптом и в розницу </title>
109
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
110
- <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
111
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
112
- <style>
113
- * {
114
- margin: 0;
115
- padding: 0;
116
- box-sizing: border-box;
117
- }
118
- body {
119
- font-family: 'Poppins', sans-serif;
120
- background: linear-gradient(135deg, #f0f2f5, #e9ecef);
121
- color: #2d3748;
122
- line-height: 1.6;
123
- transition: background 0.3s, color 0.3s;
124
- }
125
- body.dark-mode {
126
- background: linear-gradient(135deg, #1a202c, #2d3748);
127
- color: #e2e8f0;
128
- }
129
- .container {
130
- max-width: 1300px;
131
- margin: 0 auto;
132
- padding: 20px;
133
- }
134
- .header {
135
- display: flex;
136
- justify-content: space-between;
137
- align-items: center;
138
- padding: 15px 0;
139
- border-bottom: 1px solid #e2e8f0;
140
- }
141
- .header-logo {
142
- width: 60px;
143
- height: 60px;
144
- border-radius: 50%;
145
- object-fit: cover;
146
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
147
- transition: transform 0.3s ease, box-shadow 0.3s ease;
148
- }
149
- .header-logo:hover {
150
- transform: scale(1.1);
151
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
152
- }
153
- .header h1 {
154
- font-size: 1.5rem;
155
- font-weight: 600;
156
- margin-left: 15px;
157
- }
158
- .theme-toggle {
159
- background: none;
160
- border: none;
161
- font-size: 1.5rem;
162
- cursor: pointer;
163
- color: #4a5568;
164
- transition: color 0.3s ease;
165
- }
166
- .theme-toggle:hover {
167
- color: #3b82f6;
168
- }
169
- .filters-container {
170
- margin: 20px 0;
171
- display: flex;
172
- flex-wrap: wrap;
173
- gap: 10px;
174
- justify-content: center;
175
- }
176
- .search-container {
177
- margin: 20px 0;
178
- text-align: center;
179
- }
180
- #search-input {
181
- width: 90%;
182
- max-width: 600px;
183
- padding: 12px 18px;
184
- font-size: 1rem;
185
- border: 1px solid #e2e8f0;
186
- border-radius: 8px;
187
- outline: none;
188
- box-shadow: 0 2px 5px rgba(0,0,0,0.05);
189
- transition: all 0.3s ease;
190
- }
191
- #search-input:focus {
192
- border-color: #3b82f6;
193
- box-shadow: 0 4px 15px rgba(59, 130, 246, 0.2);
194
- }
195
- .category-filter {
196
- padding: 8px 16px;
197
- border: 1px solid #e2e8f0;
198
- border-radius: 8px;
199
- background-color: #fff;
200
- cursor: pointer;
201
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
202
- font-size: 0.9rem;
203
- font-weight: 400;
204
- }
205
- .category-filter.active, .category-filter:hover {
206
- background-color: #3b82f6;
207
- color: white;
208
- border-color: #3b82f6;
209
- box-shadow: 0 2px 10px rgba(59, 130, 246, 0.3);
210
- }
211
- .products-grid {
212
- display: grid;
213
- grid-template-columns: repeat(2, minmax(200px, 1fr));
214
- gap: 15px;
215
- padding: 10px;
216
- }
217
- .product {
218
- background: #fff;
219
- border-radius: 15px;
220
- padding: 15px;
221
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
222
- transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
223
- overflow: hidden;
224
- }
225
- body.dark-mode .product {
226
- background: #2d3748;
227
- color: #fff;
228
- }
229
- .product:hover {
230
- transform: translateY(-5px) scale(1.02);
231
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
232
- }
233
- .product-image {
234
- width: 100%;
235
- aspect-ratio: 1;
236
- background-color: #fff;
237
- border-radius: 10px;
238
- overflow: hidden;
239
- display: flex;
240
- justify-content: center;
241
- align-items: center;
242
- }
243
- .product-image img {
244
- max-width: 100%;
245
- max-height: 100%;
246
- object-fit: contain;
247
- transition: transform 0.3s ease;
248
- }
249
- .product-image img:hover {
250
- transform: scale(1.1);
251
- }
252
- .product h2 {
253
- font-size: 1rem;
254
- font-weight: 600;
255
- margin: 10px 0;
256
- text-align: center;
257
- white-space: nowrap;
258
- overflow: hidden;
259
- text-overflow: ellipsis;
260
- }
261
- .product-price {
262
- font-size: 1.1rem;
263
- color: #ef4444;
264
- font-weight: 700;
265
- text-align: center;
266
- margin: 5px 0;
267
- }
268
- .product-description {
269
- font-size: 0.8rem;
270
- color: #718096;
271
- text-align: center;
272
- margin-bottom: 15px;
273
- overflow: hidden;
274
- text-overflow: ellipsis;
275
- white-space: nowrap;
276
- }
277
- body.dark-mode .product-description {
278
- color: #a0aec0;
279
- }
280
- .product-button {
281
- display: block;
282
- width: 100%;
283
- padding: 8px;
284
- border: none;
285
- border-radius: 8px;
286
- background-color: #3b82f6;
287
- color: white;
288
- font-size: 0.8rem;
289
- font-weight: 500;
290
- cursor: pointer;
291
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
292
- margin: 5px 0;
293
- text-align: center;
294
- text-decoration: none;
295
- }
296
- .product-button:hover {
297
- background-color: #2563eb;
298
- box-shadow: 0 4px 15px rgba(37, 99, 235, 0.4);
299
- transform: translateY(-2px);
300
- }
301
- .add-to-cart {
302
- background-color: #10b981;
303
- }
304
- .add-to-cart:hover {
305
- background-color: #059669;
306
- box-shadow: 0 4px 15px rgba(5, 150, 105, 0.4);
307
- }
308
- #cart-button {
309
- position: fixed;
310
- bottom: 20px;
311
- right: 20px;
312
- background-color: #ef4444;
313
- color: white;
314
- border: none;
315
- border-radius: 50%;
316
- width: 50px;
317
- height: 50px;
318
- font-size: 1.2rem;
319
- cursor: pointer;
320
- display: none;
321
- box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
322
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
323
- z-index: 1000;
324
- }
325
- .modal {
326
- display: none;
327
- position: fixed;
328
- z-index: 1001;
329
- left: 0;
330
- top: 0;
331
- width: 100%;
332
- height: 100%;
333
- background-color: rgba(0,0,0,0.5);
334
- backdrop-filter: blur(5px);
335
- }
336
- .modal-content {
337
- background: #fff;
338
- margin: 5% auto;
339
- padding: 20px;
340
- border-radius: 15px;
341
- width: 90%;
342
- max-width: 700px;
343
- box-shadow: 0 10px 30px rgba(0,0,0,0.2);
344
- animation: slideIn 0.3s ease-out;
345
- }
346
- body.dark-mode .modal-content {
347
- background: #2d3748;
348
- color: #e2e8f0;
349
- }
350
- @keyframes slideIn {
351
- from { transform: translateY(-50px); opacity: 0; }
352
- to { transform: translateY(0); opacity: 1; }
353
- }
354
- .close {
355
- float: right;
356
- font-size: 1.5rem;
357
- color: #718096;
358
- cursor: pointer;
359
- transition: color 0.3s;
360
- }
361
- .close:hover {
362
- color: #2d3748;
363
- }
364
- body.dark-mode .close {
365
- color: #a0aec0;
366
- }
367
- body.dark-mode .close:hover {
368
- color: #fff;
369
- }
370
- .cart-item {
371
- display: flex;
372
- justify-content: space-between;
373
- align-items: center;
374
- padding: 15px 0;
375
- border-bottom: 1px solid #e2e8f0;
376
- }
377
- body.dark-mode .cart-item {
378
- border-bottom: 1px solid #4a5568;
379
- }
380
- .cart-item img {
381
- width: 50px;
382
- height: 50px;
383
- object-fit: contain;
384
- border-radius: 8px;
385
- margin-right: 15px;
386
- }
387
- .quantity-input, .color-select {
388
- width: 100%;
389
- max-width: 150px;
390
- padding: 8px;
391
- border: 1px solid #e2e8f0;
392
- border-radius: 8px;
393
- font-size: 1rem;
394
- margin: 5px 0;
395
- }
396
- .clear-cart {
397
- background-color: #ef4444;
398
- }
399
- .clear-cart:hover {
400
- background-color: #dc2626;
401
- box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4);
402
- }
403
- .order-button {
404
- background-color: #10b981;
405
- }
406
- .order-button:hover {
407
- background-color: #059669;
408
- box-shadow: 0 4px 15px rgba(5, 150, 105, 0.4);
409
- }
410
- </style>
411
- </head>
412
- <body>
413
- <div class="container">
414
- <div class="header">
415
- <img src="''' + LOGO_URL + '''" alt="Logo" class="header-logo">
416
- <h1>Каталог</h1>
417
- <button class="theme-toggle" onclick="toggleTheme()">
418
- <i class="fas fa-moon"></i>
419
- </button>
420
- </div>
421
- <div class="filters-container">
422
- <button class="category-filter active" data-category="all">Все категории</button>
423
- {% for category in categories %}
424
- <button class="category-filter" data-category="{{ category }}">{{ category }}</button>
425
- {% endfor %}
426
- </div>
427
- <div class="search-container">
428
- <input type="text" id="search-input" placeholder="Поиск товаров...">
429
- </div>
430
- <div class="products-grid" id="products-grid">
431
- {% for product in products %}
432
- <div class="product"
433
- data-name="{{ product['name']|lower }}"
434
- data-description="{{ product['description']|lower }}"
435
- data-category="{{ product.get('category', 'Без категории') }}">
436
- {% if product.get('photos') and product['photos']|length > 0 %}
437
- <div class="product-image">
438
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
439
- alt="{{ product['name'] }}"
440
- loading="lazy">
441
- </div>
442
- {% endif %}
443
- <h2>{{ product['name'] }}</h2>
444
- <div class="product-price">{{ product['price'] }} с</div>
445
- <p class="product-description">{{ product['description'][:50] }}{% if product['description']|length > 50 %}...{% endif %}</p>
446
- <button class="product-button" onclick="openModal({{ loop.index0 }})">Подробнее</button>
447
- <button class="product-button add-to-cart" onclick="openQuantityModal({{ loop.index0 }})">В корзину</button>
448
- </div>
449
- {% endfor %}
450
- </div>
451
- </div>
452
 
453
- <!-- Product Modal -->
454
- <div id="productModal" class="modal">
455
- <div class="modal-content">
456
- <span class="close" onclick="closeModal('productModal')">×</span>
457
- <div id="modalContent"></div>
458
- </div>
459
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
 
461
- <!-- Quantity and Color Modal -->
462
- <div id="quantityModal" class="modal">
463
- <div class="modal-content">
464
- <span class="close" onclick="closeModal('quantityModal')">×</span>
465
- <h2>Укажите количество и цвет</h2>
466
- <input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
467
- <select id="colorSelect" class="color-select"></select>
468
- <button class="product-button" onclick="confirmAddToCart()">Добавить</button>
469
- </div>
470
- </div>
471
 
472
- <!-- Cart Modal -->
473
- <div id="cartModal" class="modal">
474
- <div class="modal-content">
475
- <span class="close" onclick="closeModal('cartModal')">×</span>
476
- <h2>Корзина</h2>
477
- <div id="cartContent"></div>
478
- <div style="margin-top: 20px; text-align: right;">
479
- <strong>Итого: <span id="cartTotal">0</span> с</strong>
480
- <button class="product-button clear-cart" onclick="clearCart()">Очистить</button>
481
- <button class="product-button order-button" onclick="orderViaWhatsApp()">Заказать</button>
482
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  </div>
484
- </div>
485
-
486
- <button id="cart-button" onclick="openCartModal()">🛒</button>
487
-
488
- <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
489
- <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.3/dist/umd/popper.min.js"></script>
490
- <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
491
- <script>
492
- const products = {{ products|tojson }};
493
- let selectedProductIndex = null;
494
 
495
- function toggleTheme() {
496
- document.body.classList.toggle('dark-mode');
497
- const icon = document.querySelector('.theme-toggle i');
498
- icon.classList.toggle('fa-moon');
499
- icon.classList.toggle('fa-sun');
500
- localStorage.setItem('theme', document.body.classList.contains('dark-mode') ? 'dark' : 'light');
501
- }
502
-
503
- if (localStorage.getItem('theme') === 'dark') {
504
- document.body.classList.add('dark-mode');
505
- document.querySelector('.theme-toggle i').classList.replace('fa-moon', 'fa-sun');
506
- }
507
-
508
- function openModal(index) {
509
- loadProductDetails(index);
510
- document.getElementById('productModal').style.display = "block";
511
- }
512
-
513
- function closeModal(modalId) {
514
- document.getElementById(modalId).style.display = "none";
515
- }
516
-
517
- function loadProductDetails(index) {
518
- fetch('/product/' + index)
519
- .then(response => response.text())
520
- .then(data => {
521
- document.getElementById('modalContent').innerHTML = data;
522
- initializeSwiper();
523
- })
524
- .catch(error => console.error('Ошибка:', error));
525
- }
526
 
527
- function initializeSwiper() {
528
- new Swiper('.swiper-container', {
529
- slidesPerView: 1,
530
- spaceBetween: 20,
531
- loop: true,
532
- grabCursor: true,
533
- pagination: { el: '.swiper-pagination', clickable: true },
534
- navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
535
- zoom: { maxRatio: 3 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  });
537
- }
538
-
539
- function openQuantityModal(index) {
540
- selectedProductIndex = index;
541
- const product = products[index];
542
- const colorSelect = document.getElementById('colorSelect');
543
- colorSelect.innerHTML = '';
544
- if (product.colors && product.colors.length > 0) {
545
- product.colors.forEach(color => {
546
- const option = document.createElement('option');
547
- option.value = color;
548
- option.text = color;
549
- colorSelect.appendChild(option);
 
 
 
550
  });
551
- } else {
552
- const option = document.createElement('option');
553
- option.value = 'Нет цвета';
554
- option.text = 'Нет цвета';
555
- colorSelect.appendChild(option);
556
- }
557
- document.getElementById('quantityModal').style.display = 'block';
558
- document.getElementById('quantityInput').value = 1;
559
  }
560
-
561
- function confirmAddToCart() {
562
- if (selectedProductIndex === null) return;
563
- const quantity = parseInt(document.getElementById('quantityInput').value) || 1;
564
- const color = document.getElementById('colorSelect').value;
565
- if (quantity <= 0) {
566
- alert("Укажите количество больше 0");
567
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  }
569
- let cart = JSON.parse(localStorage.getItem('cart') || '[]');
570
- const product = products[selectedProductIndex];
571
- const cartItemId = `${product.name}-${color}`;
572
- const existingItem = cart.find(item => item.id === cartItemId);
573
-
574
- if (existingItem) {
575
- existingItem.quantity += quantity;
576
- } else {
577
- cart.push({
578
- id: cartItemId,
579
- name: product.name,
580
- price: product.price,
581
- photo: product.photos && product.photos.length > 0 ? product.photos[0] : '',
582
- quantity: quantity,
583
- color: color
 
 
 
 
 
 
584
  });
585
- }
586
-
587
- localStorage.setItem('cart', JSON.stringify(cart));
588
- closeModal('quantityModal');
589
- updateCartButton();
590
- }
591
-
592
- function updateCartButton() {
593
- const cart = JSON.parse(localStorage.getItem('cart') || '[]');
594
- document.getElementById('cart-button').style.display = cart.length > 0 ? 'block' : 'none';
595
- }
596
-
597
- function openCartModal() {
598
- const cart = JSON.parse(localStorage.getItem('cart') || '[]');
599
- const cartContent = document.getElementById('cartContent');
600
- let total = 0;
601
-
602
- cartContent.innerHTML = cart.length === 0 ? '<p>Корзина пуста</p>' : cart.map(item => {
603
- const itemTotal = item.price * item.quantity;
604
- total += itemTotal;
605
- return `
606
- <div class="cart-item">
607
- <div style="display: flex; align-items: center;">
608
- ${item.photo ? `<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/${item.photo}" alt="${item.name}">` : ''}
609
- <div>
610
- <strong>${item.name}</strong>
611
- <p>${item.price} с × ${item.quantity} (Цвет: ${item.color})</p>
612
- </div>
613
- </div>
614
- <span>${itemTotal} с</span>
615
- </div>
616
- `;
617
- }).join('');
618
-
619
- document.getElementById('cartTotal').textContent = total;
620
- document.getElementById('cartModal').style.display = 'block';
621
- }
622
-
623
- function orderViaWhatsApp() {
624
- const cart = JSON.parse(localStorage.getItem('cart') || '[]');
625
- if (cart.length === 0) {
626
- alert("Корзина пуста!");
627
- return;
628
- }
629
- let total = 0;
630
- let orderText = "Заказ:%0A";
631
- cart.forEach((item, index) => {
632
- const itemTotal = item.price * item.quantity;
633
- total += itemTotal;
634
- orderText += `${index + 1}. ${item.name} - ${item.price} с × ${item.quantity} (Цвет: ${item.color})%0A`;
635
  });
636
- orderText += `Итого: ${total} с`;
637
- window.open(`https://api.whatsapp.com/send?phone=996500654659&text=${orderText}`, '_blank');
638
- }
639
-
640
- function clearCart() {
641
- localStorage.removeItem('cart');
642
- closeModal('cartModal');
643
- updateCartButton();
644
- }
645
-
646
- window.onclick = function(event) {
647
- if (event.target.className === 'modal') event.target.style.display = "none";
648
- }
649
-
650
- document.getElementById('search-input').addEventListener('input', filterProducts);
651
- document.querySelectorAll('.category-filter').forEach(filter => {
652
- filter.addEventListener('click', function() {
653
- document.querySelectorAll('.category-filter').forEach(f => f.classList.remove('active'));
654
- this.classList.add('active');
655
- filterProducts();
656
- });
657
- });
658
-
659
- function filterProducts() {
660
- const searchTerm = document.getElementById('search-input').value.toLowerCase();
661
- const activeCategory = document.querySelector('.category-filter.active').dataset.category;
662
- document.querySelectorAll('.product').forEach(product => {
663
- const name = product.getAttribute('data-name');
664
- const description = product.getAttribute('data-description');
665
- const category = product.getAttribute('data-category');
666
- const matchesSearch = name.includes(searchTerm) || description.includes(searchTerm);
667
- const matchesCategory = activeCategory === 'all' || category === activeCategory;
668
- product.style.display = matchesSearch && matchesCategory ? 'block' : 'none';
669
  });
670
  }
671
-
672
- updateCartButton();
673
- </script>
674
- </body>
675
- </html>
676
- '''
677
- return render_template_string(catalog_html, products=products, categories=categories, repo_id=REPO_ID)
678
-
679
- @app.route('/product/<int:index>')
680
- def product_detail(index):
681
- data = load_data()
682
- products = data['products']
683
- try:
684
- product = products[index]
685
- except IndexError:
686
- return "Продукт не найден", 404
687
- detail_html = '''
688
- <div class="container" style="padding: 20px;">
689
- <h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 20px;">{{ product['name'] }}</h2>
690
- <div class="swiper-container" style="max-width: 400px; margin: 0 auto 20px;">
691
- <div class="swiper-wrapper">
692
- {% if product.get('photos') %}
693
- {% for photo in product['photos'] %}
694
- <div class="swiper-slide" style="background-color: #fff; display: flex; justify-content: center; align-items: center;">
695
- <div class="swiper-zoom-container">
696
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
697
- alt="{{ product['name'] }}"
698
- style="max-width: 100%; max-height: 300px; object-fit: contain;">
699
- </div>
700
- </div>
701
- {% endfor %}
702
- {% else %}
703
- <div class="swiper-slide">
704
- <img src="https://via.placeholder.com/300" alt="No Image">
705
- </div>
706
- {% endif %}
707
- </div>
708
- <div class="swiper-pagination"></div>
709
- <div class="swiper-button-next"></div>
710
- <div class="swiper-button-prev"></div>
711
- </div>
712
- <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
713
- <p><strong>Цена:</strong> {{ product['price'] }} с</p>
714
- <p><strong>Описание:</strong> {{ product['description'] }}</p>
715
- <p><strong>Доступные цвета:</strong> {{ product.get('colors', ['Нет цветов'])|join(', ') }}</p>
716
- </div>
717
- '''
718
- return render_template_string(detail_html, product=product, repo_id=REPO_ID)
719
-
720
- @app.route('/admin', methods=['GET', 'POST'])
721
- def admin():
722
- data = load_data()
723
- products = data['products']
724
- categories = data['categories']
725
-
726
- if request.method == 'POST':
727
- action = request.form.get('action')
728
-
729
- if action == 'add_category':
730
- category_name = request.form.get('category_name')
731
- if category_name and category_name not in categories:
732
- categories.append(category_name)
733
- save_data(data)
734
- return redirect(url_for('admin'))
735
- return "Ошибка: Категория уже существует или не указано название", 400
736
-
737
- elif action == 'delete_category':
738
- category_index = int(request.form.get('category_index'))
739
- deleted_category = categories.pop(category_index)
740
- for product in products:
741
- if product.get('category') == deleted_category:
742
- product['category'] = 'Без категории'
743
- save_data(data)
744
- return redirect(url_for('admin'))
745
-
746
- elif action == 'add':
747
- name = request.form.get('name')
748
- price = request.form.get('price')
749
- description = request.form.get('description')
750
- category = request.form.get('category')
751
- photos_files = request.files.getlist('photos')
752
- colors = request.form.getlist('colors')
753
- photos_list = []
754
-
755
- if photos_files:
756
- for photo in photos_files[:10]: # Ограничение до 10 фото
757
- if photo and photo.filename:
758
- photo_filename = secure_filename(photo.filename)
759
- uploads_dir = 'uploads'
760
- os.makedirs(uploads_dir, exist_ok=True)
761
- temp_path = os.path.join(uploads_dir, photo_filename)
762
- photo.save(temp_path)
763
- api = HfApi()
764
- api.upload_file(
765
- path_or_fileobj=temp_path,
766
- path_in_repo=f"photos/{photo_filename}",
767
- repo_id=REPO_ID,
768
- repo_type="dataset",
769
- token=HF_TOKEN_WRITE,
770
- commit_message=f"Добавлено фото для товара {name}"
771
- )
772
- photos_list.append(photo_filename)
773
- if os.path.exists(temp_path):
774
- os.remove(temp_path)
775
-
776
- if not name or not price or not description:
777
- return "Ошибка: Заполните все обязательные поля", 400
778
-
779
- price = float(price.replace(',', '.'))
780
- new_product = {
781
- 'name': name,
782
- 'price': price,
783
- 'description': description,
784
- 'category': category if category in categories else 'Без категории',
785
- 'photos': photos_list,
786
- 'colors': colors if colors else []
787
- }
788
- products.append(new_product)
789
- save_data(data)
790
- return redirect(url_for('admin'))
791
-
792
- elif action == 'edit':
793
- index = int(request.form.get('index'))
794
- name = request.form.get('name')
795
- price = request.form.get('price')
796
- description = request.form.get('description')
797
- category = request.form.get('category')
798
- photos_files = request.files.getlist('photos')
799
- colors = request.form.getlist('colors')
800
-
801
- if photos_files and any(photo.filename for photo in photos_files):
802
- new_photos_list = []
803
- for photo in photos_files[:10]: # Ограничение до 10 фото
804
- if photo and photo.filename:
805
- photo_filename = secure_filename(photo.filename)
806
- uploads_dir = 'uploads'
807
- os.makedirs(uploads_dir, exist_ok=True)
808
- temp_path = os.path.join(uploads_dir, photo_filename)
809
- photo.save(temp_path)
810
- api = HfApi()
811
- api.upload_file(
812
- path_or_fileobj=temp_path,
813
- path_in_repo=f"photos/{photo_filename}",
814
- repo_id=REPO_ID,
815
- repo_type="dataset",
816
- token=HF_TOKEN_WRITE,
817
- commit_message=f"Обновлено фото для товара {name}"
818
- )
819
- new_photos_list.append(photo_filename)
820
- if os.path.exists(temp_path):
821
- os.remove(temp_path)
822
- products[index]['photos'] = new_photos_list
823
-
824
- products[index]['name'] = name
825
- products[index]['price'] = float(price.replace(',', '.'))
826
- products[index]['description'] = description
827
- products[index]['category'] = category if category in categories else 'Без категории'
828
- products[index]['colors'] = colors if colors else []
829
- save_data(data)
830
- return redirect(url_for('admin'))
831
-
832
- elif action == 'delete':
833
- index = int(request.form.get('index'))
834
- del products[index]
835
- save_data(data)
836
- return redirect(url_for('admin'))
837
-
838
- admin_html = '''
839
- <!DOCTYPE html>
840
- <html lang="ru">
841
- <head>
842
- <meta charset="UTF-8">
843
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
844
- <title>Админ-панель</title>
845
- <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
846
- <style>
847
- body {
848
- font-family: 'Poppins', sans-serif;
849
- background: linear-gradient(135deg, #f0f2f5, #e9ecef);
850
- color: #2d3748;
851
- padding: 20px;
852
- }
853
- .container {
854
- max-width: 1200px;
855
- margin: 0 auto;
856
- }
857
- .header {
858
- display: flex;
859
- align-items: center;
860
- padding: 15px 0;
861
- border-bottom: 1px solid #e2e8f0;
862
- }
863
- .header-logo {
864
- width: 60px;
865
- height: 60px;
866
- border-radius: 50%;
867
- object-fit: cover;
868
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
869
- transition: transform 0.3s ease, box-shadow 0.3s ease;
870
- margin-right: 15px;
871
- }
872
- .header-logo:hover {
873
- transform: scale(1.1);
874
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
875
- }
876
- h1, h2 {
877
- font-weight: 600;
878
- margin-bottom: 20px;
879
- }
880
- form {
881
- background: #fff;
882
- padding: 20px;
883
- border-radius: 15px;
884
- box-shadow: 0 4px 15px rgba(0,0,0,0.1);
885
- margin-bottom: 30px;
886
- }
887
- label {
888
- font-weight: 500;
889
- margin-top: 15px;
890
- display: block;
891
- }
892
- input, textarea, select {
893
- width: 100%;
894
- padding: 12px;
895
- margin-top: 5px;
896
- border: 1px solid #e2e8f0;
897
- border-radius: 8px;
898
- font-size: 1rem;
899
- transition: all 0.3s ease;
900
- }
901
- input:focus, textarea:focus, select:focus {
902
- border-color: #3b82f6;
903
- box-shadow: 0 0 5px rgba(59, 130, 246, 0.3);
904
- outline: none;
905
- }
906
- button {
907
- padding: 12px 20px;
908
- border: none;
909
- border-radius: 8px;
910
- background-color: #3b82f6;
911
- color: white;
912
- font-weight: 500;
913
- cursor: pointer;
914
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
915
- margin-top: 15px;
916
- }
917
- button:hover {
918
- background-color: #2563eb;
919
- box-shadow: 0 4px 15px rgba(37, 99, 235, 0.4);
920
- transform: translateY(-2px);
921
- }
922
- .delete-button {
923
- background-color: #ef4444;
924
- }
925
- .delete-button:hover {
926
- background-color: #dc2626;
927
- box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4);
928
- }
929
- .product-list, .category-list {
930
- display: grid;
931
- gap: 20px;
932
- }
933
- .product-item, .category-item {
934
- background: #fff;
935
- padding: 20px;
936
- border-radius: 15px;
937
- box-shadow: 0 4px 15px rgba(0,0,0,0.1);
938
- }
939
- .edit-form {
940
- margin-top: 15px;
941
- padding: 15px;
942
- background: #f7fafc;
943
- border-radius: 10px;
944
- }
945
- .color-input-group {
946
- display: flex;
947
- gap: 10px;
948
- margin-top: 5px;
949
- }
950
- .add-color-btn {
951
- background-color: #10b981;
952
- }
953
- .add-color-btn:hover {
954
- background-color: #059669;
955
- }
956
- </style>
957
- </head>
958
- <body>
959
- <div class="container">
960
- <div class="header">
961
- <img src="''' + LOGO_URL + '''" alt="Logo" class="header-logo">
962
- <h1>Админ-панель</h1>
963
- </div>
964
- <h1>Добавление товара</h1>
965
- <form method="POST" enctype="multipart/form-data">
966
- <input type="hidden" name="action" value="add">
967
- <label>Название товара:</label>
968
- <input type="text" name="name" required>
969
- <label>Цена:</label>
970
- <input type="number" name="price" step="0.01" required>
971
- <label>Описание:</label>
972
- <textarea name="description" rows="4" required></textarea>
973
- <label>Категория:</label>
974
- <select name="category">
975
- <option value="Без категории">Без категории</option>
976
- {% for category in categories %}
977
- <option value="{{ category }}">{{ category }}</option>
978
- {% endfor %}
979
- </select>
980
- <label>Фотографии (до 10):</label>
981
- <input type="file" name="photos" accept="image/*" multiple>
982
- <label>Цвета:</label>
983
- <div id="color-inputs">
984
- <div class="color-input-group">
985
- <input type="text" name="colors" placeholder="Например: Красный">
986
- </div>
987
- </div>
988
- <button type="button" class="add-color-btn" onclick="addColorInput()">Добавить цвет</button>
989
- <button type="submit">Добавить товар</button>
990
- </form>
991
-
992
- <h1>Управление категориями</h1>
993
- <form method="POST">
994
- <input type="hidden" name="action" value="add_category">
995
- <label>Название категории:</label>
996
- <input type="text" name="category_name" required>
997
- <button type="submit">Добавить</button>
998
- </form>
999
-
1000
- <h2>Список категорий</h2>
1001
- <div class="category-list">
1002
- {% for category in categories %}
1003
- <div class="category-item">
1004
- <h3>{{ category }}</h3>
1005
- <form method="POST" style="display: inline;">
1006
- <input type="hidden" name="action" value="delete_category">
1007
- <input type="hidden" name="category_index" value="{{ loop.index0 }}">
1008
- <button type="submit" class="delete-button">Удалить</button>
1009
- </form>
1010
- </div>
1011
- {% endfor %}
1012
- </div>
1013
-
1014
- <h2>Управление базой данных</h2>
1015
- <form method="POST" action="{{ url_for('backup') }}" style="display: inline;">
1016
- <button type="submit">Создать копию</button>
1017
- </form>
1018
- <form method="GET" action="{{ url_for('download') }}" style="display: inline;">
1019
- <button type="submit">Скачать базу</button>
1020
- </form>
1021
-
1022
- <h2>Список товаров</h2>
1023
- <div class="product-list">
1024
- {% for product in products %}
1025
- <div class="product-item">
1026
- <h3>{{ product['name'] }}</h3>
1027
- <p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
1028
- <p><strong>Цена:</strong> {{ product['price'] }} с</p>
1029
- <p><strong>Описание:</strong> {{ product['description'] }}</p>
1030
- <p><strong>Цвета:</strong> {{ product.get('colors', ['Нет цветов'])|join(', ') }}</p>
1031
- {% if product.get('photos') and product['photos']|length > 0 %}
1032
- <div style="display: flex; flex-wrap: wrap; gap: 10px;">
1033
- {% for photo in product['photos'] %}
1034
- <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
1035
- alt="{{ product['name'] }}"
1036
- style="max-width: 100px; border-radius: 10px;">
1037
- {% endfor %}
1038
- </div>
1039
- {% endif %}
1040
- <details>
1041
- <summary>Редактировать</summary>
1042
- <form method="POST" enctype="multipart/form-data" class="edit-form">
1043
- <input type="hidden" name="action" value="edit">
1044
- <input type="hidden" name="index" value="{{ loop.index0 }}">
1045
- <label>Название:</label>
1046
- <input type="text" name="name" value="{{ product['name'] }}" required>
1047
- <label>Цена:</label>
1048
- <input type="number" name="price" step="0.01" value="{{ product['price'] }}" required>
1049
- <label>Описание:</label>
1050
- <textarea name="description" rows="4" required>{{ product['description'] }}</textarea>
1051
- <label>Категория:</label>
1052
- <select name="category">
1053
- <option value="Без категории" {% if product.get('category', 'Без категории') == 'Без категории' %}selected{% endif %}>Без категории</option>
1054
- {% for category in categories %}
1055
- <option value="{{ category }}" {% if product.get('category') == category %}selected{% endif %}>{{ category }}</option>
1056
- {% endfor %}
1057
- </select>
1058
- <label>Фотографии (до 10):</label>
1059
- <input type="file" name="photos" accept="image/*" multiple>
1060
- <label>Цвета:</label>
1061
- <div id="edit-color-inputs-{{ loop.index0 }}">
1062
- {% for color in product.get('colors', []) %}
1063
- <div class="color-input-group">
1064
- <input type="text" name="colors" value="{{ color }}">
1065
- </div>
1066
- {% endfor %}
1067
- </div>
1068
- <button type="button" class="add-color-btn" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')">Добавить цвет</button>
1069
- <button type="submit">Сохранить</button>
1070
- </form>
1071
- </details>
1072
- <form method="POST">
1073
- <input type="hidden" name="action" value="delete">
1074
- <input type="hidden" name="index" value="{{ loop.index0 }}">
1075
- <button type="submit" class="delete-button">Удалить</button>
1076
- </form>
1077
- </div>
1078
- {% endfor %}
1079
- </div>
1080
- </div>
1081
- <script>
1082
- function addColorInput(containerId = 'color-inputs') {
1083
- const container = document.getElementById(containerId);
1084
- const newInput = document.createElement('div');
1085
- newInput.className = 'color-input-group';
1086
- newInput.innerHTML = '<input type="text" name="colors" placeholder="Например: Красный">';
1087
- container.appendChild(newInput);
1088
- }
1089
- </script>
1090
- </body>
1091
- </html>
1092
- '''
1093
- return render_template_string(admin_html, products=products, categories=categories, repo_id=REPO_ID)
1094
-
1095
- @app.route('/backup', methods=['POST'])
1096
- def backup():
1097
- upload_db_to_hf()
1098
- return "Резервная копия создана.", 200
1099
-
1100
- @app.route('/download', methods=['GET'])
1101
- def download():
1102
- download_db_from_hf()
1103
- return "База данных скачана.", 200
1104
-
1105
- if __name__ == '__main__':
1106
- backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1107
- backup_thread.start()
1108
- try:
1109
- load_data()
1110
- except Exception as e:
1111
- logging.error(f"Не удалось загрузить базу данных: {e}")
1112
- app.run(debug=True, host='0.0.0.0', port=7860)
 
1
+ from flask import Flask, render_template, request, jsonify
2
  import json
3
+ from pywebpush import webpush, VAPID_PRIVATE_KEY, VAPID_PUBLIC_KEY
4
+ from py_vapid import VAPID
 
 
 
 
 
 
5
 
6
  app = Flask(__name__)
7
+ PORT = 7860
8
 
9
+ # Путь к файлу для хранения новостей
10
+ NEWS_FILE = 'news.json'
11
+ # Путь к файлу для хранения подписок на уведомления
12
+ SUBSCRIPTIONS_FILE = 'subscriptions.json'
13
 
14
+ # VAPID keys для push уведомлений. Сгенерируйте свои собственные!
15
+ # Безопасно храните приватный ключ.
16
+ VAPID_PUBLIC_KEY_BASE64 = "BIdP1w9i3m7C-p9tQ_97z7LgusQ49t8wZ6-L2vcl1kYRt6b-5Zg848p0h7FvL3Qv8K4j_J9z_n-J9y8v6v918" # Замените на свой публичный ключ
17
+ VAPID_PRIVATE_KEY_BASE64 = "w10j9y0z8x7c6v5b4n3m2l1k0j9h8g7f6e5d4s3a2q1z" # Замените на свой приватный ключ
18
+ VAPID_CLAIMS = {"subject": "mailto:your-email@example.com"} # Замените на свой email
19
 
20
+ # --- Вспомогательные функции ---
 
21
 
22
+ def load_news():
23
+ """Загружает новости из JSON файла."""
24
  try:
25
+ with open(NEWS_FILE, 'r', encoding='utf-8') as f:
26
+ return json.load(f)
27
+ except (FileNotFoundError, json.JSONDecodeError):
28
+ return []
29
+
30
+ def save_news(news_list):
31
+ """Сохраняет новости в JSON файл."""
32
+ with open(NEWS_FILE, 'w', encoding='utf-8') as f:
33
+ json.dump(news_list, f, ensure_ascii=False, indent=4)
34
+
35
+ def load_subscriptions():
36
+ """Загружает подписки из JSON файла."""
 
 
 
 
 
 
 
 
 
37
  try:
38
+ with open(SUBSCRIPTIONS_FILE, 'r') as f:
39
+ return json.load(f)
40
+ except (FileNotFoundError, json.JSONDecodeError):
41
+ return []
42
+
43
+ def save_subscriptions(subscriptions):
44
+ """Сохраняет подписки в JSON файл."""
45
+ with open(SUBSCRIPTIONS_FILE, 'w') as f:
46
+ json.dump(subscriptions, f, indent=4)
47
+
48
+ def send_push_notification(subscription, message_body):
49
+ """Отправляет push уведомление одному подписчику."""
50
  try:
51
+ webpush(
52
+ subscription_info=subscription,
53
+ data=message_body,
54
+ vapid_private_key=VAPID_PRIVATE_KEY_BASE64,
55
+ vapid_public_key=VAPID_PUBLIC_KEY_BASE64,
56
+ vapid_claims=VAPID_CLAIMS
 
 
57
  )
58
+ return True
59
  except Exception as e:
60
+ print(f"Ошибка отправки уведомления: {e}")
61
+ return False
62
 
63
+ def send_push_to_all(message_body):
64
+ """Отправляет push уведомление всем подписчикам."""
65
+ subscriptions = load_subscriptions()
66
+ valid_subscriptions = []
67
+ for sub in subscriptions:
68
+ if send_push_notification(sub, message_body):
69
+ valid_subscriptions.append(sub)
70
+ else:
71
+ print(f"Не удалось отправить уведомление для подписки: {sub['endpoint']}") # Логирование для отладки
72
+ save_subscriptions(valid_subscriptions) # Сохраняем только валидные подписки
 
 
 
 
 
 
 
73
 
 
 
 
 
74
 
75
+ # --- Flask маршруты ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
+ @app.route('/')
78
+ def index():
79
+ """Главная страница, отображает новости и запрашивает разрешение на уведомления."""
80
+ news = load_news()
81
+ public_key = VAPID_PUBLIC_KEY_BASE64 # Передаем публичный ключ в шаблон
82
+ return render_template('index.html', news=news, public_key=public_key)
83
+
84
+ @app.route('/add_news', methods=['POST'])
85
+ def add_news():
86
+ """API endpoint для добавления новой новости."""
87
+ news_text = request.form.get('news_text')
88
+ if news_text:
89
+ news_list = load_news()
90
+ news_list.append({"text": news_text})
91
+ save_news(news_list)
92
+
93
+ # Отправляем push уведомления о новой новости
94
+ send_push_to_all(news_text)
95
+
96
+ return jsonify({"message": "Новость добавлена и уведомления отправлены"}), 200
97
+ return jsonify({"error": "Текст новости не предоставлен"}), 400
98
+
99
+ @app.route('/get_news')
100
+ def get_news():
101
+ """API endpoint для получения списка новостей."""
102
+ news = load_news()
103
+ return jsonify(news)
104
+
105
+ @app.route('/save_subscription', methods=['POST'])
106
+ def save_subscription():
107
+ """API endpoint для сохранения подписки пользователя на уведомления."""
108
+ subscription_data = request.get_json()
109
+ if subscription_data:
110
+ subscriptions = load_subscriptions()
111
+ subscriptions.append(subscription_data)
112
+ save_subscriptions(subscriptions)
113
+ print(f"Подписка сохранена: {subscription_data['endpoint']}") # Логирование для отладки
114
+ return jsonify({"message": "Подписка сохранена"}), 201
115
+ return jsonify({"error": "Неверные данные подписки"}), 400
116
+
117
+ @app.route('/service-worker.js')
118
+ def service_worker():
119
+ """Маршрут для service-worker.js."""
120
+ return app.send_static_file('service-worker.js')
121
+
122
+ @app.route('/manifest.json')
123
+ def manifest():
124
+ """Маршрут для manifest.json."""
125
+ return app.send_static_file('manifest.json')
126
 
 
 
 
 
 
 
 
 
 
 
127
 
128
+ if __name__ == '__main__':
129
+ app.run(debug=True, port=PORT, host='0.0.0.0') # Доступно извне и на порту 7860
130
+
131
+
132
+ # --- HTML шаблоны и статические файлы ---
133
+
134
+ # Создайте папку 'templates' и файл 'index.html' внутри нее
135
+ # templates/index.html:
136
+ """
137
+ <!DOCTYPE html>
138
+ <html lang="ru">
139
+ <head>
140
+ <meta charset="UTF-8">
141
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
142
+ <title>PWA Новости</title>
143
+ <link rel="manifest" href="/manifest.json">
144
+ <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
145
+ <style>
146
+ body { padding-top: 20px; }
147
+ </style>
148
+ </head>
149
+ <body>
150
+ <div class="container">
151
+ <h1>Новости</h1>
152
+
153
+ <ul class="list-group" id="news-list">
154
+ {% for item in news %}
155
+ <li class="list-group-item">{{ item.text }}</li>
156
+ {% endfor %}
157
+ </ul>
158
+
159
+ <hr>
160
+
161
+ <h2>Добавить новость</h2>
162
+ <form id="add-news-form">
163
+ <div class="form-group">
164
+ <textarea class="form-control" id="news-text" rows="3" placeholder="Введите текст новости"></textarea>
165
  </div>
166
+ <button type="submit" class="btn btn-primary">Добавить новость</button>
167
+ </form>
 
 
 
 
 
 
 
 
168
 
169
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
+ <script>
172
+ const publicKey = '{{ public_key }}'; // Передаем публичный ключ из Flask
173
+
174
+ document.addEventListener('DOMContentLoaded', () => {
175
+ requestNotificationPermission();
176
+ registerServiceWorker();
177
+ loadNews();
178
+
179
+ const addNewsForm = document.getElementById('add-news-form');
180
+ addNewsForm.addEventListener('submit', handleAddNews);
181
+ });
182
+
183
+ function requestNotificationPermission() {
184
+ if (!("Notification" in window)) {
185
+ console.log("Браузер не поддерживает уведомления.");
186
+ } else if (Notification.permission === "default") {
187
+ Notification.requestPermission().then(permission => {
188
+ if (permission === "granted") {
189
+ console.log("Разрешение на уведомления получено.");
190
+ subscribeUserToPush(); // Подписываем пользователя после получения разрешения
191
+ } else {
192
+ console.log("Разрешение на уведомления отклонено.");
193
+ }
194
  });
195
+ } else if (Notification.permission === "granted") {
196
+ console.log("Разрешение на уведомления уже есть.");
197
+ subscribeUserToPush(); // Подписываем пользователя, если разрешение уже есть
198
+ } else {
199
+ console.log("Уведомления заблокированы.");
200
+ }
201
+ }
202
+
203
+ function registerServiceWorker() {
204
+ if ('serviceWorker' in navigator) {
205
+ navigator.serviceWorker.register('/service-worker.js')
206
+ .then(registration => {
207
+ console.log('Service Worker зарегистрирован:', registration);
208
+ })
209
+ .catch(error => {
210
+ console.error('Ошибка регистрации Service Worker:', error);
211
  });
 
 
 
 
 
 
 
 
212
  }
213
+ }
214
+
215
+ function subscribeUserToPush() {
216
+ navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
217
+ serviceWorkerRegistration.pushManager.subscribe({
218
+ userVisibleOnly: true,
219
+ applicationServerKey: urlBase64ToUint8Array(publicKey) // Используем публичный ключ
220
+ })
221
+ .then(subscription => {
222
+ console.log('Подписка на Push API:', subscription);
223
+ sendSubscriptionToServer(subscription);
224
+ })
225
+ .catch(error => {
226
+ console.error('Ошибка подписки на Push API:', error);
227
+ });
228
+ });
229
+ }
230
+
231
+ function sendSubscriptionToServer(subscription) {
232
+ fetch('/save_subscription', {
233
+ method: 'POST',
234
+ headers: {
235
+ 'Content-Type': 'application/json'
236
+ },
237
+ body: JSON.stringify(subscription)
238
+ })
239
+ .then(response => {
240
+ if (!response.ok) {
241
+ throw new Error('Ошибка сохранения подписки на сервере.');
242
  }
243
+ return response.json();
244
+ })
245
+ .then(data => {
246
+ console.log('Подписка успешно сохранена на сервере:', data.message);
247
+ })
248
+ .catch(error => {
249
+ console.error('Ошибка при сохранении подписки на сервере:', error);
250
+ });
251
+ }
252
+
253
+ function loadNews() {
254
+ fetch('/get_news')
255
+ .then(response => response.json())
256
+ .then(news => {
257
+ const newsList = document.getElementById('news-list');
258
+ newsList.innerHTML = ''; // Очищаем список перед добавлением
259
+ news.forEach(item => {
260
+ const li = document.createElement('li');
261
+ li.className = 'list-group-item';
262
+ li.textContent = item.text;
263
+ newsList.appendChild(li);
264
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  });
266
+ }
267
+
268
+ function handleAddNews(event) {
269
+ event.preventDefault();
270
+ const newsText = document.getElementById('news-text').value;
271
+ if (newsText.trim() !== '') {
272
+ fetch('/add_news', {
273
+ method: 'POST',
274
+ headers: {
275
+ 'Content-Type': 'application/x-www-form-urlencoded',
276
+ },
277
+ body: `news_text=${encodeURIComponent(newsText)}`
278
+ })
279
+ .then(response => response.json())
280
+ .then(data => {
281
+ console.log(data.message);
282
+ document.getElementById('news-text').value = ''; // Очищаем поле ввода
283
+ loadNews(); // Обновляем список новостей
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  });
285
  }
286
+ }
287
+
288
+
289
+ function urlBase64ToUint8Array(base64String) {
290
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
291
+ const base64 = (base64String + padding)
292
+ .replace(/-/g, '+')
293
+ .replace(/_/g, '/');
294
+
295
+ const rawData = window.atob(base64);
296
+ const outputArray = new Uint8Array(rawData.length);
297
+
298
+ for (let i = 0; i < rawData.length; ++i) {
299
+ outputArray[i] = rawData.charCodeAt(i);
300
+ }
301
+ return outputArray;
302
+ }
303
+
304
+
305
+ // Обработка полученных push уведомлений в foreground (если нужно что-то делать на странице)
306
+ navigator.serviceWorker.addEventListener('message', event => {
307
+ if (event.data && event.data.type === 'push-received') {
308
+ console.log('Получено push уведомление в foreground:', event.data.message);
309
+ loadNews(); // Обновляем список новостей при получении уведомления
310
+ // Можно также показать какое-то визуальное уведомление на странице, если нужно
311
+ }
312
+ });
313
+
314
+
315
+ </script>
316
+ </body>
317
+ </html>
318
+ """
319
+
320
+ # Создайте папку 'static' и файлы 'manifest.json' и 'service-worker.js' внутри нее
321
+ # static/manifest.json:
322
+ """
323
+ {
324
+ "name": "PWA Новости",
325
+ "short_name": "Новости",
326
+ "start_url": "/",
327
+ "display": "standalone",
328
+ "background_color": "#fff",
329
+ "theme_color": "#007bff"
330
+ }
331
+ """
332
+
333
+ # static/service-worker.js:
334
+ """
335
+ self.addEventListener('push', event => {
336
+ const message = event.data.text();
337
+ const title = 'Новая новость!';
338
+ const options = {
339
+ body: message,
340
+ icon: '/static/no-icon.png' // Путь к иконке (можно заменить на no-icon.png в static или убрать если не нужна иконка)
341
+ };
342
+
343
+ event.waitUntil(self.registration.showNotification(title, options));
344
+
345
+ // Отправляем сообщение клиенту (странице) о получении push уведомления
346
+ self.clients.matchAll({ type: 'window' }).then(clients => {
347
+ clients.forEach(client => {
348
+ client.postMessage({ type: 'push-received', message: message });
349
+ });
350
+ });
351
+ });
352
+
353
+ self.addEventListener('notificationclick', event => {
354
+ event.notification.close();
355
+ event.waitUntil(clients.openWindow('/')); // Открываем главное окно при клике на уведомление
356
+ });
357
+ """
358
+
359
+ # Создайте пустой файл 'news.json' и 'subscriptions.json' в корне проекта, если их еще нет.
360
+ # Они будут созданы автоматически при первом запуске, если их нет.