Eluza133 commited on
Commit
319c30e
·
verified ·
1 Parent(s): eeb63d0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +238 -107
app.py CHANGED
@@ -317,57 +317,15 @@ USER_TEMPLATE = """
317
  background-color: var(--card-bg);
318
  padding: 12px var(--padding);
319
  border-radius: var(--border-radius);
320
- display: flex;
321
- align-items: center;
322
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
323
  transition: background-color 0.2s ease;
324
  word-break: break-word;
325
  }
326
  .file-item:hover { background-color: rgba(255, 255, 255, 0.08); }
327
-
328
- .file-preview-area {
329
- flex-shrink: 0;
330
- width: 80px;
331
- height: 80px;
332
- margin-right: var(--padding);
333
- display: flex;
334
- align-items: center;
335
- justify-content: center;
336
- background-color: rgba(0,0,0,0.05);
337
- border-radius: 8px;
338
- overflow: hidden;
339
- }
340
- .file-preview-image,
341
- .file-preview-video {
342
- max-width: 100%;
343
- max-height: 100%;
344
- object-fit: cover;
345
- display: block;
346
- }
347
- .file-preview-video {
348
- width: 100%;
349
- height: 100%;
350
- }
351
- .file-preview-audio {
352
- width: 100%;
353
- transform: scale(0.85);
354
- transform-origin: center;
355
- }
356
- .file-preview-icon {
357
- font-size: 0.8em;
358
- font-weight: bold;
359
- color: var(--tg-theme-hint-color);
360
- padding: 5px;
361
- text-align: center;
362
- text-transform: uppercase;
363
- word-break: break-all;
364
- line-height: 1.2;
365
- }
366
-
367
- .file-info { flex-grow: 1; margin-right: 10px; overflow: hidden; min-width: 0;}
368
  .file-name { font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
369
  .file-meta { font-size: 0.8em; color: var(--tg-theme-hint-color); }
370
- .file-actions { flex-shrink: 0; }
371
  .file-actions a, .file-actions button {
372
  display: inline-block;
373
  padding: 6px 10px;
@@ -386,6 +344,33 @@ USER_TEMPLATE = """
386
  .loading, .no-files { text-align: center; padding: 20px; color: var(--tg-theme-hint-color); }
