Kgshop commited on
Commit
1a6da91
·
verified ·
1 Parent(s): 0c07751

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +480 -745
app.py CHANGED
@@ -1,75 +1,55 @@
1
- from flask import Flask, render_template_string, request, redirect, url_for, session, flash, jsonify
2
- from flask_caching import Cache
3
  import json
4
  import os
5
  import logging
6
  import threading
7
  import time
8
- from datetime import datetime, timedelta
9
- from huggingface_hub import HfApi, hf_hub_download
10
  from werkzeug.utils import secure_filename
11
  import uuid
12
  import html
13
- from functools import wraps
14
 
15
  app = Flask(__name__)
16
- app.secret_key = os.getenv("FLASK_SECRET_KEY", "a_very_secret_key_for_zzirix")
17
- app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
18
-
19
- ADMIN_USER = os.getenv("ADMIN_USER", "admin")
20
- ADMIN_PASS = os.getenv("ADMIN_PASS", "password")
21
-
22
- DATA_FILE = 'zzirix_catalog.json'
23
- REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/xsetst")
24
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
25
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE
26
-
27
  UPLOAD_FOLDER = 'uploads'
28
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
29
-
30
- cache = Cache(app, config={'CACHE_TYPE': 'simple'})
31
  logging.basicConfig(level=logging.INFO)
32
 
33
- LOGO_URL = "https://i.imgur.com/8aL3fB9.png"
34
 
35
  def initialize_data_structure(data):
36
  if not isinstance(data, dict):
37
- data = {'products': [], 'categories': []}
38
- data.setdefault('products', [])
39
  data.setdefault('categories', [])
 
40
  for product in data['products']:
41
- product.setdefault('id', str(uuid.uuid4()))
42
- product.setdefault('name', 'Untitled Product')
 
43
  product.setdefault('description', '')
44
- product.setdefault('price', 0.0)
45
- product.setdefault('category_id', None)
46
  product.setdefault('media', [])
47
- for category in data['categories']:
48
- category.setdefault('id', str(uuid.uuid4()))
49
- category.setdefault('name', 'Uncategorized')
50
  return data
51
 
52
- @cache.memoize(timeout=120)
53
  def load_data():
54
  try:
55
- if HF_TOKEN_READ:
56
- hf_hub_download(
57
- repo_id=REPO_ID,
58
- filename=DATA_FILE,
59
- repo_type="dataset",
60
- token=HF_TOKEN_READ,
61
- local_dir=".",
62
- local_dir_use_symlinks=False,
63
- force_download=True
64
- )
65
  if os.path.exists(DATA_FILE) and os.path.getsize(DATA_FILE) > 0:
66
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
67
  data = json.load(file)
68
  else:
69
- data = {'products': [], 'categories': []}
70
  return initialize_data_structure(data)
 
 
 
71
  except Exception as e:
72
- logging.error(f"Error loading data: {e}")
73
  return initialize_data_structure({})
74
 
75
  def save_data(data):
@@ -78,567 +58,503 @@ def save_data(data):
78
  with open(temp_file, 'w', encoding='utf-8') as file:
79
  json.dump(data, file, ensure_ascii=False, indent=4)
80
  os.replace(temp_file, DATA_FILE)
81
- if HF_TOKEN_WRITE:
82
- api = HfApi()
83
- api.upload_file(
84
- path_or_fileobj=DATA_FILE,
85
- path_in_repo=DATA_FILE,
86
- repo_id=REPO_ID,
87
- repo_type="dataset",
88
- token=HF_TOKEN_WRITE,
89
- commit_message=f"Catalog update {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
90
- )
91
- cache.clear()
92
- logging.info("Data saved successfully.")
93
  except Exception as e:
94
- logging.error(f"Error saving data: {e}")
95
  if os.path.exists(temp_file):
96
  os.remove(temp_file)
97
  raise
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  def periodic_backup():
100
  while True:
101
- time.sleep(3600)
102
- logging.info("Initiating periodic data save.")
103
  try:
104
  data = load_data()
105
  save_data(data)
106
  except Exception as e:
107
- logging.error(f"Error during periodic backup: {e}")
108
-
109
- def admin_required(f):
110
- @wraps(f)
111
- def decorated_function(*args, **kwargs):
112
- if not session.get('admin_logged_in'):
113
- return redirect(url_for('admin_login'))
114
- return f(*args, **kwargs)
115
- return decorated_function
116
 
117
  def allowed_file(filename):
118
- ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'mp4', 'mov', 'webm'}
119
- return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
120
 
121
  BASE_STYLE = '''
122
  :root {
123
- --primary-color: #009688; /* Emerald Green */
124
- --secondary-color: #00796b; /* Darker Emerald */
125
- --accent-color: #4db6ac; /* Lighter Emerald */
126
- --text-color: #333;
127
- --bg-color: #f4f4f4;
128
- --surface-color: #ffffff;
 
 
129
  --border-color: #e0e0e0;
130
- --font-sans: 'Roboto', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
131
- --shadow: 0 4px 6px rgba(0,0,0,0.1);
132
- --transition: all 0.3s ease;
133
  }
134
  @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
135
  * { margin: 0; padding: 0; box-sizing: border-box; }
136
  html { scroll-behavior: smooth; }
137
  body {
138
  font-family: var(--font-sans);
139
- background-color: var(--bg-color);
140
- color: var(--text-color);
141
- line-height: 1.6;
142
- }
143
- .container {
144
- width: 90%;
145
- max-width: 1200px;
146
- margin: 2rem auto;
147
- padding: 1rem;
148
- animation: fadeIn 0.5s ease;
149
  }
150
- .header {
151
- background-color: var(--surface-color);
152
- padding: 1rem 5%;
153
- display: flex;
154
- justify-content: space-between;
155
- align-items: center;
156
- box-shadow: var(--shadow);
157
- position: sticky;
158
- top: 0;
159
  z-index: 1000;
 
160
  }
161
- .header-brand {
162
- display: flex;
163
- align-items: center;
164
- gap: 15px;
165
- text-decoration: none;
 
 
 
 
 
 
 
 
 
166
  color: var(--primary-color);
167
- font-size: 1.8rem;
168
- font-weight: 700;
 
 
 
 
169
  }
170
- .header-brand img { width: 45px; height: 45px; }
171
- .nav-links { list-style: none; display: flex; gap: 1.5rem; }
172
- .nav-link { text-decoration: none; color: var(--text-color); font-weight: 500; font-size: 1rem; transition: var(--transition); }
173
- .nav-link:hover { color: var(--primary-color); }
174
  .btn {
175
- padding: 0.75rem 1.5rem;
176
- background-color: var(--primary-color);
177
- color: white;
178
- border: none;
179
- border-radius: 5px;
180
- cursor: pointer;
181
- font-size: 1rem;
182
- font-weight: 500;
183
  text-decoration: none;
184
  display: inline-block;
185
- transition: var(--transition);
186
  }
187
- .btn:hover { background-color: var(--secondary-color); transform: translateY(-2px); }
188
- .btn-danger { background-color: #e53935; }
189
- .btn-danger:hover { background-color: #c62828; }
 
 
190
  input, textarea, select {
191
- width: 100%;
192
- padding: 0.75rem;
193
- margin-bottom: 1rem;
194
- border: 1px solid var(--border-color);
195
- border-radius: 5px;
196
- font-size: 1rem;
197
- font-family: inherit;
198
  }
199
- input:focus, textarea:focus, select:focus { outline: 2px solid var(--primary-color); }
200
- .page-title {
201
- font-size: 2.5rem;
202
- font-weight: 700;
203
- color: var(--text-color);
204
- margin-bottom: 2rem;
205
- text-align: center;
206
- border-bottom: 2px solid var(--primary-color);
207
- padding-bottom: 0.5rem;
208
- display: inline-block;
209
  }
210
- .grid-container {
211
- display: grid;
212
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
213
- gap: 2rem;
 
 
214
  }
215
- .category-list { display: flex; justify-content: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 3rem; }
216
- .product-card, .category-card {
217
- background-color: var(--surface-color);
218
- border-radius: 8px;
219
  box-shadow: var(--shadow);
220
- overflow: hidden;
221
- transition: var(--transition);
222
- text-decoration: none;
223
- color: inherit;
224
- display: flex;
225
- flex-direction: column;
226
  }
227
- .product-card:hover, .category-card:hover { transform: translateY(-5px); box-shadow: 0 8px 12px rgba(0,0,0,0.15); }
228
- .product-card-img {
229
- width: 100%;
230
- height: 200px;
231
- object-fit: cover;
232
- background-color: #eee;
233
  }
234
- .product-card-body { padding: 1rem; flex-grow: 1; display: flex; flex-direction: column; }
235
- .product-card-title { font-size: 1.2rem; font-weight: 600; margin-bottom: 0.5rem; }
236
- .product-card-price { font-size: 1.1rem; font-weight: 700; color: var(--primary-color); margin-top: auto; }
237
- .product-detail { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; align-items: start; }
238
- .product-media-gallery { display: flex; flex-direction: column; gap: 1rem; }
239
- .main-media-display { width: 100%; height: 400px; object-fit: contain; background-color: #000; border-radius: 8px; }
240
- .media-thumbnails { display: flex; gap: 0.5rem; flex-wrap: wrap; }
241
- .thumbnail { width: 80px; height: 80px; object-fit: cover; cursor: pointer; border: 2px solid transparent; border-radius: 4px; transition: var(--transition); }
242
- .thumbnail:hover, .thumbnail.active { border-color: var(--primary-color); }
243
- .product-info h1 { font-size: 2.2rem; margin-bottom: 1rem; }
244
- .product-info .price { font-size: 1.8rem; font-weight: 700; color: var(--primary-color); margin-bottom: 1rem; }
245
- .product-info .description { line-height: 1.7; }
246
- .flash {
247
- padding: 1rem; margin-bottom: 1.5rem; border-radius: 5px; text-align: center; font-weight: 500;
248
  }
249
- .flash.success { background-color: #e8f5e9; color: #2e7d32; }
250
- .flash.error { background-color: #ffebee; color: #c62828; }
251
- .form-container { background-color: var(--surface-color); padding: 2rem; border-radius: 8px; box-shadow: var(--shadow); max-width: 700px; margin: 2rem auto; }
252
- .form-container label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
253
- .table-container { overflow-x: auto; background: var(--surface-color); padding: 1rem; border-radius: 8px; box-shadow: var(--shadow); }
254
- table { width: 100%; border-collapse: collapse; }
255
- th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--border-color); }
256
- th { background-color: var(--bg-color); font-weight: 600; }
257
- .actions { display: flex; gap: 0.5rem; }
258
- @media (max-width: 768px) {
259
- .product-detail { grid-template-columns: 1fr; }
260
- .header { flex-direction: column; gap: 1rem; }
 
 
 
 
 
261
  }
262
  '''
