Update app.py
Browse files
app.py
CHANGED
|
@@ -16,6 +16,8 @@ from functools import wraps
|
|
| 16 |
from urllib.parse import quote
|
| 17 |
import zipfile
|
| 18 |
import tempfile
|
|
|
|
|
|
|
| 19 |
|
| 20 |
app = Flask(__name__)
|
| 21 |
app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_folders_unique_tma")
|
|
@@ -39,6 +41,7 @@ BASE_STYLE = '''
|
|
| 39 |
--text-dark: #e0e0e0; --text-muted: #888; --shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
| 40 |
--glass-bg: rgba(30, 30, 30, 0.7); --transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
| 41 |
--delete-color: #ff4444; --folder-color: #ffc107; --selection-color: rgba(139, 92, 246, 0.3);
|
|
|
|
| 42 |
}
|
| 43 |
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
| 44 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
@@ -47,13 +50,14 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Robo
|
|
| 47 |
.container { margin: 0 auto; max-width: 1200px; padding: 75px 15px 15px 15px; }
|
| 48 |
.app-header { position: fixed; top: 0; left: 0; right: 0; background: var(--glass-bg); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid rgba(255,255,255,0.1); z-index: 1000; padding: 10px 20px; display: flex; justify-content: space-between; align-items: center; }
|
| 49 |
.user-info { font-weight: 600; }
|
|
|
|
| 50 |
.view-toggle button { background: none; border: none; color: var(--text-muted); font-size: 1.2em; padding: 5px; cursor: pointer; transition: var(--transition); }
|
| 51 |
.view-toggle button:hover, .view-toggle button.active { color: var(--primary); }
|
| 52 |
h2 { font-size: 1.3em; margin-bottom: 15px; margin-top: 15px; color: var(--text-dark); }
|
| 53 |
.breadcrumbs { font-size: 1em; margin-bottom: 20px; white-space: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
| 54 |
.breadcrumbs a { color: var(--accent); text-decoration: none; }
|
| 55 |
.breadcrumbs span { margin: 0 5px; color: var(--text-muted); }
|
| 56 |
-
input, select { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #333; border-radius: 12px; background: #2a2a2a; color: var(--text-dark); font-size: 1em; }
|
| 57 |
.btn { padding: 12px 24px; background: var(--primary); color: white; border: none; border-radius: 12px; cursor: pointer; font-size: 1em; font-weight: 600; transition: var(--transition); text-decoration: none; display: inline-block; text-align: center; }
|
| 58 |
.btn:hover { filter: brightness(1.2); }
|
| 59 |
.btn:active { transform: scale(0.98); }
|
|
@@ -70,6 +74,7 @@ input, select { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #33
|
|
| 70 |
.item-preview-wrapper { position: relative; width: 100%; padding-top: 75%; border-radius: 10px; overflow: hidden; margin-bottom: 10px; background: #2a2a2a; }
|
| 71 |
.item-preview { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
|
| 72 |
.item.folder .item-preview { object-fit: contain; font-size: 3.5em; color: var(--folder-color); }
|
|
|
|
| 73 |
.item-name { font-size: 0.9em; font-weight: 500; word-break: break-all; margin: 5px 0; flex-grow: 1; }
|
| 74 |
.item-info { font-size: 0.75em; color: var(--text-muted); }
|
| 75 |
.item-actions { display: none; }
|
|
@@ -77,7 +82,7 @@ input, select { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #33
|
|
| 77 |
.file-grid.list-view .item { flex-direction: row; align-items: center; text-align: left; padding: 8px; }
|
| 78 |
.file-grid.list-view .item:hover { transform: translateY(0); }
|
| 79 |
.file-grid.list-view .item-preview-wrapper { width: 45px; height: 45px; padding-top: 0; margin-bottom: 0; margin-right: 15px; flex-shrink: 0; }
|
| 80 |
-
.file-grid.list-view .item.folder .item-preview { font-size: 1.8em; }
|
| 81 |
.file-grid.list-view .item-name-info { flex-grow: 1; }
|
| 82 |
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); z-index: 2000; justify-content: center; align-items: center; }
|
| 83 |
.modal-content { display: flex; flex-direction: column; max-width: 95%; max-height: 95%; background: var(--card-bg-dark); padding: 10px; border-radius: 15px; overflow: hidden; position: relative; }
|
|
@@ -97,6 +102,9 @@ input, select { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #33
|
|
| 97 |
.fab { width: 56px; height: 56px; background: var(--accent); border-radius: 50%; border: none; box-shadow: var(--shadow); color: white; font-size: 24px; display: flex; justify-content: center; align-items: center; cursor: pointer; transition: transform 0.3s; }
|
| 98 |
.fab:active { transform: scale(0.9); }
|
| 99 |
.loading-spinner { border: 4px solid #f3f3f3; border-top: 4px solid var(--primary); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: auto; }
|
|
|
|
|
|
|
|
|
|
| 100 |
'''
|
| 101 |
|
| 102 |
def find_node_by_id(filesystem, node_id):
|
|
@@ -159,15 +167,15 @@ def get_all_folders(filesystem, exclude_ids=None):
|
|
| 159 |
traverse(filesystem, "")
|
| 160 |
return sorted(folders, key=lambda x: x['name'].lower())
|
| 161 |
|
| 162 |
-
def
|
| 163 |
if not node or not isinstance(node, dict):
|
| 164 |
return 0
|
| 165 |
count = 0
|
| 166 |
-
if node.get('type')
|
| 167 |
count += 1
|
| 168 |
if node.get('type') == 'folder' and 'children' in node:
|
| 169 |
for child in node.get('children', []):
|
| 170 |
-
count +=
|
| 171 |
return count
|
| 172 |
|
| 173 |
def initialize_user_filesystem_tma(user_data, tma_user_id_str):
|
|
@@ -199,6 +207,7 @@ def load_data():
|
|
| 199 |
data.setdefault('users', {})
|
| 200 |
for tma_user_id_str, user_data_item in data['users'].items():
|
| 201 |
initialize_user_filesystem_tma(user_data_item, tma_user_id_str)
|
|
|
|
| 202 |
return data
|
| 203 |
except Exception as e:
|
| 204 |
logging.error(f"Error loading data: {e}")
|
|
@@ -242,6 +251,45 @@ def periodic_backup():
|
|
| 242 |
upload_db_to_hf()
|
| 243 |
time.sleep(1800)
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
def get_file_type(filename):
|
| 246 |
filename_lower = filename.lower()
|
| 247 |
if filename_lower.endswith(('.mp4', '.mov', '.avi', '.webm', '.mkv')): return 'video'
|
|
@@ -316,6 +364,7 @@ def auth_via_telegram():
|
|
| 316 |
if tma_user_id_str not in data['users']:
|
| 317 |
user_info['created_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 318 |
user_info['filesystem'] = {"type": "folder", "id": "root", "name": "root", "children": []}
|
|
|
|
| 319 |
data['users'][tma_user_id_str] = user_info
|
| 320 |
initialize_user_filesystem_tma(data['users'][tma_user_id_str], tma_user_id_str)
|
| 321 |
else:
|
|
@@ -345,6 +394,7 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 345 |
<div class="app-header">
|
| 346 |
<div class="user-info">{{ display_name }}</div>
|
| 347 |
<div class="view-toggle">
|
|
|
|
| 348 |
<button id="grid-view-btn" title="Сетка"><i class="fa fa-th-large"></i></button>
|
| 349 |
<button id="list-view-btn" title="Список"><i class="fa fa-bars"></i></button>
|
| 350 |
</div>
|
|
@@ -364,10 +414,19 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 364 |
<div id="progress-container"><div id="progress-bar"></div></div>
|
| 365 |
<div class="file-grid" id="file-container">
|
| 366 |
{% for item in items %}
|
| 367 |
-
<div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
<div class="item-preview-wrapper">
|
| 369 |
{% if item.type == 'folder' %}
|
| 370 |
<div class="item-preview"><i class="fa-solid fa-folder"></i></div>
|
|
|
|
|
|
|
| 371 |
{% elif item.type == 'file' %}
|
| 372 |
{% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy">
|
| 373 |
{% elif item.file_type == 'video' %}<video class="item-preview" preload="metadata" muted loading="lazy"><source src="{{ hf_file_url_jinja(item.path, True) }}#t=0.5"></video>
|
|
@@ -378,8 +437,9 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 378 |
{% endif %}
|
| 379 |
</div>
|
| 380 |
<div class="item-name-info">
|
| 381 |
-
<p class="item-name">{{ item.name if item.type == 'folder' else item.original_filename | truncate(30, True) }}</p>
|
| 382 |
-
{% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p>
|
|
|
|
| 383 |
</div>
|
| 384 |
</div>
|
| 385 |
{% endfor %}
|
|
@@ -423,6 +483,7 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 423 |
<label for="file-input" class="btn" style="width:100%; margin-bottom: 10px; background: var(--secondary);"><i class="fa-solid fa-upload"></i> Загрузить файлы</label>
|
| 424 |
<button type="submit" id="upload-btn-modal" style="display:none;"></button>
|
| 425 |
</form>
|
|
|
|
| 426 |
<form method="POST" action="{{ url_for('create_folder_tma') }}">
|
| 427 |
<input type="hidden" name="parent_folder_id" value="{{ current_folder_id }}">
|
| 428 |
<input type="text" name="folder_name" placeholder="Имя новой папки" required>
|
|
@@ -431,31 +492,49 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 431 |
<button class="btn" style="background: #555; width: 100%; margin-top: 10px;" onclick="closeFabModal()">Отмена</button>
|
| 432 |
</div></div>
|
| 433 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
<div class="fab-container"><button id="fab" class="fab"><i class="fa-solid fa-plus"></i></button></div>
|
| 435 |
|
| 436 |
<script>
|
| 437 |
window.Telegram.WebApp.ready();
|
| 438 |
window.Telegram.WebApp.expand();
|
| 439 |
-
|
| 440 |
const haptic = window.Telegram.WebApp.HapticFeedback;
|
| 441 |
-
|
| 442 |
async function openModal(srcOrUrl, type, itemId) {
|
| 443 |
if (!srcOrUrl) return;
|
| 444 |
haptic.impactOccurred('light');
|
| 445 |
const modal = document.getElementById('mediaModal');
|
| 446 |
const modalContent = document.getElementById('modalContent');
|
| 447 |
const downloadBtn = document.getElementById('modal-download-btn');
|
| 448 |
-
|
| 449 |
modalContent.innerHTML = '<div class="loading-spinner"></div>';
|
| 450 |
modal.style.display = 'flex';
|
| 451 |
-
|
| 452 |
if (type !== 'folder' && itemId) {
|
| 453 |
downloadBtn.onclick = () => { initiateDownload(itemId); };
|
| 454 |
downloadBtn.style.display = 'inline-block';
|
| 455 |
} else {
|
| 456 |
downloadBtn.style.display = 'none';
|
| 457 |
}
|
| 458 |
-
|
| 459 |
try {
|
| 460 |
if (type === 'image') modalContent.innerHTML = `<img src="${srcOrUrl}">`;
|
| 461 |
else if (type === 'video') modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`;
|
|
@@ -467,7 +546,6 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 467 |
} else initiateDownload(itemId);
|
| 468 |
} catch (error) { modalContent.innerHTML = `<p>Ошибка предпросмотра: ${error.message}</p>`; }
|
| 469 |
}
|
| 470 |
-
|
| 471 |
function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); }
|
| 472 |
function closeModalManual() {
|
| 473 |
const modal = document.getElementById('mediaModal');
|
|
@@ -476,38 +554,26 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 476 |
document.getElementById('modalContent').innerHTML = '';
|
| 477 |
document.getElementById('modal-download-btn').style.display = 'none';
|
| 478 |
}
|
| 479 |
-
|
| 480 |
async function initiateDownload(fileId) {
|
| 481 |
haptic.impactOccurred('medium');
|
| 482 |
const downloadBtn = document.getElementById('modal-download-btn');
|
| 483 |
const originalHTML = downloadBtn.innerHTML;
|
| 484 |
downloadBtn.innerHTML = '<div class="loading-spinner" style="width:20px; height:20px; border-width:2px;"></div>';
|
| 485 |
downloadBtn.onclick = null;
|
| 486 |
-
|
| 487 |
try {
|
| 488 |
const response = await fetch(`{{ url_for('download_tma', file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId));
|
| 489 |
const data = await response.json();
|
| 490 |
if (data.status === 'success' && data.url) {
|
| 491 |
tmaDownloadFile(data.url);
|
| 492 |
closeModalManual();
|
| 493 |
-
} else {
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
} catch (error) {
|
| 497 |
-
Telegram.WebApp.showAlert('Сетевая ошибка при создании ссылки для скачивания.');
|
| 498 |
-
} finally {
|
| 499 |
-
if (downloadBtn) {
|
| 500 |
-
downloadBtn.innerHTML = originalHTML;
|
| 501 |
-
downloadBtn.onclick = () => { initiateDownload(fileId); };
|
| 502 |
-
}
|
| 503 |
-
}
|
| 504 |
}
|
| 505 |
-
|
| 506 |
function tmaDownloadFile(downloadUrl) {
|
| 507 |
if (window.Telegram && window.Telegram.WebApp && Telegram.WebApp.openLink) { Telegram.WebApp.openLink(downloadUrl, {try_instant_view: false}); }
|
| 508 |
else { window.open(downloadUrl, '_blank'); }
|
| 509 |
}
|
| 510 |
-
|
| 511 |
document.getElementById('upload-form')?.addEventListener('submit', function(e) {
|
| 512 |
e.preventDefault(); const files = document.getElementById('file-input').files;
|
| 513 |
if (files.length === 0) { Telegram.WebApp.showAlert('Выберите файлы.'); return; }
|
|
@@ -517,43 +583,31 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 517 |
const progressBar = document.getElementById('progress-bar');
|
| 518 |
progressContainer.style.display = 'block'; progressBar.style.width = '0%';
|
| 519 |
const formData = new FormData(this); const xhr = new XMLHttpRequest();
|
| 520 |
-
xhr.upload.addEventListener('progress', e => {
|
| 521 |
-
if (e.lengthComputable) progressBar.style.width = Math.round((e.loaded / e.total) * 100) + '%';
|
| 522 |
-
});
|
| 523 |
xhr.addEventListener('load', () => { haptic.notificationOccurred('success'); window.location.reload(); });
|
| 524 |
xhr.addEventListener('error', () => { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Ошибка загрузки.'); });
|
| 525 |
xhr.open('POST', this.action, true); xhr.send(formData);
|
| 526 |
});
|
| 527 |
-
|
| 528 |
let selectionMode = false; const selectedItems = new Set(); let longPressTimer;
|
| 529 |
const mainContainer = document.getElementById('main-container');
|
| 530 |
const selectionBar = document.getElementById('selection-bar');
|
| 531 |
const selectionCount = document.getElementById('selection-count');
|
| 532 |
const selectionDownloadBtn = document.getElementById('selection-download-btn');
|
| 533 |
const allItems = document.querySelectorAll('.item');
|
| 534 |
-
|
| 535 |
function toggleSelectionMode(enable) {
|
| 536 |
selectionMode = enable;
|
| 537 |
selectionBar.classList.toggle('visible', enable);
|
| 538 |
if(enable) haptic.impactOccurred('heavy');
|
| 539 |
-
if (!enable) {
|
| 540 |
-
selectedItems.clear();
|
| 541 |
-
allItems.forEach(item => item.classList.remove('selected'));
|
| 542 |
-
}
|
| 543 |
}
|
| 544 |
-
|
| 545 |
function updateSelectionUI() {
|
| 546 |
selectionCount.textContent = `Выбрано: ${selectedItems.size}`;
|
| 547 |
const firstSelectedId = selectedItems.values().next().value;
|
| 548 |
const itemElement = document.querySelector(`.item[data-id='${firstSelectedId}']`);
|
| 549 |
-
|
| 550 |
if (selectedItems.size === 1 && itemElement && itemElement.dataset.type === 'file') {
|
| 551 |
selectionDownloadBtn.style.display = 'inline-block';
|
| 552 |
-
} else {
|
| 553 |
-
selectionDownloadBtn.style.display = 'none';
|
| 554 |
-
}
|
| 555 |
}
|
| 556 |
-
|
| 557 |
allItems.forEach(item => {
|
| 558 |
item.addEventListener('pointerdown', e => {
|
| 559 |
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
@@ -568,8 +622,7 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 568 |
item.addEventListener('pointerleave', () => clearTimeout(longPressTimer));
|
| 569 |
item.addEventListener('click', e => {
|
| 570 |
if (selectionMode) {
|
| 571 |
-
haptic.impactOccurred('light');
|
| 572 |
-
e.preventDefault(); e.stopPropagation();
|
| 573 |
item.classList.toggle('selected');
|
| 574 |
if (selectedItems.has(item.dataset.id)) selectedItems.delete(item.dataset.id); else selectedItems.add(item.dataset.id);
|
| 575 |
updateSelectionUI();
|
|
@@ -577,26 +630,21 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 577 |
}
|
| 578 |
}, true);
|
| 579 |
});
|
| 580 |
-
|
| 581 |
async function performBatchAction(url, body) {
|
| 582 |
try {
|
| 583 |
-
const response = await fetch(url, {
|
| 584 |
-
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
| 585 |
-
});
|
| 586 |
const result = await response.json();
|
| 587 |
if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
|
| 588 |
else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.'); }
|
| 589 |
} catch (error) { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Сетевая ошибка.'); }
|
| 590 |
finally { toggleSelectionMode(false); }
|
| 591 |
}
|
| 592 |
-
|
| 593 |
function downloadSingleSelected() {
|
| 594 |
if (selectedItems.size !== 1) return;
|
| 595 |
const fileId = selectedItems.values().next().value;
|
| 596 |
initiateDownload(fileId);
|
| 597 |
toggleSelectionMode(false);
|
| 598 |
}
|
| 599 |
-
|
| 600 |
function showMoveModal() { if (selectedItems.size > 0) { haptic.impactOccurred('light'); document.getElementById('move-modal').style.display = 'flex'; }}
|
| 601 |
function closeMoveModal() { document.getElementById('move-modal').style.display = 'none'; }
|
| 602 |
function moveSelected() {
|
|
@@ -611,7 +659,103 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 611 |
if (ok) { haptic.impactOccurred('heavy'); performBatchAction('{{ url_for("batch_delete_tma") }}', { item_ids: Array.from(selectedItems) }); }
|
| 612 |
});
|
| 613 |
}
|
| 614 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
const gridViewBtn = document.getElementById('grid-view-btn');
|
| 616 |
const listViewBtn = document.getElementById('list-view-btn');
|
| 617 |
const fileContainer = document.getElementById('file-container');
|
|
@@ -624,28 +768,20 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
|
|
| 624 |
}
|
| 625 |
gridViewBtn.addEventListener('click', () => setView('grid'));
|
| 626 |
listViewBtn.addEventListener('click', () => setView('list'));
|
| 627 |
-
|
| 628 |
const fab = document.getElementById('fab');
|
| 629 |
const fabModal = document.getElementById('fab-modal');
|
| 630 |
fab.addEventListener('click', () => { haptic.impactOccurred('medium'); fabModal.style.display = 'flex'; });
|
| 631 |
function closeFabModal() { fabModal.style.display = 'none'; }
|
| 632 |
fabModal.addEventListener('click', e => { if (e.target.id === 'fab-modal') closeFabModal(); });
|
| 633 |
-
|
| 634 |
document.addEventListener('DOMContentLoaded', () => {
|
| 635 |
setView(localStorage.getItem('viewMode') || 'grid');
|
| 636 |
const currentFolderId = '{{ current_folder_id }}';
|
| 637 |
const parentFolderId = '{{ parent_folder_id }}';
|
| 638 |
-
|
| 639 |
if (currentFolderId !== 'root') {
|
| 640 |
let backButton = window.Telegram.WebApp.BackButton;
|
| 641 |
backButton.show();
|
| 642 |
-
backButton.onClick(() => {
|
| 643 |
-
|
| 644 |
-
window.location.href = `{{ url_for('tma_dashboard') }}?folder_id=${parentFolderId}`;
|
| 645 |
-
});
|
| 646 |
-
} else {
|
| 647 |
-
window.Telegram.WebApp.BackButton.hide();
|
| 648 |
-
}
|
| 649 |
});
|
| 650 |
</script></body></html>
|
| 651 |
'''
|
|
@@ -673,7 +809,7 @@ def tma_dashboard():
|
|
| 673 |
|
| 674 |
parent_folder_id = parent_folder.get('id', 'root') if parent_folder else 'root'
|
| 675 |
|
| 676 |
-
items_in_folder = sorted(current_folder.get('children', []), key=lambda x: (x['type']
|
| 677 |
if request.method == 'POST':
|
| 678 |
if not HF_TOKEN_WRITE:
|
| 679 |
flash('Загрузка невозможна: токен для записи не настроен.', 'error')
|
|
@@ -748,19 +884,19 @@ def create_folder_tma():
|
|
| 748 |
else: flash('Не удалось найти родительскую папку.', 'error')
|
| 749 |
return redirect(url_for('tma_dashboard', folder_id=parent_folder_id))
|
| 750 |
|
| 751 |
-
def
|
| 752 |
if not (session.get('telegram_user_id') or session.get('admin_browser_logged_in')):
|
| 753 |
return None
|
| 754 |
data = load_data()
|
| 755 |
if session.get('admin_browser_logged_in'):
|
| 756 |
for uid, udata in data.get('users', {}).items():
|
| 757 |
-
node, _ = find_node_by_id(udata.get('filesystem', {}),
|
| 758 |
-
if node
|
| 759 |
else:
|
| 760 |
user_data = data['users'].get(session['telegram_user_id'])
|
| 761 |
if user_data:
|
| 762 |
-
node, _ = find_node_by_id(user_data.get('filesystem', {}),
|
| 763 |
-
if node
|
| 764 |
return None
|
| 765 |
|
| 766 |
def get_file_node_for_admin(tma_user_id_str, file_id):
|
|
@@ -776,8 +912,8 @@ def get_file_node_for_admin(tma_user_id_str, file_id):
|
|
| 776 |
|
| 777 |
@app.route('/download_tma/<file_id>')
|
| 778 |
def download_tma(file_id):
|
| 779 |
-
file_node =
|
| 780 |
-
if not file_node:
|
| 781 |
return jsonify({'status': 'error', 'message': 'Файл не найден или доступ запрещен'}), 404
|
| 782 |
|
| 783 |
token = uuid.uuid4().hex
|
|
@@ -839,7 +975,7 @@ def batch_download_tma():
|
|
| 839 |
try:
|
| 840 |
with zipfile.ZipFile(temp_zip_file.name, 'w', zipfile.ZIP_DEFLATED) as zf:
|
| 841 |
for file_id in file_ids:
|
| 842 |
-
file_node =
|
| 843 |
if file_node and file_node.get('path'):
|
| 844 |
hf_path = file_node['path']
|
| 845 |
original_filename = file_node.get('original_filename', file_id)
|
|
@@ -860,61 +996,6 @@ def batch_download_tma():
|
|
| 860 |
if os.path.exists(temp_zip_file.name):
|
| 861 |
os.unlink(temp_zip_file.name)
|
| 862 |
|
| 863 |
-
@app.route('/delete_file_tma/<file_id>', methods=['POST'])
|
| 864 |
-
def delete_file_tma(file_id):
|
| 865 |
-
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page'))
|
| 866 |
-
tma_user_id = session['telegram_user_id']
|
| 867 |
-
data = load_data()
|
| 868 |
-
user_data = data['users'].get(tma_user_id)
|
| 869 |
-
if not user_data: return redirect(url_for('tma_entry_page'))
|
| 870 |
-
file_node, _ = find_node_by_id(user_data['filesystem'], file_id)
|
| 871 |
-
current_view_folder_id = request.form.get('current_view_folder_id', 'root')
|
| 872 |
-
if not file_node or file_node.get('type') != 'file':
|
| 873 |
-
flash('Файл не найден.', 'error')
|
| 874 |
-
else:
|
| 875 |
-
hf_path = file_node.get('path')
|
| 876 |
-
if not HF_TOKEN_WRITE:
|
| 877 |
-
flash('Удаление невозможно: токен для записи не настроен.', 'error')
|
| 878 |
-
else:
|
| 879 |
-
try:
|
| 880 |
-
api = HfApi()
|
| 881 |
-
if hf_path: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
|
| 882 |
-
removed, _ = remove_node(user_data['filesystem'], file_id)
|
| 883 |
-
if removed:
|
| 884 |
-
try: save_data(data); flash('Файл удален.')
|
| 885 |
-
except Exception: flash('Файл удален с сервера, но ошибка обновления базы.', 'error')
|
| 886 |
-
except hf_utils.EntryNotFoundError:
|
| 887 |
-
removed, _ = remove_node(user_data['filesystem'], file_id)
|
| 888 |
-
if removed:
|
| 889 |
-
try: save_data(data); flash('Файл не найден на сервере, удален из базы.')
|
| 890 |
-
except Exception: flash('Ошибка сохранения (файл не на сервере).', 'error')
|
| 891 |
-
except Exception as e:
|
| 892 |
-
flash(f'Ошибка удаления: {e}', 'error')
|
| 893 |
-
return redirect(url_for('tma_dashboard', folder_id=current_view_folder_id))
|
| 894 |
-
|
| 895 |
-
@app.route('/delete_folder_tma/<folder_id>', methods=['POST'])
|
| 896 |
-
def delete_folder_tma(folder_id):
|
| 897 |
-
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page'))
|
| 898 |
-
if folder_id == 'root':
|
| 899 |
-
flash('Нельзя удалить корневую папку!', 'error'); return redirect(url_for('tma_dashboard'))
|
| 900 |
-
tma_user_id = session['telegram_user_id']
|
| 901 |
-
data = load_data()
|
| 902 |
-
user_data = data['users'].get(tma_user_id)
|
| 903 |
-
if not user_data: return redirect(url_for('tma_entry_page'))
|
| 904 |
-
|
| 905 |
-
folder_node, parent_node = find_node_by_id(user_data['filesystem'], folder_id)
|
| 906 |
-
if not folder_node or folder_node.get('type') != 'folder' or not parent_node:
|
| 907 |
-
flash('Папка не найдена.', 'error')
|
| 908 |
-
elif folder_node.get('children'):
|
| 909 |
-
flash('Папку можно удалить только если она пуста.', 'error')
|
| 910 |
-
else:
|
| 911 |
-
removed, _ = remove_node(user_data['filesystem'], folder_id)
|
| 912 |
-
if removed:
|
| 913 |
-
try: save_data(data); flash(f'Папка "{folder_node.get("name")}" удалена.')
|
| 914 |
-
except Exception: flash('Ошибка сохранения после удаления папки.', 'error')
|
| 915 |
-
else: flash('Не удалось удалить папку.', 'error')
|
| 916 |
-
return redirect(url_for('tma_dashboard', folder_id=parent_node.get('id', 'root')))
|
| 917 |
-
|
| 918 |
@app.route('/batch_delete_tma', methods=['POST'])
|
| 919 |
def batch_delete_tma():
|
| 920 |
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
|
|
@@ -936,6 +1017,9 @@ def batch_delete_tma():
|
|
| 936 |
if node.get('children'): errors.append(f'Папка "{node.get("name")}" не пуста.'); continue
|
| 937 |
if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
|
| 938 |
else: errors.append(f'Ошибка удаления папки "{node.get("name")}".')
|
|
|
|
|
|
|
|
|
|
| 939 |
elif node.get('type') == 'file':
|
| 940 |
try:
|
| 941 |
if node.get('path') and HF_TOKEN_WRITE: api.delete_file(path_in_repo=node['path'], repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
|
|
@@ -990,7 +1074,7 @@ def batch_move_tma():
|
|
| 990 |
|
| 991 |
@app.route('/get_text_content_tma/<file_id>')
|
| 992 |
def get_text_content_tma(file_id):
|
| 993 |
-
file_node =
|
| 994 |
if not file_node or file_node.get('file_type') != 'text': return Response("Текстовый файл не найден", 404)
|
| 995 |
hf_path = file_node.get('path')
|
| 996 |
if not hf_path: return Response("Ошибка: путь к файлу отсутствует", 500)
|
|
@@ -1006,6 +1090,116 @@ def get_text_content_tma(file_id):
|
|
| 1006 |
return Response(text_content, mimetype='text/plain')
|
| 1007 |
except Exception as e: return Response(f"Ошибка загрузки: {e}", 502)
|
| 1008 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
@app.route('/tma_logout')
|
| 1010 |
def tma_logout():
|
| 1011 |
session.clear()
|
|
@@ -1039,12 +1233,13 @@ ADMIN_PANEL_HTML = '''
|
|
| 1039 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 1040 |
<style>''' + BASE_STYLE + '''
|
| 1041 |
.user-list { list-style: none; padding: 0; }
|
| 1042 |
-
.user-item { background: var(--card-bg-dark); border-radius: 12px; margin-bottom: 10px; padding: 15px; display: flex; justify-content: space-between; align-items: center;
|
| 1043 |
.user-item:hover { background: #2a2a2a; }
|
| 1044 |
-
.user-details { display: flex; align-items: center; gap: 15px; }
|
| 1045 |
.user-avatar { width: 50px; height: 50px; border-radius: 50%; object-fit: cover; background-color: #333; }
|
| 1046 |
.user-info span { display: block; }
|
| 1047 |
.user-info .id { font-size: 0.8em; color: var(--text-muted); }
|
|
|
|
| 1048 |
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px;}
|
| 1049 |
.search-form { display: flex; gap: 10px; flex-grow: 1; max-width: 400px;}
|
| 1050 |
.search-form input { margin: 0; }
|
|
@@ -1071,10 +1266,14 @@ ADMIN_PANEL_HTML = '''
|
|
| 1071 |
<strong>{{ user.get('first_name', 'N/A') }} {{ user.get('last_name', '') }}</strong> (@{{ user.get('telegram_username', 'N/A') }})
|
| 1072 |
<span class="id">ID: {{ user_id }}</span>
|
| 1073 |
<span class="id">Created: {{ user.get('created_at', 'N/A') }}</span>
|
| 1074 |
-
<span class="id">
|
|
|
|
| 1075 |
</div>
|
| 1076 |
</div>
|
| 1077 |
-
<
|
|
|
|
|
|
|
|
|
|
| 1078 |
</li>
|
| 1079 |
{% else %}
|
| 1080 |
<li>No users found.</li>
|
|
@@ -1106,7 +1305,7 @@ ADMIN_USER_FILES_HTML = '''
|
|
| 1106 |
</div>
|
| 1107 |
<div class="container">
|
| 1108 |
<div class="admin-header">
|
| 1109 |
-
<h1>
|
| 1110 |
<div class="user-details">@{{ user.get('telegram_username', 'N/A') }} (ID: {{ user_id }})</div>
|
| 1111 |
</div>
|
| 1112 |
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
|
|
@@ -1123,9 +1322,14 @@ ADMIN_USER_FILES_HTML = '''
|
|
| 1123 |
<div class="file-grid" id="file-container">
|
| 1124 |
{% for item in items %}
|
| 1125 |
<div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}">
|
| 1126 |
-
<div class="item-preview-wrapper"
|
|
|
|
|
|
|
|
|
|
| 1127 |
{% if item.type == 'folder' %}
|
| 1128 |
<div class="item-preview"><i class="fa-solid fa-folder"></i></div>
|
|
|
|
|
|
|
| 1129 |
{% elif item.type == 'file' %}
|
| 1130 |
{% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy">
|
| 1131 |
{% elif item.file_type == 'video' %}<video class="item-preview" preload="metadata" muted loading="lazy"><source src="{{ hf_file_url_jinja(item.path, True) }}#t=0.5"></video>
|
|
@@ -1136,16 +1340,15 @@ ADMIN_USER_FILES_HTML = '''
|
|
| 1136 |
{% endif %}
|
| 1137 |
</div>
|
| 1138 |
<div class="item-name-info">
|
| 1139 |
-
<p class="item-name">{{ item.name if item.type == 'folder' else item.original_filename | truncate(30, True) }}</p>
|
| 1140 |
-
{% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p>
|
|
|
|
| 1141 |
</div>
|
| 1142 |
<div class="item-actions-admin">
|
| 1143 |
-
{
|
| 1144 |
-
<
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
</form>
|
| 1148 |
-
{% endif %}
|
| 1149 |
</div>
|
| 1150 |
</div>
|
| 1151 |
{% endfor %}
|
|
@@ -1171,43 +1374,44 @@ ADMIN_USER_FILES_HTML = '''
|
|
| 1171 |
try {
|
| 1172 |
const response = await fetch(`{{ url_for('admin_download_file', tma_user_id_str=user_id, file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId));
|
| 1173 |
const data = await response.json();
|
| 1174 |
-
if (data.status === 'success' && data.url) {
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
}
|
| 1179 |
-
} catch (error) {
|
| 1180 |
-
alert('Network error while creating download link.');
|
| 1181 |
-
} finally {
|
| 1182 |
-
downloadBtn.innerHTML = originalHTML;
|
| 1183 |
-
closeModalManual();
|
| 1184 |
-
}
|
| 1185 |
}
|
| 1186 |
-
|
| 1187 |
async function openModal(srcOrUrl, type, itemId) {
|
| 1188 |
-
if (!
|
| 1189 |
const modal = document.getElementById('mediaModal');
|
| 1190 |
const modalContent = document.getElementById('modalContent');
|
| 1191 |
const downloadBtn = document.getElementById('modal-download-btn');
|
| 1192 |
modalContent.innerHTML = '<div class="loading-spinner"></div>';
|
| 1193 |
modal.style.display = 'flex';
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
downloadBtn.onclick = () => initiateDownload(itemId);
|
| 1197 |
-
downloadBtn.style.display = 'inline-block';
|
| 1198 |
-
} else {
|
| 1199 |
-
downloadBtn.style.display = 'none';
|
| 1200 |
-
}
|
| 1201 |
-
|
| 1202 |
try {
|
| 1203 |
-
if (type === '
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1207 |
const response = await fetch(srcOrUrl); if (!response.ok) throw new Error(`Error: ${response.statusText}`);
|
| 1208 |
const text = await response.text();
|
| 1209 |
modalContent.innerHTML = `<pre>${text.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}</pre>`;
|
| 1210 |
-
|
|
|
|
|
|
|
|
|
|
| 1211 |
} catch (error) { modalContent.innerHTML = `<p>Preview Error: ${error.message}</p>`; }
|
| 1212 |
}
|
| 1213 |
function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); }
|
|
@@ -1229,12 +1433,51 @@ ADMIN_USER_FILES_HTML = '''
|
|
| 1229 |
}
|
| 1230 |
gridViewBtn.addEventListener('click', () => setView('grid'));
|
| 1231 |
listViewBtn.addEventListener('click', () => setView('list'));
|
| 1232 |
-
document.addEventListener('DOMContentLoaded', () => {
|
| 1233 |
-
setView(localStorage.getItem('adminViewMode') || 'grid');
|
| 1234 |
-
});
|
| 1235 |
</script></body></html>
|
| 1236 |
'''
|
| 1237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1238 |
@app.route('/admin')
|
| 1239 |
def admin_redirect():
|
| 1240 |
return redirect(url_for('admin_login'))
|
|
@@ -1267,7 +1510,7 @@ def admin_panel():
|
|
| 1267 |
|
| 1268 |
processed_users = {}
|
| 1269 |
for user_id, user_data in all_users.items():
|
| 1270 |
-
user_data['
|
| 1271 |
processed_users[user_id] = user_data
|
| 1272 |
|
| 1273 |
if search_query:
|
|
@@ -1307,7 +1550,7 @@ def admin_user_files(tma_user_id_str):
|
|
| 1307 |
current_folder_id = 'root'
|
| 1308 |
current_folder, _ = find_node_by_id(user_data['filesystem'], 'root')
|
| 1309 |
|
| 1310 |
-
items_in_folder = sorted(current_folder.get('children', []), key=lambda x: (x['type']
|
| 1311 |
|
| 1312 |
breadcrumbs = []
|
| 1313 |
temp_id = current_folder_id
|
|
@@ -1329,6 +1572,18 @@ def admin_user_files(tma_user_id_str):
|
|
| 1329 |
breadcrumbs=breadcrumbs,
|
| 1330 |
hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}")
|
| 1331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1332 |
@app.route('/admhosto/download/<tma_user_id_str>/<file_id>')
|
| 1333 |
@admin_browser_login_required
|
| 1334 |
def admin_download_file(tma_user_id_str, file_id):
|
|
@@ -1366,58 +1621,93 @@ def admin_get_text_content(tma_user_id_str, file_id):
|
|
| 1366 |
except Exception as e:
|
| 1367 |
return Response(f"Download error: {e}", 502)
|
| 1368 |
|
| 1369 |
-
@app.route('/admhosto/
|
| 1370 |
@admin_browser_login_required
|
| 1371 |
-
def
|
| 1372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1373 |
|
| 1374 |
-
@app.route('/admhosto/
|
| 1375 |
@admin_browser_login_required
|
| 1376 |
-
def
|
| 1377 |
data = load_data()
|
| 1378 |
user_data = data['users'].get(tma_user_id_str)
|
| 1379 |
current_folder_id = request.form.get('current_folder_id', 'root')
|
| 1380 |
-
|
| 1381 |
if not user_data:
|
| 1382 |
-
flash('User not found.', 'error')
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
-
if not HF_TOKEN_WRITE:
|
| 1391 |
-
flash('Deletion not possible: write token not configured.', 'error')
|
| 1392 |
else:
|
| 1393 |
try:
|
| 1394 |
api = HfApi()
|
| 1395 |
if hf_path: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
|
| 1396 |
-
|
| 1397 |
-
if removed:
|
| 1398 |
try: save_data(data); flash('File deleted.')
|
| 1399 |
except Exception: flash('File deleted from server, but DB update failed.', 'error')
|
| 1400 |
except hf_utils.EntryNotFoundError:
|
| 1401 |
-
|
| 1402 |
-
if removed:
|
| 1403 |
try: save_data(data); flash('File not found on server, removed from DB.')
|
| 1404 |
except Exception: flash('DB save error (file not on server).', 'error')
|
| 1405 |
-
except Exception as e:
|
| 1406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1407 |
return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id))
|
| 1408 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1409 |
if __name__ == '__main__':
|
| 1410 |
if not HF_TOKEN_WRITE: logging.warning("HF_TOKEN (write) is not set. Uploads/deletions will fail.")
|
| 1411 |
if not HF_TOKEN_READ: logging.warning("HF_TOKEN_READ is not set. Downloads/previews might fail.")
|
| 1412 |
if ADMIN_TELEGRAM_ID == "YOUR_ADMIN_TELEGRAM_USER_ID_HERE": logging.warning("ADMIN_TELEGRAM_ID is not set.")
|
| 1413 |
if ADMIN_USERNAME == "admin" and ADMIN_PASSWORD == "zeusadminpass":
|
| 1414 |
logging.warning("Using default admin credentials. Please change them.")
|
| 1415 |
-
|
| 1416 |
-
|
| 1417 |
-
threading.Thread(target=periodic_backup, daemon=True).start()
|
| 1418 |
-
elif HF_TOKEN_READ:
|
| 1419 |
download_db_from_hf()
|
| 1420 |
else:
|
| 1421 |
if not os.path.exists(DATA_FILE):
|
| 1422 |
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}}, f)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1423 |
app.run(debug=False, host='0.0.0.0', port=7860)
|
|
|
|
| 16 |
from urllib.parse import quote
|
| 17 |
import zipfile
|
| 18 |
import tempfile
|
| 19 |
+
import pytz
|
| 20 |
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
| 21 |
|
| 22 |
app = Flask(__name__)
|
| 23 |
app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_folders_unique_tma")
|
|
|
|
| 41 |
--text-dark: #e0e0e0; --text-muted: #888; --shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
| 42 |
--glass-bg: rgba(30, 30, 30, 0.7); --transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
| 43 |
--delete-color: #ff4444; --folder-color: #ffc107; --selection-color: rgba(139, 92, 246, 0.3);
|
| 44 |
+
--note-color: #6a5acd;
|
| 45 |
}
|
| 46 |
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
| 47 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
| 50 |
.container { margin: 0 auto; max-width: 1200px; padding: 75px 15px 15px 15px; }
|
| 51 |
.app-header { position: fixed; top: 0; left: 0; right: 0; background: var(--glass-bg); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid rgba(255,255,255,0.1); z-index: 1000; padding: 10px 20px; display: flex; justify-content: space-between; align-items: center; }
|
| 52 |
.user-info { font-weight: 600; }
|
| 53 |
+
.view-toggle { display: flex; align-items: center; gap: 5px; }
|
| 54 |
.view-toggle button { background: none; border: none; color: var(--text-muted); font-size: 1.2em; padding: 5px; cursor: pointer; transition: var(--transition); }
|
| 55 |
.view-toggle button:hover, .view-toggle button.active { color: var(--primary); }
|
| 56 |
h2 { font-size: 1.3em; margin-bottom: 15px; margin-top: 15px; color: var(--text-dark); }
|
| 57 |
.breadcrumbs { font-size: 1em; margin-bottom: 20px; white-space: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
| 58 |
.breadcrumbs a { color: var(--accent); text-decoration: none; }
|
| 59 |
.breadcrumbs span { margin: 0 5px; color: var(--text-muted); }
|
| 60 |
+
input, select, textarea { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #333; border-radius: 12px; background: #2a2a2a; color: var(--text-dark); font-size: 1em; }
|
| 61 |
.btn { padding: 12px 24px; background: var(--primary); color: white; border: none; border-radius: 12px; cursor: pointer; font-size: 1em; font-weight: 600; transition: var(--transition); text-decoration: none; display: inline-block; text-align: center; }
|
| 62 |
.btn:hover { filter: brightness(1.2); }
|
| 63 |
.btn:active { transform: scale(0.98); }
|
|
|
|
| 74 |
.item-preview-wrapper { position: relative; width: 100%; padding-top: 75%; border-radius: 10px; overflow: hidden; margin-bottom: 10px; background: #2a2a2a; }
|
| 75 |
.item-preview { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
|
| 76 |
.item.folder .item-preview { object-fit: contain; font-size: 3.5em; color: var(--folder-color); }
|
| 77 |
+
.item.note .item-preview { object-fit: contain; font-size: 3.5em; color: var(--note-color); }
|
| 78 |
.item-name { font-size: 0.9em; font-weight: 500; word-break: break-all; margin: 5px 0; flex-grow: 1; }
|
| 79 |
.item-info { font-size: 0.75em; color: var(--text-muted); }
|
| 80 |
.item-actions { display: none; }
|
|
|
|
| 82 |
.file-grid.list-view .item { flex-direction: row; align-items: center; text-align: left; padding: 8px; }
|
| 83 |
.file-grid.list-view .item:hover { transform: translateY(0); }
|
| 84 |
.file-grid.list-view .item-preview-wrapper { width: 45px; height: 45px; padding-top: 0; margin-bottom: 0; margin-right: 15px; flex-shrink: 0; }
|
| 85 |
+
.file-grid.list-view .item.folder .item-preview, .file-grid.list-view .item.note .item-preview { font-size: 1.8em; }
|
| 86 |
.file-grid.list-view .item-name-info { flex-grow: 1; }
|
| 87 |
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); z-index: 2000; justify-content: center; align-items: center; }
|
| 88 |
.modal-content { display: flex; flex-direction: column; max-width: 95%; max-height: 95%; background: var(--card-bg-dark); padding: 10px; border-radius: 15px; overflow: hidden; position: relative; }
|
|
|
|
| 102 |
.fab { width: 56px; height: 56px; background: var(--accent); border-radius: 50%; border: none; box-shadow: var(--shadow); color: white; font-size: 24px; display: flex; justify-content: center; align-items: center; cursor: pointer; transition: transform 0.3s; }
|
| 103 |
.fab:active { transform: scale(0.9); }
|
| 104 |
.loading-spinner { border: 4px solid #f3f3f3; border-top: 4px solid var(--primary); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: auto; }
|
| 105 |
+
.reminder-item { display: flex; justify-content: space-between; align-items: center; padding: 8px; border-bottom: 1px solid #333; }
|
| 106 |
+
.reminder-item:last-child { border-bottom: none; }
|
| 107 |
+
.reminder-item button { background: var(--delete-color); color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer; }
|
| 108 |
'''
|
| 109 |
|
| 110 |
def find_node_by_id(filesystem, node_id):
|
|
|
|
| 167 |
traverse(filesystem, "")
|
| 168 |
return sorted(folders, key=lambda x: x['name'].lower())
|
| 169 |
|
| 170 |
+
def count_items_recursive(node):
|
| 171 |
if not node or not isinstance(node, dict):
|
| 172 |
return 0
|
| 173 |
count = 0
|
| 174 |
+
if node.get('type') in ['file', 'note']:
|
| 175 |
count += 1
|
| 176 |
if node.get('type') == 'folder' and 'children' in node:
|
| 177 |
for child in node.get('children', []):
|
| 178 |
+
count += count_items_recursive(child)
|
| 179 |
return count
|
| 180 |
|
| 181 |
def initialize_user_filesystem_tma(user_data, tma_user_id_str):
|
|
|
|
| 207 |
data.setdefault('users', {})
|
| 208 |
for tma_user_id_str, user_data_item in data['users'].items():
|
| 209 |
initialize_user_filesystem_tma(user_data_item, tma_user_id_str)
|
| 210 |
+
user_data_item.setdefault('reminders', [])
|
| 211 |
return data
|
| 212 |
except Exception as e:
|
| 213 |
logging.error(f"Error loading data: {e}")
|
|
|
|
| 251 |
upload_db_to_hf()
|
| 252 |
time.sleep(1800)
|
| 253 |
|
| 254 |
+
def send_telegram_message(chat_id, text):
|
| 255 |
+
if not TELEGRAM_BOT_TOKEN:
|
| 256 |
+
logging.warning("TELEGRAM_BOT_TOKEN is not set. Cannot send message.")
|
| 257 |
+
return
|
| 258 |
+
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
| 259 |
+
payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
| 260 |
+
try:
|
| 261 |
+
response = requests.post(url, json=payload)
|
| 262 |
+
response.raise_for_status()
|
| 263 |
+
logging.info(f"Sent message to {chat_id}")
|
| 264 |
+
except requests.exceptions.RequestException as e:
|
| 265 |
+
logging.error(f"Failed to send Telegram message to {chat_id}: {e}")
|
| 266 |
+
|
| 267 |
+
def check_reminders():
|
| 268 |
+
while True:
|
| 269 |
+
try:
|
| 270 |
+
data = load_data()
|
| 271 |
+
now_utc = datetime.now(pytz.utc)
|
| 272 |
+
made_changes = False
|
| 273 |
+
for user_id, user_data in data.get('users', {}).items():
|
| 274 |
+
if 'reminders' in user_data:
|
| 275 |
+
for reminder in user_data['reminders']:
|
| 276 |
+
if not reminder.get('notified', False):
|
| 277 |
+
due_time_str = reminder.get('due_datetime_utc')
|
| 278 |
+
if due_time_str:
|
| 279 |
+
due_time_utc = datetime.fromisoformat(due_time_str.replace('Z', '+00:00')).replace(tzinfo=pytz.utc)
|
| 280 |
+
if now_utc >= due_time_utc:
|
| 281 |
+
telegram_id = user_data.get('telegram_id')
|
| 282 |
+
if telegram_id:
|
| 283 |
+
message_text = f"🔔 <b>Напоминание:</b>\n\n{reminder['text']}"
|
| 284 |
+
send_telegram_message(telegram_id, message_text)
|
| 285 |
+
reminder['notified'] = True
|
| 286 |
+
made_changes = True
|
| 287 |
+
if made_changes:
|
| 288 |
+
save_data(data)
|
| 289 |
+
except Exception as e:
|
| 290 |
+
logging.error(f"Error in check_reminders thread: {e}")
|
| 291 |
+
time.sleep(60)
|
| 292 |
+
|
| 293 |
def get_file_type(filename):
|
| 294 |
filename_lower = filename.lower()
|
| 295 |
if filename_lower.endswith(('.mp4', '.mov', '.avi', '.webm', '.mkv')): return 'video'
|
|
|
|
| 364 |
if tma_user_id_str not in data['users']:
|
| 365 |
user_info['created_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 366 |
user_info['filesystem'] = {"type": "folder", "id": "root", "name": "root", "children": []}
|
| 367 |
+
user_info['reminders'] = []
|
| 368 |
data['users'][tma_user_id_str] = user_info
|
| 369 |
initialize_user_filesystem_tma(data['users'][tma_user_id_str], tma_user_id_str)
|
| 370 |
else:
|
|
|
|
| 394 |
<div class="app-header">
|
| 395 |
<div class="user-info">{{ display_name }}</div>
|
| 396 |
<div class="view-toggle">
|
| 397 |
+
<button id="reminders-btn" title="Напоминания"><i class="fa-solid fa-bell"></i></button>
|
| 398 |
<button id="grid-view-btn" title="Сетка"><i class="fa fa-th-large"></i></button>
|
| 399 |
<button id="list-view-btn" title="Список"><i class="fa fa-bars"></i></button>
|
| 400 |
</div>
|
|
|
|
| 414 |
<div id="progress-container"><div id="progress-bar"></div></div>
|
| 415 |
<div class="file-grid" id="file-container">
|
| 416 |
{% for item in items %}
|
| 417 |
+
<div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}"
|
| 418 |
+
{% if item.type == 'folder' %}
|
| 419 |
+
onclick="window.Telegram.WebApp.HapticFeedback.impactOccurred('light'); window.location.href='{{ url_for('tma_dashboard', folder_id=item.id) }}'"
|
| 420 |
+
{% elif item.type == 'note' %}
|
| 421 |
+
onclick="openNoteModal('{{ item.id }}')"
|
| 422 |
+
{% else %}
|
| 423 |
+
onclick="openModal('{{ hf_file_url_jinja(item.path) if item.file_type not in ['text', 'pdf'] else (url_for('get_text_content_tma', file_id=item.id) if item.file_type == 'text' else hf_file_url_jinja(item.path, True)) }}', '{{ item.file_type }}', '{{ item.id }}')"
|
| 424 |
+
{% endif %}>
|
| 425 |
<div class="item-preview-wrapper">
|
| 426 |
{% if item.type == 'folder' %}
|
| 427 |
<div class="item-preview"><i class="fa-solid fa-folder"></i></div>
|
| 428 |
+
{% elif item.type == 'note' %}
|
| 429 |
+
<div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div>
|
| 430 |
{% elif item.type == 'file' %}
|
| 431 |
{% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy">
|
| 432 |
{% elif item.file_type == 'video' %}<video class="item-preview" preload="metadata" muted loading="lazy"><source src="{{ hf_file_url_jinja(item.path, True) }}#t=0.5"></video>
|
|
|
|
| 437 |
{% endif %}
|
| 438 |
</div>
|
| 439 |
<div class="item-name-info">
|
| 440 |
+
<p class="item-name">{{ (item.title if item.type == 'note' else item.name if item.type == 'folder' else item.original_filename) | truncate(30, True) }}</p>
|
| 441 |
+
{% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p>
|
| 442 |
+
{% elif item.type == 'note' %}<p class="item-info">{{ item.modified_date }}</p>{% endif %}
|
| 443 |
</div>
|
| 444 |
</div>
|
| 445 |
{% endfor %}
|
|
|
|
| 483 |
<label for="file-input" class="btn" style="width:100%; margin-bottom: 10px; background: var(--secondary);"><i class="fa-solid fa-upload"></i> Загрузить файлы</label>
|
| 484 |
<button type="submit" id="upload-btn-modal" style="display:none;"></button>
|
| 485 |
</form>
|
| 486 |
+
<button class="btn" style="width:100%; margin-bottom: 10px; background: var(--note-color);" onclick="openNoteModal()"><i class="fa-solid fa-note-sticky"></i> Создать заметку</button>
|
| 487 |
<form method="POST" action="{{ url_for('create_folder_tma') }}">
|
| 488 |
<input type="hidden" name="parent_folder_id" value="{{ current_folder_id }}">
|
| 489 |
<input type="text" name="folder_name" placeholder="Имя новой папки" required>
|
|
|
|
| 492 |
<button class="btn" style="background: #555; width: 100%; margin-top: 10px;" onclick="closeFabModal()">Отмена</button>
|
| 493 |
</div></div>
|
| 494 |
|
| 495 |
+
<div class="modal" id="note-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;">
|
| 496 |
+
<h4 id="note-modal-title">Новая заметка</h4>
|
| 497 |
+
<input type="hidden" id="note-id-input">
|
| 498 |
+
<input type="text" id="note-title-input" placeholder="Заголовок заметки" style="font-size: 1.1em; margin-bottom: 10px;">
|
| 499 |
+
<textarea id="note-content-input" placeholder="Текст заметки..." style="width: 100%; height: 40vh; background: #2a2a2a; color: var(--text-dark); border: 1px solid #333; border-radius: 12px; padding: 10px; font-size: 1em; resize: vertical;"></textarea>
|
| 500 |
+
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
| 501 |
+
<button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="saveNote()">Сохранить</button>
|
| 502 |
+
<button class="btn" style="background: #555; flex-grow: 1;" onclick="closeNoteModal()">Отмена</button>
|
| 503 |
+
</div>
|
| 504 |
+
</div></div>
|
| 505 |
+
|
| 506 |
+
<div class="modal" id="reminders-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;">
|
| 507 |
+
<h4>Напоминания</h4>
|
| 508 |
+
<div id="reminders-list" style="max-height: 40vh; overflow-y: auto; margin-bottom: 15px; font-size: 0.9em;"></div>
|
| 509 |
+
<h5 style="margin-top: 15px;">Новое напоминание</h5>
|
| 510 |
+
<input type="text" id="reminder-text-input" placeholder="Текст напоминания" required>
|
| 511 |
+
<input type="datetime-local" id="reminder-datetime-input" required>
|
| 512 |
+
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
| 513 |
+
<button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="createReminder()">Создать</button>
|
| 514 |
+
<button class="btn" style="background: #555; flex-grow: 1;" onclick="closeRemindersModal()">Закрыть</button>
|
| 515 |
+
</div>
|
| 516 |
+
</div></div>
|
| 517 |
+
|
| 518 |
<div class="fab-container"><button id="fab" class="fab"><i class="fa-solid fa-plus"></i></button></div>
|
| 519 |
|
| 520 |
<script>
|
| 521 |
window.Telegram.WebApp.ready();
|
| 522 |
window.Telegram.WebApp.expand();
|
|
|
|
| 523 |
const haptic = window.Telegram.WebApp.HapticFeedback;
|
|
|
|
| 524 |
async function openModal(srcOrUrl, type, itemId) {
|
| 525 |
if (!srcOrUrl) return;
|
| 526 |
haptic.impactOccurred('light');
|
| 527 |
const modal = document.getElementById('mediaModal');
|
| 528 |
const modalContent = document.getElementById('modalContent');
|
| 529 |
const downloadBtn = document.getElementById('modal-download-btn');
|
|
|
|
| 530 |
modalContent.innerHTML = '<div class="loading-spinner"></div>';
|
| 531 |
modal.style.display = 'flex';
|
|
|
|
| 532 |
if (type !== 'folder' && itemId) {
|
| 533 |
downloadBtn.onclick = () => { initiateDownload(itemId); };
|
| 534 |
downloadBtn.style.display = 'inline-block';
|
| 535 |
} else {
|
| 536 |
downloadBtn.style.display = 'none';
|
| 537 |
}
|
|
|
|
| 538 |
try {
|
| 539 |
if (type === 'image') modalContent.innerHTML = `<img src="${srcOrUrl}">`;
|
| 540 |
else if (type === 'video') modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`;
|
|
|
|
| 546 |
} else initiateDownload(itemId);
|
| 547 |
} catch (error) { modalContent.innerHTML = `<p>Ошибка предпросмотра: ${error.message}</p>`; }
|
| 548 |
}
|
|
|
|
| 549 |
function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); }
|
| 550 |
function closeModalManual() {
|
| 551 |
const modal = document.getElementById('mediaModal');
|
|
|
|
| 554 |
document.getElementById('modalContent').innerHTML = '';
|
| 555 |
document.getElementById('modal-download-btn').style.display = 'none';
|
| 556 |
}
|
|
|
|
| 557 |
async function initiateDownload(fileId) {
|
| 558 |
haptic.impactOccurred('medium');
|
| 559 |
const downloadBtn = document.getElementById('modal-download-btn');
|
| 560 |
const originalHTML = downloadBtn.innerHTML;
|
| 561 |
downloadBtn.innerHTML = '<div class="loading-spinner" style="width:20px; height:20px; border-width:2px;"></div>';
|
| 562 |
downloadBtn.onclick = null;
|
|
|
|
| 563 |
try {
|
| 564 |
const response = await fetch(`{{ url_for('download_tma', file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId));
|
| 565 |
const data = await response.json();
|
| 566 |
if (data.status === 'success' && data.url) {
|
| 567 |
tmaDownloadFile(data.url);
|
| 568 |
closeModalManual();
|
| 569 |
+
} else { Telegram.WebApp.showAlert(data.message || 'Не удалось создать ссылку для скачивания.'); }
|
| 570 |
+
} catch (error) { Telegram.WebApp.showAlert('Сетевая ошибка при создании ссылки для скачивания.'); }
|
| 571 |
+
finally { if (downloadBtn) { downloadBtn.innerHTML = originalHTML; downloadBtn.onclick = () => { initiateDownload(fileId); }; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
}
|
|
|
|
| 573 |
function tmaDownloadFile(downloadUrl) {
|
| 574 |
if (window.Telegram && window.Telegram.WebApp && Telegram.WebApp.openLink) { Telegram.WebApp.openLink(downloadUrl, {try_instant_view: false}); }
|
| 575 |
else { window.open(downloadUrl, '_blank'); }
|
| 576 |
}
|
|
|
|
| 577 |
document.getElementById('upload-form')?.addEventListener('submit', function(e) {
|
| 578 |
e.preventDefault(); const files = document.getElementById('file-input').files;
|
| 579 |
if (files.length === 0) { Telegram.WebApp.showAlert('Выберите файлы.'); return; }
|
|
|
|
| 583 |
const progressBar = document.getElementById('progress-bar');
|
| 584 |
progressContainer.style.display = 'block'; progressBar.style.width = '0%';
|
| 585 |
const formData = new FormData(this); const xhr = new XMLHttpRequest();
|
| 586 |
+
xhr.upload.addEventListener('progress', e => { if (e.lengthComputable) progressBar.style.width = Math.round((e.loaded / e.total) * 100) + '%'; });
|
|
|
|
|
|
|
| 587 |
xhr.addEventListener('load', () => { haptic.notificationOccurred('success'); window.location.reload(); });
|
| 588 |
xhr.addEventListener('error', () => { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Ошибка загрузки.'); });
|
| 589 |
xhr.open('POST', this.action, true); xhr.send(formData);
|
| 590 |
});
|
|
|
|
| 591 |
let selectionMode = false; const selectedItems = new Set(); let longPressTimer;
|
| 592 |
const mainContainer = document.getElementById('main-container');
|
| 593 |
const selectionBar = document.getElementById('selection-bar');
|
| 594 |
const selectionCount = document.getElementById('selection-count');
|
| 595 |
const selectionDownloadBtn = document.getElementById('selection-download-btn');
|
| 596 |
const allItems = document.querySelectorAll('.item');
|
|
|
|
| 597 |
function toggleSelectionMode(enable) {
|
| 598 |
selectionMode = enable;
|
| 599 |
selectionBar.classList.toggle('visible', enable);
|
| 600 |
if(enable) haptic.impactOccurred('heavy');
|
| 601 |
+
if (!enable) { selectedItems.clear(); allItems.forEach(item => item.classList.remove('selected')); }
|
|
|
|
|
|
|
|
|
|
| 602 |
}
|
|
|
|
| 603 |
function updateSelectionUI() {
|
| 604 |
selectionCount.textContent = `Выбрано: ${selectedItems.size}`;
|
| 605 |
const firstSelectedId = selectedItems.values().next().value;
|
| 606 |
const itemElement = document.querySelector(`.item[data-id='${firstSelectedId}']`);
|
|
|
|
| 607 |
if (selectedItems.size === 1 && itemElement && itemElement.dataset.type === 'file') {
|
| 608 |
selectionDownloadBtn.style.display = 'inline-block';
|
| 609 |
+
} else { selectionDownloadBtn.style.display = 'none'; }
|
|
|
|
|
|
|
| 610 |
}
|
|
|
|
| 611 |
allItems.forEach(item => {
|
| 612 |
item.addEventListener('pointerdown', e => {
|
| 613 |
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
|
|
| 622 |
item.addEventListener('pointerleave', () => clearTimeout(longPressTimer));
|
| 623 |
item.addEventListener('click', e => {
|
| 624 |
if (selectionMode) {
|
| 625 |
+
haptic.impactOccurred('light'); e.preventDefault(); e.stopPropagation();
|
|
|
|
| 626 |
item.classList.toggle('selected');
|
| 627 |
if (selectedItems.has(item.dataset.id)) selectedItems.delete(item.dataset.id); else selectedItems.add(item.dataset.id);
|
| 628 |
updateSelectionUI();
|
|
|
|
| 630 |
}
|
| 631 |
}, true);
|
| 632 |
});
|
|
|
|
| 633 |
async function performBatchAction(url, body) {
|
| 634 |
try {
|
| 635 |
+
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
|
|
|
|
|
| 636 |
const result = await response.json();
|
| 637 |
if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
|
| 638 |
else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.'); }
|
| 639 |
} catch (error) { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Сетевая ошибка.'); }
|
| 640 |
finally { toggleSelectionMode(false); }
|
| 641 |
}
|
|
|
|
| 642 |
function downloadSingleSelected() {
|
| 643 |
if (selectedItems.size !== 1) return;
|
| 644 |
const fileId = selectedItems.values().next().value;
|
| 645 |
initiateDownload(fileId);
|
| 646 |
toggleSelectionMode(false);
|
| 647 |
}
|
|
|
|
| 648 |
function showMoveModal() { if (selectedItems.size > 0) { haptic.impactOccurred('light'); document.getElementById('move-modal').style.display = 'flex'; }}
|
| 649 |
function closeMoveModal() { document.getElementById('move-modal').style.display = 'none'; }
|
| 650 |
function moveSelected() {
|
|
|
|
| 659 |
if (ok) { haptic.impactOccurred('heavy'); performBatchAction('{{ url_for("batch_delete_tma") }}', { item_ids: Array.from(selectedItems) }); }
|
| 660 |
});
|
| 661 |
}
|
| 662 |
+
async function openNoteModal(noteId = null) {
|
| 663 |
+
haptic.impactOccurred('light');
|
| 664 |
+
closeFabModal();
|
| 665 |
+
const modal = document.getElementById('note-modal');
|
| 666 |
+
const titleEl = document.getElementById('note-modal-title');
|
| 667 |
+
const idInput = document.getElementById('note-id-input');
|
| 668 |
+
const titleInput = document.getElementById('note-title-input');
|
| 669 |
+
const contentInput = document.getElementById('note-content-input');
|
| 670 |
+
if (noteId) {
|
| 671 |
+
titleEl.textContent = 'Редактировать заметку';
|
| 672 |
+
const response = await fetch(`{{ url_for('get_note_tma', note_id='__ID__') }}`.replace('__ID__', noteId));
|
| 673 |
+
const data = await response.json();
|
| 674 |
+
if (data.status === 'success') {
|
| 675 |
+
idInput.value = data.note.id;
|
| 676 |
+
titleInput.value = data.note.title;
|
| 677 |
+
contentInput.value = data.note.content;
|
| 678 |
+
} else { Telegram.WebApp.showAlert('Ошибка з��грузки заметки.'); return; }
|
| 679 |
+
} else {
|
| 680 |
+
titleEl.textContent = 'Новая заметка';
|
| 681 |
+
idInput.value = ''; titleInput.value = ''; contentInput.value = '';
|
| 682 |
+
}
|
| 683 |
+
modal.style.display = 'flex';
|
| 684 |
+
}
|
| 685 |
+
function closeNoteModal() { document.getElementById('note-modal').style.display = 'none'; }
|
| 686 |
+
async function saveNote() {
|
| 687 |
+
const id = document.getElementById('note-id-input').value;
|
| 688 |
+
const title = document.getElementById('note-title-input').value;
|
| 689 |
+
const content = document.getElementById('note-content-input').value;
|
| 690 |
+
if (!title.trim()) { Telegram.WebApp.showAlert('Заголовок не может быть пустым.'); return; }
|
| 691 |
+
const payload = {
|
| 692 |
+
note_id: id, title: title, content: content, parent_folder_id: '{{ current_folder_id }}'
|
| 693 |
+
};
|
| 694 |
+
const response = await fetch('{{ url_for("create_or_update_note_tma") }}', {
|
| 695 |
+
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
| 696 |
+
});
|
| 697 |
+
const result = await response.json();
|
| 698 |
+
if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
|
| 699 |
+
else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка сохранения.'); }
|
| 700 |
+
}
|
| 701 |
+
function closeRemindersModal() { document.getElementById('reminders-modal').style.display = 'none'; }
|
| 702 |
+
async function openRemindersModal() {
|
| 703 |
+
haptic.impactOccurred('light');
|
| 704 |
+
const modal = document.getElementById('reminders-modal');
|
| 705 |
+
const listEl = document.getElementById('reminders-list');
|
| 706 |
+
listEl.innerHTML = '<div class="loading-spinner"></div>';
|
| 707 |
+
modal.style.display = 'flex';
|
| 708 |
+
try {
|
| 709 |
+
const response = await fetch('{{ url_for("get_reminders_tma") }}');
|
| 710 |
+
const data = await response.json();
|
| 711 |
+
if (data.status === 'success') {
|
| 712 |
+
listEl.innerHTML = '';
|
| 713 |
+
if (data.reminders.length === 0) listEl.innerHTML = '<p>Напоминаний нет.</p>';
|
| 714 |
+
data.reminders.forEach(r => {
|
| 715 |
+
const dt = new Date(r.due_datetime_local);
|
| 716 |
+
const el = document.createElement('div');
|
| 717 |
+
el.className = 'reminder-item';
|
| 718 |
+
el.innerHTML = `
|
| 719 |
+
<div>
|
| 720 |
+
<span>${r.text}</span><br>
|
| 721 |
+
<small style="color:var(--text-muted)">${dt.toLocaleString()}</small>
|
| 722 |
+
</div>
|
| 723 |
+
<button onclick="deleteReminder('${r.id}')">×</button>
|
| 724 |
+
`;
|
| 725 |
+
listEl.appendChild(el);
|
| 726 |
+
});
|
| 727 |
+
} else { listEl.innerHTML = '<p>Ошибка загрузки.</p>'; }
|
| 728 |
+
} catch (e) { listEl.innerHTML = '<p>Сетевая ошибка.</p>'; }
|
| 729 |
+
}
|
| 730 |
+
async function createReminder() {
|
| 731 |
+
const text = document.getElementById('reminder-text-input').value;
|
| 732 |
+
const datetimeLocal = document.getElementById('reminder-datetime-input').value;
|
| 733 |
+
if (!text.trim() || !datetimeLocal) { Telegram.WebApp.showAlert('Заполните все поля.'); return; }
|
| 734 |
+
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
| 735 |
+
const payload = { text, datetime_local: datetimeLocal, user_timezone: userTimezone };
|
| 736 |
+
const response = await fetch('{{ url_for("create_reminder_tma") }}', {
|
| 737 |
+
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
| 738 |
+
});
|
| 739 |
+
const result = await response.json();
|
| 740 |
+
if (result.status === 'success') {
|
| 741 |
+
haptic.notificationOccurred('success');
|
| 742 |
+
document.getElementById('reminder-text-input').value = '';
|
| 743 |
+
document.getElementById('reminder-datetime-input').value = '';
|
| 744 |
+
openRemindersModal();
|
| 745 |
+
} else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка.'); }
|
| 746 |
+
}
|
| 747 |
+
async function deleteReminder(reminderId) {
|
| 748 |
+
Telegram.WebApp.showConfirm('Удалить это напоминание?', async (ok) => {
|
| 749 |
+
if (ok) {
|
| 750 |
+
haptic.impactOccurred('heavy');
|
| 751 |
+
const response = await fetch(`{{ url_for('delete_reminder_tma', reminder_id='__ID__') }}`.replace('__ID__', reminderId), { method: 'POST' });
|
| 752 |
+
const result = await response.json();
|
| 753 |
+
if (result.status === 'success') { openRemindersModal(); }
|
| 754 |
+
else { Telegram.WebApp.showAlert('Ошибка удаления.'); }
|
| 755 |
+
}
|
| 756 |
+
});
|
| 757 |
+
}
|
| 758 |
+
document.getElementById('reminders-btn').addEventListener('click', openRemindersModal);
|
| 759 |
const gridViewBtn = document.getElementById('grid-view-btn');
|
| 760 |
const listViewBtn = document.getElementById('list-view-btn');
|
| 761 |
const fileContainer = document.getElementById('file-container');
|
|
|
|
| 768 |
}
|
| 769 |
gridViewBtn.addEventListener('click', () => setView('grid'));
|
| 770 |
listViewBtn.addEventListener('click', () => setView('list'));
|
|
|
|
| 771 |
const fab = document.getElementById('fab');
|
| 772 |
const fabModal = document.getElementById('fab-modal');
|
| 773 |
fab.addEventListener('click', () => { haptic.impactOccurred('medium'); fabModal.style.display = 'flex'; });
|
| 774 |
function closeFabModal() { fabModal.style.display = 'none'; }
|
| 775 |
fabModal.addEventListener('click', e => { if (e.target.id === 'fab-modal') closeFabModal(); });
|
|
|
|
| 776 |
document.addEventListener('DOMContentLoaded', () => {
|
| 777 |
setView(localStorage.getItem('viewMode') || 'grid');
|
| 778 |
const currentFolderId = '{{ current_folder_id }}';
|
| 779 |
const parentFolderId = '{{ parent_folder_id }}';
|
|
|
|
| 780 |
if (currentFolderId !== 'root') {
|
| 781 |
let backButton = window.Telegram.WebApp.BackButton;
|
| 782 |
backButton.show();
|
| 783 |
+
backButton.onClick(() => { haptic.impactOccurred('light'); window.location.href = `{{ url_for('tma_dashboard') }}?folder_id=${parentFolderId}`; });
|
| 784 |
+
} else { window.Telegram.WebApp.BackButton.hide(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
});
|
| 786 |
</script></body></html>
|
| 787 |
'''
|
|
|
|
| 809 |
|
| 810 |
parent_folder_id = parent_folder.get('id', 'root') if parent_folder else 'root'
|
| 811 |
|
| 812 |
+
items_in_folder = sorted(current_folder.get('children', []), key=lambda x: (x['type'] not in ['folder', 'note'], x.get('name', x.get('original_filename', x.get('title', ''))).lower()))
|
| 813 |
if request.method == 'POST':
|
| 814 |
if not HF_TOKEN_WRITE:
|
| 815 |
flash('Загрузка невозможна: токен для записи не настроен.', 'error')
|
|
|
|
| 884 |
else: flash('Не удалось найти родительскую папку.', 'error')
|
| 885 |
return redirect(url_for('tma_dashboard', folder_id=parent_folder_id))
|
| 886 |
|
| 887 |
+
def get_item_node_for_user(item_id):
|
| 888 |
if not (session.get('telegram_user_id') or session.get('admin_browser_logged_in')):
|
| 889 |
return None
|
| 890 |
data = load_data()
|
| 891 |
if session.get('admin_browser_logged_in'):
|
| 892 |
for uid, udata in data.get('users', {}).items():
|
| 893 |
+
node, _ = find_node_by_id(udata.get('filesystem', {}), item_id)
|
| 894 |
+
if node: return node
|
| 895 |
else:
|
| 896 |
user_data = data['users'].get(session['telegram_user_id'])
|
| 897 |
if user_data:
|
| 898 |
+
node, _ = find_node_by_id(user_data.get('filesystem', {}), item_id)
|
| 899 |
+
if node: return node
|
| 900 |
return None
|
| 901 |
|
| 902 |
def get_file_node_for_admin(tma_user_id_str, file_id):
|
|
|
|
| 912 |
|
| 913 |
@app.route('/download_tma/<file_id>')
|
| 914 |
def download_tma(file_id):
|
| 915 |
+
file_node = get_item_node_for_user(file_id)
|
| 916 |
+
if not file_node or file_node.get('type') != 'file':
|
| 917 |
return jsonify({'status': 'error', 'message': 'Файл не найден или доступ запрещен'}), 404
|
| 918 |
|
| 919 |
token = uuid.uuid4().hex
|
|
|
|
| 975 |
try:
|
| 976 |
with zipfile.ZipFile(temp_zip_file.name, 'w', zipfile.ZIP_DEFLATED) as zf:
|
| 977 |
for file_id in file_ids:
|
| 978 |
+
file_node = get_item_node_for_user(file_id)
|
| 979 |
if file_node and file_node.get('path'):
|
| 980 |
hf_path = file_node['path']
|
| 981 |
original_filename = file_node.get('original_filename', file_id)
|
|
|
|
| 996 |
if os.path.exists(temp_zip_file.name):
|
| 997 |
os.unlink(temp_zip_file.name)
|
| 998 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 999 |
@app.route('/batch_delete_tma', methods=['POST'])
|
| 1000 |
def batch_delete_tma():
|
| 1001 |
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
|
|
|
|
| 1017 |
if node.get('children'): errors.append(f'Папка "{node.get("name")}" не пуста.'); continue
|
| 1018 |
if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
|
| 1019 |
else: errors.append(f'Ошибка удаления папки "{node.get("name")}".')
|
| 1020 |
+
elif node.get('type') == 'note':
|
| 1021 |
+
if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
|
| 1022 |
+
else: errors.append(f'Ошибка удаления "{node.get("title")}".')
|
| 1023 |
elif node.get('type') == 'file':
|
| 1024 |
try:
|
| 1025 |
if node.get('path') and HF_TOKEN_WRITE: api.delete_file(path_in_repo=node['path'], repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
|
|
|
|
| 1074 |
|
| 1075 |
@app.route('/get_text_content_tma/<file_id>')
|
| 1076 |
def get_text_content_tma(file_id):
|
| 1077 |
+
file_node = get_item_node_for_user(file_id)
|
| 1078 |
if not file_node or file_node.get('file_type') != 'text': return Response("Текстовый файл не найден", 404)
|
| 1079 |
hf_path = file_node.get('path')
|
| 1080 |
if not hf_path: return Response("Ошибка: путь к файлу отсутствует", 500)
|
|
|
|
| 1090 |
return Response(text_content, mimetype='text/plain')
|
| 1091 |
except Exception as e: return Response(f"Ошибка загрузки: {e}", 502)
|
| 1092 |
|
| 1093 |
+
@app.route('/get_note_tma/<note_id>')
|
| 1094 |
+
def get_note_tma(note_id):
|
| 1095 |
+
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
|
| 1096 |
+
note_node = get_item_node_for_user(note_id)
|
| 1097 |
+
if not note_node or note_node.get('type') != 'note':
|
| 1098 |
+
return jsonify({'status': 'error', 'message': 'Note not found'}), 404
|
| 1099 |
+
return jsonify({'status': 'success', 'note': note_node})
|
| 1100 |
+
|
| 1101 |
+
@app.route('/create_or_update_note_tma', methods=['POST'])
|
| 1102 |
+
def create_or_update_note_tma():
|
| 1103 |
+
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
|
| 1104 |
+
tma_user_id = session['telegram_user_id']
|
| 1105 |
+
data = load_data()
|
| 1106 |
+
user_data = data['users'].get(tma_user_id)
|
| 1107 |
+
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
|
| 1108 |
+
|
| 1109 |
+
payload = request.json
|
| 1110 |
+
note_id = payload.get('note_id')
|
| 1111 |
+
title = payload.get('title', '').strip()
|
| 1112 |
+
content = payload.get('content', '')
|
| 1113 |
+
parent_folder_id = payload.get('parent_folder_id', 'root')
|
| 1114 |
+
now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
|
| 1115 |
+
|
| 1116 |
+
if not title: return jsonify({'status': 'error', 'message': 'Title cannot be empty.'}), 400
|
| 1117 |
+
|
| 1118 |
+
if note_id:
|
| 1119 |
+
node, _ = find_node_by_id(user_data['filesystem'], note_id)
|
| 1120 |
+
if not node or node.get('type') != 'note':
|
| 1121 |
+
return jsonify({'status': 'error', 'message': 'Note not found'}), 404
|
| 1122 |
+
node['title'] = title
|
| 1123 |
+
node['content'] = content
|
| 1124 |
+
node['modified_date'] = now_str
|
| 1125 |
+
else:
|
| 1126 |
+
new_note_id = uuid.uuid4().hex
|
| 1127 |
+
note_data = {
|
| 1128 |
+
'type': 'note', 'id': new_note_id, 'title': title, 'content': content,
|
| 1129 |
+
'created_date': now_str, 'modified_date': now_str
|
| 1130 |
+
}
|
| 1131 |
+
if not add_node(user_data['filesystem'], parent_folder_id, note_data):
|
| 1132 |
+
return jsonify({'status': 'error', 'message': 'Parent folder not found'}), 404
|
| 1133 |
+
|
| 1134 |
+
try:
|
| 1135 |
+
save_data(data)
|
| 1136 |
+
return jsonify({'status': 'success', 'message': 'Note saved.'})
|
| 1137 |
+
except Exception as e:
|
| 1138 |
+
return jsonify({'status': 'error', 'message': f'Failed to save data: {e}'}), 500
|
| 1139 |
+
|
| 1140 |
+
@app.route('/get_reminders_tma')
|
| 1141 |
+
def get_reminders_tma():
|
| 1142 |
+
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
|
| 1143 |
+
user_data = load_data()['users'].get(session['telegram_user_id'])
|
| 1144 |
+
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
|
| 1145 |
+
|
| 1146 |
+
reminders = sorted(user_data.get('reminders', []), key=lambda r: r.get('due_datetime_utc', ''))
|
| 1147 |
+
return jsonify({'status': 'success', 'reminders': reminders})
|
| 1148 |
+
|
| 1149 |
+
@app.route('/create_reminder_tma', methods=['POST'])
|
| 1150 |
+
def create_reminder_tma():
|
| 1151 |
+
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
|
| 1152 |
+
tma_user_id = session['telegram_user_id']
|
| 1153 |
+
data = load_data()
|
| 1154 |
+
user_data = data['users'].get(tma_user_id)
|
| 1155 |
+
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
|
| 1156 |
+
|
| 1157 |
+
payload = request.json
|
| 1158 |
+
text = payload.get('text', '').strip()
|
| 1159 |
+
dt_local_str = payload.get('datetime_local')
|
| 1160 |
+
user_tz_str = payload.get('user_timezone', 'UTC')
|
| 1161 |
+
if not text or not dt_local_str:
|
| 1162 |
+
return jsonify({'status': 'error', 'message': 'Missing required fields'}), 400
|
| 1163 |
+
|
| 1164 |
+
try:
|
| 1165 |
+
user_tz = ZoneInfo(user_tz_str)
|
| 1166 |
+
except ZoneInfoNotFoundError:
|
| 1167 |
+
user_tz = pytz.timezone('UTC')
|
| 1168 |
+
|
| 1169 |
+
dt_local = datetime.fromisoformat(dt_local_str)
|
| 1170 |
+
dt_aware = dt_local.astimezone(user_tz)
|
| 1171 |
+
dt_utc = dt_aware.astimezone(pytz.utc)
|
| 1172 |
+
|
| 1173 |
+
new_reminder = {
|
| 1174 |
+
'id': uuid.uuid4().hex, 'text': text,
|
| 1175 |
+
'due_datetime_utc': dt_utc.isoformat().replace('+00:00', 'Z'),
|
| 1176 |
+
'due_datetime_local': dt_local.isoformat(),
|
| 1177 |
+
'user_timezone': user_tz_str, 'notified': False
|
| 1178 |
+
}
|
| 1179 |
+
user_data.setdefault('reminders', []).append(new_reminder)
|
| 1180 |
+
try:
|
| 1181 |
+
save_data(data)
|
| 1182 |
+
return jsonify({'status': 'success'})
|
| 1183 |
+
except Exception as e:
|
| 1184 |
+
return jsonify({'status': 'error', 'message': f'Failed to save: {e}'}), 500
|
| 1185 |
+
|
| 1186 |
+
@app.route('/delete_reminder_tma/<reminder_id>', methods=['POST'])
|
| 1187 |
+
def delete_reminder_tma(reminder_id):
|
| 1188 |
+
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
|
| 1189 |
+
tma_user_id = session['telegram_user_id']
|
| 1190 |
+
data = load_data()
|
| 1191 |
+
user_data = data['users'].get(tma_user_id)
|
| 1192 |
+
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
|
| 1193 |
+
|
| 1194 |
+
reminders = user_data.get('reminders', [])
|
| 1195 |
+
user_data['reminders'] = [r for r in reminders if r.get('id') != reminder_id]
|
| 1196 |
+
|
| 1197 |
+
try:
|
| 1198 |
+
save_data(data)
|
| 1199 |
+
return jsonify({'status': 'success'})
|
| 1200 |
+
except Exception as e:
|
| 1201 |
+
return jsonify({'status': 'error', 'message': f'Failed to save: {e}'}), 500
|
| 1202 |
+
|
| 1203 |
@app.route('/tma_logout')
|
| 1204 |
def tma_logout():
|
| 1205 |
session.clear()
|
|
|
|
| 1233 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 1234 |
<style>''' + BASE_STYLE + '''
|
| 1235 |
.user-list { list-style: none; padding: 0; }
|
| 1236 |
+
.user-item { background: var(--card-bg-dark); border-radius: 12px; margin-bottom: 10px; padding: 15px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
|
| 1237 |
.user-item:hover { background: #2a2a2a; }
|
| 1238 |
+
.user-details { display: flex; align-items: center; gap: 15px; flex-grow: 1; }
|
| 1239 |
.user-avatar { width: 50px; height: 50px; border-radius: 50%; object-fit: cover; background-color: #333; }
|
| 1240 |
.user-info span { display: block; }
|
| 1241 |
.user-info .id { font-size: 0.8em; color: var(--text-muted); }
|
| 1242 |
+
.user-actions { display: flex; gap: 10px; flex-shrink: 0; }
|
| 1243 |
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px;}
|
| 1244 |
.search-form { display: flex; gap: 10px; flex-grow: 1; max-width: 400px;}
|
| 1245 |
.search-form input { margin: 0; }
|
|
|
|
| 1266 |
<strong>{{ user.get('first_name', 'N/A') }} {{ user.get('last_name', '') }}</strong> (@{{ user.get('telegram_username', 'N/A') }})
|
| 1267 |
<span class="id">ID: {{ user_id }}</span>
|
| 1268 |
<span class="id">Created: {{ user.get('created_at', 'N/A') }}</span>
|
| 1269 |
+
<span class="id">Items: <strong>{{ user.get('item_count', 0) }}</strong></span>
|
| 1270 |
+
<span class="id">Reminders: <strong>{{ user.get('reminders', [])|length }}</strong></span>
|
| 1271 |
</div>
|
| 1272 |
</div>
|
| 1273 |
+
<div class="user-actions">
|
| 1274 |
+
<a href="{{ url_for('admin_user_files', tma_user_id_str=user_id) }}" class="btn">View Items</a>
|
| 1275 |
+
<a href="{{ url_for('admin_user_reminders', tma_user_id_str=user_id) }}" class="btn folder-btn">Reminders</a>
|
| 1276 |
+
</div>
|
| 1277 |
</li>
|
| 1278 |
{% else %}
|
| 1279 |
<li>No users found.</li>
|
|
|
|
| 1305 |
</div>
|
| 1306 |
<div class="container">
|
| 1307 |
<div class="admin-header">
|
| 1308 |
+
<h1>Items for {{ user.get('first_name', 'N/A') }}</h1>
|
| 1309 |
<div class="user-details">@{{ user.get('telegram_username', 'N/A') }} (ID: {{ user_id }})</div>
|
| 1310 |
</div>
|
| 1311 |
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
|
|
|
|
| 1322 |
<div class="file-grid" id="file-container">
|
| 1323 |
{% for item in items %}
|
| 1324 |
<div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}">
|
| 1325 |
+
<div class="item-preview-wrapper"
|
| 1326 |
+
{% if item.type == 'folder' %} onclick="window.location.href='{{ url_for('admin_user_files', tma_user_id_str=user_id, folder_id=item.id) }}'"
|
| 1327 |
+
{% elif item.type == 'note' %} onclick="openModal(null, 'note', '{{ item.id }}')"
|
| 1328 |
+
{% else %} onclick="openModal('{{ hf_file_url_jinja(item.path) if item.file_type not in ['text', 'pdf'] else (url_for('admin_get_text_content', tma_user_id_str=user_id, file_id=item.id) if item.file_type == 'text' else hf_file_url_jinja(item.path, True)) }}', '{{ item.file_type }}', '{{ item.id }}')" {% endif %}>
|
| 1329 |
{% if item.type == 'folder' %}
|
| 1330 |
<div class="item-preview"><i class="fa-solid fa-folder"></i></div>
|
| 1331 |
+
{% elif item.type == 'note' %}
|
| 1332 |
+
<div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div>
|
| 1333 |
{% elif item.type == 'file' %}
|
| 1334 |
{% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy">
|
| 1335 |
{% elif item.file_type == 'video' %}<video class="item-preview" preload="metadata" muted loading="lazy"><source src="{{ hf_file_url_jinja(item.path, True) }}#t=0.5"></video>
|
|
|
|
| 1340 |
{% endif %}
|
| 1341 |
</div>
|
| 1342 |
<div class="item-name-info">
|
| 1343 |
+
<p class="item-name">{{ (item.title if item.type == 'note' else item.name if item.type == 'folder' else item.original_filename) | truncate(30, True) }}</p>
|
| 1344 |
+
{% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p>
|
| 1345 |
+
{% elif item.type == 'note' %}<p class="item-info">{{ item.modified_date }}</p>{% endif %}
|
| 1346 |
</div>
|
| 1347 |
<div class="item-actions-admin">
|
| 1348 |
+
<form action="{{ url_for('admin_delete_item', tma_user_id_str=user_id, item_id=item.id) }}" method="post" onsubmit="return confirm('Are you sure you want to delete this?');">
|
| 1349 |
+
<input type="hidden" name="current_folder_id" value="{{ current_folder_id }}">
|
| 1350 |
+
<button type="submit" title="Delete Item"><i class="fa fa-trash"></i></button>
|
| 1351 |
+
</form>
|
|
|
|
|
|
|
| 1352 |
</div>
|
| 1353 |
</div>
|
| 1354 |
{% endfor %}
|
|
|
|
| 1374 |
try {
|
| 1375 |
const response = await fetch(`{{ url_for('admin_download_file', tma_user_id_str=user_id, file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId));
|
| 1376 |
const data = await response.json();
|
| 1377 |
+
if (data.status === 'success' && data.url) { window.open(data.url, '_blank'); }
|
| 1378 |
+
else { alert(data.message || 'Failed to create download link.'); }
|
| 1379 |
+
} catch (error) { alert('Network error while creating download link.'); }
|
| 1380 |
+
finally { downloadBtn.innerHTML = originalHTML; closeModalManual(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1381 |
}
|
|
|
|
| 1382 |
async function openModal(srcOrUrl, type, itemId) {
|
| 1383 |
+
if (!itemId) return;
|
| 1384 |
const modal = document.getElementById('mediaModal');
|
| 1385 |
const modalContent = document.getElementById('modalContent');
|
| 1386 |
const downloadBtn = document.getElementById('modal-download-btn');
|
| 1387 |
modalContent.innerHTML = '<div class="loading-spinner"></div>';
|
| 1388 |
modal.style.display = 'flex';
|
| 1389 |
+
downloadBtn.style.display = 'none';
|
| 1390 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1391 |
try {
|
| 1392 |
+
if (type === 'note') {
|
| 1393 |
+
const response = await fetch(`{{ url_for('admin_get_note', tma_user_id_str=user_id, note_id='__ID__') }}`.replace('__ID__', itemId));
|
| 1394 |
+
const data = await response.json();
|
| 1395 |
+
if(data.status === 'success') {
|
| 1396 |
+
modalContent.innerHTML = `<div style="padding:15px; text-align:left;"><h3>${data.note.title.replace(/</g,"<")}</h3><hr style="border-color:#333; margin:10px 0;"><pre>${data.note.content.replace(/</g,"<")}</pre></div>`;
|
| 1397 |
+
} else { throw new Error(data.message); }
|
| 1398 |
+
} else if (type === 'image') {
|
| 1399 |
+
modalContent.innerHTML = `<img src="${srcOrUrl}">`;
|
| 1400 |
+
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
|
| 1401 |
+
} else if (type === 'video') {
|
| 1402 |
+
modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`;
|
| 1403 |
+
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
|
| 1404 |
+
} else if (type === 'pdf') {
|
| 1405 |
+
modalContent.innerHTML = `<iframe src="${srcOrUrl}"></iframe>`;
|
| 1406 |
+
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
|
| 1407 |
+
} else if (type === 'text') {
|
| 1408 |
const response = await fetch(srcOrUrl); if (!response.ok) throw new Error(`Error: ${response.statusText}`);
|
| 1409 |
const text = await response.text();
|
| 1410 |
modalContent.innerHTML = `<pre>${text.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}</pre>`;
|
| 1411 |
+
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
|
| 1412 |
+
} else {
|
| 1413 |
+
initiateDownload(itemId);
|
| 1414 |
+
}
|
| 1415 |
} catch (error) { modalContent.innerHTML = `<p>Preview Error: ${error.message}</p>`; }
|
| 1416 |
}
|
| 1417 |
function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); }
|
|
|
|
| 1433 |
}
|
| 1434 |
gridViewBtn.addEventListener('click', () => setView('grid'));
|
| 1435 |
listViewBtn.addEventListener('click', () => setView('list'));
|
| 1436 |
+
document.addEventListener('DOMContentLoaded', () => { setView(localStorage.getItem('adminViewMode') || 'grid'); });
|
|
|
|
|
|
|
| 1437 |
</script></body></html>
|
| 1438 |
'''
|
| 1439 |
|
| 1440 |
+
ADMIN_USER_REMINDERS_HTML = '''
|
| 1441 |
+
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Admin - User Reminders</title>
|
| 1442 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 1443 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
| 1444 |
+
<style>''' + BASE_STYLE + '''
|
| 1445 |
+
.admin-header { padding-bottom: 15px; border-bottom: 1px solid #333; margin-bottom: 20px; }
|
| 1446 |
+
.reminders-list { list-style: none; }
|
| 1447 |
+
.reminder-item-admin { background: var(--card-bg-dark); border-radius: 12px; padding: 15px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
|
| 1448 |
+
.reminder-info span { display: block; }
|
| 1449 |
+
.reminder-info .utc-time { font-size: 0.8em; color: var(--text-muted); }
|
| 1450 |
+
</style></head><body>
|
| 1451 |
+
<div class="app-header">
|
| 1452 |
+
<div class="user-info"><a href="{{ url_for('admin_panel') }}" style="color: var(--primary); text-decoration: none;">Admin Panel</a></div>
|
| 1453 |
+
</div>
|
| 1454 |
+
<div class="container">
|
| 1455 |
+
<div class="admin-header">
|
| 1456 |
+
<h1>Reminders for {{ user.get('first_name', 'N/A') }}</h1>
|
| 1457 |
+
<div class="user-details">@{{ user.get('telegram_username', 'N/A') }} (ID: {{ user_id }})</div>
|
| 1458 |
+
</div>
|
| 1459 |
+
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
|
| 1460 |
+
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %}
|
| 1461 |
+
{% endif %}{% endwith %}
|
| 1462 |
+
<ul class="reminders-list">
|
| 1463 |
+
{% for reminder in reminders %}
|
| 1464 |
+
<li class="reminder-item-admin">
|
| 1465 |
+
<div class="reminder-info">
|
| 1466 |
+
<strong>{{ reminder.text }}</strong>
|
| 1467 |
+
<span class="utc-time">Due (UTC): {{ reminder.due_datetime_utc }}</span>
|
| 1468 |
+
<span class="utc-time">Notified: {{ 'Yes' if reminder.notified else 'No' }}</span>
|
| 1469 |
+
</div>
|
| 1470 |
+
<form action="{{ url_for('admin_delete_reminder', tma_user_id_str=user_id, reminder_id=reminder.id) }}" method="post" onsubmit="return confirm('Delete this reminder?');">
|
| 1471 |
+
<button type="submit" class="btn delete-btn" style="padding: 8px 12px;"><i class="fa fa-trash"></i></button>
|
| 1472 |
+
</form>
|
| 1473 |
+
</li>
|
| 1474 |
+
{% else %}
|
| 1475 |
+
<li>No reminders for this user.</li>
|
| 1476 |
+
{% endfor %}
|
| 1477 |
+
</ul>
|
| 1478 |
+
</div></body></html>
|
| 1479 |
+
'''
|
| 1480 |
+
|
| 1481 |
@app.route('/admin')
|
| 1482 |
def admin_redirect():
|
| 1483 |
return redirect(url_for('admin_login'))
|
|
|
|
| 1510 |
|
| 1511 |
processed_users = {}
|
| 1512 |
for user_id, user_data in all_users.items():
|
| 1513 |
+
user_data['item_count'] = count_items_recursive(user_data.get('filesystem'))
|
| 1514 |
processed_users[user_id] = user_data
|
| 1515 |
|
| 1516 |
if search_query:
|
|
|
|
| 1550 |
current_folder_id = 'root'
|
| 1551 |
current_folder, _ = find_node_by_id(user_data['filesystem'], 'root')
|
| 1552 |
|
| 1553 |
+
items_in_folder = sorted(current_folder.get('children', []), key=lambda x: (x['type'] not in ['folder', 'note'], x.get('name', x.get('original_filename', x.get('title', ''))).lower()))
|
| 1554 |
|
| 1555 |
breadcrumbs = []
|
| 1556 |
temp_id = current_folder_id
|
|
|
|
| 1572 |
breadcrumbs=breadcrumbs,
|
| 1573 |
hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}")
|
| 1574 |
|
| 1575 |
+
@app.route('/admhosto/user/<tma_user_id_str>/reminders')
|
| 1576 |
+
@admin_browser_login_required
|
| 1577 |
+
def admin_user_reminders(tma_user_id_str):
|
| 1578 |
+
data = load_data()
|
| 1579 |
+
user_data = data['users'].get(tma_user_id_str)
|
| 1580 |
+
if not user_data:
|
| 1581 |
+
flash('User not found.', 'error')
|
| 1582 |
+
return redirect(url_for('admin_panel'))
|
| 1583 |
+
|
| 1584 |
+
reminders = sorted(user_data.get('reminders', []), key=lambda r: r.get('due_datetime_utc', ''), reverse=True)
|
| 1585 |
+
return render_template_string(ADMIN_USER_REMINDERS_HTML, user_id=tma_user_id_str, user=user_data, reminders=reminders)
|
| 1586 |
+
|
| 1587 |
@app.route('/admhosto/download/<tma_user_id_str>/<file_id>')
|
| 1588 |
@admin_browser_login_required
|
| 1589 |
def admin_download_file(tma_user_id_str, file_id):
|
|
|
|
| 1621 |
except Exception as e:
|
| 1622 |
return Response(f"Download error: {e}", 502)
|
| 1623 |
|
| 1624 |
+
@app.route('/admhosto/note/<tma_user_id_str>/<note_id>')
|
| 1625 |
@admin_browser_login_required
|
| 1626 |
+
def admin_get_note(tma_user_id_str, note_id):
|
| 1627 |
+
data = load_data()
|
| 1628 |
+
user_data = data['users'].get(tma_user_id_str)
|
| 1629 |
+
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
|
| 1630 |
+
node, _ = find_node_by_id(user_data['filesystem'], note_id)
|
| 1631 |
+
if not node or node.get('type') != 'note':
|
| 1632 |
+
return jsonify({'status': 'error', 'message': 'Note not found'}), 404
|
| 1633 |
+
return jsonify({'status': 'success', 'note': node})
|
| 1634 |
|
| 1635 |
+
@app.route('/admhosto/delete_item/<tma_user_id_str>/<item_id>', methods=['POST'])
|
| 1636 |
@admin_browser_login_required
|
| 1637 |
+
def admin_delete_item(tma_user_id_str, item_id):
|
| 1638 |
data = load_data()
|
| 1639 |
user_data = data['users'].get(tma_user_id_str)
|
| 1640 |
current_folder_id = request.form.get('current_folder_id', 'root')
|
|
|
|
| 1641 |
if not user_data:
|
| 1642 |
+
flash('User not found.', 'error'); return redirect(url_for('admin_panel'))
|
| 1643 |
+
|
| 1644 |
+
node, _ = find_node_by_id(user_data['filesystem'], item_id)
|
| 1645 |
+
if not node:
|
| 1646 |
+
flash('Item not found.', 'error')
|
| 1647 |
+
elif node.get('type') == 'file':
|
| 1648 |
+
hf_path = node.get('path')
|
| 1649 |
+
if not HF_TOKEN_WRITE: flash('Deletion not possible: write token not configured.', 'error')
|
|
|
|
|
|
|
| 1650 |
else:
|
| 1651 |
try:
|
| 1652 |
api = HfApi()
|
| 1653 |
if hf_path: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
|
| 1654 |
+
if remove_node(user_data['filesystem'], item_id)[0]:
|
|
|
|
| 1655 |
try: save_data(data); flash('File deleted.')
|
| 1656 |
except Exception: flash('File deleted from server, but DB update failed.', 'error')
|
| 1657 |
except hf_utils.EntryNotFoundError:
|
| 1658 |
+
if remove_node(user_data['filesystem'], item_id)[0]:
|
|
|
|
| 1659 |
try: save_data(data); flash('File not found on server, removed from DB.')
|
| 1660 |
except Exception: flash('DB save error (file not on server).', 'error')
|
| 1661 |
+
except Exception as e: flash(f'Deletion error: {e}', 'error')
|
| 1662 |
+
elif node.get('type') == 'note':
|
| 1663 |
+
if remove_node(user_data['filesystem'], item_id)[0]:
|
| 1664 |
+
try: save_data(data); flash('Note deleted.')
|
| 1665 |
+
except Exception: flash('DB update failed after note deletion.', 'error')
|
| 1666 |
+
elif node.get('type') == 'folder':
|
| 1667 |
+
if node.get('children'): flash('Folder is not empty.', 'error')
|
| 1668 |
+
else:
|
| 1669 |
+
if remove_node(user_data['filesystem'], item_id)[0]:
|
| 1670 |
+
try: save_data(data); flash('Folder deleted.')
|
| 1671 |
+
except Exception: flash('DB update failed after folder deletion.', 'error')
|
| 1672 |
return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id))
|
| 1673 |
|
| 1674 |
+
@app.route('/admhosto/user/<tma_user_id_str>/delete_reminder/<reminder_id>', methods=['POST'])
|
| 1675 |
+
@admin_browser_login_required
|
| 1676 |
+
def admin_delete_reminder(tma_user_id_str, reminder_id):
|
| 1677 |
+
data = load_data()
|
| 1678 |
+
user_data = data['users'].get(tma_user_id_str)
|
| 1679 |
+
if not user_data:
|
| 1680 |
+
flash('User not found.', 'error'); return redirect(url_for('admin_panel'))
|
| 1681 |
+
|
| 1682 |
+
reminders = user_data.get('reminders', [])
|
| 1683 |
+
initial_len = len(reminders)
|
| 1684 |
+
user_data['reminders'] = [r for r in reminders if r.get('id') != reminder_id]
|
| 1685 |
+
if len(user_data['reminders']) < initial_len:
|
| 1686 |
+
try:
|
| 1687 |
+
save_data(data)
|
| 1688 |
+
flash('Reminder deleted successfully.', 'success')
|
| 1689 |
+
except Exception as e:
|
| 1690 |
+
flash(f'Failed to save data: {e}', 'error')
|
| 1691 |
+
else:
|
| 1692 |
+
flash('Reminder not found.', 'error')
|
| 1693 |
+
return redirect(url_for('admin_user_reminders', tma_user_id_str=tma_user_id_str))
|
| 1694 |
+
|
| 1695 |
if __name__ == '__main__':
|
| 1696 |
if not HF_TOKEN_WRITE: logging.warning("HF_TOKEN (write) is not set. Uploads/deletions will fail.")
|
| 1697 |
if not HF_TOKEN_READ: logging.warning("HF_TOKEN_READ is not set. Downloads/previews might fail.")
|
| 1698 |
if ADMIN_TELEGRAM_ID == "YOUR_ADMIN_TELEGRAM_USER_ID_HERE": logging.warning("ADMIN_TELEGRAM_ID is not set.")
|
| 1699 |
if ADMIN_USERNAME == "admin" and ADMIN_PASSWORD == "zeusadminpass":
|
| 1700 |
logging.warning("Using default admin credentials. Please change them.")
|
| 1701 |
+
|
| 1702 |
+
if HF_TOKEN_WRITE or HF_TOKEN_READ:
|
|
|
|
|
|
|
| 1703 |
download_db_from_hf()
|
| 1704 |
else:
|
| 1705 |
if not os.path.exists(DATA_FILE):
|
| 1706 |
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}}, f)
|
| 1707 |
+
|
| 1708 |
+
if HF_TOKEN_WRITE:
|
| 1709 |
+
threading.Thread(target=periodic_backup, daemon=True).start()
|
| 1710 |
+
|
| 1711 |
+
threading.Thread(target=check_reminders, daemon=True).start()
|
| 1712 |
+
|
| 1713 |
app.run(debug=False, host='0.0.0.0', port=7860)
|