387
  .progress-bar { width: 100%; background-color: #ddd; border-radius: 4px; height: 8px; margin-top: 5px; display: none; }
388
  .progress-bar-inner { height: 100%; width: 0%; background-color: var(--tg-theme-button-color); border-radius: 4px; transition: width 0.1s linear; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  .spinner {
390
  border: 4px solid rgba(255, 255, 255, 0.3);
391
  border-radius: 50%;
@@ -422,6 +407,12 @@ USER_TEMPLATE = """
422
  </ul>
423
  </section>
424
  </div>
 
 
 
 
 
 
425
  <script>
426
  const tg = window.Telegram.WebApp;
427
  const MAX_FILES = {{ max_files }};
@@ -437,6 +428,10 @@ USER_TEMPLATE = """
437
  const loadingSpinner = document.getElementById('loading-spinner');
438
  const progressBar = document.getElementById('progress-bar');
439
  const progressBarInner = document.getElementById('progress-bar-inner');
 
 
 
 
440
 
441
  function applyTheme(themeParams) {
442
  const root = document.documentElement;
@@ -475,45 +470,9 @@ USER_TEMPLATE = """
475
  }
476
  noFilesMessage.style.display = 'none';
477
  files.sort((a, b) => b.uploaded_at_ts - a.uploaded_at_ts);
478
-
479
  files.forEach(file => {
480
  const li = document.createElement('li');
481
  li.classList.add('file-item');
482
-
483
- const previewDiv = document.createElement('div');
484
- previewDiv.classList.add('file-preview-area');
485
- const downloadUrl = `/download/${encodeURIComponent(file.filename)}?initData=${encodeURIComponent(userInitData)}`;
486
- const mimeType = file.content_type || '';
487
-
488
- if (mimeType.startsWith('image/')) {
489
- const img = document.createElement('img');
490
- img.src = downloadUrl;
491
- img.alt = file.filename;
492
- img.classList.add('file-preview-image');
493
- previewDiv.appendChild(img);
494
- } else if (mimeType.startsWith('video/')) {
495
- const video = document.createElement('video');
496
- video.src = downloadUrl;
497
- video.controls = true;
498
- video.classList.add('file-preview-video');
499
- video.preload = "metadata";
500
- previewDiv.appendChild(video);
501
- } else if (mimeType.startsWith('audio/')) {
502
- const audio = document.createElement('audio');
503
- audio.src = downloadUrl;
504
- audio.controls = true;
505
- audio.classList.add('file-preview-audio');
506
- audio.preload = "metadata";
507
- previewDiv.appendChild(audio);
508
- } else {
509
- const iconPlaceholder = document.createElement('div');
510
- iconPlaceholder.classList.add('file-preview-icon');
511
- const ext = file.filename.split('.').pop().toLowerCase() || 'file';
512
- iconPlaceholder.textContent = ext.substring(0,4);
513
- previewDiv.appendChild(iconPlaceholder);
514
- }
515
- li.appendChild(previewDiv);
516
-
517
  const fileInfoDiv = document.createElement('div');
518
  fileInfoDiv.classList.add('file-info');
519
  const fileNameSpan = document.createElement('span');
@@ -526,18 +485,31 @@ USER_TEMPLATE = """
526
  const size = file.size ? formatBytes(file.size) : '';
527
  fileMetaSpan.textContent = `${date}${size ? ' - ' + size : ''}`;
528
  fileInfoDiv.appendChild(fileMetaSpan);
529
- li.appendChild(fileInfoDiv);
530
-
531
  const fileActionsDiv = document.createElement('div');
532
  fileActionsDiv.classList.add('file-actions');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  const downloadLink = document.createElement('a');
534
  downloadLink.classList.add('download-btn');
535
  downloadLink.textContent = 'Скачать';
536
- downloadLink.href = downloadUrl;
537
  downloadLink.setAttribute('download', file.filename);
538
  downloadLink.title = 'Скачать файл';
539
  downloadLink.onclick = (e) => e.stopPropagation();
540
  fileActionsDiv.appendChild(downloadLink);
 
541
  li.appendChild(fileActionsDiv);
542
  fileListUl.appendChild(li);
543
  });
@@ -648,19 +620,67 @@ USER_TEMPLATE = """
648
  uploadStatusDiv.style.color = 'red';
649
  } finally {
650
  progressBar.style.display = 'none';
651
- if (currentFiles.length > 0 && fileInput.files.length > 0) { // Check also fileInput as currentFiles might not be reset on some errors
652
  uploadButton.classList.add('enabled');
653
  uploadButton.disabled = false;
654
- } else { // Default to disabled if no files or error
655
  uploadButton.classList.remove('enabled');
656
  uploadButton.disabled = true;
657
- selectedFilesDiv.textContent = 'Файлы не выбраны'; // Ensure this is accurate
658
- fileInput.value = ''; // Clear file input on final step
659
- currentFiles = [];
660
  }
661
  }
662
  }
663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  function setupTelegram() {
665
  if (!tg || !tg.initData) {
666
  console.error("Telegram WebApp script not loaded or initData is missing.");
@@ -866,12 +886,46 @@ ADMIN_USER_FILES_TEMPLATE = """
866
  .file-table td { font-size: 0.95em; word-break: break-word; }
867
  .filename { font-weight: 500; }
868
  .filesize, .filedate { color: var(--admin-secondary); font-size: 0.9em; }
869
- .actions a {
870
- display: inline-block; padding: 6px 12px; background-color: var(--admin-primary); color: white;
871
- text-decoration: none; border-radius: 6px; font-size: 0.85em; transition: background-color 0.2s ease;
 
 
872
  }
873
- .actions a:hover { background-color: #0b5ed7; }
 
 
 
874
  .no-files { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  @media screen and (max-width: 768px) {
876
  .file-table { border: 0; box-shadow: none; }
877
  .file-table thead { display: none; }
@@ -892,6 +946,7 @@ ADMIN_USER_FILES_TEMPLATE = """
892
  <a href="{{ url_for('admin_panel') }}" class="back-link">← Назад к списку пользователей</a>
893
  <h1>Файлы пользователя</h1>
894
  <div class="user-identifier">{{ user_info.first_name or '' }} {{ user_info.last_name or '' }} (ID: {{ user_id }})</div>
 
895
  {% if files %}
896
  <table class="file-table">
897
  <thead>
@@ -904,14 +959,19 @@ ADMIN_USER_FILES_TEMPLATE = """
904
  </tr>
905
  </thead>
906
  <tbody>
907
- {% for file in files|sort(attribute='uploaded_at_ts', reverse=true) %}
908
  <tr>
909
- <td data-label="Имя файла" class="filename">{{ file.filename }}</td>
910
- <td data-label="Размер" class="filesize">{{ file.size | filesizeformat if file.size else 'N/A' }}</td>
911
- <td data-label="Дата" class="filedate">{{ file.uploaded_at_str or 'N/A' }}</td>
912
- <td data-label="Тип">{{ file.content_type or 'N/A' }}</td>
913
  <td data-label="Действия" class="actions">
914
- <a href="{{ url_for('admin_download_file', user_id=user_id, filename=file.filename) }}" target="_blank">Скачать</a>
 
 
 
 
 
915
  </td>
916
  </tr>
917
  {% endfor %}
@@ -921,6 +981,14 @@ ADMIN_USER_FILES_TEMPLATE = """
921
  <p class="no-files">У этого пользователя нет загруженных файлов.</p>
922
  {% endif %}
923
  </div>
 
 
 
 
 
 
 
 
924
  <script>
925
  function formatBytes(bytes, decimals = 2) {
926
  if (!+bytes) return '0 Bytes'
@@ -930,6 +998,62 @@ ADMIN_USER_FILES_TEMPLATE = """
930
  const i = Math.floor(Math.log(bytes) / Math.log(k))
931
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
932
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
933
  </script>
934
  </body>
935
  </html>
@@ -1100,10 +1224,9 @@ def admin_user_files(user_id):
1100
  user_info=user_info,
1101
  files=files)
1102
 
1103
- @app.route('/admin/download/<user_id>/<path:filename>', methods=['GET'])
1104
- def admin_download_file(user_id, filename):
1105
  user_id_str = str(user_id)
1106
- logging.info(f"Admin request to download file '{filename}' for user {user_id}")
1107
  api = get_hf_api(write=False)
1108
  if not api:
1109
  return "Server error: Cannot connect to storage.", 500
@@ -1123,22 +1246,30 @@ def admin_download_file(user_id, filename):
1123
  force_download=False,
1124
  etag_timeout=10
1125
  )
1126
- logging.info(f"Admin download: File {path_in_repo} cached at {local_file_path}")
1127
  content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
1128
  if file_metadata and 'content_type' in file_metadata:
1129
  content_type = file_metadata['content_type'] or content_type
1130
  return send_file(
1131
  local_file_path,
1132
  mimetype=content_type,
1133
- as_attachment=True,
1134
- download_name=filename
1135
  )
1136
  except EntryNotFoundError:
1137
- logging.error(f"Admin download: File not found on Hugging Face: {path_in_repo}")
1138
  return "File not found on storage.", 404
1139
  except Exception as e:
1140
- logging.error(f"Admin download: Error for file {path_in_repo}: {e}", exc_info=True)
1141
- return "Server error during download.", 500
 
 
 
 
 
 
 
 
 
1142
 
1143
  @app.route('/admin/download_metadata', methods=['POST'])
1144
  def admin_trigger_download_metadata():
 
317
  background-color: var(--card-bg);
318
  padding: 12px var(--padding);
319
  border-radius: var(--border-radius);
320
+ display: flex; align-items: center; justify-content: space-between;
 
321
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
322
  transition: background-color 0.2s ease;
323
  word-break: break-word;
324
  }
325
  .file-item:hover { background-color: rgba(255, 255, 255, 0.08); }
326
+ .file-info { flex-grow: 1; margin-right: 10px; overflow: hidden; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  .file-name { font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
328
  .file-meta { font-size: 0.8em; color: var(--tg-theme-hint-color); }
 
329
  .file-actions a, .file-actions button {
330
  display: inline-block;
331
  padding: 6px 10px;
 
344
  .loading, .no-files { text-align: center; padding: 20px; color: var(--tg-theme-hint-color); }
345
  .progress-bar { width: 100%; background-color: #ddd; border-radius: 4px; height: 8px; margin-top: 5px; display: none; }
346
  .progress-bar-inner { height: 100%; width: 0%; background-color: var(--tg-theme-button-color); border-radius: 4px; transition: width 0.1s linear; }
347
+ .modal {
348
+ display: none; position: fixed; z-index: 1001;
349
+ left: 0; top: 0; width: 100%; height: 100%;
350
+ overflow: auto; background-color: rgba(0,0,0,0.8);
351
+ backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
352
+ animation: fadeIn 0.3s ease-out;
353
+ }
354
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
355
+ .modal-content {
356
+ margin: auto; display: block; max-width: 90%; max-height: 85%;
357
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
358
+ }
359
+ .modal-content img, .modal-content video, .modal-content audio {
360
+ display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; margin: auto;
361
+ background-color: var(--tg-theme-bg-color);
362
+ }
363
+ .modal-close {
364
+ position: absolute; top: 15px; right: 35px; color: #f1f1f1; font-size: 40px;
365
+ font-weight: bold; transition: 0.3s; cursor: pointer; z-index: 1002;
366
+ text-shadow: 0 1px 3px rgba(0,0,0,0.5);
367
+ }
368
+ .modal-close:hover, .modal-close:focus { color: #bbb; text-decoration: none; }
369
+ .modal-caption {
370
+ margin: auto; display: block; width: 80%; max-width: 700px; text-align: center;
371
+ color: #ccc; padding: 10px 0; height: 50px; position: absolute; bottom: 15px; left: 50%; transform: translateX(-50%);
372
+ background: rgba(0,0,0,0.5); border-radius: 8px;
373
+ }
374
  .spinner {
375
  border: 4px solid rgba(255, 255, 255, 0.3);
376
  border-radius: 50%;
 
407
  </ul>
408
  </section>
409
  </div>
410
+ <div id="viewerModal" class="modal">
411
+ <span class="modal-close" id="modalCloseBtn">×</span>
412
+ <div id="modalContent" class="modal-content">
413
+ </div>
414
+ <div id="modalCaption" class="modal-caption"></div>
415
+ </div>
416
  <script>
417
  const tg = window.Telegram.WebApp;
418
  const MAX_FILES = {{ max_files }};
 
428
  const loadingSpinner = document.getElementById('loading-spinner');
429
  const progressBar = document.getElementById('progress-bar');
430
  const progressBarInner = document.getElementById('progress-bar-inner');
431
+ const modal = document.getElementById('viewerModal');
432
+ const modalContent = document.getElementById('modalContent');
433
+ const modalCaption = document.getElementById('modalCaption');
434
+ const modalCloseBtn = document.getElementById('modalCloseBtn');
435
 
436
  function applyTheme(themeParams) {
437
  const root = document.documentElement;
 
470
  }
471
  noFilesMessage.style.display = 'none';
472
  files.sort((a, b) => b.uploaded_at_ts - a.uploaded_at_ts);
 
473
  files.forEach(file => {
474
  const li = document.createElement('li');
475
  li.classList.add('file-item');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  const fileInfoDiv = document.createElement('div');
477
  fileInfoDiv.classList.add('file-info');
478
  const fileNameSpan = document.createElement('span');
 
485
  const size = file.size ? formatBytes(file.size) : '';
486
  fileMetaSpan.textContent = `${date}${size ? ' - ' + size : ''}`;
487
  fileInfoDiv.appendChild(fileMetaSpan);
 
 
488
  const fileActionsDiv = document.createElement('div');
489
  fileActionsDiv.classList.add('file-actions');
490
+ const mimeType = file.content_type || '';
491
+ if (mimeType.startsWith('image/') || mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
492
+ const viewButton = document.createElement('button');
493
+ viewButton.textContent = '👁️';
494
+ viewButton.classList.add('view-btn');
495
+ viewButton.style.backgroundColor = '#6f42c1';
496
+ viewButton.style.color = 'white';
497
+ viewButton.title = 'Просмотр';
498
+ viewButton.onclick = (e) => {
499
+ e.stopPropagation();
500
+ openViewer(file);
501
+ };
502
+ fileActionsDiv.appendChild(viewButton);
503
+ }
504
  const downloadLink = document.createElement('a');
505
  downloadLink.classList.add('download-btn');
506
  downloadLink.textContent = 'Скачать';
507
+ downloadLink.href = `/download/${encodeURIComponent(file.filename)}?initData=${encodeURIComponent(userInitData)}`;
508
  downloadLink.setAttribute('download', file.filename);
509
  downloadLink.title = 'Скачать файл';
510
  downloadLink.onclick = (e) => e.stopPropagation();
511
  fileActionsDiv.appendChild(downloadLink);
512
+ li.appendChild(fileInfoDiv);
513
  li.appendChild(fileActionsDiv);
514
  fileListUl.appendChild(li);
515
  });
 
620
  uploadStatusDiv.style.color = 'red';
621
  } finally {
622
  progressBar.style.display = 'none';
623
+ if (currentFiles.length > 0) { // Re-check currentFiles for enabling upload button
624
  uploadButton.classList.add('enabled');
625
  uploadButton.disabled = false;
626
+ } else {
627
  uploadButton.classList.remove('enabled');
628
  uploadButton.disabled = true;
629
+ selectedFilesDiv.textContent = 'Файлы не выбраны';
 
 
630
  }
631
  }
632
  }
633
 
634
+ function openViewer(file) {
635
+ modal.style.display = 'block';
636
+ modalContent.innerHTML = '';
637
+ modalCaption.textContent = file.filename;
638
+ const mimeType = file.content_type || '';
639
+ const downloadUrl = `/download/${encodeURIComponent(file.filename)}?initData=${encodeURIComponent(userInitData)}`;
640
+ let element;
641
+ if (mimeType.startsWith('image/')) {
642
+ element = document.createElement('img');
643
+ element.src = downloadUrl;
644
+ element.alt = file.filename;
645
+ } else if (mimeType.startsWith('video/')) {
646
+ element = document.createElement('video');
647
+ element.src = downloadUrl;
648
+ element.controls = true;
649
+ element.autoplay = true;
650
+ } else if (mimeType.startsWith('audio/')) {
651
+ element = document.createElement('audio');
652
+ element.src = downloadUrl;
653
+ element.controls = true;
654
+ element.autoplay = true;
655
+ element.style.padding = '20px';
656
+ }
657
+ if (element) {
658
+ modalContent.appendChild(element);
659
+ if (tg.HapticFeedback) {
660
+ tg.HapticFeedback.impactOccurred('light');
661
+ }
662
+ } else {
663
+ modalCaption.textContent = 'Предпросмотр недоступен для этого типа файла.';
664
+ }
665
+ }
666
+
667
+ function closeViewer() {
668
+ modal.style.display = 'none';
669
+ const mediaElement = modalContent.querySelector('video, audio');
670
+ if (mediaElement) {
671
+ mediaElement.pause();
672
+ mediaElement.src = '';
673
+ }
674
+ modalContent.innerHTML = '';
675
+ }
676
+
677
+ modalCloseBtn.onclick = closeViewer;
678
+ modal.onclick = function(event) {
679
+ if (event.target === modal) {
680
+ closeViewer();
681
+ }
682
+ };
683
+
684
  function setupTelegram() {
685
  if (!tg || !tg.initData) {
686
  console.error("Telegram WebApp script not loaded or initData is missing.");
 
886
  .file-table td { font-size: 0.95em; word-break: break-word; }
887
  .filename { font-weight: 500; }
888
  .filesize, .filedate { color: var(--admin-secondary); font-size: 0.9em; }
889
+ .actions a, .actions button {
890
+ display: inline-block; padding: 6px 10px; margin-left: 8px; border-radius: 6px;
891
+ text-decoration: none; font-size: 0.85em; font-weight: 500; cursor: pointer;
892
+ border: none; transition: opacity 0.2s ease, background-color 0.2s ease;
893
+ vertical-align: middle;
894
  }
895
+ .actions a:hover, .actions button:hover { opacity: 0.8; }
896
+ .actions .download-btn { background-color: var(--admin-primary); color: white; }
897
+ .actions .view-btn { background-color: #6f42c1; color: white; }
898
+
899
  .no-files { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; }
900
+
901
+ .modal {
902
+ display: none; position: fixed; z-index: 1001;
903
+ left: 0; top: 0; width: 100%; height: 100%;
904
+ overflow: auto; background-color: rgba(0,0,0,0.8);
905
+ backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
906
+ animation: fadeInAdmin 0.3s ease-out;
907
+ }
908
+ @keyframes fadeInAdmin { from { opacity: 0; } to { opacity: 1; } }
909
+ .modal-content {
910
+ margin: auto; display: block; max-width: 90%; max-height: 85%;
911
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
912
+ }
913
+ .modal-content img, .modal-content video, .modal-content audio {
914
+ display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; margin: auto;
915
+ background-color: var(--admin-bg);
916
+ }
917
+ .modal-close {
918
+ position: absolute; top: 15px; right: 35px; color: #f1f1f1; font-size: 40px;
919
+ font-weight: bold; transition: 0.3s; cursor: pointer; z-index: 1002;
920
+ text-shadow: 0 1px 3px rgba(0,0,0,0.5);
921
+ }
922
+ .modal-close:hover, .modal-close:focus { color: #bbb; text-decoration: none; }
923
+ .modal-caption {
924
+ margin: auto; display: block; width: 80%; max-width: 700px; text-align: center;
925
+ color: #ccc; padding: 10px 0; height: 50px; position: absolute; bottom: 15px; left: 50%; transform: translateX(-50%);
926
+ background: rgba(0,0,0,0.5); border-radius: 8px;
927
+ }
928
+
929
  @media screen and (max-width: 768px) {
930
  .file-table { border: 0; box-shadow: none; }
931
  .file-table thead { display: none; }
 
946
  <a href="{{ url_for('admin_panel') }}" class="back-link">← Назад к списку пользователей</a>
947
  <h1>Файлы пользователя</h1>
948
  <div class="user-identifier">{{ user_info.first_name or '' }} {{ user_info.last_name or '' }} (ID: {{ user_id }})</div>
949
+
950
  {% if files %}
951
  <table class="file-table">
952
  <thead>
 
959
  </tr>
960
  </thead>
961
  <tbody>
962
+ {% for file_item in files|sort(attribute='uploaded_at_ts', reverse=true) %}
963
  <tr>
964
+ <td data-label="Имя файла" class="filename">{{ file_item.filename }}</td>
965
+ <td data-label="Размер" class="filesize">{{ file_item.size | filesizeformat if file_item.size else 'N/A' }}</td>
966
+ <td data-label="Дата" class="filedate">{{ file_item.uploaded_at_str or 'N/A' }}</td>
967
+ <td data-label="Тип">{{ file_item.content_type or 'N/A' }}</td>
968
  <td data-label="Действия" class="actions">
969
+ {% set mime_type = file_item.content_type or '' %}
970
+ {% if mime_type.startswith('image/') or mime_type.startswith('video/') or mime_type.startswith('audio/') %}
971
+ <button class="view-btn" title="Просмотр"
972
+ onclick="openAdminViewer('{{ user_id }}', '{{ file_item.filename | e }}', '{{ mime_type | e }}')">👁️</button>
973
+ {% endif %}
974
+ <a href="{{ url_for('admin_download_file', user_id=user_id, filename=file_item.filename) }}" class="download-btn">Скачать</a>
975
  </td>
976
  </tr>
977
  {% endfor %}
 
981
  <p class="no-files">У этого пользователя нет загруженных файлов.</p>
982
  {% endif %}
983
  </div>
984
+
985
+ <div id="adminViewerModal" class="modal">
986
+ <span class="modal-close" id="adminModalCloseBtn">×</span>
987
+ <div id="adminModalContent" class="modal-content">
988
+ </div>
989
+ <div id="adminModalCaption" class="modal-caption"></div>
990
+ </div>
991
+
992
  <script>
993
  function formatBytes(bytes, decimals = 2) {
994
  if (!+bytes) return '0 Bytes'
 
998
  const i = Math.floor(Math.log(bytes) / Math.log(k))
999
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
1000
  }
1001
+
1002
+ const adminModal = document.getElementById('adminViewerModal');
1003
+ const adminModalContent = document.getElementById('adminModalContent');
1004
+ const adminModalCaption = document.getElementById('adminModalCaption');
1005
+ const adminModalCloseBtn = document.getElementById('adminModalCloseBtn');
1006
+
1007
+ function openAdminViewer(userId, filename, mimeType) {
1008
+ adminModal.style.display = 'block';
1009
+ adminModalContent.innerHTML = '';
1010
+ adminModalCaption.textContent = filename;
1011
+
1012
+ const viewUrl = `/admin/view/${encodeURIComponent(userId)}/${encodeURIComponent(filename)}`;
1013
+ let element;
1014
+
1015
+ if (mimeType.startsWith('image/')) {
1016
+ element = document.createElement('img');
1017
+ element.src = viewUrl;
1018
+ element.alt = filename;
1019
+ } else if (mimeType.startsWith('video/')) {
1020
+ element = document.createElement('video');
1021
+ element.src = viewUrl;
1022
+ element.controls = true;
1023
+ element.autoplay = true;
1024
+ } else if (mimeType.startsWith('audio/')) {
1025
+ element = document.createElement('audio');
1026
+ element.src = viewUrl;
1027
+ element.controls = true;
1028
+ element.autoplay = true;
1029
+ element.style.padding = '20px';
1030
+ }
1031
+
1032
+ if (element) {
1033
+ adminModalContent.appendChild(element);
1034
+ } else {
1035
+ adminModalCaption.textContent = 'Предпросмотр недоступен для этого типа файла.';
1036
+ }
1037
+ }
1038
+
1039
+ function closeAdminViewer() {
1040
+ adminModal.style.display = 'none';
1041
+ const mediaElement = adminModalContent.querySelector('video, audio');
1042
+ if (mediaElement) {
1043
+ mediaElement.pause();
1044
+ mediaElement.src = '';
1045
+ }
1046
+ adminModalContent.innerHTML = '';
1047
+ }
1048
+
1049
+ if(adminModalCloseBtn) adminModalCloseBtn.onclick = closeAdminViewer;
1050
+ if(adminModal) {
1051
+ adminModal.onclick = function(event) {
1052
+ if (event.target === adminModal) {
1053
+ closeAdminViewer();
1054
+ }
1055
+ };
1056
+ }
1057
  </script>
1058
  </body>
1059
  </html>
 
1224
  user_info=user_info,
1225
  files=files)
1226
 
1227
+ def _admin_serve_file(user_id, filename, as_attachment):
 
1228
  user_id_str = str(user_id)
1229
+ logging.info(f"Admin serving file '{filename}' for user {user_id_str}, as_attachment={as_attachment}")
1230
  api = get_hf_api(write=False)
1231
  if not api:
1232
  return "Server error: Cannot connect to storage.", 500
 
1246
  force_download=False,
1247
  etag_timeout=10
1248
  )
 
1249
  content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
1250
  if file_metadata and 'content_type' in file_metadata:
1251
  content_type = file_metadata['content_type'] or content_type
1252
  return send_file(
1253
  local_file_path,
1254
  mimetype=content_type,
1255
+ as_attachment=as_attachment,
1256
+ download_name=filename if as_attachment else None
1257
  )
1258
  except EntryNotFoundError:
1259
+ logging.error(f"Admin serving: File not found on Hugging Face: {path_in_repo}")
1260
  return "File not found on storage.", 404
1261
  except Exception as e:
1262
+ logging.error(f"Admin serving: Error for file {path_in_repo}: {e}", exc_info=True)
1263
+ return "Server error during file serving.", 500
1264
+
1265
+ @app.route('/admin/download/<user_id>/<path:filename>', methods=['GET'])
1266
+ def admin_download_file(user_id, filename):
1267
+ return _admin_serve_file(user_id, filename, as_attachment=True)
1268
+
1269
+ @app.route('/admin/view/<user_id>/<path:filename>', methods=['GET'])
1270
+ def admin_view_file(user_id, filename):
1271
+ return _admin_serve_file(user_id, filename, as_attachment=False)
1272
+
1273
 
1274
  @app.route('/admin/download_metadata', methods=['POST'])
1275
  def admin_trigger_download_metadata():