263
 
264
- HEADER_HTML = '''
265
- <header class="header">
266
- <a href="{{ url_for('index') }}" class="header-brand">
267
- <img src="{{ logo_url }}" alt="ZZIRIX Logo">
268
- <span>ZZIRIX</span>
269
- </a>
270
- <nav>
271
- <ul class="nav-links">
272
- <li><a href="{{ url_for('index') }}" class="nav-link">Home</a></li>
273
- <li><a href="{{ url_for('admin_dashboard') }}" class="nav-link">Admin</a></li>
274
- </ul>
275
- </nav>
276
- </header>
277
- '''
278
-
279
- ADMIN_HEADER_HTML = '''
280
- <header class="header">
281
- <a href="{{ url_for('admin_dashboard') }}" class="header-brand">
282
- <img src="{{ logo_url }}" alt="ZZIRIX Logo">
283
- <span>ZZIRIX Admin</span>
284
- </a>
285
- <nav>
286
- <ul class="nav-links">
287
- <li><a href="{{ url_for('admin_dashboard') }}" class="nav-link">Products</a></li>
288
- <li><a href="{{ url_for('manage_categories') }}" class="nav-link">Categories</a></li>
289
- <li><a href="{{ url_for('index') }}" class="nav-link" target="_blank">View Site</a></li>
290
- <li><a href="{{ url_for('admin_logout') }}" class="nav-link">Logout</a></li>
291
- </ul>
292
  </nav>
293
- </header>
294
  '''
295
 
296
  @app.route('/')
297
- def index():
298
  data = load_data()
299
  products = data.get('products', [])
300
- categories = data.get('categories', [])
301
- html_content = '''
 
302
  <!DOCTYPE html>
303
- <html lang="en">
304
  <head>
305
  <meta charset="UTF-8">
306
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
307
- <title>ZZIRIX Catalog</title>
308
- <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
309
- <style>''' + BASE_STYLE + '''</style>
310
  </head>
311
  <body>
312
- ''' + HEADER_HTML + '''
313
- <main class="container">
314
- <div style="text-align: center; margin-bottom: 3rem;">
315
- <h1 class="page-title">Our Catalog</h1>
316
  </div>
317
-
318
- <nav class="category-list">
319
- <a href="{{ url_for('index') }}" class="btn">All Products</a>
320
- {% for category in categories %}
321
- <a href="{{ url_for('category_page', category_id=category.id) }}" class="btn">{{ category.name | escape }}</a>
322
- {% endfor %}
323
- </nav>
324
-
325
- <div class="grid-container">
326
  {% for product in products %}
327
- <a href="{{ url_for('product_page', product_id=product.id) }}" class="product-card">
328
- {% if product.media %}
329
- {% set first_media = product.media[0] %}
330
- {% set media_url = "https://huggingface.co/datasets/" + repo_id + "/resolve/main/media/" + first_media.filename %}
331
- {% if first_media.type == 'video' %}
332
- <video class="product-card-img" src="{{ media_url }}" muted preload="metadata"></video>
 
 
 
 
 
 
333
  {% else %}
334
- <img class="product-card-img" src="{{ media_url }}" alt="{{ product.name | escape }}" loading="lazy">
335
  {% endif %}
336
- {% else %}
337
- <div class="product-card-img"></div>
338
- {% endif %}
339
- <div class="product-card-body">
340
- <h3 class="product-card-title">{{ product.name | escape }}</h3>
341
- <p class="product-card-price">${{ "%.2f"|format(product.price) }}</p>
342
  </div>
343
- </a>
344
  {% else %}
345
- <p>No products found.</p>
346
  {% endfor %}
347
  </div>
348
- </main>
349
  </body>
350
  </html>
351
- '''
352
- return render_template_string(html_content, products=products, categories=categories, repo_id=REPO_ID, logo_url=LOGO_URL)
353
-
354
- @app.route('/category/<category_id>')
355
- def category_page(category_id):
 
 
 
 
 
 
 
356
  data = load_data()
357
- categories = data.get('categories', [])
358
- category = next((c for c in categories if c['id'] == category_id), None)
359
- if not category:
360
- flash("Category not found.", "error")
361
- return redirect(url_for('index'))
362
-
363
- products = [p for p in data.get('products', []) if p.get('category_id') == category_id]
364
-
365
- html_content = '''
366
  <!DOCTYPE html>
367
- <html lang="en">
368
  <head>
369
  <meta charset="UTF-8">
370
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
371
- <title>{{ category.name | escape }} - ZZIRIX Catalog</title>
372
- <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
373
- <style>''' + BASE_STYLE + '''</style>
374
  </head>
375
  <body>
376
- ''' + HEADER_HTML + '''
377
- <main class="container">
378
- <div style="text-align: center; margin-bottom: 3rem;">
379
- <h1 class="page-title">{{ category.name | escape }}</h1>
380
  </div>
381
-
382
- <nav class="category-list">
383
- <a href="{{ url_for('index') }}" class="btn">All Products</a>
384
- {% for cat in all_categories %}
385
- <a href="{{ url_for('category_page', category_id=cat.id) }}" class="btn" {% if cat.id == category.id %}style="background-color: var(--secondary-color);"{% endif %}>{{ cat.name | escape }}</a>
386
- {% endfor %}
387
- </nav>
388
-
389
- <div class="grid-container">
390
  {% for product in products %}
391
- <a href="{{ url_for('product_page', product_id=product.id) }}" class="product-card">
392
- {% if product.media %}
393
- {% set first_media = product.media[0] %}
394
- {% set media_url = "https://huggingface.co/datasets/" + repo_id + "/resolve/main/media/" + first_media.filename %}
395
- {% if first_media.type == 'video' %}
396
- <video class="product-card-img" src="{{ media_url }}" muted preload="metadata"></video>
 
 
 
 
 
 
397
  {% else %}
398
- <img class="product-card-img" src="{{ media_url }}" alt="{{ product.name | escape }}" loading="lazy">
399
  {% endif %}
400
- {% else %}
401
- <div class="product-card-img"></div>
402
- {% endif %}
403
- <div class="product-card-body">
404
- <h3 class="product-card-title">{{ product.name | escape }}</h3>
405
- <p class="product-card-price">${{ "%.2f"|format(product.price) }}</p>
406
  </div>
407
- </a>
408
  {% else %}
409
- <p>No products found in this category.</p>
410
  {% endfor %}
411
  </div>
412
- </main>
413
  </body>
414
  </html>
415
- '''
416
- return render_template_string(html_content, products=products, category=category, all_categories=categories, repo_id=REPO_ID, logo_url=LOGO_URL)
 
 
 
 
 
 
 
 
417
 
418
  @app.route('/product/<product_id>')
419
  def product_page(product_id):
420
  data = load_data()
421
- product = next((p for p in data.get('products', []) if p['id'] == product_id), None)
422
- if not product:
423
- flash("Product not found.", "error")
424
- return redirect(url_for('index'))
 
 
425
 
426
- html_content = '''
427
  <!DOCTYPE html>
428
- <html lang="en">
429
  <head>
430
  <meta charset="UTF-8">
431
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
432
- <title>{{ product.name | escape }} - ZZIRIX Catalog</title>
433
- <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
434
- <style>''' + BASE_STYLE + '''</style>
435
  </head>
436
  <body>
437
- ''' + HEADER_HTML + '''
438
- <main class="container">
439
- <div class="product-detail">
440
- <div class="product-media-gallery">
441
- {% if product.media %}
442
- {% set first_media = product.media[0] %}
443
- <div id="main-media-wrapper">
444
- {% if first_media.type == 'video' %}
445
- <video id="main-media" class="main-media-display" src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/media/{{ first_media.filename }}" controls></video>
446
- {% else %}
447
- <img id="main-media" class="main-media-display" src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/media/{{ first_media.filename }}" alt="{{ product.name | escape }}">
448
- {% endif %}
449
- </div>
450
- <div class="media-thumbnails">
451
- {% for media_item in product.media %}
452
- {% set media_url = "https://huggingface.co/datasets/" + repo_id + "/resolve/main/media/" + media_item.filename %}
453
- <img class="thumbnail {% if loop.first %}active{% endif %}" src="{{ media_url if media_item.type == 'photo' else media_url + '?is_video=true' }}"
454
- data-src="{{ media_url }}" data-type="{{ media_item.type }}" onclick="changeMedia(this)">
455
- {% endfor %}
456
- </div>
457
  {% else %}
458
- <div class="main-media-display" style="background-color: #eee; display:flex; align-items:center; justify-content:center;">No Image</div>
459
  {% endif %}
460
- </div>
461
- <div class="product-info">
462
- <h1>{{ product.name | escape }}</h1>
463
- <p class="price">${{ "%.2f"|format(product.price) }}</p>
464
- <div class="description">
465
- {{ product.description | escape | replace('\\n', '<br>') | safe }}
466
- </div>
467
- </div>
468
  </div>
469
- </main>
470
- <script>
471
- function changeMedia(element) {
472
- const mainMediaWrapper = document.getElementById('main-media-wrapper');
473
- const newSrc = element.dataset.src;
474
- const newType = element.dataset.type;
475
- let newMediaElement;
476
-
477
- if (newType === 'video') {
478
- newMediaElement = document.createElement('video');
479
- newMediaElement.controls = true;
480
- } else {
481
- newMediaElement = document.createElement('img');
482
- }
483
- newMediaElement.id = 'main-media';
484
- newMediaElement.className = 'main-media-display';
485
- newMediaElement.src = newSrc;
486
-
487
- mainMediaWrapper.innerHTML = '';
488
- mainMediaWrapper.appendChild(newMediaElement);
489
-
490
- document.querySelectorAll('.thumbnail').forEach(thumb => thumb.classList.remove('active'));
491
- element.classList.add('active');
492
- }
493
- </script>
494
- </body>
495
- </html>
496
- '''
497
- return render_template_string(html_content, product=product, repo_id=REPO_ID, logo_url=LOGO_URL)
498
-
499
- @app.route('/admin/login', methods=['GET', 'POST'])
500
- def admin_login():
501
- if request.method == 'POST':
502
- username = request.form.get('username')
503
- password = request.form.get('password')
504
- if username == ADMIN_USER and password == ADMIN_PASS:
505
- session['admin_logged_in'] = True
506
- session.permanent = True
507
- flash('Login successful!', 'success')
508
- return redirect(url_for('admin_dashboard'))
509
- else:
510
- flash('Invalid credentials.', 'error')
511
-
512
- html_content = '''
513
- <!DOCTYPE html>
514
- <html lang="en">
515
- <head>
516
- <meta charset="UTF-8">
517
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
518
- <title>Admin Login - ZZIRIX</title>
519
- <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
520
- <style>''' + BASE_STYLE + '''
521
- body { display: flex; justify-content: center; align-items: center; min-height: 100vh; }
522
- </style>
523
- </head>
524
- <body>
525
- <div class="form-container" style="max-width: 400px;">
526
- <h1 style="text-align: center; font-size: 1.8rem; margin-bottom: 1.5rem;">Admin Login</h1>
527
- {% with messages = get_flashed_messages(with_categories=true) %}
528
- {% if messages %}
529
- {% for category, message in messages %}
530
- <div class="flash {{ category }}">{{ message }}</div>
531
- {% endfor %}
532
- {% endif %}
533
- {% endwith %}
534
- <form method="POST">
535
- <label for="username">Username</label>
536
- <input type="text" id="username" name="username" required>
537
- <label for="password">Password</label>
538
- <input type="password" id="password" name="password" required>
539
- <button type="submit" class="btn" style="width: 100%;">Login</button>
540
- </form>
541
  </div>
542
  </body>
543
  </html>
544
- '''
545
- return render_template_string(html_content)
546
-
547
- @app.route('/admin/logout')
548
- def admin_logout():
549
- session.pop('admin_logged_in', None)
550
- flash('You have been logged out.', 'success')
551
- return redirect(url_for('admin_login'))
552
-
553
- @app.route('/admin')
554
- @admin_required
555
- def admin_dashboard():
556
- data = load_data()
557
- products = data.get('products', [])
558
- categories_dict = {c['id']: c['name'] for c in data.get('categories', [])}
559
- html_content = '''
560
- <!DOCTYPE html>
561
- <html lang="en">
562
- <head>
563
- <meta charset="UTF-8">
564
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
565
- <title>Admin Dashboard - ZZIRIX</title>
566
- <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
567
- <style>''' + BASE_STYLE + '''</style>
568
- </head>
569
- <body>
570
- ''' + ADMIN_HEADER_HTML + '''
571
- <main class="container">
572
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
573
- <h1>Manage Products</h1>
574
- <a href="{{ url_for('add_product') }}" class="btn">Add New Product</a>
575
- </div>
576
- {% with messages = get_flashed_messages(with_categories=true) %}
577
- {% if messages %}
578
- {% for category, message in messages %}
579
- <div class="flash {{ category }}">{{ message }}</div>
580
- {% endfor %}
581
- {% endif %}
582
- {% endwith %}
583
- <div class="table-container">
584
- <table>
585
- <thead>
586
- <tr><th>Name</th><th>Price</th><th>Category</th><th>Media Count</th><th>Actions</th></tr>
587
- </thead>
588
- <tbody>
589
- {% for product in products %}
590
- <tr>
591
- <td>{{ product.name | escape }}</td>
592
- <td>${{ "%.2f"|format(product.price) }}</td>
593
- <td>{{ categories.get(product.category_id, 'N/A') | escape }}</td>
594
- <td>{{ product.media|length }}</td>
595
- <td class="actions">
596
- <a href="{{ url_for('edit_product', product_id=product.id) }}" class="btn">Edit</a>
597
- <form method="POST" action="{{ url_for('delete_product', product_id=product.id) }}" onsubmit="return confirm('Are you sure you want to delete this product?');">
598
- <button type="submit" class="btn btn-danger">Delete</button>
599
- </form>
600
- </td>
601
- </tr>
602
- {% endfor %}
603
- </tbody>
604
- </table>
605
- </div>
606
- </main>
607
- </body>
608
- </html>
609
- '''
610
- return render_template_string(html_content, products=products, categories=categories_dict, logo_url=LOGO_URL)
611
-
612
- @app.route('/admin/categories', methods=['GET', 'POST'])
613
- @admin_required
614
- def manage_categories():
615
  data = load_data()
616
  if request.method == 'POST':
617
- category_name = request.form.get('name', '').strip()
618
- if category_name:
619
- new_category = {'id': str(uuid.uuid4()), 'name': category_name}
620
- data.setdefault('categories', []).append(new_category)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  save_data(data)
622
- flash('Category added successfully.', 'success')
623
- else:
624
- flash('Category name cannot be empty.', 'error')
625
- return redirect(url_for('manage_categories'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
 
627
- categories = data.get('categories', [])
628
- html_content = '''
629
  <!DOCTYPE html>
630
- <html lang="en">
631
  <head>
632
  <meta charset="UTF-8">
633
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
634
- <title>Manage Categories - ZZIRIX</title>
635
- <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
636
- <style>''' + BASE_STYLE + '''</style>
637
  </head>
638
  <body>
639
- ''' + ADMIN_HEADER_HTML + '''
640
- <main class="container">
641
- <h1>Manage Categories</h1>
642
  {% with messages = get_flashed_messages(with_categories=true) %}
643
  {% if messages %}
644
  {% for category, message in messages %}
@@ -646,276 +562,95 @@ def manage_categories():
646
  {% endfor %}
647
  {% endif %}
648
  {% endwith %}
649
- <div class="form-container">
650
- <h2>Add New Category</h2>
651
- <form method="POST">
652
- <label for="name">Category Name</label>
653
- <input type="text" id="name" name="name" required>
654
- <button type="submit" class="btn">Add Category</button>
 
655
  </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  </div>
657
- <div class="table-container" style="margin-top: 2rem;">
658
- <table>
659
- <thead>
660
- <tr><th>Name</th><th>Actions</th></tr>
661
- </thead>
662
- <tbody>
663
- {% for category in categories %}
664
- <tr>
665
- <td>{{ category.name | escape }}</td>
666
- <td class="actions">
667
- <form method="POST" action="{{ url_for('delete_category', category_id=category.id) }}" onsubmit="return confirm('Are you sure you want to delete this category?');">
668
- <button type="submit" class="btn btn-danger">Delete</button>
669
- </form>
670
- </td>
671
- </tr>
672
- {% endfor %}
673
- </tbody>
674
- </table>
675
- </div>
676
- </main>
677
- </body>
678
- </html>
679
- '''
680
- return render_template_string(html_content, categories=categories, logo_url=LOGO_URL)
681
 
682
- @app.route('/admin/delete_category/<category_id>', methods=['POST'])
683
- @admin_required
684
- def delete_category(category_id):
685
- data = load_data()
686
- products_in_category = [p for p in data.get('products', []) if p.get('category_id') == category_id]
687
- if products_in_category:
688
- flash('Cannot delete category. It contains products.', 'error')
689
- return redirect(url_for('manage_categories'))
690
-
691
- data['categories'] = [c for c in data.get('categories', []) if c.get('id') != category_id]
692
- save_data(data)
693
- flash('Category deleted successfully.', 'success')
694
- return redirect(url_for('manage_categories'))
695
-
696
- @app.route('/admin/product/add', methods=['GET', 'POST'])
697
- @admin_required
698
- def add_product():
699
- data = load_data()
700
- if request.method == 'POST':
701
- name = request.form.get('name', '').strip()
702
- description = request.form.get('description', '').strip()
703
- price = float(request.form.get('price', 0.0))
704
- category_id = request.form.get('category_id')
705
- files = request.files.getlist('media')
706
-
707
- if not name:
708
- flash('Product name is required.', 'error')
709
- return redirect(url_for('add_product'))
710
-
711
- new_product = {
712
- 'id': str(uuid.uuid4()),
713
- 'name': name,
714
- 'description': description,
715
- 'price': price,
716
- 'category_id': category_id if category_id != 'None' else None,
717
- 'media': []
718
- }
719
-
720
- api = HfApi() if HF_TOKEN_WRITE else None
721
- for file in files:
722
- if file and file.filename != '' and allowed_file(file.filename):
723
- filename = secure_filename(f"{new_product['id']}_{file.filename}")
724
- file_type = 'video' if filename.lower().rsplit('.', 1)[1] in {'mp4', 'mov', 'webm'} else 'photo'
725
-
726
- temp_path = os.path.join(UPLOAD_FOLDER, filename)
727
- file.save(temp_path)
728
-
729
- if HF_TOKEN_WRITE:
730
- api.upload_file(
731
- path_or_fileobj=temp_path,
732
- path_in_repo=f"media/{filename}",
733
- repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE
734
- )
735
- os.remove(temp_path)
736
-
737
- new_product['media'].append({'filename': filename, 'type': file_type})
738
-
739
- data.setdefault('products', []).append(new_product)
740
- save_data(data)
741
- flash('Product added successfully.', 'success')
742
- return redirect(url_for('admin_dashboard'))
743
-
744
- categories = data.get('categories', [])
745
- return render_product_form(categories=categories)
746
-
747
- @app.route('/admin/product/edit/<product_id>', methods=['GET', 'POST'])
748
- @admin_required
749
- def edit_product(product_id):
750
- data = load_data()
751
- product_index = next((i for i, p in enumerate(data.get('products', [])) if p.get('id') == product_id), None)
752
- if product_index is None:
753
- flash('Product not found.', 'error')
754
- return redirect(url_for('admin_dashboard'))
755
-
756
- if request.method == 'POST':
757
- product = data['products'][product_index]
758
- product['name'] = request.form.get('name', '').strip()
759
- product['description'] = request.form.get('description', '').strip()
760
- product['price'] = float(request.form.get('price', 0.0))
761
- product['category_id'] = request.form.get('category_id')
762
- if product['category_id'] == 'None':
763
- product['category_id'] = None
764
-
765
- files = request.files.getlist('media')
766
-
767
- api = HfApi() if HF_TOKEN_WRITE else None
768
- for file in files:
769
- if file and file.filename != '' and allowed_file(file.filename):
770
- filename = secure_filename(f"{product['id']}_{file.filename}")
771
- file_type = 'video' if filename.lower().rsplit('.', 1)[1] in {'mp4', 'mov', 'webm'} else 'photo'
772
-
773
- temp_path = os.path.join(UPLOAD_FOLDER, filename)
774
- file.save(temp_path)
775
-
776
- if HF_TOKEN_WRITE:
777
- api.upload_file(
778
- path_or_fileobj=temp_path,
779
- path_in_repo=f"media/{filename}",
780
- repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE
781
- )
782
- os.remove(temp_path)
783
-
784
- product.setdefault('media', []).append({'filename': filename, 'type': file_type})
785
-
786
- save_data(data)
787
- flash('Product updated successfully.', 'success')
788
- return redirect(url_for('admin_dashboard'))
789
-
790
- categories = data.get('categories', [])
791
- product = data['products'][product_index]
792
- return render_product_form(product=product, categories=categories)
793
-
794
- def render_product_form(product=None, categories=None):
795
- is_edit = product is not None
796
- html_content = '''
797
- <!DOCTYPE html>
798
- <html lang="en">
799
- <head>
800
- <meta charset="UTF-8">
801
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
802
- <title>{{ 'Edit' if is_edit else 'Add' }} Product - ZZIRIX</title>
803
- <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
804
- <style>''' + BASE_STYLE + '''
805
- .current-media { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 1rem; }
806
- .media-item { position: relative; }
807
- .media-item img, .media-item video { width: 100px; height: 100px; object-fit: cover; border-radius: 4px; }
808
- .delete-media-btn {
809
- position: absolute; top: -5px; right: -5px; background: #e53935; color: white;
810
- border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer;
811
- font-weight: bold; display: flex; align-items: center; justify-content: center;
812
- }
813
- </style>
814
- </head>
815
- <body>
816
- ''' + ADMIN_HEADER_HTML + '''
817
- <main class="container">
818
- <div class="form-container">
819
- <h1>{{ 'Edit' if is_edit else 'Add' }} Product</h1>
820
  <form method="POST" enctype="multipart/form-data">
821
- <label for="name">Product Name</label>
822
- <input type="text" id="name" name="name" value="{{ product.name if product else '' }}" required>
823
-
824
- <label for="description">Description</label>
825
- <textarea id="description" name="description" rows="5">{{ product.description if product else '' }}</textarea>
826
-
827
- <label for="price">Price</label>
828
- <input type="number" id="price" name="price" step="0.01" value="{{ product.price if product else '0.00' }}" required>
829
-
830
- <label for="category_id">Category</label>
831
- <select id="category_id" name="category_id">
832
- <option value="None">-- No Category --</option>
833
  {% for category in categories %}
834
- <option value="{{ category.id }}" {% if product and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
835
  {% endfor %}
836
  </select>
 
 
 
 
 
837
 
838
- <label for="media">Add Photos/Videos</label>
839
- <input type="file" id="media" name="media" multiple accept="image/*,video/*">
840
-
841
- {% if is_edit and product.media %}
842
- <h2>Current Media</h2>
843
- <div class="current-media">
844
- {% for media_item in product.media %}
845
- <div class="media-item">
846
- {% set media_url = "https://huggingface.co/datasets/" + repo_id + "/resolve/main/media/" + media_item.filename %}
847
- {% if media_item.type == 'video' %}
848
- <video src="{{ media_url }}"></video>
849
- {% else %}
850
- <img src="{{ media_url }}" alt="media thumbnail">
851
- {% endif %}
852
- <a href="{{ url_for('delete_media', product_id=product.id, filename=media_item.filename) }}" class="delete-media-btn"
853
- onclick="return confirm('Delete this file?');">&times;</a>
854
  </div>
855
- {% endfor %}
856
- </div>
857
- {% endif %}
858
-
859
- <button type="submit" class="btn" style="margin-top: 2rem;">{{ 'Save Changes' if is_edit else 'Add Product' }}</button>
860
- </form>
 
 
 
 
861
  </div>
862
- </main>
863
  </body>
864
  </html>
865
- '''
866
- return render_template_string(html_content, product=product, categories=categories or [], is_edit=is_edit, repo_id=REPO_ID, logo_url=LOGO_URL)
867
-
868
- @app.route('/admin/delete_product/<product_id>', methods=['POST'])
869
- @admin_required
870
- def delete_product(product_id):
871
- data = load_data()
872
- product_to_delete = next((p for p in data.get('products', []) if p.get('id') == product_id), None)
873
- if product_to_delete and HF_TOKEN_WRITE:
874
- api = HfApi()
875
- for media_item in product_to_delete.get('media', []):
876
- try:
877
- api.delete_file(
878
- path_in_repo=f"media/{media_item['filename']}",
879
- repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
880
- )
881
- except Exception as e:
882
- logging.warning(f"Could not delete media file {media_item['filename']} from HF: {e}")
883
-
884
- data['products'] = [p for p in data.get('products', []) if p.get('id') != product_id]
885
- save_data(data)
886
- flash('Product deleted successfully.', 'success')
887
- return redirect(url_for('admin_dashboard'))
888
-
889
- @app.route('/admin/delete_media/<product_id>/<filename>')
890
- @admin_required
891
- def delete_media(product_id, filename):
892
- data = load_data()
893
- product_index = next((i for i, p in enumerate(data.get('products', [])) if p.get('id') == product_id), None)
894
- if product_index is None:
895
- flash('Product not found.', 'error')
896
- return redirect(url_for('admin_dashboard'))
897
-
898
- product = data['products'][product_index]
899
-
900
- if HF_TOKEN_WRITE:
901
- try:
902
- api = HfApi()
903
- api.delete_file(
904
- path_in_repo=f"media/{filename}",
905
- repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE
906
- )
907
- except Exception as e:
908
- logging.warning(f"Could not delete media file {filename} from HF: {e}")
909
-
910
- product['media'] = [m for m in product.get('media', []) if m.get('filename') != filename]
911
- save_data(data)
912
- flash('Media file deleted.', 'success')
913
- return redirect(url_for('edit_product', product_id=product_id))
914
 
915
  if __name__ == '__main__':
916
  if not os.path.exists(DATA_FILE):
917
- load_data()
 
918
 
 
 
 
 
 
 
 
 
 
919
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
920
  backup_thread.start()
 
921
  app.run(debug=False, host='0.0.0.0', port=7860)
 
1
+ from flask import Flask, render_template_string, request, redirect, url_for, flash
 
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, HfFolder
9
  from werkzeug.utils import secure_filename
10
  import uuid
11
  import html
 
12
 
13
  app = Flask(__name__)
14
+ app.secret_key = os.getenv("FLASK_SECRET_KEY", "zzirix_secret_key")
15
+ DATA_FILE = 'data_zzirix.json'
16
+ REPO_ID = "Kgshop/testxet"
 
 
 
 
 
17
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
18
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE
 
19
  UPLOAD_FOLDER = 'uploads'
20
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
 
 
21
  logging.basicConfig(level=logging.INFO)
22
 
23
+ LOGO_URL = "https://cdn-avatars.huggingface.co/v1/production/uploads/673b00f35373479538ac373c/W_dumUND8K6IlMxVmpUgS.jpeg"
24
 
25
  def initialize_data_structure(data):
26
  if not isinstance(data, dict):
27
+ data = {'categories': [], 'products': []}
 
28
  data.setdefault('categories', [])
29
+ data.setdefault('products', [])
30
  for product in data['products']:
31
+ if 'id' not in product:
32
+ product['id'] = str(uuid.uuid4())
33
+ product.setdefault('name', 'Без названия')
34
  product.setdefault('description', '')
35
+ product.setdefault('category', 'Без категории')
 
36
  product.setdefault('media', [])
 
 
 
37
  return data
38
 
 
39
  def load_data():
40
  try:
41
+ download_db_from_hf()
 
 
 
 
 
 
 
 
 
42
  if os.path.exists(DATA_FILE) and os.path.getsize(DATA_FILE) > 0:
43
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
44
  data = json.load(file)
45
  else:
46
+ data = {'categories': [], 'products': []}
47
  return initialize_data_structure(data)
48
+ except (json.JSONDecodeError, FileNotFoundError):
49
+ logging.error(f"Ошибка при чтении {DATA_FILE}. Создается пустая структура.")
50
+ return initialize_data_structure({})
51
  except Exception as e:
52
+ logging.error(f"Ошибка при загрузке данных: {e}")
53
  return initialize_data_structure({})
54
 
55
  def save_data(data):
 
58
  with open(temp_file, 'w', encoding='utf-8') as file:
59
  json.dump(data, file, ensure_ascii=False, indent=4)
60
  os.replace(temp_file, DATA_FILE)
61
+ upload_db_to_hf()
62
+ logging.info("Данные сохранены и выгружены в HF")
 
 
 
 
 
 
 
 
 
 
63
  except Exception as e:
64
+ logging.error(f"Ошибка при сохранении данных: {e}")
65
  if os.path.exists(temp_file):
66
  os.remove(temp_file)
67
  raise
68
 
69
+ def upload_db_to_hf():
70
+ if not HF_TOKEN_WRITE:
71
+ logging.warning("HF_TOKEN_WRITE не установлен. Пропуск выгрузки.")
72
+ return
73
+ try:
74
+ api = HfApi()
75
+ api.upload_file(
76
+ path_or_fileobj=DATA_FILE,
77
+ path_in_repo=DATA_FILE,
78
+ repo_id=REPO_ID,
79
+ repo_type="dataset",
80
+ token=HF_TOKEN_WRITE,
81
+ commit_message=f"Резервная копия {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
82
+ )
83
+ logging.info("База данных выгружена в Hugging Face")
84
+ except Exception as e:
85
+ logging.error(f"Ошибка при выгрузке базы данных: {e}")
86
+
87
+ def download_db_from_hf():
88
+ if not HF_TOKEN_READ:
89
+ logging.warning("HF_TOKEN_READ не установлен. Пропуск загрузки.")
90
+ if not os.path.exists(DATA_FILE):
91
+ with open(DATA_FILE, 'w', encoding='utf-8') as f:
92
+ json.dump(initialize_data_structure({}), f)
93
+ return
94
+ try:
95
+ hf_hub_download(
96
+ repo_id=REPO_ID,
97
+ filename=DATA_FILE,
98
+ repo_type="dataset",
99
+ token=HF_TOKEN_READ,
100
+ local_dir=".",
101
+ local_dir_use_symlinks=False,
102
+ force_download=True
103
+ )
104
+ logging.info("База данных загружена с Hugging Face")
105
+ except Exception as e:
106
+ logging.error(f"Ошибка при загрузке базы данных: {e}")
107
+ if not os.path.exists(DATA_FILE):
108
+ logging.info("Создается пустой файл базы данных, так как загрузка не удалась и файл не существует.")
109
+ with open(DATA_FILE, 'w', encoding='utf-8') as f:
110
+ json.dump(initialize_data_structure({}), f)
111
+
112
  def periodic_backup():
113
  while True:
114
+ time.sleep(1800)
115
+ logging.info("Запуск периодического резервного копирования.")
116
  try:
117
  data = load_data()
118
  save_data(data)
119
  except Exception as e:
120
+ logging.error(f"Ошибка во время периодического резервного копирования: {e}")
 
 
 
 
 
 
 
 
121
 
122
  def allowed_file(filename):
123
+ return '.' in filename and \
124
+ filename.rsplit('.', 1)[1].lower() in {'png', 'jpg', 'jpeg', 'gif', 'mp4', 'mov', 'webm'}
125
 
126
  BASE_STYLE = '''
127
  :root {
128
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
129
+ --primary-color: #008080;
130
+ --secondary-color: #004d4d;
131
+ --accent-color: #f0f8ff;
132
+ --text-dark: #2c3e50;
133
+ --text-light: #555;
134
+ --bg-light: #ffffff;
135
+ --bg-medium: #f9f9f9;
136
  --border-color: #e0e0e0;
137
+ --shadow: 0 4px 15px rgba(0, 0, 0, 0.07);
138
+ --transition-fast: all 0.2s ease-in-out;
 
139
  }
140
  @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
141
  * { margin: 0; padding: 0; box-sizing: border-box; }
142
  html { scroll-behavior: smooth; }
143
  body {
144
  font-family: var(--font-sans);
145
+ background-color: var(--bg-light);
146
+ color: var(--text-dark);
147
+ line-height: 1.7;
148
+ overflow-x: hidden;
 
 
 
 
 
 
149
  }
150
+ .sidebar {
151
+ position: fixed; top: 0; left: 0; width: 260px; height: 100%;
152
+ background: var(--bg-medium);
153
+ border-right: 1px solid var(--border-color);
154
+ padding: 25px;
 
 
 
 
155
  z-index: 1000;
156
+ display: flex; flex-direction: column;
157
  }
158
+ .sidebar-header {
159
+ display: flex; align-items: center; gap: 15px; margin-bottom: 30px;
160
+ padding-bottom: 20px; border-bottom: 1px solid var(--border-color);
161
+ }
162
+ .logo { width: 45px; height: 45px; border-radius: 12px; }
163
+ .nav-brand { font-size: 1.8em; font-weight: 700; color: var(--primary-color); }
164
+ .nav-links { display: flex; flex-direction: column; gap: 8px; }
165
+ .nav-link {
166
+ display: block; padding: 12px 18px; color: var(--text-light);
167
+ text-decoration: none; border-radius: 8px; font-weight: 500;
168
+ transition: var(--transition-fast);
169
+ }
170
+ .nav-link:hover { color: var(--primary-color); background: #e8f3f3; }
171
+ .nav-link.active {
172
  color: var(--primary-color);
173
+ background: #e0f0f0;
174
+ font-weight: 600;
175
+ }
176
+ .container {
177
+ margin-left: 280px; padding: 40px;
178
+ max-width: 1300px;
179
  }
180
+ .container.animated { animation: fadeIn 0.5s ease; }
 
 
 
181
  .btn {
182
+ padding: 12px 25px;
183
+ background: var(--primary-color);
184
+ color: white; border: none; border-radius: 8px; cursor: pointer;
185
+ font-size: 1em; font-weight: 600;
186
+ transition: var(--transition-fast);
 
 
 
187
  text-decoration: none;
188
  display: inline-block;
189
+ text-align: center;
190
  }
191
+ .btn:hover { background: var(--secondary-color); transform: translateY(-2px); box-shadow: var(--shadow); }
192
+ .btn-danger { background: #e74c3c; }
193
+ .btn-danger:hover { background: #c0392b; }
194
+ .btn-secondary { background: #7f8c8d; }
195
+ .btn-secondary:hover { background: #34495e; }
196
  input, textarea, select {
197
+ width: 100%; padding: 12px 15px; margin: 8px 0;
198
+ border: 1px solid var(--border-color); border-radius: 8px;
199
+ background: var(--bg-light); color: var(--text-dark);
200
+ font-size: 1em; font-family: inherit; transition: var(--transition-fast);
 
 
 
201
  }
202
+ input:focus, textarea:focus, select:focus {
203
+ outline: none;
204
+ border-color: var(--primary-color);
205
+ box-shadow: 0 0 0 3px rgba(0, 128, 128, 0.15);
206
+ }
207
+ textarea { min-height: 120px; resize: vertical; }
208
+ .flash {
209
+ padding: 15px; margin-bottom: 20px; border-radius: 8px;
210
+ font-weight: 500; border: 1px solid transparent;
 
211
  }
212
+ .flash.success { background-color: #e8f3f3; color: var(--secondary-color); border-color: var(--primary-color); }
213
+ .flash.error { background-color: #fbeae5; color: #c0392b; border-color: #e74c3c; }
214
+ .page-header {
215
+ display: flex; justify-content: space-between; align-items: center;
216
+ border-bottom: 1px solid var(--border-color);
217
+ padding-bottom: 20px; margin-bottom: 30px;
218
  }
219
+ h1 { font-size: 2.2em; font-weight: 700; color: var(--text-dark); }
220
+ .product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 30px; }
221
+ .product-item {
222
+ background: var(--bg-light); border-radius: 12px;
223
  box-shadow: var(--shadow);
224
+ transition: var(--transition-fast);
225
+ border: 1px solid var(--border-color);
226
+ overflow: hidden; display: flex; flex-direction: column;
 
 
 
227
  }
228
+ .product-item:hover { transform: translateY(-5px); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); }
229
+ .product-media-link {
230
+ display: block; text-decoration: none; color: inherit;
231
+ background-color: #000; height: 220px; overflow: hidden;
 
 
232
  }
233
+ .product-media { width: 100%; height: 100%; object-fit: cover; }
234
+ .product-item-info { padding: 20px; }
235
+ .product-item h3 { font-size: 1.2em; font-weight: 600; margin-bottom: 8px; color: var(--text-dark); }
236
+ .product-item p { font-size: 0.9em; color: var(--text-light); }
237
+ .product-detail-media { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px; margin-bottom: 20px; }
238
+ .product-detail-media img, .product-detail-media video {
239
+ width: 100%; border-radius: 8px; max-height: 70vh; object-fit: contain; background-color: #000;
 
 
 
 
 
 
 
240
  }
241
+ .admin-section {
242
+ background: var(--bg-medium); padding: 25px;
243
+ border-radius: 12px; border: 1px solid var(--border-color);
244
+ margin-bottom: 30px;
245
+ }
246
+ .admin-section h2 { margin-top: 0; margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px; color: var(--primary-color); }
247
+ .category-list, .product-list { list-style: none; padding: 0; }
248
+ .category-list li, .product-list li {
249
+ display: flex; justify-content: space-between; align-items: center;
250
+ padding: 12px; border-bottom: 1px solid var(--border-color);
251
+ }
252
+ .category-list li:last-child, .product-list li:last-child { border-bottom: none; }
253
+ .form-inline { display: flex; gap: 10px; align-items: center; }
254
+ .form-inline input { flex-grow: 1; margin: 0; }
255
+ @media (max-width: 900px) {
256
+ .sidebar { display: none; }
257
+ .container { margin-left: 0; padding: 20px; }
258
  }
259
  '''
260
 
261
+ NAV_HTML = '''
262
+ <aside class="sidebar">
263
+ <div class="sidebar-header">
264
+ <img src="{{ logo_url }}" alt="ZZIRIX Logo" class="logo">
265
+ <span class="nav-brand">ZZIRIX</span>
266
+ </div>
267
+ <nav class="nav-links">
268
+ <a href="{{ url_for('catalog') }}" class="nav-link {% if not active_category %}active{% endif %}">Все товары</a>
269
+ {% for category in categories %}
270
+ <a href="{{ url_for('catalog_by_category', category_name=category) }}" class="nav-link {% if active_category == category %}active{% endif %}">{{ category | escape }}</a>
271
+ {% endfor %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  </nav>
273
+ </aside>
274
  '''
275
 
276
  @app.route('/')
277
+ def catalog():
278
  data = load_data()
279
  products = data.get('products', [])
280
+ categories = sorted(data.get('categories', []))
281
+
282
+ return render_template_string('''
283
  <!DOCTYPE html>
284
+ <html lang="ru">
285
  <head>
286
  <meta charset="UTF-8">
287
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
288
+ <title>Каталог - ZZIRIX</title>
289
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
290
+ <style>{{ base_style }}</style>
291
  </head>
292
  <body>
293
+ {{ nav_html | safe }}
294
+ <div class="container animated">
295
+ <div class="page-header">
296
+ <h1>Каталог товаров</h1>
297
  </div>
298
+ <div class="product-grid">
 
 
 
 
 
 
 
 
299
  {% for product in products %}
300
+ <div class="product-item">
301
+ <a href="{{ url_for('product_page', product_id=product.id) }}" class="product-media-link">
302
+ {% if product.media %}
303
+ {% set first_media = product.media[0] %}
304
+ {% set media_url = "https://huggingface.co/datasets/" + repo_id + "/resolve/main/media/" + first_media.filename %}
305
+ {% if first_media.type == 'video' %}
306
+ <video class="product-media" preload="metadata" muted loop autoplay playsinline>
307
+ <source src="{{ media_url }}" type="video/mp4">
308
+ </video>
309
+ {% else %}
310
+ <img class="product-media" src="{{ media_url }}" alt="{{ product.name | escape }}">
311
+ {% endif %}
312
  {% else %}
313
+ <div style="height: 100%; display: flex; align-items: center; justify-content: center; background: #eee; color: #aaa;">Нет изображения</div>
314
  {% endif %}
315
+ </a>
316
+ <div class="product-item-info">
317
+ <h3><a href="{{ url_for('product_page', product_id=product.id) }}" style="text-decoration:none; color:inherit;">{{ product.name | escape }}</a></h3>
318
+ <p>{{ product.category | escape }}</p>
 
 
319
  </div>
320
+ </div>
321
  {% else %}
322
+ <p style="grid-column: 1 / -1; text-align: center;">Товаров пока нет. Загляните позже!</p>
323
  {% endfor %}
324
  </div>
325
+ </div>
326
  </body>
327
  </html>
328
+ ''',
329
+ base_style=BASE_STYLE,
330
+ nav_html=NAV_HTML,
331
+ products=reversed(products),
332
+ categories=categories,
333
+ repo_id=REPO_ID,
334
+ logo_url=LOGO_URL,
335
+ active_category=None
336
+ )
337
+
338
+ @app.route('/category/<category_name>')
339
+ def catalog_by_category(category_name):
340
  data = load_data()
341
+ products = [p for p in data.get('products', []) if p.get('category') == category_name]
342
+ categories = sorted(data.get('categories', []))
343
+
344
+ return render_template_string('''
 
 
 
 
 
345
  <!DOCTYPE html>
346
+ <html lang="ru">
347
  <head>
348
  <meta charset="UTF-8">
349
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
350
+ <title>{{ category_name | escape }} - ZZIRIX</title>
351
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
352
+ <style>{{ base_style }}</style>
353
  </head>
354
  <body>
355
+ {{ nav_html | safe }}
356
+ <div class="container animated">
357
+ <div class="page-header">
358
+ <h1>{{ category_name | escape }}</h1>
359
  </div>
360
+ <div class="product-grid">
 
 
 
 
 
 
 
 
361
  {% for product in products %}
362
+ <div class="product-item">
363
+ <a href="{{ url_for('product_page', product_id=product.id) }}" class="product-media-link">
364
+ {% if product.media %}
365
+ {% set first_media = product.media[0] %}
366
+ {% set media_url = "https://huggingface.co/datasets/" + repo_id + "/resolve/main/media/" + first_media.filename %}
367
+ {% if first_media.type == 'video' %}
368
+ <video class="product-media" preload="metadata" muted loop autoplay playsinline>
369
+ <source src="{{ media_url }}" type="video/mp4">
370
+ </video>
371
+ {% else %}
372
+ <img class="product-media" src="{{ media_url }}" alt="{{ product.name | escape }}">
373
+ {% endif %}
374
  {% else %}
375
+ <div style="height: 100%; display: flex; align-items: center; justify-content: center; background: #eee; color: #aaa;">Нет изображения</div>
376
  {% endif %}
377
+ </a>
378
+ <div class="product-item-info">
379
+ <h3><a href="{{ url_for('product_page', product_id=product.id) }}" style="text-decoration:none; color:inherit;">{{ product.name | escape }}</a></h3>
380
+ <p>{{ product.category | escape }}</p>
 
 
381
  </div>
382
+ </div>
383
  {% else %}
384
+ <p style="grid-column: 1 / -1; text-align: center;">В этой категории пока нет товаров.</p>
385
  {% endfor %}
386
  </div>
387
+ </div>
388
  </body>
389
  </html>
390
+ ''',
391
+ base_style=BASE_STYLE,
392
+ nav_html=NAV_HTML,
393
+ products=reversed(products),
394
+ categories=categories,
395
+ repo_id=REPO_ID,
396
+ logo_url=LOGO_URL,
397
+ active_category=category_name,
398
+ category_name=category_name
399
+ )
400
 
401
  @app.route('/product/<product_id>')
402
  def product_page(product_id):
403
  data = load_data()
404
+ product = next((p for p in data.get('products', []) if p.get('id') == product_id), None)
405
+ categories = sorted(data.get('categories', []))
406
+
407
+ if product is None:
408
+ flash('Товар не найден.', 'error')
409
+ return redirect(url_for('catalog'))
410
 
411
+ return render_template_string('''
412
  <!DOCTYPE html>
413
+ <html lang="ru">
414
  <head>
415
  <meta charset="UTF-8">
416
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
417
+ <title>{{ product.name | escape }} - ZZIRIX</title>
418
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
419
+ <style>{{ base_style }}</style>
420
  </head>
421
  <body>
422
+ {{ nav_html | safe }}
423
+ <div class="container animated">
424
+ <div class="page-header">
425
+ <h1>{{ product.name | escape }}</h1>
426
+ <a href="{{ url_for('catalog_by_category', category_name=product.category) }}" class="btn btn-secondary">Назад в категорию</a>
427
+ </div>
428
+ <p style="font-size: 1.1em; color: var(--text-light); margin-bottom: 30px; white-space: pre-wrap;">{{ product.description | escape }}</p>
429
+
430
+ {% if product.media %}
431
+ <div class="product-detail-media">
432
+ {% for media_item in product.media %}
433
+ {% set media_url = "https://huggingface.co/datasets/" + repo_id + "/resolve/main/media/" + media_item.filename %}
434
+ {% if media_item.type == 'video' %}
435
+ <video controls preload="metadata">
436
+ <source src="{{ media_url }}" type="video/mp4">
437
+ </video>
 
 
 
 
438
  {% else %}
439
+ <img src="{{ media_url }}" alt="{{ product.name | escape }}">
440
  {% endif %}
441
+ {% endfor %}
 
 
 
 
 
 
 
442
  </div>
443
+ {% else %}
444
+ <p>Для этого товара нет фото или видео.</p>
445
+ {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  </div>
447
  </body>
448
  </html>
449
+ ''',
450
+ base_style=BASE_STYLE,
451
+ nav_html=NAV_HTML,
452
+ product=product,
453
+ categories=categories,
454
+ repo_id=REPO_ID,
455
+ logo_url=LOGO_URL,
456
+ active_category=product.get('category')
457
+ )
458
+
459
+ @app.route('/admin', methods=['GET', 'POST'])
460
+ def admin():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  data = load_data()
462
  if request.method == 'POST':
463
+ action = request.form.get('action')
464
+ api = HfApi()
465
+
466
+ if action == 'add_category':
467
+ category_name = request.form.get('category_name', '').strip()
468
+ if category_name and category_name not in data['categories']:
469
+ data['categories'].append(category_name)
470
+ save_data(data)
471
+ flash('Категория успешно добавлена.', 'success')
472
+ else:
473
+ flash('Неверное имя категории или она уже существует.', 'error')
474
+
475
+ elif action == 'delete_category':
476
+ category_name = request.form.get('category_name')
477
+ if category_name in data['categories']:
478
+ data['categories'].remove(category_name)
479
+ for p in data['products']:
480
+ if p['category'] == category_name:
481
+ p['category'] = 'Без категории'
482
+ save_data(data)
483
+ flash('Категория удалена.', 'success')
484
+
485
+ elif action == 'add_product':
486
+ name = request.form.get('name', '').strip()
487
+ description = request.form.get('description', '').strip()
488
+ category = request.form.get('category')
489
+ files = request.files.getlist('files')
490
+
491
+ if not name or not category:
492
+ flash('Название и категория обязательны.', 'error')
493
+ return redirect(url_for('admin'))
494
+
495
+ media_list = []
496
+ for file in files:
497
+ if file and file.filename and allowed_file(file.filename):
498
+ filename = secure_filename(f"{int(time.time())}_{uuid.uuid4().hex[:6]}_{file.filename}")
499
+ temp_path = os.path.join(UPLOAD_FOLDER, filename)
500
+ file.save(temp_path)
501
+
502
+ file_type = 'video' if filename.lower().endswith(('.mp4', '.mov', '.webm')) else 'photo'
503
+ path_in_repo = f"media/{filename}"
504
+
505
+ try:
506
+ api.upload_file(path_or_fileobj=temp_path, path_in_repo=path_in_repo, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
507
+ media_list.append({'type': file_type, 'filename': filename})
508
+ except Exception as e:
509
+ flash(f'Ошибка при загрузке файла {file.filename}: {e}', 'error')
510
+ finally:
511
+ if os.path.exists(temp_path):
512
+ os.remove(temp_path)
513
+
514
+ new_product = {
515
+ 'id': str(uuid.uuid4()),
516
+ 'name': name,
517
+ 'description': description,
518
+ 'category': category,
519
+ 'media': media_list
520
+ }
521
+ data['products'].append(new_product)
522
  save_data(data)
523
+ flash('Товар успешно добавлен.', 'success')
524
+
525
+ elif action == 'delete_product':
526
+ product_id = request.form.get('product_id')
527
+ product_to_delete = next((p for p in data['products'] if p.get('id') == product_id), None)
528
+ if product_to_delete:
529
+ for media_item in product_to_delete.get('media', []):
530
+ try:
531
+ api.delete_file(path_in_repo=f"media/{media_item['filename']}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
532
+ except Exception as e:
533
+ logging.error(f"Не удалось удалить файл {media_item['filename']} из HF: {e}")
534
+
535
+ data['products'] = [p for p in data['products'] if p.get('id') != product_id]
536
+ save_data(data)
537
+ flash('Товар удален.', 'success')
538
+
539
+ return redirect(url_for('admin'))
540
+
541
+ categories = sorted(data.get('categories', []))
542
+ products = data.get('products', [])
543
 
544
+ return render_template_string('''
 
545
  <!DOCTYPE html>
546
+ <html lang="ru">
547
  <head>
548
  <meta charset="UTF-8">
549
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
550
+ <title>Админ-панель - ZZIRIX</title>
551
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
552
+ <style>{{ base_style }}</style>
553
  </head>
554
  <body>
555
+ <div class="container animated" style="margin-left:auto; margin-right:auto;">
556
+ <h1>Админ-панель ZZIRIX</h1>
557
+ <a href="{{ url_for('catalog') }}" class="btn btn-secondary" style="margin-bottom:20px;">Перейти в каталог</a>
558
  {% with messages = get_flashed_messages(with_categories=true) %}
559
  {% if messages %}
560
  {% for category, message in messages %}
 
562
  {% endfor %}
563
  {% endif %}
564
  {% endwith %}
565
+
566
+ <div class="admin-section">
567
+ <h2>Управление категориями</h2>
568
+ <form method="POST" class="form-inline" style="margin-bottom: 20px;">
569
+ <input type="hidden" name="action" value="add_category">
570
+ <input type="text" name="category_name" placeholder="Название новой категории" required>
571
+ <button type="submit" class="btn">Добавить</button>
572
  </form>
573
+ <ul class="category-list">
574
+ {% for category in categories %}
575
+ <li>
576
+ <span>{{ category | escape }}</span>
577
+ <form method="POST" onsubmit="return confirm('Вы уверены? Товары в этой категории станут «Без категории».');">
578
+ <input type="hidden" name="action" value="delete_category">
579
+ <input type="hidden" name="category_name" value="{{ category }}">
580
+ <button type="submit" class="btn btn-danger" style="padding: 5px 10px; font-size: 0.8em;">Удалить</button>
581
+ </form>
582
+ </li>
583
+ {% else %}
584
+ <li>Категорий пока нет.</li>
585
+ {% endfor %}
586
+ </ul>
587
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
 
589
+ <div class="admin-section">
590
+ <h2>Добавить новый товар</h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  <form method="POST" enctype="multipart/form-data">
592
+ <input type="hidden" name="action" value="add_product">
593
+ <label for="name">Название товара *</label>
594
+ <input type="text" id="name" name="name" required>
595
+ <label for="description">Описание</label>
596
+ <textarea id="description" name="description" rows="4"></textarea>
597
+ <label for="category">Категория *</label>
598
+ <select id="category" name="category" required>
599
+ <option value="" disabled selected>-- Выберите категорию --</option>
 
 
 
 
600
  {% for category in categories %}
601
+ <option value="{{ category }}">{{ category }}</option>
602
  {% endfor %}
603
  </select>
604
+ <label for="files">Фото и видео (можно выбрать несколько)</label>
605
+ <input type="file" id="files" name="files" multiple accept="image/*,video/*">
606
+ <button type="submit" class="btn" style="width: 100%; margin-top: 20px;">Добавить товар</button>
607
+ </form>
608
+ </div>
609
 
610
+ <div class="admin-section">
611
+ <h2>Список товаров</h2>
612
+ <ul class="product-list">
613
+ {% for product in products | reverse %}
614
+ <li>
615
+ <div>
616
+ <strong>{{ product.name | escape }}</strong><br>
617
+ <small style="color: #777;">Категория: {{ product.category }} | Медиа: {{ product.media|length }}</small>
 
 
 
 
 
 
 
 
618
  </div>
619
+ <form method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить этот товар?');">
620
+ <input type="hidden" name="action" value="delete_product">
621
+ <input type="hidden" name="product_id" value="{{ product.id }}">
622
+ <button type="submit" class="btn btn-danger">Удалить</button>
623
+ </form>
624
+ </li>
625
+ {% else %}
626
+ <li>Товаров пока нет.</li>
627
+ {% endfor %}
628
+ </ul>
629
  </div>
630
+ </div>
631
  </body>
632
  </html>
633
+ ''',
634
+ base_style=BASE_STYLE,
635
+ categories=categories,
636
+ products=products
637
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
 
639
  if __name__ == '__main__':
640
  if not os.path.exists(DATA_FILE):
641
+ logging.info(f"{DATA_FILE} не найден, попытка загрузки с HF.")
642
+ download_db_from_hf()
643
 
644
+ try:
645
+ current_data = load_data()
646
+ initialized_data = initialize_data_structure(current_data)
647
+ if json.dumps(initialized_data, sort_keys=True) != json.dumps(current_data, sort_keys=True):
648
+ logging.info("Применение обновлений структуры данных...")
649
+ save_data(initialized_data)
650
+ except Exception as e:
651
+ logging.error(f"Ошибка во время начальной проверки/обновления структуры данных: {e}")
652
+
653
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
654
  backup_thread.start()
655
+ logging.info("Запуск Flask приложения...")
656
  app.run(debug=False, host='0.0.0.0', port=7860)