|
|
|
|
|
import flask |
|
|
from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify, Response, stream_with_context |
|
|
from flask_caching import Cache |
|
|
import json |
|
|
import os |
|
|
import logging |
|
|
import threading |
|
|
import time |
|
|
from datetime import datetime, timedelta |
|
|
from huggingface_hub import HfApi, hf_hub_download, utils as hf_utils |
|
|
from werkzeug.utils import secure_filename |
|
|
import requests |
|
|
from io import BytesIO |
|
|
import uuid |
|
|
from functools import wraps |
|
|
from urllib.parse import quote, quote_plus |
|
|
import zipfile |
|
|
import tempfile |
|
|
import pytz |
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError |
|
|
import re |
|
|
|
|
|
app = Flask(__name__) |
|
|
app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_folders_unique_tma") |
|
|
DATA_FILE = 'cloudeng_data_tma.json' |
|
|
REPO_ID = "Eluza133/Z1e1u" |
|
|
HF_TOKEN_WRITE = os.getenv("HF_TOKEN") |
|
|
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE |
|
|
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4") |
|
|
ADMIN_TELEGRAM_ID = os.getenv("ADMIN_TELEGRAM_ID", "YOUR_ADMIN_TELEGRAM_USER_ID_HERE") |
|
|
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") |
|
|
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "zeusadminpass") |
|
|
UPLOAD_FOLDER = 'uploads_tma' |
|
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True) |
|
|
cache = Cache(app, config={'CACHE_TYPE': 'simple'}) |
|
|
logging.basicConfig(level=logging.INFO) |
|
|
save_data_lock = threading.Lock() |
|
|
|
|
|
BASE_STYLE = ''' |
|
|
:root { |
|
|
--primary: #ff4d6d; --secondary: #00ddeb; --accent: #8b5cf6; |
|
|
--background-dark: #121212; --card-bg-dark: #1e1e1e; |
|
|
--text-dark: #e0e0e0; --text-muted: #888; --shadow: 0 10px 30px rgba(0, 0, 0, 0.2); |
|
|
--glass-bg: rgba(30, 30, 30, 0.7); --transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); |
|
|
--delete-color: #ff4444; --folder-color: #ffc107; --selection-color: rgba(139, 92, 246, 0.3); |
|
|
--note-color: #6a5acd; --share-color: #4caf50; --archive-color: #78909c; |
|
|
--todolist-color: #29b6f6; --shoppinglist-color: #ffa726; --business-color: #fd7e14; |
|
|
} |
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
|
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
|
|
html { scroll-behavior: smooth; } |
|
|
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--background-dark); color: var(--text-dark); line-height: 1.6; -webkit-tap-highlight-color: transparent; } |
|
|
.container { margin: 0 auto; max-width: 1200px; padding: 75px 15px 15px 15px; } |
|
|
.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; } |
|
|
.user-info { font-weight: 600; } |
|
|
.view-toggle { display: flex; align-items: center; gap: 5px; } |
|
|
.view-toggle button, .view-toggle a { background: none; border: none; color: var(--text-muted); font-size: 1.2em; padding: 5px; cursor: pointer; transition: var(--transition); text-decoration: none;} |
|
|
.view-toggle button:hover, .view-toggle button.active, .view-toggle a:hover { color: var(--primary); } |
|
|
h1, h2, h3, h4, h5 { color: var(--text-dark); } |
|
|
h2 { font-size: 1.3em; margin-bottom: 15px; margin-top: 15px; } |
|
|
.breadcrumbs { font-size: 1em; margin-bottom: 20px; white-space: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; } |
|
|
.breadcrumbs a { color: var(--accent); text-decoration: none; } |
|
|
.breadcrumbs span { margin: 0 5px; color: var(--text-muted); } |
|
|
input, select, textarea, label { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #333; border-radius: 12px; background: #2a2a2a; color: var(--text-dark); font-size: 1em; } |
|
|
label { padding: 0; margin: 0; border: none; background: none; } |
|
|
.checkbox-label { display: flex; align-items: center; gap: 10px; width: auto; } |
|
|
.checkbox-label input[type="checkbox"] { width: auto; margin: 0; } |
|
|
.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; } |
|
|
.btn:hover { filter: brightness(1.2); } |
|
|
.btn:active { transform: scale(0.98); } |
|
|
.download-btn { background: var(--secondary); } |
|
|
.delete-btn { background: var(--delete-color); } |
|
|
.folder-btn { background: var(--folder-color); } |
|
|
.share-btn { background: var(--share-color); } |
|
|
.archive-btn { background: var(--archive-color); } |
|
|
.business-btn { background: var(--business-color); } |
|
|
.flash { text-align: center; margin-bottom: 15px; padding: 12px; border-radius: 10px; background: rgba(0, 221, 235, 0.1); color: var(--secondary); } |
|
|
.flash.error { background: rgba(255, 68, 68, 0.1); color: var(--delete-color); } |
|
|
.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; } |
|
|
.item { background: var(--card-bg-dark); border-radius: 16px; text-align: center; transition: var(--transition); position: relative; border: 2px solid transparent; user-select: none; padding: 10px; display: flex; flex-direction: column; cursor: pointer; } |
|
|
.item:hover { transform: translateY(-5px); box-shadow: var(--shadow); } |
|
|
.item:active { transform: scale(0.97); } |
|
|
.item.selected { border-color: var(--accent); background-color: var(--selection-color); } |
|
|
.item-preview-wrapper { position: relative; width: 100%; padding-top: 75%; border-radius: 10px; overflow: hidden; margin-bottom: 10px; background: #2a2a2a; } |
|
|
.item-preview { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; } |
|
|
.item.folder .item-preview { object-fit: contain; font-size: 3.5em; color: var(--folder-color); } |
|
|
.item.note .item-preview { object-fit: contain; font-size: 3.5em; color: var(--note-color); } |
|
|
.item.todolist .item-preview { object-fit: contain; font-size: 3.5em; color: var(--todolist-color); } |
|
|
.item.shoppinglist .item-preview { object-fit: contain; font-size: 3.5em; color: var(--shoppinglist-color); } |
|
|
.item-name { font-size: 0.9em; font-weight: 500; word-break: break-all; margin: 5px 0; flex-grow: 1; } |
|
|
.item-info { font-size: 0.75em; color: var(--text-muted); } |
|
|
.file-grid.list-view { display: flex; flex-direction: column; gap: 8px; } |
|
|
.file-grid.list-view .item { flex-direction: row; align-items: center; text-align: left; padding: 8px; } |
|
|
.file-grid.list-view .item:hover { transform: translateY(0); } |
|
|
.file-grid.list-view .item-preview-wrapper { width: 45px; height: 45px; padding-top: 0; margin-bottom: 0; margin-right: 15px; flex-shrink: 0; } |
|
|
.file-grid.list-view .item.folder .item-preview, .file-grid.list-view .item.note .item-preview, |
|
|
.file-grid.list-view .item.todolist .item-preview, .file-grid.list-view .item.shoppinglist .item-preview { font-size: 1.8em; } |
|
|
.file-grid.list-view .item-name-info { flex-grow: 1; } |
|
|
.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; } |
|
|
.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; } |
|
|
.modal-main-content { flex-grow: 1; overflow-y: auto; padding: 10px; } |
|
|
.modal-main-content img, .modal-main-content video, .modal-main-content iframe, .modal-main-content pre { max-width: 100%; max-height: 85vh; display: block; margin: auto; border-radius: 10px; } |
|
|
.modal-main-content iframe { width: 80vw; height: 85vh; border: none; } |
|
|
.modal-main-content pre { background: #121212; padding: 15px; border-radius: 8px; white-space: pre-wrap; word-wrap: break-word; max-height: 85vh; color: var(--text-dark); } |
|
|
.modal-actions { padding: 10px; text-align: center; border-top: 1px solid #333; } |
|
|
.modal-close-btn { position: absolute; top: 15px; right: 25px; font-size: 30px; color: #aaa; cursor: pointer; background: rgba(0,0,0,0.5); border-radius: 50%; width: 30px; height: 30px; line-height: 30px; text-align: center; z-index: 2001;} |
|
|
#progress-container { width: 100%; background: #333; border-radius: 10px; margin: 15px 0; display: none; height: 10px; } |
|
|
#progress-bar { width: 0%; height: 100%; background: var(--primary); border-radius: 10px; transition: width 0.3s ease; } |
|
|
#selection-bar { position: fixed; bottom: -120px; left: 10px; right: 10px; background: var(--glass-bg); backdrop-filter: blur(10px); padding: 10px; border-radius: 15px; box-shadow: var(--shadow); z-index: 1000; display: flex; justify-content: space-around; align-items: center; transition: bottom 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); } |
|
|
#selection-bar.visible { bottom: 10px; } |
|
|
#selection-bar .btn { margin: 0 5px; padding: 10px 15px; font-size: 0.9em; flex-grow: 1; } |
|
|
#move-modal .modal-content { padding: 20px; max-width: 400px; } |
|
|
.fab-container { position: fixed; bottom: 20px; right: 20px; z-index: 1050; } |
|
|
.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; } |
|
|
.fab:active { transform: scale(0.9); } |
|
|
.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; } |
|
|
#fab-modal .modal-content { padding: 20px; max-width: 500px; background: var(--card-bg-dark); text-align: center; } |
|
|
.fab-options { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 15px; margin-bottom: 20px; } |
|
|
.fab-option { display: flex; flex-direction: column; align-items: center; justify-content: center; background: #2a2a2a; border-radius: 12px; padding: 15px; cursor: pointer; transition: var(--transition); text-decoration:none; color: var(--text-dark); } |
|
|
.fab-option:hover { background: #333; transform: translateY(-3px); } |
|
|
.fab-option i { font-size: 2em; margin-bottom: 8px; } |
|
|
#fab-option-upload i { color: var(--secondary); } |
|
|
#fab-option-note i { color: var(--note-color); } |
|
|
#fab-option-folder i { color: var(--folder-color); } |
|
|
#fab-option-todolist i { color: var(--todolist-color); } |
|
|
#fab-option-shoppinglist i { color: var(--shoppinglist-color); } |
|
|
#fab-option-business i { color: var(--business-color); } |
|
|
#create-folder-form { display: none; margin-top: 15px; } |
|
|
.shared-link-item { display: flex; justify-content: space-between; align-items: center; padding: 8px; border-bottom: 1px solid #333; } |
|
|
.shared-link-item:last-child { border-bottom: none; } |
|
|
.shared-link-info { text-align: left; } |
|
|
.shared-link-info strong { word-break: break-all; } |
|
|
.shared-link-info small { color: var(--text-muted); display: block; } |
|
|
.shared-link-actions button { background: none; border: none; color: var(--text-muted); font-size: 1.1em; cursor: pointer; padding: 5px; } |
|
|
.list-editor-item { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; } |
|
|
.list-editor-item:hover { background-color: #2a2a2a; } |
|
|
.list-editor-item input[type=checkbox] { width: 20px; height: 20px; flex-shrink: 0; } |
|
|
.list-editor-item input[type=text] { margin: 0; flex-grow: 1; } |
|
|
.list-editor-item .quantity-controls { display: flex; align-items: center; gap: 5px; } |
|
|
.list-editor-item .quantity-controls input { width: 50px; text-align: center; padding: 8px; margin: 0; } |
|
|
.list-editor-item .quantity-controls button { background: #333; border: none; color: white; border-radius: 50%; width: 28px; height: 28px; font-weight: bold; cursor: pointer; } |
|
|
.list-editor-item .delete-item-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.2em; } |
|
|
.list-editor-item.completed span { text-decoration: line-through; color: var(--text-muted); } |
|
|
.public-list-item { display: flex; align-items: center; gap: 15px; padding: 12px; border-bottom: 1px solid #333; } |
|
|
.public-list-item input[type=checkbox] { width: 22px; height: 22px; cursor: pointer; } |
|
|
.public-list-item label { flex-grow: 1; cursor: pointer; } |
|
|
.public-list-item.purchased label { text-decoration: line-through; color: var(--text-muted); } |
|
|
.public-list-item .quantity { font-weight: bold; color: var(--secondary); background: #2a2a2a; padding: 2px 8px; border-radius: 6px; } |
|
|
.form-group { margin-bottom: 15px; text-align: left; } |
|
|
.form-group small { color: var(--text-muted); font-size: 0.8em; margin-top: 4px; display: block; } |
|
|
''' |
|
|
|
|
|
PUBLIC_SHARE_PAGE_HTML = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Общая папка: {{ folder.name }}</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<style>''' + BASE_STYLE + ''' |
|
|
body { padding-bottom: 30px; } |
|
|
.public-header { padding: 15px; text-align: center; border-bottom: 1px solid #333; margin-bottom: 20px; } |
|
|
.item { cursor: default; } |
|
|
.item .download-icon { position: absolute; top: 10px; right: 10px; font-size: 1.2em; color: var(--text-muted); cursor: pointer; transition: var(--transition); } |
|
|
.item .download-icon:hover { color: var(--secondary); } |
|
|
.list-view .item .download-icon { position: static; margin-left: auto; padding: 5px 10px; } |
|
|
</style></head><body> |
|
|
<div class="public-header"> |
|
|
<h1>Общая папка</h1> |
|
|
<h2>{{ folder.name }}</h2> |
|
|
<p style="color: var(--text-muted);">Автор: {{ user.first_name or user.telegram_username }}</p> |
|
|
</div> |
|
|
<div class="container" style="padding-top: 15px;"> |
|
|
<div class="file-grid list-view"> |
|
|
{% for item in items %} |
|
|
<div class="item {{ item.type }}"> |
|
|
<div class="item-preview-wrapper"> |
|
|
{% if item.type == 'folder' %} |
|
|
<a href="{{ url_for('shared_folder_view', link_id=link.id, subfolder_id=item.id) }}" style="text-decoration: none; color: inherit;"> |
|
|
<div class="item-preview"><i class="fa-solid fa-folder"></i></div> |
|
|
</a> |
|
|
{% elif item.type == 'note' %} |
|
|
<div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div> |
|
|
{% elif item.file_type == 'image' %}<div class="item-preview" style="background-image: url({{ hf_file_url_jinja(item.path) }}); background-size: cover; background-position: center;"></div> |
|
|
{% else %}<div class="item-preview" style="font-size: 1.8em; display: flex; align-items: center; justify-content: center;"><i class="fa-solid fa-file"></i></div>{% endif %} |
|
|
</div> |
|
|
<div class="item-name-info"> |
|
|
<p class="item-name"> |
|
|
{% if item.type == 'folder' %} |
|
|
<a href="{{ url_for('shared_folder_view', link_id=link.id, subfolder_id=item.id) }}" style="text-decoration: none; color: inherit;">{{ item.name }}</a> |
|
|
{% else %} |
|
|
{{ item.title if item.type == 'note' else item.original_filename }} |
|
|
{% endif %} |
|
|
</p> |
|
|
<p class="item-info">{% if item.type == 'file' %}{{ item.upload_date }}{% elif item.type == 'note' %}{{ item.modified_date }}{% endif %}</p> |
|
|
</div> |
|
|
{% if item.type != 'folder' %} |
|
|
<a href="{{ url_for('public_download_via_link', link_id=link.id, item_id=item.id) }}" class="download-icon" title="Скачать"> |
|
|
<i class="fa-solid fa-download"></i> |
|
|
</a> |
|
|
{% endif %} |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% if not items %}<p>Эта папка пуста.</p>{% endif %} |
|
|
</div> |
|
|
</div></body></html> |
|
|
''' |
|
|
|
|
|
PUBLIC_SHOPPING_LIST_HTML = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Список покупок: {{ list_data.title }}</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<style>''' + BASE_STYLE + ''' |
|
|
body { padding-bottom: 30px; } |
|
|
.public-header { padding: 15px; text-align: center; border-bottom: 1px solid #333; margin-bottom: 20px; } |
|
|
.list-container { max-width: 700px; margin: 0 auto; background: var(--card-bg-dark); border-radius: 16px; padding: 10px; } |
|
|
</style></head><body> |
|
|
<div class="public-header"> |
|
|
<h1>Список покупок</h1> |
|
|
<h2 id="list-title">{{ list_data.title }}</h2> |
|
|
<p style="color: var(--text-muted);">Автор: {{ user.first_name or user.telegram_username }}</p> |
|
|
</div> |
|
|
<div class="container" style="padding-top: 15px;"> |
|
|
<div class="list-container" id="shopping-list-container"> |
|
|
<div class="loading-spinner"></div> |
|
|
</div> |
|
|
</div> |
|
|
<script> |
|
|
const linkId = '{{ link.id }}'; |
|
|
const listContainer = document.getElementById('shopping-list-container'); |
|
|
|
|
|
function renderList(items) { |
|
|
listContainer.innerHTML = ''; |
|
|
if (!items || items.length === 0) { |
|
|
listContainer.innerHTML = '<p style="text-align: center; padding: 20px;">Список пуст.</p>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
items.sort((a, b) => a.purchased - b.purchased); |
|
|
|
|
|
items.forEach(item => { |
|
|
const itemEl = document.createElement('div'); |
|
|
itemEl.className = 'public-list-item'; |
|
|
if (item.purchased) { |
|
|
itemEl.classList.add('purchased'); |
|
|
} |
|
|
itemEl.innerHTML = ` |
|
|
<input type="checkbox" id="item-${item.id}" ${item.purchased ? 'checked' : ''} onchange="toggleItem('${item.id}')"> |
|
|
<label for="item-${item.id}">${item.name}</label> |
|
|
<span class="quantity">${item.quantity}</span> |
|
|
`; |
|
|
listContainer.appendChild(itemEl); |
|
|
}); |
|
|
} |
|
|
|
|
|
async function fetchList() { |
|
|
try { |
|
|
const response = await fetch(`{{ url_for('public_list_data', link_id=link.id) }}`); |
|
|
if (!response.ok) { |
|
|
listContainer.innerHTML = '<p>Ошибка загрузки списка.</p>'; |
|
|
return; |
|
|
} |
|
|
const data = await response.json(); |
|
|
if (data.status === 'success') { |
|
|
document.getElementById('list-title').textContent = data.list.title; |
|
|
renderList(data.list.items); |
|
|
} else { |
|
|
listContainer.innerHTML = `<p>${data.message || 'Ошибка.'}</p>`; |
|
|
} |
|
|
} catch (e) { |
|
|
listContainer.innerHTML = '<p>Сетевая ошибка.</p>'; |
|
|
} |
|
|
} |
|
|
|
|
|
async function toggleItem(itemId) { |
|
|
try { |
|
|
const checkbox = document.getElementById(`item-${itemId}`); |
|
|
if(checkbox) checkbox.disabled = true; |
|
|
|
|
|
await fetch(`{{ url_for('public_toggle_item', link_id=link.id, item_id='ITEM_ID') }}`.replace('ITEM_ID', itemId), { |
|
|
method: 'POST' |
|
|
}); |
|
|
await fetchList(); |
|
|
} catch (e) { |
|
|
alert('Не удалось обновить элемент. Пожалуйста, обновите страницу.'); |
|
|
} finally { |
|
|
const checkbox = document.getElementById(`item-${itemId}`); |
|
|
if(checkbox) checkbox.disabled = false; |
|
|
} |
|
|
} |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
fetchList(); |
|
|
setInterval(fetchList, 5000); |
|
|
}); |
|
|
</script> |
|
|
</body></html> |
|
|
''' |
|
|
|
|
|
PUBLIC_BUSINESS_PAGE_HTML = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>{{ page.org_name }}</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<style>''' + BASE_STYLE + ''' |
|
|
body { background: var(--card-bg-dark); padding-bottom: 80px; } |
|
|
.container { max-width: 800px; padding-top: 20px; } |
|
|
.biz-header { text-align: center; margin-bottom: 30px; } |
|
|
.biz-avatar { width: 100px; height: 100px; border-radius: 50%; object-fit: cover; margin: 0 auto 15px auto; border: 3px solid var(--accent); } |
|
|
.biz-header h1 { font-size: 2em; color: var(--text-dark); } |
|
|
.product-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; } |
|
|
@media (max-width: 600px) { .product-grid { grid-template-columns: 1fr; } } |
|
|
.product-card { background: var(--background-dark); border-radius: 16px; overflow: hidden; box-shadow: 0 5px 15px rgba(0,0,0,0.2); transition: var(--transition); display: flex; flex-direction: column; } |
|
|
.product-card:hover { transform: translateY(-5px); } |
|
|
.product-image { width: 100%; padding-top: 100%; position: relative; background-color: #2a2a2a; } |
|
|
.product-image img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; } |
|
|
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; } |
|
|
.product-name { font-size: 1.1em; font-weight: 600; margin-bottom: 5px; flex-grow: 1; } |
|
|
.product-price { font-size: 1.2em; font-weight: bold; color: var(--secondary); margin-bottom: 10px; } |
|
|
.product-desc { font-size: 0.9em; color: var(--text-muted); margin-bottom: 15px; } |
|
|
.add-to-cart-btn { margin-top: auto; background: var(--accent); } |
|
|
.add-to-cart-btn:hover { background: var(--accent); filter: brightness(1.2); } |
|
|
.order-fab { position: fixed; bottom: 20px; right: 20px; z-index: 100; } |
|
|
.order-btn { display: flex; align-items: center; gap: 10px; padding: 15px 25px; border-radius: 30px; font-size: 1.1em; font-weight: 600; box-shadow: var(--shadow); } |
|
|
.order-btn.whatsapp { background: #25D366; color: white; } |
|
|
.order-btn.telegram { background: #0088cc; color: white; } |
|
|
.order-btn i { font-size: 1.4em; } |
|
|
.cart-fab { position: fixed; bottom: 20px; left: 20px; z-index: 1050; width: 60px; height: 60px; background: var(--accent); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 24px; cursor: pointer; box-shadow: var(--shadow); transition: transform 0.3s; } |
|
|
.cart-fab .cart-count { position: absolute; top: 0; right: 0; background: var(--primary); color: white; border-radius: 50%; width: 22px; height: 22px; font-size: 12px; font-weight: bold; display: flex; align-items: center; justify-content: center; border: 2px solid var(--accent); } |
|
|
.cart-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 2000; } |
|
|
.cart-modal.visible { display: block; } |
|
|
.cart-modal-content { position: fixed; bottom: 0; left: 0; right: 0; background: var(--card-bg-dark); border-top-left-radius: 20px; border-top-right-radius: 20px; padding: 20px; max-height: 80vh; display: flex; flex-direction: column; transform: translateY(100%); animation: slideUp 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) forwards; } |
|
|
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } |
|
|
.cart-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 10px; } |
|
|
.cart-modal-header h2 { margin: 0; } |
|
|
.cart-modal-close { font-size: 24px; cursor: pointer; background: none; border: none; color: var(--text-muted); } |
|
|
.cart-items { flex-grow: 1; overflow-y: auto; } |
|
|
.cart-item { display: flex; align-items: center; gap: 15px; margin-bottom: 15px; } |
|
|
.cart-item-img { width: 50px; height: 50px; object-fit: cover; border-radius: 8px; } |
|
|
.cart-item-details { flex-grow: 1; } |
|
|
.cart-item-name { font-weight: 500; } |
|
|
.cart-item-price { font-size: 0.9em; color: var(--text-muted); } |
|
|
.cart-quantity { display: flex; align-items: center; gap: 8px; } |
|
|
.cart-quantity button { width: 28px; height: 28px; border-radius: 50%; background: #333; color: white; border: none; cursor: pointer; font-weight: bold; } |
|
|
.cart-footer { margin-top: 20px; border-top: 1px solid #333; padding-top: 15px; } |
|
|
.cart-total { display: flex; justify-content: space-between; font-size: 1.2em; font-weight: bold; margin-bottom: 15px; } |
|
|
</style></head><body> |
|
|
<div class="container"> |
|
|
<div class="biz-header"> |
|
|
{% if page.avatar_path %} |
|
|
<img src="{{ hf_file_url_jinja(page.avatar_path) }}" alt="Avatar" class="biz-avatar"> |
|
|
{% endif %} |
|
|
<h1>{{ page.org_name }}</h1> |
|
|
</div> |
|
|
|
|
|
{% if page.products %} |
|
|
<div class="product-grid"> |
|
|
{% for product in page.products %} |
|
|
<div class="product-card"> |
|
|
<div class="product-image"> |
|
|
{% if product.photo_path %} |
|
|
<img src="{{ hf_file_url_jinja(product.photo_path) }}" alt="{{ product.name }}"> |
|
|
{% endif %} |
|
|
</div> |
|
|
<div class="product-info"> |
|
|
<h3 class="product-name">{{ product.name }}</h3> |
|
|
<p class="product-desc">{{ product.description }}</p> |
|
|
{% if page.show_prices and product.price %} |
|
|
<p class="product-price">{{ "%.2f"|format(product.price|float) }} {{ page.currency }}</p> |
|
|
<button class="btn add-to-cart-btn" |
|
|
data-id="{{ product.id }}" |
|
|
data-name="{{ product.name }}" |
|
|
data-price="{{ product.price }}" |
|
|
data-image="{{ product.photo_path or '' }}"> |
|
|
<i class="fa-solid fa-cart-plus"></i> В корзину |
|
|
</button> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
{% else %} |
|
|
<p style="text-align: center;">Товары скоро появятся.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
|
|
|
<div class="order-fab"> |
|
|
{% set phone_number = page.contact_number | replace('+', '') | replace(' ', '') %} |
|
|
{% if page.order_destination == 'whatsapp' %} |
|
|
<a href="https://wa.me/{{ phone_number }}" class="btn order-btn whatsapp" target="_blank"> |
|
|
<i class="fab fa-whatsapp"></i> Заказать |
|
|
</a> |
|
|
{% elif page.order_destination == 'telegram' %} |
|
|
<a href="https://t.me/{{ phone_number }}" class="btn order-btn telegram" target="_blank"> |
|
|
<i class="fab fa-telegram"></i> Заказать |
|
|
</a> |
|
|
{% endif %} |
|
|
</div> |
|
|
|
|
|
{% if page.show_prices %} |
|
|
<div id="cart-fab" class="cart-fab"> |
|
|
<i class="fa-solid fa-shopping-cart"></i> |
|
|
<span id="cart-count" class="cart-count">0</span> |
|
|
</div> |
|
|
|
|
|
<div id="cart-modal" class="cart-modal"> |
|
|
<div class="cart-modal-content"> |
|
|
<div class="cart-modal-header"> |
|
|
<h2>Корзина</h2> |
|
|
<button id="cart-modal-close" class="cart-modal-close">×</button> |
|
|
</div> |
|
|
<div id="cart-items-container" class="cart-items"> |
|
|
<p>Корзина пуста.</p> |
|
|
</div> |
|
|
<div class="cart-footer"> |
|
|
<div class="cart-total"> |
|
|
<span>Итого:</span> |
|
|
<span id="cart-total-price">0.00 {{ page.currency }}</span> |
|
|
</div> |
|
|
<button id="checkout-btn" class="btn business-btn" style="width: 100%;">Оформить заказ</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{% endif %} |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
{% if page.show_prices %} |
|
|
const cartManager = { |
|
|
login: '{{ page.login }}', |
|
|
currency: '{{ page.currency }}', |
|
|
cart: {}, |
|
|
|
|
|
init() { |
|
|
this.load(); |
|
|
this.render(); |
|
|
this.attachEventListeners(); |
|
|
}, |
|
|
|
|
|
load() { |
|
|
this.cart = JSON.parse(localStorage.getItem(`cart_${this.login}`)) || {}; |
|
|
}, |
|
|
|
|
|
save() { |
|
|
localStorage.setItem(`cart_${this.login}`, JSON.stringify(this.cart)); |
|
|
}, |
|
|
|
|
|
add(id, name, price, image) { |
|
|
if (this.cart[id]) { |
|
|
this.cart[id].quantity++; |
|
|
} else { |
|
|
this.cart[id] = { name, price: parseFloat(price), image, quantity: 1 }; |
|
|
} |
|
|
this.save(); |
|
|
this.render(); |
|
|
}, |
|
|
|
|
|
updateQuantity(id, change) { |
|
|
if (this.cart[id]) { |
|
|
this.cart[id].quantity += change; |
|
|
if (this.cart[id].quantity <= 0) { |
|
|
delete this.cart[id]; |
|
|
} |
|
|
this.save(); |
|
|
this.render(); |
|
|
} |
|
|
}, |
|
|
|
|
|
clear() { |
|
|
this.cart = {}; |
|
|
this.save(); |
|
|
this.render(); |
|
|
}, |
|
|
|
|
|
render() { |
|
|
const totalItems = Object.values(this.cart).reduce((sum, item) => sum + item.quantity, 0); |
|
|
const totalPrice = Object.values(this.cart).reduce((sum, item) => sum + item.price * item.quantity, 0); |
|
|
|
|
|
document.getElementById('cart-count').textContent = totalItems; |
|
|
document.getElementById('cart-total-price').textContent = `${totalPrice.toFixed(2)} ${this.currency}`; |
|
|
|
|
|
const itemsContainer = document.getElementById('cart-items-container'); |
|
|
itemsContainer.innerHTML = ''; |
|
|
|
|
|
if (totalItems === 0) { |
|
|
itemsContainer.innerHTML = '<p style="text-align:center; color:var(--text-muted);">Корзина пуста</p>'; |
|
|
document.getElementById('checkout-btn').disabled = true; |
|
|
} else { |
|
|
document.getElementById('checkout-btn').disabled = false; |
|
|
for (const [id, item] of Object.entries(this.cart)) { |
|
|
const itemEl = document.createElement('div'); |
|
|
itemEl.className = 'cart-item'; |
|
|
const imageHtml = item.image ? `<img src="{{ hf_file_url_jinja('') }}${item.image}" class="cart-item-img">` : '<div class="cart-item-img" style="background:#333"></div>'; |
|
|
itemEl.innerHTML = ` |
|
|
${imageHtml} |
|
|
<div class="cart-item-details"> |
|
|
<div class="cart-item-name">${item.name}</div> |
|
|
<div class="cart-item-price">${item.price.toFixed(2)} ${this.currency}</div> |
|
|
</div> |
|
|
<div class="cart-quantity"> |
|
|
<button class="quantity-btn" data-id="${id}" data-change="-1">-</button> |
|
|
<span>${item.quantity}</span> |
|
|
<button class="quantity-btn" data-id="${id}" data-change="1">+</button> |
|
|
</div> |
|
|
`; |
|
|
itemsContainer.appendChild(itemEl); |
|
|
} |
|
|
} |
|
|
}, |
|
|
|
|
|
async checkout() { |
|
|
const btn = document.getElementById('checkout-btn'); |
|
|
btn.disabled = true; |
|
|
btn.innerHTML = '<div class="loading-spinner" style="width:20px; height:20px; border-width:2px;"></div>'; |
|
|
|
|
|
try { |
|
|
const response = await fetch("{{ url_for('create_order', login=page.login) }}", { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(this.cart) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (result.status === 'success' && result.order_url) { |
|
|
this.clear(); |
|
|
window.location.href = result.order_url; |
|
|
} else { |
|
|
throw new Error(result.message || 'Не удалось создать заказ.'); |
|
|
} |
|
|
} catch (error) { |
|
|
alert('Ошибка: ' + error.message); |
|
|
btn.disabled = false; |
|
|
btn.innerHTML = 'Оформить заказ'; |
|
|
} |
|
|
}, |
|
|
|
|
|
attachEventListeners() { |
|
|
document.querySelectorAll('.add-to-cart-btn').forEach(btn => { |
|
|
btn.addEventListener('click', (e) => { |
|
|
const data = e.currentTarget.dataset; |
|
|
this.add(data.id, data.name, data.price, data.image); |
|
|
|
|
|
// Animation |
|
|
const cartFab = document.getElementById('cart-fab'); |
|
|
cartFab.style.transform = 'scale(1.2)'; |
|
|
setTimeout(() => cartFab.style.transform = 'scale(1)', 200); |
|
|
}); |
|
|
}); |
|
|
|
|
|
const cartModal = document.getElementById('cart-modal'); |
|
|
document.getElementById('cart-fab').addEventListener('click', () => cartModal.classList.add('visible')); |
|
|
document.getElementById('cart-modal-close').addEventListener('click', () => cartModal.classList.remove('visible')); |
|
|
cartModal.addEventListener('click', (e) => { |
|
|
if (e.target.id === 'cart-modal') { |
|
|
cartModal.classList.remove('visible'); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('cart-items-container').addEventListener('click', (e) => { |
|
|
if (e.target.classList.contains('quantity-btn')) { |
|
|
const id = e.target.dataset.id; |
|
|
const change = parseInt(e.target.dataset.change); |
|
|
this.updateQuantity(id, change); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('checkout-btn').addEventListener('click', () => this.checkout()); |
|
|
} |
|
|
}; |
|
|
|
|
|
cartManager.init(); |
|
|
{% endif %} |
|
|
}); |
|
|
</script> |
|
|
</body></html> |
|
|
''' |
|
|
|
|
|
PUBLIC_ORDER_PAGE_HTML = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Заказ {{ order.id[:8] }}</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<style>''' + BASE_STYLE + ''' |
|
|
body { background: var(--card-bg-dark); } |
|
|
.container { max-width: 800px; padding-top: 20px; padding-bottom: 100px; } |
|
|
.order-header { text-align: center; margin-bottom: 20px; } |
|
|
.order-header h1 { font-size: 1.8em; } |
|
|
.order-header p { color: var(--text-muted); } |
|
|
.order-details { background: var(--background-dark); border-radius: 16px; padding: 20px; margin-bottom: 20px; } |
|
|
.order-item { display: flex; align-items: center; gap: 15px; padding: 10px 0; border-bottom: 1px solid #333; } |
|
|
.order-item:last-child { border-bottom: none; } |
|
|
.item-image { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; background-color: #2a2a2a; flex-shrink: 0; } |
|
|
.item-info { flex-grow: 1; } |
|
|
.item-name { font-weight: 600; } |
|
|
.item-price { font-size: 0.9em; color: var(--text-muted); } |
|
|
.item-subtotal { font-weight: bold; } |
|
|
.order-total { text-align: right; font-size: 1.3em; font-weight: bold; margin-top: 20px; } |
|
|
.send-order-fab { position: fixed; bottom: 20px; right: 20px; z-index: 100; } |
|
|
.send-order-btn { display: flex; align-items: center; gap: 10px; padding: 15px 25px; border-radius: 30px; font-size: 1.1em; font-weight: 600; box-shadow: var(--shadow); text-decoration: none; } |
|
|
.send-order-btn.whatsapp { background: #25D366; color: white; } |
|
|
.send-order-btn.telegram { background: #0088cc; color: white; } |
|
|
.send-order-btn i { font-size: 1.4em; } |
|
|
</style></head><body> |
|
|
<div class="container"> |
|
|
<div class="order-header"> |
|
|
<h1>Ваш заказ для {{ business.org_name }}</h1> |
|
|
<p>Номер заказа: {{ order.id[:8] }}</p> |
|
|
<p>Дата: {{ order.timestamp }}</p> |
|
|
</div> |
|
|
<div class="order-details"> |
|
|
{% for item in order.items %} |
|
|
<div class="order-item"> |
|
|
{% if item.image %} |
|
|
<img src="{{ hf_file_url_jinja(item.image) }}" alt="{{ item.name }}" class="item-image"> |
|
|
{% else %} |
|
|
<div class="item-image" style="display: flex; align-items: center; justify-content: center; color: var(--text-muted);"><i class="fa-solid fa-box-open"></i></div> |
|
|
{% endif %} |
|
|
<div class="item-info"> |
|
|
<div class="item-name">{{ item.name }}</div> |
|
|
<div class="item-price">{{ item.quantity }} x {{ "%.2f"|format(item.price|float) }} {{ business.currency }}</div> |
|
|
</div> |
|
|
<div class="item-subtotal">{{ "%.2f"|format(item.price|float * item.quantity|int) }} {{ business.currency }}</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
<div class="order-total"> |
|
|
Итого: {{ "%.2f"|format(order.total_price|float) }} {{ business.currency }} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="send-order-fab"> |
|
|
{% set phone_number = business.contact_number | replace('+', '') | replace(' ', '') %} |
|
|
{% set message = "Здравствуйте, хочу сделать заказ. Ссылка на мой заказ: " + url_for('public_order_page', order_id=order.id, _external=True) %} |
|
|
{% set encoded_message = quote_plus(message) %} |
|
|
|
|
|
{% if business.order_destination == 'whatsapp' %} |
|
|
<a href="https://wa.me/{{ phone_number }}?text={{ encoded_message }}" class="send-order-btn whatsapp" target="_blank"> |
|
|
<i class="fab fa-whatsapp"></i> Отправить заказ |
|
|
</a> |
|
|
{% elif business.order_destination == 'telegram' %} |
|
|
<a href="https://t.me/{{ phone_number }}?text={{ encoded_message }}" class="send-order-btn telegram" target="_blank"> |
|
|
<i class="fab fa-telegram"></i> Отправить заказ |
|
|
</a> |
|
|
{% endif %} |
|
|
</div> |
|
|
</body></html> |
|
|
''' |
|
|
|
|
|
def find_node_by_id(filesystem, node_id): |
|
|
if not filesystem: return None, None |
|
|
if filesystem.get('id') == node_id: |
|
|
return filesystem, None |
|
|
queue = [(filesystem, None)] |
|
|
while queue: |
|
|
current_node, parent = queue.pop(0) |
|
|
if 'children' in current_node and isinstance(current_node['children'], list): |
|
|
for i, child in enumerate(current_node['children']): |
|
|
if isinstance(child, dict) and child.get('id') == node_id: |
|
|
return child, current_node |
|
|
if isinstance(child, dict) and child.get('type') == 'folder': |
|
|
queue.append((child, current_node)) |
|
|
return None, None |
|
|
|
|
|
def add_node(filesystem, parent_id, node_data): |
|
|
parent_node, _ = find_node_by_id(filesystem, parent_id) |
|
|
if parent_node and parent_node.get('type') == 'folder': |
|
|
if 'children' not in parent_node: |
|
|
parent_node['children'] = [] |
|
|
parent_node['children'].append(node_data) |
|
|
return True |
|
|
return False |
|
|
|
|
|
def remove_node(filesystem, node_id): |
|
|
node_to_remove, parent_node = find_node_by_id(filesystem, node_id) |
|
|
if node_to_remove and parent_node and 'children' in parent_node: |
|
|
parent_node['children'] = [child for child in parent_node['children'] if not (isinstance(child, dict) and child.get('id') == node_id)] |
|
|
return True, node_to_remove |
|
|
return False, None |
|
|
|
|
|
def get_node_path_string(filesystem, node_id): |
|
|
path_list = [] |
|
|
current_id = node_id |
|
|
while current_id: |
|
|
node, parent = find_node_by_id(filesystem, current_id) |
|
|
if not node: break |
|
|
if node.get('id') != 'root': |
|
|
path_list.append(node.get('name', node.get('original_filename', ''))) |
|
|
if not parent: break |
|
|
current_id = parent.get('id') if parent else None |
|
|
return " / ".join(reversed(path_list)) or "Root" |
|
|
|
|
|
def get_all_folders(filesystem, exclude_ids=None): |
|
|
if exclude_ids is None: |
|
|
exclude_ids = set() |
|
|
folders = [] |
|
|
def traverse(node, path_prefix): |
|
|
if node.get('type') == 'folder': |
|
|
if node.get('id') not in exclude_ids: |
|
|
folder_name = f"{path_prefix}{node.get('name', 'Unnamed')}" if path_prefix else (node.get('name') if node.get('id') != 'root' else 'Главная (Root)') |
|
|
folders.append({'id': node.get('id'), 'name': folder_name}) |
|
|
new_prefix = f"{path_prefix}{node.get('name', '')}/" if node.get('id') != 'root' else "" |
|
|
for child in node.get('children', []): |
|
|
traverse(child, new_prefix) |
|
|
traverse(filesystem, "") |
|
|
return sorted(folders, key=lambda x: x['name'].lower()) |
|
|
|
|
|
def get_all_archived_items(filesystem): |
|
|
archived_items = [] |
|
|
def traverse(node): |
|
|
if isinstance(node, dict): |
|
|
if node.get('is_archived'): |
|
|
archived_items.append(node) |
|
|
if 'children' in node: |
|
|
for child in node.get('children', []): |
|
|
traverse(child) |
|
|
traverse(filesystem) |
|
|
return archived_items |
|
|
|
|
|
def count_items_recursive(node): |
|
|
if not node or not isinstance(node, dict): |
|
|
return 0 |
|
|
count = 0 |
|
|
if node.get('type') in ['file', 'note', 'todolist', 'shoppinglist']: |
|
|
count += 1 |
|
|
if node.get('type') == 'folder' and 'children' in node: |
|
|
for child in node.get('children', []): |
|
|
count += count_items_recursive(child) |
|
|
return count |
|
|
|
|
|
def initialize_user_filesystem_tma(user_data, tma_user_id_str): |
|
|
if 'filesystem' not in user_data or not isinstance(user_data['filesystem'], dict): |
|
|
user_data['filesystem'] = {"type": "folder", "id": "root", "name": "root", "children": []} |
|
|
if 'files' in user_data and isinstance(user_data['files'], list): |
|
|
for old_file in user_data['files']: |
|
|
file_id = old_file.get('id', uuid.uuid4().hex) |
|
|
original_filename = old_file.get('filename', 'unknown_file') |
|
|
name_part, ext_part = os.path.splitext(original_filename) |
|
|
unique_suffix = uuid.uuid4().hex[:8] |
|
|
unique_filename = f"{name_part}_{unique_suffix}{ext_part}" |
|
|
hf_path = f"cloud_files/{tma_user_id_str}/root/{unique_filename}" |
|
|
file_node = { |
|
|
'type': 'file', 'id': file_id, 'original_filename': original_filename, |
|
|
'unique_filename': unique_filename, 'path': hf_path, |
|
|
'file_type': get_file_type(original_filename), |
|
|
'upload_date': old_file.get('upload_date', datetime.now().strftime('%Y-%m-%d %H:%M:%S')) |
|
|
} |
|
|
add_node(user_data['filesystem'], 'root', file_node) |
|
|
del user_data['files'] |
|
|
user_data.setdefault('owned_business_pages', []) |
|
|
|
|
|
|
|
|
@cache.memoize(timeout=300) |
|
|
def load_data(): |
|
|
try: |
|
|
download_db_from_hf() |
|
|
with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) |
|
|
if not isinstance(data, dict): data = {'users': {}, 'shared_links': {}, 'business_pages': {}, 'orders': {}} |
|
|
data.setdefault('users', {}) |
|
|
data.setdefault('shared_links', {}) |
|
|
data.setdefault('business_pages', {}) |
|
|
data.setdefault('orders', {}) |
|
|
for tma_user_id_str, user_data_item in data['users'].items(): |
|
|
initialize_user_filesystem_tma(user_data_item, tma_user_id_str) |
|
|
user_data_item.setdefault('reminders', []) |
|
|
return data |
|
|
except Exception as e: |
|
|
logging.error(f"Error loading data: {e}") |
|
|
return {'users': {}, 'shared_links': {}, 'business_pages': {}, 'orders': {}} |
|
|
|
|
|
def save_data(data): |
|
|
with save_data_lock: |
|
|
try: |
|
|
with open(DATA_FILE, 'w', encoding='utf-8') as file: |
|
|
json.dump(data, file, ensure_ascii=False, indent=4) |
|
|
upload_db_to_hf() |
|
|
cache.clear() |
|
|
except Exception as e: |
|
|
logging.error(f"Error saving data: {e}") |
|
|
raise |
|
|
|
|
|
def upload_db_to_hf(): |
|
|
if not HF_TOKEN_WRITE: return |
|
|
try: |
|
|
api = HfApi() |
|
|
api.upload_file(path_or_fileobj=DATA_FILE, path_in_repo=DATA_FILE, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") |
|
|
except Exception as e: |
|
|
logging.error(f"Error uploading database: {e}") |
|
|
|
|
|
def download_db_from_hf(): |
|
|
if not HF_TOKEN_READ: |
|
|
if not os.path.exists(DATA_FILE): |
|
|
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}, 'shared_links': {}, 'business_pages': {}, 'orders': {}}, f) |
|
|
return |
|
|
try: |
|
|
hf_hub_download(repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False) |
|
|
except (hf_utils.RepositoryNotFoundError, hf_utils.EntryNotFoundError): |
|
|
if not os.path.exists(DATA_FILE): |
|
|
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}, 'shared_links': {}, 'business_pages': {}, 'orders': {}}, f) |
|
|
except Exception as e: |
|
|
logging.error(f"Error downloading database: {e}") |
|
|
if not os.path.exists(DATA_FILE): |
|
|
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}, 'shared_links': {}, 'business_pages': {}, 'orders': {}}, f) |
|
|
|
|
|
def periodic_backup(): |
|
|
while True: |
|
|
upload_db_to_hf() |
|
|
time.sleep(1800) |
|
|
|
|
|
def send_telegram_message(chat_id, text): |
|
|
if not TELEGRAM_BOT_TOKEN: |
|
|
logging.warning("TELEGRAM_BOT_TOKEN is not set. Cannot send message.") |
|
|
return |
|
|
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" |
|
|
payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"} |
|
|
try: |
|
|
response = requests.post(url, json=payload) |
|
|
response.raise_for_status() |
|
|
logging.info(f"Sent message to {chat_id}") |
|
|
except requests.exceptions.RequestException as e: |
|
|
logging.error(f"Failed to send Telegram message to {chat_id}: {e}") |
|
|
|
|
|
def check_reminders(): |
|
|
while True: |
|
|
try: |
|
|
data = load_data() |
|
|
now_utc = datetime.now(pytz.utc) |
|
|
made_changes = False |
|
|
for user_id, user_data in data.get('users', {}).items(): |
|
|
if 'reminders' in user_data: |
|
|
for reminder in user_data['reminders']: |
|
|
if not reminder.get('notified', False): |
|
|
due_time_str = reminder.get('due_datetime_utc') |
|
|
if due_time_str: |
|
|
due_time_utc = datetime.fromisoformat(due_time_str.replace('Z', '+00:00')).replace(tzinfo=pytz.utc) |
|
|
if now_utc >= due_time_utc: |
|
|
telegram_id = user_data.get('telegram_id') |
|
|
if telegram_id: |
|
|
message_text = f"🔔 <b>Напоминание:</b>\n\n{reminder['text']}" |
|
|
send_telegram_message(telegram_id, message_text) |
|
|
reminder['notified'] = True |
|
|
made_changes = True |
|
|
if made_changes: |
|
|
save_data(data) |
|
|
except Exception as e: |
|
|
logging.error(f"Error in check_reminders thread: {e}") |
|
|
time.sleep(60) |
|
|
|
|
|
def get_file_type(filename): |
|
|
filename_lower = filename.lower() |
|
|
if filename_lower.endswith(('.mp4', '.mov', '.avi', '.webm', '.mkv')): return 'video' |
|
|
elif filename_lower.endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg')): return 'image' |
|
|
elif filename_lower.endswith('.pdf'): return 'pdf' |
|
|
elif filename_lower.endswith('.txt'): return 'text' |
|
|
return 'other' |
|
|
|
|
|
def is_admin_tma(): |
|
|
if not ADMIN_TELEGRAM_ID or ADMIN_TELEGRAM_ID == "YOUR_ADMIN_TELEGRAM_USER_ID_HERE": |
|
|
return False |
|
|
return 'telegram_user_id' in session and str(session['telegram_user_id']) == str(ADMIN_TELEGRAM_ID) |
|
|
|
|
|
def admin_browser_login_required(f): |
|
|
@wraps(f) |
|
|
def decorated_function(*args, **kwargs): |
|
|
if not session.get('admin_browser_logged_in'): |
|
|
return redirect(url_for('admin_login', next=request.url)) |
|
|
return f(*args, **kwargs) |
|
|
return decorated_function |
|
|
|
|
|
TMA_ENTRY_HTML = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Zeus Cloud TMA</title><script src="https://telegram.org/js/telegram-web-app.js"></script> |
|
|
<style>body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #121212; color: #e0e0e0; } .loading { font-size: 1.5em; }</style> |
|
|
</head><body><div class="loading">Загрузка приложения...</div> |
|
|
<script> |
|
|
window.Telegram.WebApp.ready(); |
|
|
const initData = window.Telegram.WebApp.initData; |
|
|
const initDataUnsafe = window.Telegram.WebApp.initDataUnsafe; |
|
|
if (initDataUnsafe && initDataUnsafe.user) { |
|
|
fetch("{{ url_for('auth_via_telegram') }}", { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ user: initDataUnsafe.user, initData: initData }) |
|
|
}).then(response => response.json()).then(data => { |
|
|
if (data.status === 'success') { window.location.href = data.redirect_url; } |
|
|
else { document.body.innerHTML = `<div class="loading">Ошибка авторизации: ${data.message}</div>`; Telegram.WebApp.showAlert(data.message || "Ошибка авторизации"); } |
|
|
}).catch(error => { document.body.innerHTML = `<div class="loading">Ошибка сети: ${error}</div>`; Telegram.WebApp.showAlert("Ошибка сети при авторизации."); }); |
|
|
} else { |
|
|
document.body.innerHTML = '<div class="loading">Не удалось получить данные. Попробуйте перезапустить приложение.</div>'; |
|
|
Telegram.WebApp.showAlert("Не удалось получить данные пользователя Telegram."); |
|
|
} |
|
|
</script></body></html>''' |
|
|
|
|
|
@app.route('/tma') |
|
|
def tma_entry_page(): |
|
|
return render_template_string(TMA_ENTRY_HTML) |
|
|
|
|
|
@app.route('/') |
|
|
def root_redirect(): |
|
|
return redirect(url_for('tma_entry_page')) |
|
|
|
|
|
@app.route('/auth_via_telegram', methods=['POST']) |
|
|
def auth_via_telegram(): |
|
|
try: |
|
|
payload = request.json |
|
|
tg_user_data = payload.get('user') |
|
|
if not tg_user_data or not tg_user_data.get('id'): |
|
|
return jsonify({'status': 'error', 'message': 'Отсутствуют данные пользователя Telegram.'}), 400 |
|
|
tma_user_id_str = str(tg_user_data['id']) |
|
|
data = load_data() |
|
|
|
|
|
user_info = { |
|
|
'telegram_id': tg_user_data['id'], |
|
|
'telegram_username': tg_user_data.get('username'), |
|
|
'first_name': tg_user_data.get('first_name'), |
|
|
'last_name': tg_user_data.get('last_name'), |
|
|
'photo_url': tg_user_data.get('photo_url') |
|
|
} |
|
|
|
|
|
if tma_user_id_str not in data['users']: |
|
|
user_info['created_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') |
|
|
user_info['filesystem'] = {"type": "folder", "id": "root", "name": "root", "children": []} |
|
|
user_info['reminders'] = [] |
|
|
user_info['owned_business_pages'] = [] |
|
|
data['users'][tma_user_id_str] = user_info |
|
|
initialize_user_filesystem_tma(data['users'][tma_user_id_str], tma_user_id_str) |
|
|
else: |
|
|
data['users'][tma_user_id_str].update(user_info) |
|
|
data['users'][tma_user_id_str].setdefault('owned_business_pages', []) |
|
|
|
|
|
|
|
|
try: save_data(data) |
|
|
except Exception as e: |
|
|
logging.error(f"Save data error for TMA user {tma_user_id_str}: {e}") |
|
|
return jsonify({'status': 'error', 'message': 'Ошибка сохранения данных пользователя.'}), 500 |
|
|
|
|
|
session['telegram_user_id'] = tma_user_id_str |
|
|
display_name = tg_user_data.get('first_name') or tg_user_data.get('username') or f"User {tma_user_id_str}" |
|
|
session['telegram_display_name'] = display_name |
|
|
return jsonify({'status': 'success', 'redirect_url': url_for('tma_dashboard')}) |
|
|
except Exception as e: |
|
|
logging.error(f"Error in auth_via_telegram: {e}") |
|
|
return jsonify({'status': 'error', 'message': 'Внутренняя ошибка сервера при авторизации.'}), 500 |
|
|
|
|
|
TMA_DASHBOARD_HTML_TEMPLATE = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> |
|
|
<title>Zeus Cloud</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<script src="https://telegram.org/js/telegram-web-app.js"></script> |
|
|
<style>''' + BASE_STYLE + '''</style></head><body> |
|
|
<div class="app-header"> |
|
|
<div class="user-info">{{ display_name }}</div> |
|
|
<div class="view-toggle"> |
|
|
<button id="archive-btn" title="Архив"><i class="fa-solid fa-box-archive"></i></button> |
|
|
<button id="reminders-btn" title="Напоминания"><i class="fa-solid fa-bell"></i></button> |
|
|
<button id="grid-view-btn" title="Сетка"><i class="fa fa-th-large"></i></button> |
|
|
<button id="list-view-btn" title="Список"><i class="fa fa-bars"></i></button> |
|
|
</div> |
|
|
</div> |
|
|
<div class="container" id="main-container"> |
|
|
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %} |
|
|
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %} |
|
|
{% endif %}{% endwith %} |
|
|
<div class="breadcrumbs"> |
|
|
{% for crumb in breadcrumbs %} |
|
|
{% if crumb.is_link %}<a href="{{ url_for('tma_dashboard', folder_id=crumb.id) }}">{{ crumb.name if crumb.id != 'root' else 'Главная' }}</a> |
|
|
{% else %}<span>{{ crumb.name if crumb.id != 'root' else 'Главная' }}</span>{% endif %} |
|
|
{% if not loop.last %}<span>/</span>{% endif %} |
|
|
{% endfor %} |
|
|
</div> |
|
|
<h2>{{ current_folder.name if current_folder_id != 'root' else 'Главная' }}</h2> |
|
|
<div id="progress-container"><div id="progress-bar"></div></div> |
|
|
<div class="file-grid" id="file-container"> |
|
|
{% for item in items %} |
|
|
<div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}" |
|
|
{% if item.type == 'folder' %} |
|
|
onclick="window.Telegram.WebApp.HapticFeedback.impactOccurred('light'); window.location.href='{{ url_for('tma_dashboard', folder_id=item.id) }}'" |
|
|
{% elif item.type == 'note' %} |
|
|
onclick="openNoteModal('{{ item.id }}')" |
|
|
{% elif item.type in ['todolist', 'shoppinglist'] %} |
|
|
onclick="openListEditorModal('{{ item.id }}', '{{ item.type }}')" |
|
|
{% elif item.type == 'file' %} |
|
|
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 }}')" |
|
|
{% endif %}> |
|
|
<div class="item-preview-wrapper"> |
|
|
{% if item.type == 'folder' %}<div class="item-preview"><i class="fa-solid fa-folder"></i></div> |
|
|
{% elif item.type == 'note' %}<div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div> |
|
|
{% elif item.type == 'todolist' %}<div class="item-preview"><i class="fa-solid fa-list-check"></i></div> |
|
|
{% elif item.type == 'shoppinglist' %}<div class="item-preview"><i class="fa-solid fa-cart-shopping"></i></div> |
|
|
{% elif item.type == 'file' %} |
|
|
{% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy"> |
|
|
{% 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> |
|
|
{% elif item.file_type == 'pdf' %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--accent);"><i class="fa-solid fa-file-pdf"></i></div> |
|
|
{% elif item.file_type == 'text' %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--secondary);"><i class="fa-solid fa-file-lines"></i></div> |
|
|
{% else %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--text-muted);"><i class="fa-solid fa-file-circle-question"></i></div> |
|
|
{% endif %} |
|
|
{% endif %} |
|
|
</div> |
|
|
<div class="item-name-info"> |
|
|
<p class="item-name">{{ (item.title if item.type in ['note', 'todolist', 'shoppinglist'] else item.name if item.type == 'folder' else item.original_filename) | truncate(30, True) }}</p> |
|
|
{% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p> |
|
|
{% elif item.type in ['note', 'todolist', 'shoppinglist'] %}<p class="item-info">{{ item.modified_date }}</p>{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% if not items %} <p>Эта папка пуста.</p> {% endif %} |
|
|
</div> |
|
|
</div> |
|
|
<div class="modal" id="mediaModal" onclick="closeModal(event)"> |
|
|
<div class="modal-content"> |
|
|
<span onclick="closeModalManual()" class="modal-close-btn">×</span> |
|
|
<div class="modal-main-content" id="modalContent"></div> |
|
|
<div class="modal-actions"> |
|
|
<a id="modal-download-btn" class="btn download-btn" style="display: none; width: 80%;"> |
|
|
<i class="fa-solid fa-download"></i> Скачать |
|
|
</a> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="selection-bar"> |
|
|
<span id="selection-count"></span> |
|
|
<button id="selection-share-btn" class="btn share-btn" onclick="openShareModal()" style="display:none;"><i class="fa-solid fa-share-alt"></i></button> |
|
|
<button id="selection-archive-btn" class="btn archive-btn" onclick="archiveSelected()" style="display:none;"><i class="fa-solid fa-box-archive"></i></button> |
|
|
<button id="selection-download-btn" class="btn download-btn" onclick="downloadSingleSelected()" style="display:none;"><i class="fa-solid fa-download"></i></button> |
|
|
<button class="btn" style="background: var(--accent);" onclick="showMoveModal()"><i class="fa-solid fa-arrow-right-to-bracket"></i></button> |
|
|
<button class="btn delete-btn" onclick="deleteSelected()"><i class="fa-solid fa-trash-can"></i></button> |
|
|
<button class="btn" style="background: #555;" onclick="toggleSelectionMode(false)">Отмена</button> |
|
|
</div> |
|
|
|
|
|
<div class="modal" id="move-modal"><div class="modal-content"> |
|
|
<h4>Переместить в:</h4> |
|
|
<select id="folder-destination-select"> |
|
|
{% for folder in all_folders_for_move %}<option value="{{ folder.id }}">{{ folder.name }}</option>{% endfor %} |
|
|
</select> |
|
|
<button class="btn" style="background: var(--accent); width: 100%; margin-top: 10px;" onclick="moveSelected()">Переместить</button> |
|
|
<button class="btn" style="background: #555; width: 100%;" onclick="closeMoveModal()">Отмена</button> |
|
|
</div></div> |
|
|
|
|
|
<div class="modal" id="fab-modal"><div class="modal-content"> |
|
|
<h4>Добавить в "{{ current_folder.name if current_folder_id != 'root' else 'Главная' }}"</h4> |
|
|
<div class="fab-options"> |
|
|
<label for="file-input" class="fab-option" id="fab-option-upload"><i class="fa-solid fa-upload"></i><span>Файлы</span></label> |
|
|
<div class="fab-option" id="fab-option-note" onclick="openNoteModal()"><i class="fa-solid fa-note-sticky"></i><span>Заметку</span></div> |
|
|
<div class="fab-option" id="fab-option-folder"><i class="fa-solid fa-folder-plus"></i><span>Папку</span></div> |
|
|
<div class="fab-option" id="fab-option-todolist" onclick="openListEditorModal(null, 'todolist')"><i class="fa-solid fa-list-check"></i><span>Список дел</span></div> |
|
|
<div class="fab-option" id="fab-option-shoppinglist" onclick="openListEditorModal(null, 'shoppinglist')"><i class="fa-solid fa-cart-shopping"></i><span>Покупки</span></div> |
|
|
<div class="fab-option" id="fab-option-business" onclick="window.location.href='{{ url_for('tma_manage_business_pages') }}'"><i class="fa-solid fa-store"></i><span>Бизнес</span></div> |
|
|
</div> |
|
|
<form id="upload-form" method="POST" enctype="multipart/form-data" action="{{ url_for('tma_dashboard') }}" style="display:none;"> |
|
|
<input type="hidden" name="current_folder_id" value="{{ current_folder_id }}"> |
|
|
<input type="file" name="files" id="file-input" multiple required onchange="document.getElementById('upload-btn-modal').click()"> |
|
|
<button type="submit" id="upload-btn-modal"></button> |
|
|
</form> |
|
|
<form method="POST" action="{{ url_for('create_folder_tma') }}" id="create-folder-form"> |
|
|
<input type="hidden" name="parent_folder_id" value="{{ current_folder_id }}"> |
|
|
<input type="text" name="folder_name" placeholder="Имя новой папки" required> |
|
|
<button type="submit" class="btn folder-btn" style="width:100%">Создать</button> |
|
|
</form> |
|
|
<button class="btn" style="background: #555; width: 100%; margin-top: 10px;" onclick="closeFabModal()">Закрыть</button> |
|
|
</div></div> |
|
|
|
|
|
<div class="modal" id="note-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;"> |
|
|
<h4 id="note-modal-title">Новая заметка</h4> |
|
|
<input type="hidden" id="note-id-input"> |
|
|
<input type="text" id="note-title-input" placeholder="Заголовок заметки" style="font-size: 1.1em; margin-bottom: 10px;"> |
|
|
<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> |
|
|
<div style="display: flex; gap: 10px; margin-top: 15px;"> |
|
|
<button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="saveNote()">Сохранить</button> |
|
|
<button class="btn" style="background: #555; flex-grow: 1;" onclick="closeNoteModal()">Отмена</button> |
|
|
</div> |
|
|
</div></div> |
|
|
|
|
|
<div class="modal" id="list-editor-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;"> |
|
|
<h4 id="list-editor-title">Новый список</h4> |
|
|
<input type="hidden" id="list-editor-id"> |
|
|
<input type="hidden" id="list-editor-type"> |
|
|
<input type="text" id="list-editor-title-input" placeholder="Название списка" style="font-size: 1.1em; margin-bottom: 10px;"> |
|
|
<div id="list-editor-items-container" style="max-height: 45vh; overflow-y: auto; margin-bottom: 10px;"></div> |
|
|
<div style="display: flex; gap: 10px; margin-bottom: 15px;"> |
|
|
<input type="text" id="list-editor-new-item-text" placeholder="Новый элемент..." style="margin: 0;"> |
|
|
<button class="btn" onclick="addListItemToEditor()" style="padding: 14px 20px;">+</button> |
|
|
</div> |
|
|
<div style="display: flex; gap: 10px;"> |
|
|
<button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="saveList()">Сохранить</button> |
|
|
<button class="btn" style="background: #555; flex-grow: 1;" onclick="closeListEditorModal()">Отмена</button> |
|
|
</div> |
|
|
</div></div> |
|
|
|
|
|
<div class="modal" id="reminders-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;"> |
|
|
<h4>Напоминания</h4> |
|
|
<div id="reminders-list" style="max-height: 40vh; overflow-y: auto; margin-bottom: 15px; font-size: 0.9em;"></div> |
|
|
<h5 style="margin-top: 15px;">Новое напоминание</h5> |
|
|
<input type="text" id="reminder-text-input" placeholder="Текст напоминания" required> |
|
|
<input type="datetime-local" id="reminder-datetime-input" required> |
|
|
<div style="display: flex; gap: 10px; margin-top: 15px;"> |
|
|
<button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="createReminder()">Создать</button> |
|
|
<button class="btn" style="background: #555; flex-grow: 1;" onclick="closeRemindersModal()">Закрыть</button> |
|
|
</div> |
|
|
</div></div> |
|
|
|
|
|
<div class="modal" id="share-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;"> |
|
|
<h4 id="share-modal-title">Поделиться</h4> |
|
|
<div id="existing-links-list" style="max-height: 30vh; overflow-y: auto; margin-bottom: 15px;"></div> |
|
|
<h5 style="margin-top: 15px;">Создать новую ссылку</h5> |
|
|
<input type="text" id="share-link-name" placeholder="Название ссылки (необязательно)"> |
|
|
<select id="share-link-duration"> |
|
|
<option value="1">1 час</option><option value="24">24 часа</option><option value="168">7 дней</option><option value="0">Всегда</option> |
|
|
</select> |
|
|
<div style="display: flex; gap: 10px; margin-top: 15px;"> |
|
|
<button class="btn share-btn" style="flex-grow: 1;" onclick="createShareLink()">Создать</button> |
|
|
<button class="btn" style="background: #555; flex-grow: 1;" onclick="closeShareModal()">Закрыть</button> |
|
|
</div> |
|
|
</div></div> |
|
|
|
|
|
<div class="fab-container"><button id="fab" class="fab"><i class="fa-solid fa-plus"></i></button></div> |
|
|
|
|
|
<script> |
|
|
window.Telegram.WebApp.ready(); |
|
|
window.Telegram.WebApp.expand(); |
|
|
const haptic = window.Telegram.WebApp.HapticFeedback; |
|
|
async function openModal(srcOrUrl, type, itemId) { |
|
|
if (!srcOrUrl) return; |
|
|
haptic.impactOccurred('light'); |
|
|
const modal = document.getElementById('mediaModal'); |
|
|
const modalContent = document.getElementById('modalContent'); |
|
|
const downloadBtn = document.getElementById('modal-download-btn'); |
|
|
modalContent.innerHTML = '<div class="loading-spinner"></div>'; |
|
|
modal.style.display = 'flex'; |
|
|
if (type !== 'folder' && itemId) { |
|
|
downloadBtn.onclick = () => { initiateDownload(itemId); }; |
|
|
downloadBtn.style.display = 'inline-block'; |
|
|
} else { |
|
|
downloadBtn.style.display = 'none'; |
|
|
} |
|
|
try { |
|
|
if (type === 'image') modalContent.innerHTML = `<img src="${srcOrUrl}">`; |
|
|
else if (type === 'video') modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`; |
|
|
else if (type === 'pdf') modalContent.innerHTML = `<iframe src="${srcOrUrl}"></iframe>`; |
|
|
else if (type === 'text') { |
|
|
const response = await fetch(srcOrUrl); if (!response.ok) throw new Error(`Ошибка: ${response.statusText}`); |
|
|
const text = await response.text(); |
|
|
modalContent.innerHTML = `<pre>${text.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}</pre>`; |
|
|
} else initiateDownload(itemId); |
|
|
} catch (error) { modalContent.innerHTML = `<p>Ошибка предпросмотра: ${error.message}</p>`; } |
|
|
} |
|
|
function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); } |
|
|
function closeModalManual() { |
|
|
const modal = document.getElementById('mediaModal'); |
|
|
modal.style.display = 'none'; |
|
|
const video = modal.querySelector('video'); if (video) video.pause(); |
|
|
document.getElementById('modalContent').innerHTML = ''; |
|
|
document.getElementById('modal-download-btn').style.display = 'none'; |
|
|
} |
|
|
async function initiateDownload(fileId) { |
|
|
haptic.impactOccurred('medium'); |
|
|
const downloadBtn = document.getElementById('modal-download-btn'); |
|
|
const originalHTML = downloadBtn.innerHTML; |
|
|
downloadBtn.innerHTML = '<div class="loading-spinner" style="width:20px; height:20px; border-width:2px;"></div>'; |
|
|
downloadBtn.onclick = null; |
|
|
try { |
|
|
const response = await fetch(`{{ url_for('download_tma', file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId)); |
|
|
const data = await response.json(); |
|
|
if (data.status === 'success' && data.url) { |
|
|
tmaDownloadFile(data.url); |
|
|
closeModalManual(); |
|
|
} else { Telegram.WebApp.showAlert(data.message || 'Не удалось создать ссылку для скачивания.'); } |
|
|
} catch (error) { Telegram.WebApp.showAlert('Сетевая ошибка при создании ссылки для скачивания.'); } |
|
|
finally { if (downloadBtn) { downloadBtn.innerHTML = originalHTML; downloadBtn.onclick = () => { initiateDownload(fileId); }; } } |
|
|
} |
|
|
function tmaDownloadFile(downloadUrl) { |
|
|
if (window.Telegram && window.Telegram.WebApp && Telegram.WebApp.openLink) { Telegram.WebApp.openLink(downloadUrl, {try_instant_view: false}); } |
|
|
else { window.open(downloadUrl, '_blank'); } |
|
|
} |
|
|
document.getElementById('upload-form')?.addEventListener('submit', function(e) { |
|
|
e.preventDefault(); const files = document.getElementById('file-input').files; |
|
|
if (files.length === 0) { Telegram.WebApp.showAlert('Выберите файлы.'); return; } |
|
|
if (files.length > 20) { Telegram.WebApp.showAlert('Максимум 20 файлов за раз!'); return; } |
|
|
closeFabModal(); |
|
|
const progressContainer = document.getElementById('progress-container'); |
|
|
const progressBar = document.getElementById('progress-bar'); |
|
|
progressContainer.style.display = 'block'; progressBar.style.width = '0%'; |
|
|
const formData = new FormData(this); const xhr = new XMLHttpRequest(); |
|
|
xhr.upload.addEventListener('progress', e => { if (e.lengthComputable) progressBar.style.width = Math.round((e.loaded / e.total) * 100) + '%'; }); |
|
|
xhr.addEventListener('load', () => { haptic.notificationOccurred('success'); window.location.reload(); }); |
|
|
xhr.addEventListener('error', () => { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Ошибка загрузки.'); }); |
|
|
xhr.open('POST', this.action, true); xhr.send(formData); |
|
|
}); |
|
|
let selectionMode = false; const selectedItems = new Set(); let longPressTimer; |
|
|
const mainContainer = document.getElementById('main-container'); |
|
|
const selectionBar = document.getElementById('selection-bar'); |
|
|
const selectionCount = document.getElementById('selection-count'); |
|
|
const selectionDownloadBtn = document.getElementById('selection-download-btn'); |
|
|
const selectionShareBtn = document.getElementById('selection-share-btn'); |
|
|
const selectionArchiveBtn = document.getElementById('selection-archive-btn'); |
|
|
const allItems = document.querySelectorAll('.item'); |
|
|
function toggleSelectionMode(enable) { |
|
|
selectionMode = enable; |
|
|
selectionBar.classList.toggle('visible', enable); |
|
|
if(enable) haptic.impactOccurred('heavy'); |
|
|
if (!enable) { selectedItems.clear(); allItems.forEach(item => item.classList.remove('selected')); } |
|
|
} |
|
|
function updateSelectionUI() { |
|
|
selectionCount.textContent = `Выбрано: ${selectedItems.size}`; |
|
|
const firstSelectedId = selectedItems.values().next().value; |
|
|
const itemElement = document.querySelector(`.item[data-id='${firstSelectedId}']`); |
|
|
const itemType = itemElement?.dataset.type; |
|
|
const isSingleSelection = selectedItems.size === 1; |
|
|
|
|
|
selectionDownloadBtn.style.display = (isSingleSelection && itemType === 'file') ? 'inline-block' : 'none'; |
|
|
selectionShareBtn.style.display = (isSingleSelection && (itemType === 'folder' || itemType === 'shoppinglist')) ? 'inline-block' : 'none'; |
|
|
selectionArchiveBtn.style.display = (selectedItems.size > 0 && Array.from(selectedItems).every(id => { |
|
|
const el = document.querySelector(`.item[data-id='${id}']`); |
|
|
return el && (el.dataset.type === 'todolist' || el.dataset.type === 'shoppinglist'); |
|
|
})) ? 'inline-block' : 'none'; |
|
|
} |
|
|
allItems.forEach(item => { |
|
|
item.addEventListener('pointerdown', e => { |
|
|
if (e.pointerType === 'mouse' && e.button !== 0) return; |
|
|
longPressTimer = setTimeout(() => { |
|
|
if (!selectionMode) toggleSelectionMode(true); |
|
|
item.classList.toggle('selected'); |
|
|
if (selectedItems.has(item.dataset.id)) selectedItems.delete(item.dataset.id); else selectedItems.add(item.dataset.id); |
|
|
updateSelectionUI(); |
|
|
}, 500); |
|
|
}); |
|
|
item.addEventListener('pointerup', () => clearTimeout(longPressTimer)); |
|
|
item.addEventListener('pointerleave', () => clearTimeout(longPressTimer)); |
|
|
item.addEventListener('click', e => { |
|
|
if (selectionMode) { |
|
|
haptic.impactOccurred('light'); e.preventDefault(); e.stopPropagation(); |
|
|
item.classList.toggle('selected'); |
|
|
if (selectedItems.has(item.dataset.id)) selectedItems.delete(item.dataset.id); else selectedItems.add(item.dataset.id); |
|
|
updateSelectionUI(); |
|
|
if (selectedItems.size === 0) toggleSelectionMode(false); |
|
|
} |
|
|
}, true); |
|
|
}); |
|
|
async function performBatchAction(url, body) { |
|
|
try { |
|
|
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
|
|
const result = await response.json(); |
|
|
if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); } |
|
|
else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.'); } |
|
|
} catch (error) { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Сетевая ошибка.'); } |
|
|
finally { toggleSelectionMode(false); } |
|
|
} |
|
|
function downloadSingleSelected() { |
|
|
if (selectedItems.size !== 1) return; |
|
|
const fileId = selectedItems.values().next().value; |
|
|
initiateDownload(fileId); |
|
|
toggleSelectionMode(false); |
|
|
} |
|
|
function showMoveModal() { if (selectedItems.size > 0) { haptic.impactOccurred('light'); document.getElementById('move-modal').style.display = 'flex'; }} |
|
|
function closeMoveModal() { document.getElementById('move-modal').style.display = 'none'; } |
|
|
function moveSelected() { |
|
|
const destinationId = document.getElementById('folder-destination-select').value; |
|
|
performBatchAction('{{ url_for("batch_move_tma") }}', { item_ids: Array.from(selectedItems), destination_id: destinationId }); |
|
|
closeMoveModal(); |
|
|
} |
|
|
function deleteSelected() { |
|
|
if (selectedItems.size === 0) return; |
|
|
haptic.impactOccurred('medium'); |
|
|
Telegram.WebApp.showConfirm(`Удалить ${selectedItems.size} элемент(ов)?`, ok => { |
|
|
if (ok) { haptic.impactOccurred('heavy'); performBatchAction('{{ url_for("batch_delete_tma") }}', { item_ids: Array.from(selectedItems) }); } |
|
|
}); |
|
|
} |
|
|
function archiveSelected() { |
|
|
if (selectedItems.size === 0) return; |
|
|
haptic.impactOccurred('medium'); |
|
|
Telegram.WebApp.showConfirm(`Архивировать ${selectedItems.size} списк(ов)?`, ok => { |
|
|
if(ok) { |
|
|
haptic.impactOccurred('heavy'); |
|
|
performBatchAction('{{ url_for("batch_archive_tma") }}', { item_ids: Array.from(selectedItems) }); |
|
|
} |
|
|
}); |
|
|
} |
|
|
async function openNoteModal(noteId = null) { |
|
|
haptic.impactOccurred('light'); |
|
|
closeFabModal(); |
|
|
const modal = document.getElementById('note-modal'); |
|
|
const titleEl = document.getElementById('note-modal-title'); |
|
|
const idInput = document.getElementById('note-id-input'); |
|
|
const titleInput = document.getElementById('note-title-input'); |
|
|
const contentInput = document.getElementById('note-content-input'); |
|
|
if (noteId) { |
|
|
titleEl.textContent = 'Редактировать заметку'; |
|
|
const response = await fetch(`{{ url_for('get_note_tma', note_id='__ID__') }}`.replace('__ID__', noteId)); |
|
|
const data = await response.json(); |
|
|
if (data.status === 'success') { |
|
|
idInput.value = data.note.id; |
|
|
titleInput.value = data.note.title; |
|
|
contentInput.value = data.note.content; |
|
|
} else { Telegram.WebApp.showAlert('Ошибка загрузки заметки.'); return; } |
|
|
} else { |
|
|
titleEl.textContent = 'Новая заметка'; |
|
|
idInput.value = ''; titleInput.value = ''; contentInput.value = ''; |
|
|
} |
|
|
modal.style.display = 'flex'; |
|
|
} |
|
|
function closeNoteModal() { document.getElementById('note-modal').style.display = 'none'; } |
|
|
async function saveNote() { |
|
|
const id = document.getElementById('note-id-input').value; |
|
|
const title = document.getElementById('note-title-input').value; |
|
|
const content = document.getElementById('note-content-input').value; |
|
|
if (!title.trim()) { Telegram.WebApp.showAlert('Заголовок не может быть пустым.'); return; } |
|
|
const payload = { |
|
|
note_id: id, title: title, content: content, parent_folder_id: '{{ current_folder_id }}' |
|
|
}; |
|
|
const response = await fetch('{{ url_for("create_or_update_note_tma") }}', { |
|
|
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); } |
|
|
else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка сохранения.'); } |
|
|
} |
|
|
function closeRemindersModal() { document.getElementById('reminders-modal').style.display = 'none'; } |
|
|
async function openRemindersModal() { |
|
|
haptic.impactOccurred('light'); |
|
|
const modal = document.getElementById('reminders-modal'); |
|
|
const listEl = document.getElementById('reminders-list'); |
|
|
listEl.innerHTML = '<div class="loading-spinner"></div>'; |
|
|
modal.style.display = 'flex'; |
|
|
try { |
|
|
const response = await fetch('{{ url_for("get_reminders_tma") }}'); |
|
|
const data = await response.json(); |
|
|
if (data.status === 'success') { |
|
|
listEl.innerHTML = ''; |
|
|
if (data.reminders.length === 0) listEl.innerHTML = '<p>Напоминаний нет.</p>'; |
|
|
data.reminders.forEach(r => { |
|
|
const dt = new Date(r.due_datetime_local); |
|
|
const el = document.createElement('div'); |
|
|
el.className = 'reminder-item'; |
|
|
el.innerHTML = ` |
|
|
<div> |
|
|
<span>${r.text}</span><br> |
|
|
<small style="color:var(--text-muted)">${dt.toLocaleString()}</small> |
|
|
</div> |
|
|
<button onclick="deleteReminder('${r.id}')">×</button> |
|
|
`; |
|
|
listEl.appendChild(el); |
|
|
}); |
|
|
} else { listEl.innerHTML = '<p>Ошибка загрузки.</p>'; } |
|
|
} catch (e) { listEl.innerHTML = '<p>Сетевая ошибка.</p>'; } |
|
|
} |
|
|
async function createReminder() { |
|
|
const text = document.getElementById('reminder-text-input').value; |
|
|
const datetimeLocal = document.getElementById('reminder-datetime-input').value; |
|
|
if (!text.trim() || !datetimeLocal) { Telegram.WebApp.showAlert('Заполните все поля.'); return; } |
|
|
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; |
|
|
const payload = { text, datetime_local: datetimeLocal, user_timezone: userTimezone }; |
|
|
const response = await fetch('{{ url_for("create_reminder_tma") }}', { |
|
|
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (result.status === 'success') { |
|
|
haptic.notificationOccurred('success'); |
|
|
document.getElementById('reminder-text-input').value = ''; |
|
|
document.getElementById('reminder-datetime-input').value = ''; |
|
|
openRemindersModal(); |
|
|
} else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка.'); } |
|
|
} |
|
|
async function deleteReminder(reminderId) { |
|
|
Telegram.WebApp.showConfirm('Удалить это напоминание?', async (ok) => { |
|
|
if (ok) { |
|
|
haptic.impactOccurred('heavy'); |
|
|
const response = await fetch(`{{ url_for('delete_reminder_tma', reminder_id='__ID__') }}`.replace('__ID__', reminderId), { method: 'POST' }); |
|
|
const result = await response.json(); |
|
|
if (result.status === 'success') { openRemindersModal(); } |
|
|
else { Telegram.WebApp.showAlert('Ошибка удаления.'); } |
|
|
} |
|
|
}); |
|
|
} |
|
|
function closeShareModal() { document.getElementById('share-modal').style.display = 'none'; } |
|
|
async function openShareModal() { |
|
|
if (selectedItems.size !== 1) return; |
|
|
haptic.impactOccurred('light'); |
|
|
const itemId = selectedItems.values().next().value; |
|
|
const itemElement = document.querySelector(`.item[data-id='${itemId}']`); |
|
|
const itemType = itemElement.dataset.type; |
|
|
const title = itemType === 'folder' ? 'Поделиться папкой' : 'Поделиться списком покупок'; |
|
|
document.getElementById('share-modal-title').textContent = title; |
|
|
const listEl = document.getElementById('existing-links-list'); |
|
|
listEl.innerHTML = '<div class="loading-spinner"></div>'; |
|
|
document.getElementById('share-modal').style.display = 'flex'; |
|
|
const response = await fetch(`{{ url_for('get_public_links', item_id='ITEM_ID') }}`.replace('ITEM_ID', itemId)); |
|
|
const data = await response.json(); |
|
|
listEl.innerHTML = ''; |
|
|
if (data.status === 'success' && data.links.length > 0) { |
|
|
data.links.forEach(link => { |
|
|
const el = document.createElement('div'); |
|
|
el.className = 'shared-link-item'; |
|
|
const expiration = link.expires_at ? new Date(link.expires_at).toLocaleString() : 'Никогда'; |
|
|
el.innerHTML = ` |
|
|
<div class="shared-link-info"> |
|
|
<strong>${link.name || 'Безымянная ссылка'}</strong> |
|
|
<small>Истекает: ${expiration}</small> |
|
|
</div> |
|
|
<div class="shared-link-actions"> |
|
|
<button onclick="copyToClipboard('${link.url}')" title="Копировать"><i class="fa-solid fa-copy"></i></button> |
|
|
<button onclick="deleteShareLink('${link.id}')" title="Удалить"><i class="fa-solid fa-trash"></i></button> |
|
|
</div>`; |
|
|
listEl.appendChild(el); |
|
|
}); |
|
|
} else { |
|
|
listEl.innerHTML = '<p>Публичных ссылок для этого элемента нет.</p>'; |
|
|
} |
|
|
} |
|
|
async function createShareLink() { |
|
|
const itemId = selectedItems.values().next().value; |
|
|
const itemElement = document.querySelector(`.item[data-id='${itemId}']`); |
|
|
const itemType = itemElement.dataset.type; |
|
|
const name = document.getElementById('share-link-name').value; |
|
|
const duration_hours = document.getElementById('share-link-duration').value; |
|
|
const response = await fetch('{{ url_for("create_public_link") }}', { |
|
|
method: 'POST', headers: {'Content-Type': 'application/json'}, |
|
|
body: JSON.stringify({ item_id: itemId, item_type: itemType, name: name, duration_hours: parseInt(duration_hours) }) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (result.status === 'success') { |
|
|
haptic.notificationOccurred('success'); |
|
|
openShareModal(); |
|
|
copyToClipboard(result.url); |
|
|
Telegram.WebApp.showAlert('Ссылка создана и скопирована!'); |
|
|
} else { |
|
|
haptic.notificationOccurred('error'); |
|
|
Telegram.WebApp.showAlert(result.message || 'Ошибка создания ссылки.'); |
|
|
} |
|
|
} |
|
|
async function deleteShareLink(linkId) { |
|
|
Telegram.WebApp.showConfirm('Удалить эту публичную ссылку?', async (ok) => { |
|
|
if(ok) { |
|
|
haptic.impactOccurred('heavy'); |
|
|
const response = await fetch('{{ url_for("delete_public_link") }}', { |
|
|
method: 'POST', headers: {'Content-Type': 'application/json'}, |
|
|
body: JSON.stringify({ link_id: linkId }) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (result.status === 'success') { openShareModal(); } |
|
|
else { Telegram.WebApp.showAlert('Ошибка удаления.'); } |
|
|
} |
|
|
}); |
|
|
} |
|
|
function copyToClipboard(text) { |
|
|
navigator.clipboard.writeText(text).then(() => { |
|
|
haptic.notificationOccurred('success'); |
|
|
Telegram.WebApp.showAlert('Скопировано!'); |
|
|
}, () => { |
|
|
haptic.notificationOccurred('error'); |
|
|
}); |
|
|
} |
|
|
document.getElementById('reminders-btn').addEventListener('click', openRemindersModal); |
|
|
document.getElementById('archive-btn').addEventListener('click', () => { window.location.href = '{{ url_for("tma_archive_view") }}'; }); |
|
|
const gridViewBtn = document.getElementById('grid-view-btn'); |
|
|
const listViewBtn = document.getElementById('list-view-btn'); |
|
|
const fileContainer = document.getElementById('file-container'); |
|
|
function setView(view) { |
|
|
haptic.impactOccurred('light'); |
|
|
fileContainer.classList.toggle('list-view', view === 'list'); |
|
|
listViewBtn.classList.toggle('active', view === 'list'); |
|
|
gridViewBtn.classList.toggle('active', view !== 'list'); |
|
|
localStorage.setItem('viewMode', view); |
|
|
} |
|
|
gridViewBtn.addEventListener('click', () => setView('grid')); |
|
|
listViewBtn.addEventListener('click', () => setView('list')); |
|
|
const fab = document.getElementById('fab'); |
|
|
const fabModal = document.getElementById('fab-modal'); |
|
|
fab.addEventListener('click', () => { haptic.impactOccurred('medium'); fabModal.style.display = 'flex'; }); |
|
|
function closeFabModal() { |
|
|
fabModal.style.display = 'none'; |
|
|
document.getElementById('create-folder-form').style.display = 'none'; |
|
|
} |
|
|
fabModal.addEventListener('click', e => { if (e.target.id === 'fab-modal') closeFabModal(); }); |
|
|
document.getElementById('fab-option-folder').addEventListener('click', () => { |
|
|
document.getElementById('create-folder-form').style.display = 'block'; |
|
|
}); |
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
setView(localStorage.getItem('viewMode') || 'grid'); |
|
|
const currentFolderId = '{{ current_folder_id }}'; |
|
|
const parentFolderId = '{{ parent_folder_id }}'; |
|
|
if (currentFolderId !== 'root') { |
|
|
let backButton = window.Telegram.WebApp.BackButton; |
|
|
backButton.show(); |
|
|
backButton.onClick(() => { haptic.impactOccurred('light'); window.location.href = `{{ url_for('tma_dashboard') }}?folder_id=${parentFolderId}`; }); |
|
|
} else { window.Telegram.WebApp.BackButton.hide(); } |
|
|
}); |
|
|
function closeListEditorModal() { document.getElementById('list-editor-modal').style.display = 'none'; } |
|
|
async function openListEditorModal(listId = null, listType) { |
|
|
haptic.impactOccurred('light'); |
|
|
closeFabModal(); |
|
|
const modal = document.getElementById('list-editor-modal'); |
|
|
const titleEl = document.getElementById('list-editor-title'); |
|
|
const idInput = document.getElementById('list-editor-id'); |
|
|
const typeInput = document.getElementById('list-editor-type'); |
|
|
const titleInput = document.getElementById('list-editor-title-input'); |
|
|
const itemsContainer = document.getElementById('list-editor-items-container'); |
|
|
|
|
|
typeInput.value = listType; |
|
|
titleEl.textContent = listType === 'todolist' ? 'Список дел' : 'Список покупок'; |
|
|
titleInput.value = ''; |
|
|
itemsContainer.innerHTML = ''; |
|
|
idInput.value = ''; |
|
|
|
|
|
if (listId) { |
|
|
idInput.value = listId; |
|
|
const response = await fetch(`{{ url_for('get_list_tma', list_id='__ID__') }}`.replace('__ID__', listId)); |
|
|
const data = await response.json(); |
|
|
if (data.status === 'success') { |
|
|
titleInput.value = data.list.title; |
|
|
(data.list.items || []).forEach(item => addListItemToEditor(item)); |
|
|
} else { |
|
|
Telegram.WebApp.showAlert('Ошибка загрузки списка'); |
|
|
return; |
|
|
} |
|
|
} |
|
|
modal.style.display = 'flex'; |
|
|
} |
|
|
function addListItemToEditor(item = null) { |
|
|
const textInput = document.getElementById('list-editor-new-item-text'); |
|
|
const listType = document.getElementById('list-editor-type').value; |
|
|
const text = item ? (item.name || item.text) : textInput.value.trim(); |
|
|
if (!text) return; |
|
|
const itemId = item ? item.id : 'new_' + new Date().getTime(); |
|
|
const checked = item ? (item.completed || item.purchased) : false; |
|
|
const quantity = item ? item.quantity : 1; |
|
|
const itemsContainer = document.getElementById('list-editor-items-container'); |
|
|
const itemEl = document.createElement('div'); |
|
|
itemEl.className = 'list-editor-item'; |
|
|
itemEl.dataset.id = itemId; |
|
|
let itemHTML = ` |
|
|
<input type="checkbox" ${checked ? 'checked' : ''}> |
|
|
<input type="text" value="${text.replace(/"/g, '"')}"> |
|
|
`; |
|
|
if (listType === 'shoppinglist') { |
|
|
itemHTML += ` |
|
|
<div class="quantity-controls"> |
|
|
<button type="button" onclick="changeQuantity(this, -1)">-</button> |
|
|
<input type="number" value="${quantity || 1}" min="1" onchange="this.value = Math.max(1, parseInt(this.value) || 1)"> |
|
|
<button type="button" onclick="changeQuantity(this, 1)">+</button> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
itemHTML += `<button type="button" class="delete-item-btn" onclick="this.parentElement.remove()"><i class="fa-solid fa-times"></i></button>`; |
|
|
itemEl.innerHTML = itemHTML; |
|
|
itemsContainer.appendChild(itemEl); |
|
|
if (!item) textInput.value = ''; |
|
|
} |
|
|
function changeQuantity(btn, delta) { |
|
|
const input = btn.parentElement.querySelector('input[type=number]'); |
|
|
let value = parseInt(input.value) || 1; |
|
|
value += delta; |
|
|
if (value < 1) value = 1; |
|
|
input.value = value; |
|
|
} |
|
|
async function saveList() { |
|
|
const id = document.getElementById('list-editor-id').value; |
|
|
const type = document.getElementById('list-editor-type').value; |
|
|
const title = document.getElementById('list-editor-title-input').value.trim(); |
|
|
if (!title) { Telegram.WebApp.showAlert('Название не может быть пустым.'); return; } |
|
|
const items = []; |
|
|
document.querySelectorAll('#list-editor-items-container .list-editor-item').forEach(el => { |
|
|
const itemText = el.querySelector('input[type=text]').value.trim(); |
|
|
if (!itemText) return; |
|
|
const item = { |
|
|
id: el.dataset.id.startsWith('new_') ? null : el.dataset.id, |
|
|
text: itemText, |
|
|
name: itemText, |
|
|
completed: el.querySelector('input[type=checkbox]').checked, |
|
|
purchased: el.querySelector('input[type=checkbox]').checked |
|
|
}; |
|
|
if (type === 'shoppinglist') { |
|
|
item.quantity = parseInt(el.querySelector('input[type=number]').value) || 1; |
|
|
} |
|
|
items.push(item); |
|
|
}); |
|
|
const payload = { |
|
|
list_id: id, type: type, title: title, items: items, parent_folder_id: '{{ current_folder_id }}' |
|
|
}; |
|
|
const response = await fetch('{{ url_for("create_or_update_list_tma") }}', { |
|
|
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); } |
|
|
else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка сохранения.'); } |
|
|
} |
|
|
</script></body></html> |
|
|
''' |
|
|
|
|
|
ARCHIVED_LISTS_HTML = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> |
|
|
<title>Архив</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<script src="https://telegram.org/js/telegram-web-app.js"></script> |
|
|
<style>''' + BASE_STYLE + '''</style></head><body> |
|
|
<div class="app-header"> |
|
|
<div class="user-info">{{ display_name }}</div> |
|
|
<div class="view-toggle"> |
|
|
<a href="{{ url_for('tma_dashboard') }}" style="color: var(--text-muted); font-size: 1.2em; padding: 5px;"><i class="fa-solid fa-arrow-left"></i></a> |
|
|
</div> |
|
|
</div> |
|
|
<div class="container"> |
|
|
<h2>Архив</h2> |
|
|
<div class="file-grid list-view"> |
|
|
{% for item in items %} |
|
|
<div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}"> |
|
|
<div class="item-preview-wrapper"> |
|
|
{% if item.type == 'todolist' %}<div class="item-preview"><i class="fa-solid fa-list-check"></i></div> |
|
|
{% elif item.type == 'shoppinglist' %}<div class="item-preview"><i class="fa-solid fa-cart-shopping"></i></div> |
|
|
{% endif %} |
|
|
</div> |
|
|
<div class="item-name-info"> |
|
|
<p class="item-name">{{ item.title }}</p> |
|
|
<p class="item-info">Заархивировано</p> |
|
|
</div> |
|
|
<button class="btn archive-btn" onclick="unarchiveItem('{{ item.id }}')" style="margin-left:auto; padding: 8px 12px; font-size: 0.8em;">Восстановить</button> |
|
|
<button class="btn delete-btn" onclick="deleteItem('{{ item.id }}')" style="padding: 8px 12px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i></button> |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% if not items %}<p>Архив пуст.</p>{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
<script> |
|
|
window.Telegram.WebApp.ready(); |
|
|
const haptic = window.Telegram.WebApp.HapticFeedback; |
|
|
|
|
|
async function performAction(url, body) { |
|
|
try { |
|
|
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
|
|
const result = await response.json(); |
|
|
if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); } |
|
|
else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.'); } |
|
|
} catch (error) { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Сетевая ошибка.'); } |
|
|
} |
|
|
|
|
|
function unarchiveItem(itemId) { |
|
|
haptic.impactOccurred('medium'); |
|
|
performAction('{{ url_for("batch_unarchive_tma") }}', { item_ids: [itemId] }); |
|
|
} |
|
|
|
|
|
function deleteItem(itemId) { |
|
|
haptic.impactOccurred('medium'); |
|
|
Telegram.WebApp.showConfirm('Удалить этот список навсегда?', ok => { |
|
|
if (ok) { |
|
|
haptic.impactOccurred('heavy'); |
|
|
performAction('{{ url_for("batch_delete_tma") }}', { item_ids: [itemId] }); |
|
|
} |
|
|
}); |
|
|
} |
|
|
</script> |
|
|
</body></html> |
|
|
''' |
|
|
|
|
|
TMA_MANAGE_BUSINESS_HTML = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> |
|
|
<title>Мои бизнес страницы</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<script src="https://telegram.org/js/telegram-web-app.js"></script> |
|
|
<style>''' + BASE_STYLE + '''</style></head><body> |
|
|
<div class="app-header"> |
|
|
<div class="user-info">{{ display_name }}</div> |
|
|
<div class="view-toggle"> |
|
|
<a href="{{ url_for('tma_dashboard') }}" title="Назад"><i class="fa-solid fa-arrow-left"></i></a> |
|
|
</div> |
|
|
</div> |
|
|
<div class="container"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> |
|
|
<h2>Мои бизнес страницы</h2> |
|
|
<a href="{{ url_for('tma_create_business_page') }}" class="btn business-btn"><i class="fa-solid fa-plus"></i> Создать</a> |
|
|
</div> |
|
|
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %} |
|
|
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %} |
|
|
{% endif %}{% endwith %} |
|
|
<div class="file-grid list-view"> |
|
|
{% for page in pages %} |
|
|
<div class="item"> |
|
|
<div class="item-preview-wrapper" style="background-color: #2a2a2a;"> |
|
|
{% if page.avatar_path %} |
|
|
<img src="{{ hf_file_url_jinja(page.avatar_path) }}" class="item-preview" style="border-radius: 50%;"> |
|
|
{% else %} |
|
|
<div class="item-preview" style="font-size: 1.8em; color: var(--business-color); display:flex; align-items:center; justify-content:center;"><i class="fa-solid fa-store"></i></div> |
|
|
{% endif %} |
|
|
</div> |
|
|
<div class="item-name-info"> |
|
|
<p class="item-name">{{ page.org_name }}</p> |
|
|
<p class="item-info">/business/{{ page.login }}</p> |
|
|
</div> |
|
|
<button onclick="copyToClipboard('{{ url_for('public_business_page', login=page.login, _external=True) }}')" class="btn share-btn" style="padding: 8px 12px; font-size: 0.8em; margin-left: auto;"><i class="fa-solid fa-copy"></i></button> |
|
|
<a href="{{ url_for('tma_manage_products', login=page.login) }}" class="btn" style="padding: 8px 12px; font-size: 0.8em;"><i class="fa-solid fa-list-check"></i></a> |
|
|
<a href="{{ url_for('tma_edit_business_page', login=page.login) }}" class="btn" style="background: var(--accent); padding: 8px 12px; font-size: 0.8em;"><i class="fa-solid fa-pencil"></i></a> |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% if not pages %} |
|
|
<p>У вас еще нет бизнес страниц.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
<script> |
|
|
window.Telegram.WebApp.ready(); |
|
|
const haptic = window.Telegram.WebApp.HapticFeedback; |
|
|
function copyToClipboard(text) { |
|
|
navigator.clipboard.writeText(text).then(() => { |
|
|
haptic.notificationOccurred('success'); |
|
|
Telegram.WebApp.showAlert('Ссылка скопирована!'); |
|
|
}, () => { |
|
|
haptic.notificationOccurred('error'); |
|
|
Telegram.WebApp.showAlert('Ошибка копирования.'); |
|
|
}); |
|
|
} |
|
|
</script> |
|
|
</body></html> |
|
|
''' |
|
|
|
|
|
TMA_CREATE_EDIT_BUSINESS_FORM_HTML = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> |
|
|
<title>{{ 'Редактировать' if page else 'Создать' }} страницу</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<script src="https://telegram.org/js/telegram-web-app.js"></script> |
|
|
<style>''' + BASE_STYLE + ''' |
|
|
.form-container { max-width: 600px; margin: auto; } |
|
|
</style></head><body> |
|
|
<div class="app-header"> |
|
|
<div class="user-info">{{ display_name }}</div> |
|
|
<div class="view-toggle"> |
|
|
<a href="{{ url_for('tma_manage_business_pages') }}" title="Назад"><i class="fa-solid fa-arrow-left"></i></a> |
|
|
</div> |
|
|
</div> |
|
|
<div class="container form-container"> |
|
|
<h2>{{ 'Редактировать' if page else 'Создать' }} бизнес страницу</h2> |
|
|
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %} |
|
|
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %} |
|
|
{% endif %}{% endwith %} |
|
|
<form method="POST" enctype="multipart/form-data"> |
|
|
<div class="form-group"> |
|
|
<label for="org_name">Название организации</label> |
|
|
<input type="text" name="org_name" id="org_name" value="{{ page.org_name if page else '' }}" required> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label for="login">Логин (URL)</label> |
|
|
<input type="text" name="login" id="login" value="{{ page.login if page else '' }}" pattern="[a-zA-Z0-9_.-]+" {{ 'readonly' if page else '' }} required> |
|
|
<small>Только латинские буквы, цифры и символы (_, ., -). Это будет в ссылке: /business/логин</small> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label for="avatar">Аватар (опционально)</label> |
|
|
<input type="file" name="avatar" id="avatar" accept="image/*"> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label for="currency">Валюта</label> |
|
|
<select name="currency" id="currency" required> |
|
|
{% set currencies = {'Тенге': 'KZT', 'Рубль': 'RUB', 'Кыргызский сом': 'KGS', 'Узбекский сум': 'UZS', 'Украинская гривна': 'UAH'} %} |
|
|
{% for name, code in currencies.items() %} |
|
|
<option value="{{ code }}" {{ 'selected' if page and page.currency == code }}>{{ name }} ({{ code }})</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label class="checkbox-label"> |
|
|
<input type="checkbox" name="show_prices" value="true" {{ 'checked' if page and page.show_prices }}> |
|
|
<span>Указывать цены на товары</span> |
|
|
</label> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label>Куда будут приходить заказы?</label> |
|
|
<select name="order_destination" id="order_destination" required> |
|
|
<option value="whatsapp" {{ 'selected' if page and page.order_destination == 'whatsapp' }}>WhatsApp</option> |
|
|
<option value="telegram" {{ 'selected' if page and page.order_destination == 'telegram' }}>Telegram</option> |
|
|
</select> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label for="contact_number">Номер телефона или username Telegram</label> |
|
|
<input type="text" name="contact_number" id="contact_number" value="{{ page.contact_number if page else '' }}" required> |
|
|
<small>Для WhatsApp: номер с кодом страны (e.g., +77001234567). Для Telegram: @username (без @).</small> |
|
|
</div> |
|
|
<button type="submit" class="btn business-btn" style="width: 100%;">{{ 'Сохранить изменения' if page else 'Создать страницу' }}</button> |
|
|
</form> |
|
|
{% if page %} |
|
|
<form method="POST" action="{{ url_for('tma_delete_business_page', login=page.login) }}" onsubmit="return confirm('Вы уверены, что хотите удалить эту страницу? Это действие необратимо.');" style="margin-top:20px;"> |
|
|
<button type="submit" class="btn delete-btn" style="width:100%;">Удалить страницу</button> |
|
|
</form> |
|
|
{% endif %} |
|
|
</div> |
|
|
<script> |
|
|
window.Telegram.WebApp.ready(); |
|
|
</script> |
|
|
</body></html> |
|
|
''' |
|
|
|
|
|
TMA_MANAGE_PRODUCTS_HTML = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> |
|
|
<title>Управление товарами</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<script src="https://telegram.org/js/telegram-web-app.js"></script> |
|
|
<style>''' + BASE_STYLE + '''</style></head><body> |
|
|
<div class="app-header"> |
|
|
<div class="user-info">{{ page.org_name }}</div> |
|
|
<div class="view-toggle"> |
|
|
<a href="{{ url_for('tma_manage_business_pages') }}" title="Назад"><i class="fa-solid fa-arrow-left"></i></a> |
|
|
</div> |
|
|
</div> |
|
|
<div class="container"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> |
|
|
<h2>Товары</h2> |
|
|
<button onclick="openProductModal()" class="btn business-btn"><i class="fa-solid fa-plus"></i> Добавить товар</button> |
|
|
</div> |
|
|
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %} |
|
|
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %} |
|
|
{% endif %}{% endwith %} |
|
|
<div class="file-grid list-view" id="product-list"> |
|
|
{% for product in page.products %} |
|
|
<div class="item"> |
|
|
<div class="item-preview-wrapper" style="background-color: #2a2a2a;"> |
|
|
{% if product.photo_path %} |
|
|
<img src="{{ hf_file_url_jinja(product.photo_path) }}" class="item-preview"> |
|
|
{% else %} |
|
|
<div class="item-preview" style="font-size: 1.8em; color: var(--text-muted); display:flex; align-items:center; justify-content:center;"><i class="fa-solid fa-box-open"></i></div> |
|
|
{% endif %} |
|
|
</div> |
|
|
<div class="item-name-info"> |
|
|
<p class="item-name">{{ product.name }}</p> |
|
|
{% if page.show_prices %} |
|
|
<p class="item-info">{{ "%.2f"|format(product.price|float) }} {{ page.currency }}</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
<button onclick="openProductModal('{{ product.id }}')" class="btn" style="background: var(--accent); padding: 8px 12px; font-size: 0.8em; margin-left: auto;"><i class="fa-solid fa-pencil"></i></button> |
|
|
<form action="{{ url_for('tma_delete_product', login=page.login, product_id=product.id) }}" method="post" onsubmit="return confirm('Удалить этот товар?');"> |
|
|
<button type="submit" class="btn delete-btn" style="padding: 8px 12px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i></button> |
|
|
</form> |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% if not page.products %} |
|
|
<p>У вас еще нет товаров.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="modal" id="product-modal"> |
|
|
<div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;"> |
|
|
<h4 id="product-modal-title">Новый товар</h4> |
|
|
<form id="product-form" method="POST" enctype="multipart/form-data"> |
|
|
<input type="hidden" name="product_id" id="product_id"> |
|
|
<div class="form-group"> |
|
|
<label for="name">Название</label> |
|
|
<input type="text" name="name" id="name" required> |
|
|
</div> |
|
|
<div class="form-group"> |
|
|
<label for="description">Описание</label> |
|
|
<textarea name="description" id="description" rows="3"></textarea> |
|
|
</div> |
|
|
{% if page.show_prices %} |
|
|
<div class="form-group"> |
|
|
<label for="price">Цена ({{ page.currency }})</label> |
|
|
<input type="number" name="price" id="price" step="0.01"> |
|
|
</div> |
|
|
{% endif %} |
|
|
<div class="form-group"> |
|
|
<label for="photo">Фото</label> |
|
|
<input type="file" name="photo" id="photo" accept="image/*"> |
|
|
</div> |
|
|
<div style="display: flex; gap: 10px; margin-top: 15px;"> |
|
|
<button type="submit" class="btn business-btn" style="flex-grow: 1;">Сохранить</button> |
|
|
<button type="button" class="btn" style="background: #555; flex-grow: 1;" onclick="closeProductModal()">Отмена</button> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
window.Telegram.WebApp.ready(); |
|
|
const products = {{ page.products|tojson }}; |
|
|
const modal = document.getElementById('product-modal'); |
|
|
const form = document.getElementById('product-form'); |
|
|
|
|
|
function openProductModal(productId = null) { |
|
|
form.reset(); |
|
|
document.getElementById('product_id').value = ''; |
|
|
document.getElementById('product-modal-title').textContent = 'Новый товар'; |
|
|
form.action = "{{ url_for('tma_add_product', login=page.login) }}"; |
|
|
|
|
|
if (productId) { |
|
|
const product = products.find(p => p.id === productId); |
|
|
if (product) { |
|
|
document.getElementById('product-modal-title').textContent = 'Редактировать товар'; |
|
|
document.getElementById('product_id').value = product.id; |
|
|
document.getElementById('name').value = product.name; |
|
|
document.getElementById('description').value = product.description; |
|
|
if (document.getElementById('price')) { |
|
|
document.getElementById('price').value = product.price; |
|
|
} |
|
|
form.action = "{{ url_for('tma_edit_product', login=page.login) }}"; |
|
|
} |
|
|
} |
|
|
modal.style.display = 'flex'; |
|
|
} |
|
|
|
|
|
function closeProductModal() { |
|
|
modal.style.display = 'none'; |
|
|
} |
|
|
</script> |
|
|
</body></html> |
|
|
''' |
|
|
|
|
|
@app.route('/tma_dashboard', methods=['GET', 'POST']) |
|
|
def tma_dashboard(): |
|
|
if 'telegram_user_id' not in session: |
|
|
flash('Пожалуйста, авторизуйтесь через Telegram.', 'error') |
|
|
return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
display_name = session.get('telegram_display_name', 'Пользователь') |
|
|
data = load_data() |
|
|
if tma_user_id not in data['users']: |
|
|
session.clear() |
|
|
flash('Пользователь не найден. Пожалуйста, перезапустите приложение.', 'error') |
|
|
return redirect(url_for('tma_entry_page')) |
|
|
user_data = data['users'][tma_user_id] |
|
|
initialize_user_filesystem_tma(user_data, tma_user_id) |
|
|
current_folder_id = request.args.get('folder_id', 'root') |
|
|
current_folder, parent_folder = find_node_by_id(user_data['filesystem'], current_folder_id) |
|
|
if not current_folder or current_folder.get('type') != 'folder': |
|
|
flash('Папка не найдена!', 'error') |
|
|
current_folder_id = 'root' |
|
|
current_folder, parent_folder = find_node_by_id(user_data['filesystem'], 'root') |
|
|
|
|
|
parent_folder_id = parent_folder.get('id', 'root') if parent_folder else 'root' |
|
|
|
|
|
items_in_folder = [item for item in current_folder.get('children', []) if not item.get('is_archived')] |
|
|
items_in_folder = sorted(items_in_folder, key=lambda x: (x['type'] not in ['folder', 'note', 'todolist', 'shoppinglist'], x.get('name', x.get('original_filename', x.get('title', ''))).lower())) |
|
|
|
|
|
if request.method == 'POST': |
|
|
if not HF_TOKEN_WRITE: |
|
|
flash('Загрузка невозможна: токен для записи не настроен.', 'error') |
|
|
return redirect(url_for('tma_dashboard', folder_id=current_folder_id)) |
|
|
files = request.files.getlist('files') |
|
|
if not files or all(not f.filename for f in files): |
|
|
flash('Файлы для загрузки не выбраны.', 'error') |
|
|
return redirect(url_for('tma_dashboard', folder_id=current_folder_id)) |
|
|
target_folder_id = request.form.get('current_folder_id', 'root') |
|
|
uploaded_count = 0 |
|
|
errors_list = [] |
|
|
api = HfApi() |
|
|
for file_obj in files: |
|
|
if file_obj and file_obj.filename: |
|
|
original_filename = secure_filename(file_obj.filename) |
|
|
name_part, ext_part = os.path.splitext(original_filename) |
|
|
unique_suffix = uuid.uuid4().hex[:8] |
|
|
unique_filename = f"{name_part}_{unique_suffix}{ext_part}" |
|
|
file_id = uuid.uuid4().hex |
|
|
hf_path = f"cloud_files/{tma_user_id}/{target_folder_id}/{unique_filename}" |
|
|
temp_path = os.path.join(UPLOAD_FOLDER, f"{file_id}_{unique_filename}") |
|
|
try: |
|
|
file_obj.save(temp_path) |
|
|
api.upload_file(path_or_fileobj=temp_path, path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
|
|
file_info = {'type': 'file', 'id': file_id, 'original_filename': original_filename, 'unique_filename': unique_filename, 'path': hf_path, 'file_type': get_file_type(original_filename), 'upload_date': datetime.now().strftime('%Y-%m-%d')} |
|
|
if add_node(user_data['filesystem'], target_folder_id, file_info): |
|
|
uploaded_count += 1 |
|
|
except Exception as e: |
|
|
errors_list.append(f"Ошибка загрузки {original_filename}: {e}") |
|
|
finally: |
|
|
if os.path.exists(temp_path): os.remove(temp_path) |
|
|
if uploaded_count > 0: |
|
|
try: save_data(data); flash(f'{uploaded_count} файл(ов) успешно загружено!') |
|
|
except Exception: flash('Файлы загружены, но ошибка сохранения метаданных.', 'error') |
|
|
if errors_list: |
|
|
for error_msg in errors_list: flash(error_msg, 'error') |
|
|
return redirect(url_for('tma_dashboard', folder_id=target_folder_id)) |
|
|
|
|
|
breadcrumbs = [] |
|
|
temp_id = current_folder_id |
|
|
while temp_id: |
|
|
node, parent_node_bc = find_node_by_id(user_data['filesystem'], temp_id) |
|
|
if not node: break |
|
|
is_link = (node['id'] != current_folder_id) |
|
|
breadcrumbs.append({'id': node['id'], 'name': node.get('name', 'Root'), 'is_link': is_link}) |
|
|
if not parent_node_bc: break |
|
|
temp_id = parent_node_bc.get('id') |
|
|
breadcrumbs.reverse() |
|
|
|
|
|
all_folders_for_move = get_all_folders(user_data['filesystem']) |
|
|
|
|
|
return render_template_string(TMA_DASHBOARD_HTML_TEMPLATE, display_name=display_name, items=items_in_folder, current_folder_id=current_folder_id, current_folder=current_folder, parent_folder_id=parent_folder_id, breadcrumbs=breadcrumbs, hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}", is_tma_user_admin_flag=is_admin_tma(), all_folders_for_move=all_folders_for_move) |
|
|
|
|
|
@app.route('/tma_archive') |
|
|
def tma_archive_view(): |
|
|
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
display_name = session.get('telegram_display_name', 'Пользователь') |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return redirect(url_for('tma_entry_page')) |
|
|
|
|
|
archived_items = get_all_archived_items(user_data.get('filesystem')) |
|
|
sorted_items = sorted(archived_items, key=lambda x: x.get('modified_date', ''), reverse=True) |
|
|
|
|
|
return render_template_string(ARCHIVED_LISTS_HTML, display_name=display_name, items=sorted_items) |
|
|
|
|
|
@app.route('/create_folder_tma', methods=['POST']) |
|
|
def create_folder_tma(): |
|
|
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return redirect(url_for('tma_entry_page')) |
|
|
|
|
|
parent_folder_id = request.form.get('parent_folder_id', 'root') |
|
|
folder_name = request.form.get('folder_name', '').strip() |
|
|
if not folder_name: |
|
|
flash('Имя папки не может быть пустым!', 'error') |
|
|
else: |
|
|
folder_id = uuid.uuid4().hex |
|
|
folder_data = {'type': 'folder', 'id': folder_id, 'name': folder_name, 'children': []} |
|
|
if add_node(user_data['filesystem'], parent_folder_id, folder_data): |
|
|
try: save_data(data); flash(f'Папка "{folder_name}" создана.') |
|
|
except Exception: flash('Ошибка сохранения данных.', 'error') |
|
|
else: flash('Не удалось найти родительскую папку.', 'error') |
|
|
return redirect(url_for('tma_dashboard', folder_id=parent_folder_id)) |
|
|
|
|
|
def get_item_node_for_user(item_id): |
|
|
if not (session.get('telegram_user_id') or session.get('admin_browser_logged_in')): |
|
|
return None |
|
|
data = load_data() |
|
|
if session.get('admin_browser_logged_in'): |
|
|
for uid, udata in data.get('users', {}).items(): |
|
|
node, _ = find_node_by_id(udata.get('filesystem', {}), item_id) |
|
|
if node: return node |
|
|
else: |
|
|
user_data = data['users'].get(session['telegram_user_id']) |
|
|
if user_data: |
|
|
node, _ = find_node_by_id(user_data.get('filesystem', {}), item_id) |
|
|
if node: return node |
|
|
return None |
|
|
|
|
|
def get_file_node_for_admin(tma_user_id_str, file_id): |
|
|
if not session.get('admin_browser_logged_in'): |
|
|
return None |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id_str) |
|
|
if user_data: |
|
|
node, _ = find_node_by_id(user_data.get('filesystem', {}), file_id) |
|
|
if node and node.get('type') == 'file': |
|
|
return node |
|
|
return None |
|
|
|
|
|
@app.route('/download_tma/<file_id>') |
|
|
def download_tma(file_id): |
|
|
file_node = get_item_node_for_user(file_id) |
|
|
if not file_node or file_node.get('type') != 'file': |
|
|
return jsonify({'status': 'error', 'message': 'Файл не найден или доступ запрещен'}), 404 |
|
|
|
|
|
token = uuid.uuid4().hex |
|
|
cache.set(f"download_token_{token}", file_node, timeout=300) |
|
|
public_url = url_for('public_download', token=token, _external=True) |
|
|
return jsonify({'status': 'success', 'url': public_url}) |
|
|
|
|
|
@app.route('/public_download/<token>') |
|
|
def public_download(token): |
|
|
file_node = cache.get(f"download_token_{token}") |
|
|
if not file_node: |
|
|
return Response("Ссылка для скачивания недействительна или истекла.", status=404) |
|
|
|
|
|
hf_path = file_node.get('path') |
|
|
original_filename = file_node.get('original_filename', 'downloaded_file') |
|
|
if not hf_path: |
|
|
return Response("Ошибка: Путь к файлу не найден.", status=500) |
|
|
|
|
|
try: |
|
|
hf_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(hf_path)}" |
|
|
headers = {} |
|
|
if HF_TOKEN_READ: |
|
|
headers["Authorization"] = f"Bearer {HF_TOKEN_READ}" |
|
|
|
|
|
req = requests.get(hf_url, headers=headers, stream=True, allow_redirects=True) |
|
|
req.raise_for_status() |
|
|
|
|
|
encoded_filename = quote(original_filename) |
|
|
response_headers = { |
|
|
'Content-Type': req.headers.get('Content-Type', 'application/octet-stream'), |
|
|
'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}", |
|
|
'Content-Length': req.headers.get('Content-Length') |
|
|
} |
|
|
response_headers = {k: v for k, v in response_headers.items() if v is not None} |
|
|
|
|
|
return Response(stream_with_context(req.iter_content(chunk_size=8192)), headers=response_headers) |
|
|
|
|
|
except requests.exceptions.HTTPError as e: |
|
|
if e.response.status_code == 404: |
|
|
logging.error(f"File not found on Hugging Face during streaming: {hf_path}") |
|
|
return Response("Файл не найден на удаленном хранилище.", status=404) |
|
|
else: |
|
|
logging.error(f"HTTP error downloading from HF via streaming: {e}") |
|
|
return Response(f'Ошибка HTTP при скачивании файла: {e}', status=502) |
|
|
except Exception as e: |
|
|
logging.error(f"Error streaming with token {token} from HF: {e}") |
|
|
return Response(f'Ошибка скачивания файла: {e}', status=502) |
|
|
|
|
|
@app.route('/batch_download_tma') |
|
|
def batch_download_tma(): |
|
|
if 'telegram_user_id' not in session: return Response("Unauthorized", 401) |
|
|
file_ids_str = request.args.get('file_ids') |
|
|
if not file_ids_str: return Response("No file IDs provided", 400) |
|
|
file_ids = file_ids_str.split(',') |
|
|
temp_zip_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") |
|
|
try: |
|
|
with zipfile.ZipFile(temp_zip_file.name, 'w', zipfile.ZIP_DEFLATED) as zf: |
|
|
for file_id in file_ids: |
|
|
file_node = get_item_node_for_user(file_id) |
|
|
if file_node and file_node.get('path'): |
|
|
hf_path = file_node['path'] |
|
|
original_filename = file_node.get('original_filename', file_id) |
|
|
try: |
|
|
local_file_path = hf_hub_download( |
|
|
repo_id=REPO_ID, |
|
|
filename=hf_path, |
|
|
repo_type="dataset", |
|
|
token=HF_TOKEN_READ, |
|
|
cache_dir=os.path.join(UPLOAD_FOLDER, 'hf_download_cache') |
|
|
) |
|
|
zf.write(local_file_path, arcname=original_filename) |
|
|
except Exception as e: |
|
|
logging.error(f"Failed to download and add {original_filename} to zip: {e}") |
|
|
|
|
|
return send_file(temp_zip_file.name, as_attachment=True, download_name='archive.zip', mimetype='application/zip') |
|
|
finally: |
|
|
if os.path.exists(temp_zip_file.name): |
|
|
os.unlink(temp_zip_file.name) |
|
|
|
|
|
@app.route('/batch_delete_tma', methods=['POST']) |
|
|
def batch_delete_tma(): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404 |
|
|
item_ids = request.json.get('item_ids', []) |
|
|
if not item_ids: return jsonify({'status': 'error', 'message': 'Не выбраны элементы.'}), 400 |
|
|
|
|
|
api = HfApi() |
|
|
success_count = 0; errors = [] |
|
|
for item_id in item_ids: |
|
|
node, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
if not node: |
|
|
errors.append(f"Элемент {item_id} не найден.") |
|
|
continue |
|
|
node_type = node.get('type') |
|
|
node_name = node.get('name') or node.get('title') or node.get('original_filename', 'элемент') |
|
|
|
|
|
if node_type == 'folder': |
|
|
if node.get('children'): errors.append(f'Папка "{node_name}" не пуста.'); continue |
|
|
|
|
|
if node_type in ['folder', 'note', 'todolist', 'shoppinglist', 'file']: |
|
|
if node_type == 'file': |
|
|
try: |
|
|
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) |
|
|
except hf_utils.EntryNotFoundError: pass |
|
|
except Exception as e: errors.append(f'Ошибка удаления "{node_name}" с сервера: {e}'); continue |
|
|
|
|
|
if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1 |
|
|
else: errors.append(f'Ошибка удаления "{node_name}" из базы.') |
|
|
|
|
|
if success_count > 0: |
|
|
try: save_data(data) |
|
|
except Exception as e: return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}) |
|
|
if errors: return jsonify({'status': 'error', 'message': f'Удалено {success_count}. Ошибки: ' + "; ".join(errors)}) |
|
|
return jsonify({'status': 'success', 'message': f'Удалено {success_count} элемент(ов).'}) |
|
|
|
|
|
@app.route('/batch_move_tma', methods=['POST']) |
|
|
def batch_move_tma(): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404 |
|
|
item_ids = request.json.get('item_ids', []) |
|
|
destination_id = request.json.get('destination_id') |
|
|
if not item_ids or not destination_id: return jsonify({'status': 'error', 'message': 'Не указаны файлы или папка.'}), 400 |
|
|
destination_node, _ = find_node_by_id(user_data['filesystem'], destination_id) |
|
|
if not destination_node or destination_node.get('type') != 'folder': return jsonify({'status': 'error', 'message': 'Папка назначения не найдена.'}), 404 |
|
|
|
|
|
descendant_ids = set() |
|
|
for item_id in item_ids: |
|
|
node, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
if node and node.get('type') == 'folder': |
|
|
queue = [node] |
|
|
while queue: |
|
|
curr = queue.pop(0) |
|
|
descendant_ids.add(curr.get('id')) |
|
|
if 'children' in curr: queue.extend(curr['children']) |
|
|
if destination_id in descendant_ids: return jsonify({'status': 'error', 'message': 'Нельзя переместить папку в саму себя.'}) |
|
|
|
|
|
moved_count = 0; errors = [] |
|
|
for item_id in item_ids: |
|
|
if item_id == destination_id: continue |
|
|
removed, node_to_move = remove_node(user_data['filesystem'], item_id) |
|
|
if removed and node_to_move: |
|
|
node_to_move['is_archived'] = False |
|
|
if add_node(user_data['filesystem'], destination_id, node_to_move): moved_count += 1 |
|
|
else: errors.append(f'Ошибка добавления {item_id} в новую папку.') |
|
|
else: errors.append(f'Не удалось извлечь {item_id}.') |
|
|
|
|
|
if moved_count > 0: |
|
|
try: save_data(data) |
|
|
except Exception as e: return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}) |
|
|
if errors: return jsonify({'status': 'error', 'message': f'Перемещено {moved_count}. Ошибки: ' + "; ".join(errors)}) |
|
|
return jsonify({'status': 'success', 'message': f'Перемещено {moved_count} элемент(ов).'}) |
|
|
|
|
|
@app.route('/batch_archive_tma', methods=['POST']) |
|
|
def batch_archive_tma(): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404 |
|
|
item_ids = request.json.get('item_ids', []) |
|
|
if not item_ids: return jsonify({'status': 'error', 'message': 'Не выбраны элементы.'}), 400 |
|
|
|
|
|
archived_count = 0 |
|
|
for item_id in item_ids: |
|
|
node, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
if node and node.get('type') in ['todolist', 'shoppinglist']: |
|
|
node['is_archived'] = True |
|
|
archived_count += 1 |
|
|
|
|
|
if archived_count > 0: |
|
|
try: |
|
|
save_data(data) |
|
|
return jsonify({'status': 'success', 'message': f'Архивировано {archived_count} списк(ов).'}) |
|
|
except Exception as e: |
|
|
return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500 |
|
|
return jsonify({'status': 'error', 'message': 'Не найдено списков для архивации.'}) |
|
|
|
|
|
@app.route('/batch_unarchive_tma', methods=['POST']) |
|
|
def batch_unarchive_tma(): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404 |
|
|
item_ids = request.json.get('item_ids', []) |
|
|
if not item_ids: return jsonify({'status': 'error', 'message': 'Не выбраны элементы.'}), 400 |
|
|
|
|
|
unarchived_count = 0 |
|
|
for item_id in item_ids: |
|
|
node, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
if node and node.get('is_archived'): |
|
|
node['is_archived'] = False |
|
|
unarchived_count += 1 |
|
|
|
|
|
if unarchived_count > 0: |
|
|
try: |
|
|
save_data(data) |
|
|
return jsonify({'status': 'success', 'message': f'Восстановлено {unarchived_count} списк(ов).'}) |
|
|
except Exception as e: |
|
|
return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500 |
|
|
return jsonify({'status': 'error', 'message': 'Не найдено списков для восстановления.'}) |
|
|
|
|
|
@app.route('/get_text_content_tma/<file_id>') |
|
|
def get_text_content_tma(file_id): |
|
|
file_node = get_item_node_for_user(file_id) |
|
|
if not file_node or file_node.get('file_type') != 'text': return Response("Текстовый файл не найден", 404) |
|
|
hf_path = file_node.get('path') |
|
|
if not hf_path: return Response("Ошибка: путь к файлу отсутствует", 500) |
|
|
file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(hf_path)}?download=true" |
|
|
try: |
|
|
req_headers = {}; |
|
|
if HF_TOKEN_READ: req_headers["authorization"] = f"Bearer {HF_TOKEN_READ}" |
|
|
response = requests.get(file_url, headers=req_headers) |
|
|
response.raise_for_status() |
|
|
if len(response.content) > 1 * 1024 * 1024: return Response("Файл слишком большой для предпросмотра.", 413) |
|
|
try: text_content = response.content.decode('utf-8') |
|
|
except UnicodeDecodeError: text_content = response.content.decode('latin-1', errors='ignore') |
|
|
return Response(text_content, mimetype='text/plain') |
|
|
except Exception as e: return Response(f"Ошибка загрузки: {e}", 502) |
|
|
|
|
|
@app.route('/get_note_tma/<note_id>') |
|
|
def get_note_tma(note_id): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401 |
|
|
note_node = get_item_node_for_user(note_id) |
|
|
if not note_node or note_node.get('type') != 'note': |
|
|
return jsonify({'status': 'error', 'message': 'Note not found'}), 404 |
|
|
return jsonify({'status': 'success', 'note': note_node}) |
|
|
|
|
|
@app.route('/create_or_update_note_tma', methods=['POST']) |
|
|
def create_or_update_note_tma(): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404 |
|
|
|
|
|
payload = request.json |
|
|
note_id = payload.get('note_id') |
|
|
title = payload.get('title', '').strip() |
|
|
content = payload.get('content', '') |
|
|
parent_folder_id = payload.get('parent_folder_id', 'root') |
|
|
now_str = datetime.now().strftime('%Y-%m-%d %H:%M') |
|
|
|
|
|
if not title: return jsonify({'status': 'error', 'message': 'Title cannot be empty.'}), 400 |
|
|
|
|
|
if note_id: |
|
|
node, _ = find_node_by_id(user_data['filesystem'], note_id) |
|
|
if not node or node.get('type') != 'note': |
|
|
return jsonify({'status': 'error', 'message': 'Note not found'}), 404 |
|
|
node['title'] = title |
|
|
node['content'] = content |
|
|
node['modified_date'] = now_str |
|
|
else: |
|
|
new_note_id = uuid.uuid4().hex |
|
|
note_data = { |
|
|
'type': 'note', 'id': new_note_id, 'title': title, 'content': content, |
|
|
'created_date': now_str, 'modified_date': now_str |
|
|
} |
|
|
if not add_node(user_data['filesystem'], parent_folder_id, note_data): |
|
|
return jsonify({'status': 'error', 'message': 'Parent folder not found'}), 404 |
|
|
|
|
|
try: |
|
|
save_data(data) |
|
|
return jsonify({'status': 'success', 'message': 'Note saved.'}) |
|
|
except Exception as e: |
|
|
return jsonify({'status': 'error', 'message': f'Failed to save data: {e}'}), 500 |
|
|
|
|
|
@app.route('/get_list_tma/<list_id>') |
|
|
def get_list_tma(list_id): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401 |
|
|
list_node = get_item_node_for_user(list_id) |
|
|
if not list_node or list_node.get('type') not in ['todolist', 'shoppinglist']: |
|
|
return jsonify({'status': 'error', 'message': 'List not found'}), 404 |
|
|
return jsonify({'status': 'success', 'list': list_node}) |
|
|
|
|
|
@app.route('/create_or_update_list_tma', methods=['POST']) |
|
|
def create_or_update_list_tma(): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404 |
|
|
|
|
|
payload = request.json |
|
|
list_id = payload.get('list_id') |
|
|
list_type = payload.get('type') |
|
|
title = payload.get('title', '').strip() |
|
|
items = payload.get('items', []) |
|
|
parent_folder_id = payload.get('parent_folder_id', 'root') |
|
|
now_str = datetime.now().strftime('%Y-%m-%d %H:%M') |
|
|
|
|
|
if not title: return jsonify({'status': 'error', 'message': 'Title cannot be empty.'}), 400 |
|
|
if list_type not in ['todolist', 'shoppinglist']: return jsonify({'status': 'error', 'message': 'Invalid list type.'}), 400 |
|
|
|
|
|
for item in items: |
|
|
if not item.get('id'): item['id'] = uuid.uuid4().hex |
|
|
|
|
|
if list_id: |
|
|
node, _ = find_node_by_id(user_data['filesystem'], list_id) |
|
|
if not node or node.get('type') != list_type: return jsonify({'status': 'error', 'message': 'List not found'}), 404 |
|
|
node['title'] = title |
|
|
node['items'] = items |
|
|
node['modified_date'] = now_str |
|
|
else: |
|
|
new_list_id = uuid.uuid4().hex |
|
|
list_data = { |
|
|
'type': list_type, 'id': new_list_id, 'title': title, 'items': items, |
|
|
'created_date': now_str, 'modified_date': now_str, 'is_archived': False |
|
|
} |
|
|
if not add_node(user_data['filesystem'], parent_folder_id, list_data): |
|
|
return jsonify({'status': 'error', 'message': 'Parent folder not found'}), 404 |
|
|
|
|
|
try: |
|
|
save_data(data) |
|
|
return jsonify({'status': 'success', 'message': 'List saved.'}) |
|
|
except Exception as e: |
|
|
return jsonify({'status': 'error', 'message': f'Failed to save data: {e}'}), 500 |
|
|
|
|
|
@app.route('/get_reminders_tma') |
|
|
def get_reminders_tma(): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401 |
|
|
user_data = load_data()['users'].get(session['telegram_user_id']) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404 |
|
|
|
|
|
reminders = sorted(user_data.get('reminders', []), key=lambda r: r.get('due_datetime_utc', '')) |
|
|
return jsonify({'status': 'success', 'reminders': reminders}) |
|
|
|
|
|
@app.route('/create_reminder_tma', methods=['POST']) |
|
|
def create_reminder_tma(): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404 |
|
|
|
|
|
payload = request.json |
|
|
text = payload.get('text', '').strip() |
|
|
dt_local_str = payload.get('datetime_local') |
|
|
user_tz_str = payload.get('user_timezone', 'UTC') |
|
|
if not text or not dt_local_str: |
|
|
return jsonify({'status': 'error', 'message': 'Missing required fields'}), 400 |
|
|
|
|
|
try: |
|
|
user_tz = ZoneInfo(user_tz_str) |
|
|
except ZoneInfoNotFoundError: |
|
|
user_tz = pytz.timezone('UTC') |
|
|
|
|
|
dt_local = datetime.fromisoformat(dt_local_str) |
|
|
dt_aware = dt_local.astimezone(user_tz) |
|
|
dt_utc = dt_aware.astimezone(pytz.utc) |
|
|
|
|
|
new_reminder = { |
|
|
'id': uuid.uuid4().hex, 'text': text, |
|
|
'due_datetime_utc': dt_utc.isoformat().replace('+00:00', 'Z'), |
|
|
'due_datetime_local': dt_local.isoformat(), |
|
|
'user_timezone': user_tz_str, 'notified': False |
|
|
} |
|
|
user_data.setdefault('reminders', []).append(new_reminder) |
|
|
try: |
|
|
save_data(data) |
|
|
return jsonify({'status': 'success'}) |
|
|
except Exception as e: |
|
|
return jsonify({'status': 'error', 'message': f'Failed to save: {e}'}), 500 |
|
|
|
|
|
@app.route('/delete_reminder_tma/<reminder_id>', methods=['POST']) |
|
|
def delete_reminder_tma(reminder_id): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404 |
|
|
|
|
|
reminders = user_data.get('reminders', []) |
|
|
user_data['reminders'] = [r for r in reminders if r.get('id') != reminder_id] |
|
|
|
|
|
try: |
|
|
save_data(data) |
|
|
return jsonify({'status': 'success'}) |
|
|
except Exception as e: |
|
|
return jsonify({'status': 'error', 'message': f'Failed to save: {e}'}), 500 |
|
|
|
|
|
@app.route('/tma_logout') |
|
|
def tma_logout(): |
|
|
session.clear() |
|
|
flash('Вы вышли из сессии приложения.') |
|
|
return redirect(url_for('tma_entry_page')) |
|
|
|
|
|
@app.route('/create_public_link', methods=['POST']) |
|
|
def create_public_link(): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404 |
|
|
|
|
|
payload = request.json |
|
|
item_id = payload.get('item_id') |
|
|
item_type = payload.get('item_type') |
|
|
name = payload.get('name') |
|
|
duration_hours = payload.get('duration_hours', 0) |
|
|
|
|
|
item_node, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
if not item_node or item_node.get('type') != item_type or item_type not in ['folder', 'shoppinglist']: |
|
|
return jsonify({'status': 'error', 'message': 'Элемент не найден или не может быть опубликован.'}), 404 |
|
|
|
|
|
now = datetime.now(pytz.utc) |
|
|
expires_at = None |
|
|
if duration_hours > 0: |
|
|
expires_at = now + timedelta(hours=duration_hours) |
|
|
expires_at_iso = expires_at.isoformat() |
|
|
else: |
|
|
expires_at_iso = None |
|
|
|
|
|
link_id = uuid.uuid4().hex |
|
|
link_data = { |
|
|
'id': link_id, 'user_id': tma_user_id, 'item_id': item_id, 'item_type': item_type, |
|
|
'name': name, 'created_at': now.isoformat(), 'expires_at': expires_at_iso |
|
|
} |
|
|
data['shared_links'][link_id] = link_data |
|
|
item_node.setdefault('public_links', []).append(link_id) |
|
|
|
|
|
try: |
|
|
save_data(data) |
|
|
public_url = url_for('shared_item_view', link_id=link_id, _external=True) |
|
|
return jsonify({'status': 'success', 'url': public_url}) |
|
|
except Exception as e: |
|
|
return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500 |
|
|
|
|
|
@app.route('/delete_public_link', methods=['POST']) |
|
|
def delete_public_link(): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
|
|
|
link_id = request.json.get('link_id') |
|
|
link_data = data['shared_links'].get(link_id) |
|
|
|
|
|
if not link_data or link_data.get('user_id') != tma_user_id: |
|
|
return jsonify({'status': 'error', 'message': 'Ссылка не найдена или нет доступа.'}), 404 |
|
|
|
|
|
item_id = link_data.get('item_id') |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if user_data: |
|
|
item, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
if item and 'public_links' in item: |
|
|
item['public_links'] = [l for l in item['public_links'] if l != link_id] |
|
|
|
|
|
del data['shared_links'][link_id] |
|
|
|
|
|
try: |
|
|
save_data(data) |
|
|
return jsonify({'status': 'success'}) |
|
|
except Exception as e: |
|
|
return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500 |
|
|
|
|
|
@app.route('/get_public_links/<item_id>') |
|
|
def get_public_links(item_id): |
|
|
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401 |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404 |
|
|
|
|
|
item, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
if not item: return jsonify({'status': 'error', 'message': 'Элемент не найден.'}), 404 |
|
|
|
|
|
link_ids = item.get('public_links', []) |
|
|
links_details = [] |
|
|
for link_id in link_ids: |
|
|
link_data = data['shared_links'].get(link_id) |
|
|
if link_data: |
|
|
link_data['url'] = url_for('shared_item_view', link_id=link_id, _external=True) |
|
|
links_details.append(link_data) |
|
|
|
|
|
return jsonify({'status': 'success', 'links': links_details}) |
|
|
|
|
|
@app.route('/shared/<link_id>') |
|
|
def shared_item_view(link_id): |
|
|
data = load_data() |
|
|
link_data = data.get('shared_links', {}).get(link_id) |
|
|
if not link_data: return "Ссылка недействительна.", 404 |
|
|
|
|
|
if link_data.get('expires_at'): |
|
|
expires_at = datetime.fromisoformat(link_data['expires_at']) |
|
|
if datetime.now(pytz.utc) > expires_at: |
|
|
return "Срок действия ссылки истек.", 410 |
|
|
|
|
|
user_id = link_data['user_id'] |
|
|
user_data = data['users'].get(user_id) |
|
|
if not user_data: return "Владелец не найден.", 404 |
|
|
|
|
|
item_id = link_data['item_id'] |
|
|
item_node, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
|
|
|
if not item_node: return "Элемент не найден.", 404 |
|
|
|
|
|
if link_data['item_type'] == 'folder': |
|
|
return redirect(url_for('shared_folder_view', link_id=link_id)) |
|
|
elif link_data['item_type'] == 'shoppinglist': |
|
|
return render_template_string(PUBLIC_SHOPPING_LIST_HTML, list_data=item_node, user=user_data, link=link_data) |
|
|
else: |
|
|
return "Неподдерживаемый тип элемента для обмена.", 400 |
|
|
|
|
|
|
|
|
@app.route('/shared/<link_id>/folder') |
|
|
@app.route('/shared/<link_id>/folder/<subfolder_id>') |
|
|
def shared_folder_view(link_id, subfolder_id=None): |
|
|
data = load_data() |
|
|
link_data = data['shared_links'].get(link_id) |
|
|
|
|
|
if not link_data or link_data['item_type'] != 'folder': return "Ссылка недействительна.", 404 |
|
|
|
|
|
if link_data.get('expires_at'): |
|
|
expires_at = datetime.fromisoformat(link_data['expires_at']) |
|
|
if datetime.now(pytz.utc) > expires_at: |
|
|
return "Срок действия ссылки истек.", 410 |
|
|
|
|
|
user_id = link_data['user_id'] |
|
|
user_data = data['users'].get(user_id) |
|
|
if not user_data: return "Владелец не найден.", 404 |
|
|
|
|
|
folder_id_to_show = subfolder_id if subfolder_id else link_data['item_id'] |
|
|
folder_node, _ = find_node_by_id(user_data['filesystem'], folder_id_to_show) |
|
|
|
|
|
if not folder_node or folder_node.get('type') != 'folder': |
|
|
return "Папка не найдена.", 404 |
|
|
|
|
|
items_in_folder = sorted(folder_node.get('children', []), key=lambda x: (x['type'] != 'folder', x.get('name', x.get('original_filename', x.get('title', ''))).lower())) |
|
|
|
|
|
return render_template_string(PUBLIC_SHARE_PAGE_HTML, folder=folder_node, items=items_in_folder, user=user_data, link=link_data, hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}") |
|
|
|
|
|
@app.route('/public_download/<link_id>/<item_id>') |
|
|
def public_download_via_link(link_id, item_id): |
|
|
data = load_data() |
|
|
link_data = data['shared_links'].get(link_id) |
|
|
if not link_data: return Response("Ссылка недействительна.", status=404) |
|
|
|
|
|
if link_data.get('expires_at'): |
|
|
expires_at = datetime.fromisoformat(link_data['expires_at']) |
|
|
if datetime.now(pytz.utc) > expires_at: |
|
|
return Response("Срок действия ссылки истек.", status=410) |
|
|
|
|
|
user_id = link_data['user_id'] |
|
|
user_data = data['users'].get(user_id) |
|
|
if not user_data: return Response("Владелец не найден.", status=404) |
|
|
|
|
|
item_node, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
if not item_node: return Response("Элемент не найден.", status=404) |
|
|
|
|
|
token = uuid.uuid4().hex |
|
|
cache.set(f"download_token_{token}", item_node, timeout=300) |
|
|
return redirect(url_for('public_download', token=token)) |
|
|
|
|
|
@app.route('/api/public_list_data/<link_id>') |
|
|
def public_list_data(link_id): |
|
|
data = load_data() |
|
|
link_data = data.get('shared_links', {}).get(link_id) |
|
|
if not link_data: return jsonify({'status': 'error', 'message': 'Ссылка не найдена.'}), 404 |
|
|
if link_data.get('expires_at') and datetime.now(pytz.utc) > datetime.fromisoformat(link_data['expires_at']): |
|
|
return jsonify({'status': 'error', 'message': 'Срок действия ссылки истек.'}), 410 |
|
|
|
|
|
user_id = link_data['user_id'] |
|
|
user_data = data['users'].get(user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'Владелец не найден.'}), 404 |
|
|
|
|
|
list_node, _ = find_node_by_id(user_data['filesystem'], link_data['item_id']) |
|
|
if not list_node: return jsonify({'status': 'error', 'message': 'Список не найден.'}), 404 |
|
|
|
|
|
return jsonify({'status': 'success', 'list': list_node}) |
|
|
|
|
|
@app.route('/api/public_toggle_item/<link_id>/<item_id>', methods=['POST']) |
|
|
def public_toggle_item(link_id, item_id): |
|
|
data = load_data() |
|
|
link_data = data.get('shared_links', {}).get(link_id) |
|
|
if not link_data: return jsonify({'status': 'error', 'message': 'Ссылка не найдена.'}), 404 |
|
|
if link_data.get('expires_at') and datetime.now(pytz.utc) > datetime.fromisoformat(link_data['expires_at']): |
|
|
return jsonify({'status': 'error', 'message': 'Срок действия ссылки истек.'}), 410 |
|
|
|
|
|
user_id = link_data['user_id'] |
|
|
user_data = data['users'].get(user_id) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'Владелец не найден.'}), 404 |
|
|
|
|
|
list_node, _ = find_node_by_id(user_data['filesystem'], link_data['item_id']) |
|
|
if not list_node: return jsonify({'status': 'error', 'message': 'Список не найден.'}), 404 |
|
|
|
|
|
item_found = False |
|
|
for item in list_node.get('items', []): |
|
|
if item.get('id') == item_id: |
|
|
item['purchased'] = not item.get('purchased', False) |
|
|
item_found = True |
|
|
break |
|
|
|
|
|
if item_found: |
|
|
list_node['modified_date'] = datetime.now().strftime('%Y-%m-%d %H:%M') |
|
|
try: |
|
|
save_data(data) |
|
|
return jsonify({'status': 'success'}) |
|
|
except Exception as e: |
|
|
return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500 |
|
|
else: |
|
|
return jsonify({'status': 'error', 'message': 'Элемент в списке не найден.'}), 404 |
|
|
|
|
|
@app.route('/tma_business') |
|
|
def tma_manage_business_pages(): |
|
|
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
display_name = session.get('telegram_display_name', 'Пользователь') |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id) |
|
|
if not user_data: return redirect(url_for('tma_entry_page')) |
|
|
|
|
|
owned_logins = user_data.get('owned_business_pages', []) |
|
|
pages = [data['business_pages'][login] for login in owned_logins if login in data['business_pages']] |
|
|
|
|
|
return render_template_string(TMA_MANAGE_BUSINESS_HTML, display_name=display_name, pages=pages, hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}") |
|
|
|
|
|
@app.route('/tma_business/create', methods=['GET', 'POST']) |
|
|
def tma_create_business_page(): |
|
|
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
display_name = session.get('telegram_display_name', 'Пользователь') |
|
|
|
|
|
if request.method == 'POST': |
|
|
data = load_data() |
|
|
login = request.form.get('login', '').strip().lower() |
|
|
|
|
|
if not re.match(r'^[a-z0-9_.-]+$', login): |
|
|
flash('Логин содержит недопустимые символы.', 'error') |
|
|
return redirect(url_for('tma_create_business_page')) |
|
|
if login in data['business_pages']: |
|
|
flash('Этот логин уже занят.', 'error') |
|
|
return redirect(url_for('tma_create_business_page')) |
|
|
|
|
|
new_page = { |
|
|
'owner_id': tma_user_id, |
|
|
'login': login, |
|
|
'org_name': request.form.get('org_name'), |
|
|
'currency': request.form.get('currency'), |
|
|
'show_prices': 'show_prices' in request.form, |
|
|
'order_destination': request.form.get('order_destination'), |
|
|
'contact_number': request.form.get('contact_number').replace('@', ''), |
|
|
'avatar_path': None, |
|
|
'products': [] |
|
|
} |
|
|
|
|
|
avatar = request.files.get('avatar') |
|
|
if avatar and avatar.filename: |
|
|
if not HF_TOKEN_WRITE: |
|
|
flash('Загрузка аватара невозможна: токен для записи не настроен.', 'error') |
|
|
return redirect(url_for('tma_create_business_page')) |
|
|
try: |
|
|
api = HfApi() |
|
|
unique_filename = f"avatar_{uuid.uuid4().hex[:8]}{os.path.splitext(secure_filename(avatar.filename))[1]}" |
|
|
hf_path = f"business_pages/{login}/{unique_filename}" |
|
|
api.upload_file(path_or_fileobj=BytesIO(avatar.read()), path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
|
|
new_page['avatar_path'] = hf_path |
|
|
except Exception as e: |
|
|
flash(f'Ошибка загрузки аватара: {e}', 'error') |
|
|
return redirect(url_for('tma_create_business_page')) |
|
|
|
|
|
data['business_pages'][login] = new_page |
|
|
data['users'][tma_user_id].setdefault('owned_business_pages', []).append(login) |
|
|
try: |
|
|
save_data(data) |
|
|
flash('Бизнес страница успешно создана!', 'success') |
|
|
return redirect(url_for('tma_manage_business_pages')) |
|
|
except Exception as e: |
|
|
flash(f'Ошибка сохранения данных: {e}', 'error') |
|
|
|
|
|
return render_template_string(TMA_CREATE_EDIT_BUSINESS_FORM_HTML, display_name=display_name, page=None) |
|
|
|
|
|
@app.route('/tma_business/edit/<login>', methods=['GET', 'POST']) |
|
|
def tma_edit_business_page(login): |
|
|
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
display_name = session.get('telegram_display_name', 'Пользователь') |
|
|
data = load_data() |
|
|
page = data.get('business_pages', {}).get(login) |
|
|
|
|
|
if not page or page.get('owner_id') != tma_user_id: |
|
|
flash('Страница не найдена или у вас нет доступа.', 'error') |
|
|
return redirect(url_for('tma_manage_business_pages')) |
|
|
|
|
|
if request.method == 'POST': |
|
|
page['org_name'] = request.form.get('org_name') |
|
|
page['currency'] = request.form.get('currency') |
|
|
page['show_prices'] = 'show_prices' in request.form |
|
|
page['order_destination'] = request.form.get('order_destination') |
|
|
page['contact_number'] = request.form.get('contact_number').replace('@', '') |
|
|
|
|
|
avatar = request.files.get('avatar') |
|
|
if avatar and avatar.filename: |
|
|
if not HF_TOKEN_WRITE: |
|
|
flash('Загрузка аватара невозможна: токен для записи не настроен.', 'error') |
|
|
else: |
|
|
try: |
|
|
api = HfApi() |
|
|
if page.get('avatar_path'): |
|
|
try: |
|
|
api.delete_file(path_in_repo=page['avatar_path'], repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
|
|
except hf_utils.EntryNotFoundError: |
|
|
pass |
|
|
unique_filename = f"avatar_{uuid.uuid4().hex[:8]}{os.path.splitext(secure_filename(avatar.filename))[1]}" |
|
|
hf_path = f"business_pages/{login}/{unique_filename}" |
|
|
api.upload_file(path_or_fileobj=BytesIO(avatar.read()), path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
|
|
page['avatar_path'] = hf_path |
|
|
except Exception as e: |
|
|
flash(f'Ошибка загрузки аватара: {e}', 'error') |
|
|
try: |
|
|
save_data(data) |
|
|
flash('Изменения сохранены.', 'success') |
|
|
return redirect(url_for('tma_manage_business_pages')) |
|
|
except Exception as e: |
|
|
flash(f'Ошибка сохранения данных: {e}', 'error') |
|
|
|
|
|
return render_template_string(TMA_CREATE_EDIT_BUSINESS_FORM_HTML, display_name=display_name, page=page) |
|
|
|
|
|
@app.route('/tma_business/delete/<login>', methods=['POST']) |
|
|
def tma_delete_business_page(login): |
|
|
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
page = data.get('business_pages', {}).get(login) |
|
|
|
|
|
if not page or page.get('owner_id') != tma_user_id: |
|
|
flash('Страница не найдена или у вас нет доступа.', 'error') |
|
|
return redirect(url_for('tma_manage_business_pages')) |
|
|
|
|
|
if HF_TOKEN_WRITE: |
|
|
api = HfApi() |
|
|
try: |
|
|
api.delete_folder(folder_path=f"business_pages/{login}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
|
|
except Exception as e: |
|
|
logging.error(f"Could not delete business page folder from HF: {e}") |
|
|
|
|
|
del data['business_pages'][login] |
|
|
if login in data['users'][tma_user_id].get('owned_business_pages', []): |
|
|
data['users'][tma_user_id]['owned_business_pages'].remove(login) |
|
|
|
|
|
try: |
|
|
save_data(data) |
|
|
flash('Бизнес страница удалена.', 'success') |
|
|
except Exception as e: |
|
|
flash(f'Ошибка сохранения после удаления: {e}', 'error') |
|
|
|
|
|
return redirect(url_for('tma_manage_business_pages')) |
|
|
|
|
|
@app.route('/business/<login>') |
|
|
def public_business_page(login): |
|
|
data = load_data() |
|
|
page = data.get('business_pages', {}).get(login) |
|
|
if not page: |
|
|
return "Страница не найдена.", 404 |
|
|
return render_template_string(PUBLIC_BUSINESS_PAGE_HTML, page=page, hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}") |
|
|
|
|
|
@app.route('/tma_business/manage/<login>') |
|
|
def tma_manage_products(login): |
|
|
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
page = data.get('business_pages', {}).get(login) |
|
|
|
|
|
if not page or page.get('owner_id') != tma_user_id: |
|
|
flash('Страница не найдена или у вас нет доступа.', 'error') |
|
|
return redirect(url_for('tma_manage_business_pages')) |
|
|
|
|
|
return render_template_string(TMA_MANAGE_PRODUCTS_HTML, page=page, hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}") |
|
|
|
|
|
@app.route('/tma_business/product/add/<login>', methods=['POST']) |
|
|
def tma_add_product(login): |
|
|
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
page = data.get('business_pages', {}).get(login) |
|
|
|
|
|
if not page or page.get('owner_id') != tma_user_id: |
|
|
flash('Страница не найдена или у вас нет доступа.', 'error') |
|
|
return redirect(url_for('tma_manage_business_pages')) |
|
|
|
|
|
new_product = { |
|
|
'id': uuid.uuid4().hex, |
|
|
'name': request.form.get('name'), |
|
|
'description': request.form.get('description', ''), |
|
|
'price': request.form.get('price', 0), |
|
|
'photo_path': None |
|
|
} |
|
|
|
|
|
photo = request.files.get('photo') |
|
|
if photo and photo.filename: |
|
|
if not HF_TOKEN_WRITE: flash('Загрузка фото невозможна: токен не настроен.', 'error') |
|
|
else: |
|
|
try: |
|
|
api = HfApi() |
|
|
unique_filename = f"product_{new_product['id'][:8]}{os.path.splitext(secure_filename(photo.filename))[1]}" |
|
|
hf_path = f"business_pages/{login}/{unique_filename}" |
|
|
api.upload_file(path_or_fileobj=BytesIO(photo.read()), path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
|
|
new_product['photo_path'] = hf_path |
|
|
except Exception as e: |
|
|
flash(f'Ошибка загрузки фото: {e}', 'error') |
|
|
|
|
|
page.setdefault('products', []).append(new_product) |
|
|
try: |
|
|
save_data(data) |
|
|
flash('Товар добавлен.', 'success') |
|
|
except Exception as e: |
|
|
flash(f'Ошибка сохранения: {e}', 'error') |
|
|
|
|
|
return redirect(url_for('tma_manage_products', login=login)) |
|
|
|
|
|
@app.route('/tma_business/product/edit/<login>', methods=['POST']) |
|
|
def tma_edit_product(login): |
|
|
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
page = data.get('business_pages', {}).get(login) |
|
|
product_id = request.form.get('product_id') |
|
|
|
|
|
if not page or page.get('owner_id') != tma_user_id or not product_id: |
|
|
flash('Ошибка доступа.', 'error'); return redirect(url_for('tma_manage_business_pages')) |
|
|
|
|
|
product_to_edit = next((p for p in page.get('products', []) if p['id'] == product_id), None) |
|
|
if not product_to_edit: |
|
|
flash('Товар не найден.', 'error'); return redirect(url_for('tma_manage_products', login=login)) |
|
|
|
|
|
product_to_edit['name'] = request.form.get('name') |
|
|
product_to_edit['description'] = request.form.get('description', '') |
|
|
product_to_edit['price'] = request.form.get('price', 0) |
|
|
|
|
|
photo = request.files.get('photo') |
|
|
if photo and photo.filename: |
|
|
if not HF_TOKEN_WRITE: flash('Загрузка фото невозможна: токен не настроен.', 'error') |
|
|
else: |
|
|
try: |
|
|
api = HfApi() |
|
|
if product_to_edit.get('photo_path'): |
|
|
try: api.delete_file(path_in_repo=product_to_edit['photo_path'], repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
|
|
except hf_utils.EntryNotFoundError: pass |
|
|
unique_filename = f"product_{product_id[:8]}{os.path.splitext(secure_filename(photo.filename))[1]}" |
|
|
hf_path = f"business_pages/{login}/{unique_filename}" |
|
|
api.upload_file(path_or_fileobj=BytesIO(photo.read()), path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
|
|
product_to_edit['photo_path'] = hf_path |
|
|
except Exception as e: |
|
|
flash(f'Ошибка загрузки фото: {e}', 'error') |
|
|
|
|
|
try: |
|
|
save_data(data) |
|
|
flash('Товар обновлен.', 'success') |
|
|
except Exception as e: |
|
|
flash(f'Ошибка сохранения: {e}', 'error') |
|
|
|
|
|
return redirect(url_for('tma_manage_products', login=login)) |
|
|
|
|
|
@app.route('/tma_business/product/delete/<login>/<product_id>', methods=['POST']) |
|
|
def tma_delete_product(login, product_id): |
|
|
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page')) |
|
|
tma_user_id = session['telegram_user_id'] |
|
|
data = load_data() |
|
|
page = data.get('business_pages', {}).get(login) |
|
|
|
|
|
if not page or page.get('owner_id') != tma_user_id: |
|
|
flash('Ошибка доступа.', 'error'); return redirect(url_for('tma_manage_business_pages')) |
|
|
|
|
|
product_to_delete = next((p for p in page.get('products', []) if p['id'] == product_id), None) |
|
|
if product_to_delete and product_to_delete.get('photo_path') and HF_TOKEN_WRITE: |
|
|
try: |
|
|
api = HfApi() |
|
|
api.delete_file(path_in_repo=product_to_delete['photo_path'], repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
|
|
except Exception as e: |
|
|
logging.error(f"Could not delete product photo from HF: {e}") |
|
|
|
|
|
page['products'] = [p for p in page.get('products', []) if p['id'] != product_id] |
|
|
try: |
|
|
save_data(data) |
|
|
flash('Товар удален.', 'success') |
|
|
except Exception as e: |
|
|
flash(f'Ошибка сохранения: {e}', 'error') |
|
|
|
|
|
return redirect(url_for('tma_manage_products', login=login)) |
|
|
|
|
|
@app.route('/api/business/<login>/create_order', methods=['POST']) |
|
|
def create_order(login): |
|
|
data = load_data() |
|
|
page = data.get('business_pages', {}).get(login) |
|
|
if not page: |
|
|
return jsonify({'status': 'error', 'message': 'Business page not found.'}), 404 |
|
|
|
|
|
cart_data = request.json |
|
|
if not cart_data: |
|
|
return jsonify({'status': 'error', 'message': 'Cart data is empty.'}), 400 |
|
|
|
|
|
order_items = [] |
|
|
total_price = 0 |
|
|
|
|
|
products_on_server = {p['id']: p for p in page.get('products', [])} |
|
|
|
|
|
for product_id, cart_item in cart_data.items(): |
|
|
server_product = products_on_server.get(product_id) |
|
|
if not server_product: |
|
|
continue |
|
|
|
|
|
quantity = int(cart_item.get('quantity', 0)) |
|
|
if quantity <= 0: |
|
|
continue |
|
|
|
|
|
price = float(server_product.get('price', 0)) |
|
|
|
|
|
order_items.append({ |
|
|
'id': product_id, |
|
|
'name': server_product['name'], |
|
|
'price': price, |
|
|
'quantity': quantity, |
|
|
'image': server_product.get('photo_path') |
|
|
}) |
|
|
total_price += price * quantity |
|
|
|
|
|
if not order_items: |
|
|
return jsonify({'status': 'error', 'message': 'No valid items in the order.'}), 400 |
|
|
|
|
|
order_id = uuid.uuid4().hex |
|
|
new_order = { |
|
|
'id': order_id, |
|
|
'business_login': login, |
|
|
'items': order_items, |
|
|
'total_price': total_price, |
|
|
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S') |
|
|
} |
|
|
|
|
|
data.setdefault('orders', {})[order_id] = new_order |
|
|
try: |
|
|
save_data(data) |
|
|
return jsonify({ |
|
|
'status': 'success', |
|
|
'order_url': url_for('public_order_page', order_id=order_id, _external=True) |
|
|
}) |
|
|
except Exception as e: |
|
|
logging.error(f"Error saving order: {e}") |
|
|
return jsonify({'status': 'error', 'message': 'Could not save the order.'}), 500 |
|
|
|
|
|
@app.route('/order/<order_id>') |
|
|
def public_order_page(order_id): |
|
|
data = load_data() |
|
|
order = data.get('orders', {}).get(order_id) |
|
|
if not order: |
|
|
return "Заказ не найден.", 404 |
|
|
|
|
|
business_page = data.get('business_pages', {}).get(order['business_login']) |
|
|
if not business_page: |
|
|
return "Страница бизнеса, связанная с этим заказом, не найдена.", 404 |
|
|
|
|
|
return render_template_string(PUBLIC_ORDER_PAGE_HTML, order=order, business=business_page, hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}") |
|
|
|
|
|
|
|
|
ADMIN_LOGIN_HTML = ''' |
|
|
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Admin Login</title> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<style>''' + BASE_STYLE + ''' |
|
|
body { display: flex; justify-content: center; align-items: center; min-height: 100vh; } |
|
|
.login-card { background: var(--card-bg-dark); padding: 40px; border-radius: 16px; box-shadow: var(--shadow); width: 100%; max-width: 400px; } |
|
|
h1 { text-align: center; margin-bottom: 25px; color: var(--primary); } |
|
|
</style></head><body> |
|
|
<div class="login-card"> |
|
|
<h1>Admin Login</h1> |
|
|
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %} |
|
|
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %} |
|
|
{% endif %}{% endwith %} |
|
|
<form method="post"> |
|
|
<input type="hidden" name="next" value="{{ request.args.get('next', '') }}"> |
|
|
<label for="username">Username</label><input type="text" name="username" required> |
|
|
<label for="password">Password</label><input type="password" name="password" required> |
|
|
<button type="submit" class="btn" style="width:100%;">Login</button> |
|
|
</form> |
|
|
</div></body></html> |
|
|
''' |
|
|
|
|
|
ADMIN_PANEL_HTML = ''' |
|
|
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Admin Panel</title> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<style>''' + BASE_STYLE + ''' |
|
|
.user-list { list-style: none; padding: 0; } |
|
|
.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;} |
|
|
.user-item:hover { background: #2a2a2a; } |
|
|
.user-details { display: flex; align-items: center; gap: 15px; flex-grow: 1; } |
|
|
.user-avatar { width: 50px; height: 50px; border-radius: 50%; object-fit: cover; background-color: #333; } |
|
|
.user-info span { display: block; } |
|
|
.user-info .id { font-size: 0.8em; color: var(--text-muted); } |
|
|
.user-actions { display: flex; gap: 10px; flex-shrink: 0; } |
|
|
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px;} |
|
|
.search-form { display: flex; gap: 10px; flex-grow: 1; max-width: 400px;} |
|
|
.search-form input { margin: 0; } |
|
|
.search-form .btn { padding: 14px 20px; } |
|
|
</style></head><body> |
|
|
<div class="container"> |
|
|
<div class="header-actions"> |
|
|
<h2>Admin Panel ({{ users|length }})</h2> |
|
|
<a href="{{ url_for('admin_logout') }}" class="btn delete-btn">Logout</a> |
|
|
</div> |
|
|
<div class="header-actions"> |
|
|
<form method="get" action="{{ url_for('admin_panel') }}" class="search-form"> |
|
|
<input type="search" name="q" placeholder="Search by name or username..." value="{{ search_query or '' }}"> |
|
|
<button type="submit" class="btn">Search</button> |
|
|
</form> |
|
|
</div> |
|
|
<ul class="user-list"> |
|
|
{% for user_id, user in users %} |
|
|
<li class="user-item"> |
|
|
<div class="user-details"> |
|
|
{% if user.photo_url %}<img src="{{ user.photo_url }}" alt="Avatar" class="user-avatar"> |
|
|
{% else %}<div class="user-avatar" style="display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 1.5em; color: var(--primary);">{{ user.get('first_name', 'U')[0] }}</div>{% endif %} |
|
|
<div class="user-info"> |
|
|
<strong>{{ user.get('first_name', 'N/A') }} {{ user.get('last_name', '') }}</strong> (@{{ user.get('telegram_username', 'N/A') }}) |
|
|
<span class="id">ID: {{ user_id }}</span> |
|
|
<span class="id">Created: {{ user.get('created_at', 'N/A') }}</span> |
|
|
<span class="id">Items: <strong>{{ user.get('item_count', 0) }}</strong></span> |
|
|
<span class="id">Reminders: <strong>{{ user.get('reminders', [])|length }}</strong></span> |
|
|
<span class="id">Business Pages: <strong>{{ user.get('owned_business_pages', [])|length }}</strong></span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="user-actions"> |
|
|
<a href="{{ url_for('admin_user_files', tma_user_id_str=user_id) }}" class="btn">View Items</a> |
|
|
<a href="{{ url_for('admin_user_reminders', tma_user_id_str=user_id) }}" class="btn folder-btn">Reminders</a> |
|
|
</div> |
|
|
</li> |
|
|
{% else %} |
|
|
<li>No users found.</li> |
|
|
{% endfor %} |
|
|
</ul> |
|
|
</div></body></html> |
|
|
''' |
|
|
|
|
|
ADMIN_USER_FILES_HTML = ''' |
|
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Admin - User Files</title> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<style>''' + BASE_STYLE + ''' |
|
|
.admin-header { padding-bottom: 15px; border-bottom: 1px solid #333; margin-bottom: 20px; } |
|
|
.admin-header h1 { font-size: 1.5em; } |
|
|
.admin-header .user-details { color: var(--text-muted); } |
|
|
.item-actions-admin { position: absolute; top: 5px; right: 5px; display: flex; gap: 5px; } |
|
|
.item-actions-admin form button { background: var(--delete-color); border: none; color: white; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s; } |
|
|
.item:hover .item-actions-admin form button { opacity: 1; } |
|
|
.list-view .item .item-actions-admin { display: flex; position: static; } |
|
|
.list-view .item .item-actions-admin form button { opacity: 1; } |
|
|
</style></head><body> |
|
|
<div class="app-header"> |
|
|
<div class="user-info"><a href="{{ url_for('admin_panel') }}" style="color: var(--primary); text-decoration: none;">Admin Panel</a></div> |
|
|
<div class="view-toggle"> |
|
|
<button id="grid-view-btn" title="Grid View"><i class="fa fa-th-large"></i></button> |
|
|
<button id="list-view-btn" title="List View"><i class="fa fa-bars"></i></button> |
|
|
</div> |
|
|
</div> |
|
|
<div class="container"> |
|
|
<div class="admin-header"> |
|
|
<h1>Items for {{ user.get('first_name', 'N/A') }}</h1> |
|
|
<div class="user-details">@{{ user.get('telegram_username', 'N/A') }} (ID: {{ user_id }})</div> |
|
|
</div> |
|
|
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %} |
|
|
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %} |
|
|
{% endif %}{% endwith %} |
|
|
<div class="breadcrumbs"> |
|
|
{% for crumb in breadcrumbs %} |
|
|
{% if crumb.is_link %}<a href="{{ url_for('admin_user_files', tma_user_id_str=user_id, folder_id=crumb.id) }}">{{ crumb.name if crumb.id != 'root' else 'Root' }}</a> |
|
|
{% else %}<span>{{ crumb.name if crumb.id != 'root' else 'Root' }}</span>{% endif %} |
|
|
{% if not loop.last %}<span>/</span>{% endif %} |
|
|
{% endfor %} |
|
|
</div> |
|
|
<h2>{{ current_folder.name if current_folder_id != 'root' else 'Root Folder' }}</h2> |
|
|
<div class="file-grid" id="file-container"> |
|
|
{% for item in items %} |
|
|
<div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}"> |
|
|
<div class="item-preview-wrapper" |
|
|
{% if item.type == 'folder' %} onclick="window.location.href='{{ url_for('admin_user_files', tma_user_id_str=user_id, folder_id=item.id) }}'" |
|
|
{% elif item.type in ['note', 'todolist', 'shoppinglist'] %} onclick="openModal(null, '{{ item.type }}', '{{ item.id }}')" |
|
|
{% 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 %}> |
|
|
|
|
|
{% if item.type == 'folder' %}<div class="item-preview"><i class="fa-solid fa-folder"></i></div> |
|
|
{% elif item.type == 'note' %}<div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div> |
|
|
{% elif item.type == 'todolist' %}<div class="item-preview"><i class="fa-solid fa-list-check"></i></div> |
|
|
{% elif item.type == 'shoppinglist' %}<div class="item-preview"><i class="fa-solid fa-cart-shopping"></i></div> |
|
|
{% elif item.type == 'file' %} |
|
|
{% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy"> |
|
|
{% 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> |
|
|
{% elif item.file_type == 'pdf' %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--accent);"><i class="fa-solid fa-file-pdf"></i></div> |
|
|
{% elif item.file_type == 'text' %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--secondary);"><i class="fa-solid fa-file-lines"></i></div> |
|
|
{% else %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--text-muted);"><i class="fa-solid fa-file-circle-question"></i></div> |
|
|
{% endif %} |
|
|
{% endif %} |
|
|
</div> |
|
|
<div class="item-name-info"> |
|
|
<p class="item-name">{{ (item.title if item.type in ['note', 'todolist', 'shoppinglist'] else item.name if item.type == 'folder' else item.original_filename) | truncate(30, True) }}</p> |
|
|
{% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p> |
|
|
{% elif item.type in ['note', 'todolist', 'shoppinglist'] %}<p class="item-info">{{ item.modified_date }}</p>{% endif %} |
|
|
</div> |
|
|
<div class="item-actions-admin"> |
|
|
<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?');"> |
|
|
<input type="hidden" name="current_folder_id" value="{{ current_folder_id }}"> |
|
|
<button type="submit" title="Delete Item"><i class="fa fa-trash"></i></button> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% if not items %} <p>This folder is empty.</p> {% endif %} |
|
|
</div> |
|
|
</div> |
|
|
<div class="modal" id="mediaModal" onclick="closeModal(event)"> |
|
|
<div class="modal-content"> |
|
|
<span onclick="closeModalManual()" class="modal-close-btn">×</span> |
|
|
<div class="modal-main-content" id="modalContent"></div> |
|
|
<div class="modal-actions"> |
|
|
<a id="modal-download-btn" class="btn download-btn" href="javascript:void(0);" style="display: none; width: 80%;"> |
|
|
<i class="fa-solid fa-download"></i> Download |
|
|
</a> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<script> |
|
|
async function initiateDownload(fileId) { |
|
|
const downloadBtn = document.getElementById('modal-download-btn'); |
|
|
const originalHTML = downloadBtn.innerHTML; |
|
|
downloadBtn.innerHTML = '<div class="loading-spinner" style="width:20px; height:20px; border-width:2px;"></div>'; |
|
|
try { |
|
|
const response = await fetch(`{{ url_for('admin_download_file', tma_user_id_str=user_id, file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId)); |
|
|
const data = await response.json(); |
|
|
if (data.status === 'success' && data.url) { window.open(data.url, '_blank'); } |
|
|
else { alert(data.message || 'Failed to create download link.'); } |
|
|
} catch (error) { alert('Network error while creating download link.'); } |
|
|
finally { downloadBtn.innerHTML = originalHTML; closeModalManual(); } |
|
|
} |
|
|
async function openModal(srcOrUrl, type, itemId) { |
|
|
if (!itemId) return; |
|
|
const modal = document.getElementById('mediaModal'); |
|
|
const modalContent = document.getElementById('modalContent'); |
|
|
const downloadBtn = document.getElementById('modal-download-btn'); |
|
|
modalContent.innerHTML = '<div class="loading-spinner"></div>'; |
|
|
modal.style.display = 'flex'; |
|
|
downloadBtn.style.display = 'none'; |
|
|
|
|
|
try { |
|
|
if (type === 'note' || type === 'todolist' || type === 'shoppinglist') { |
|
|
const response = await fetch(`{{ url_for('admin_get_item', tma_user_id_str=user_id, item_id='__ID__') }}`.replace('__ID__', itemId)); |
|
|
const data = await response.json(); |
|
|
if(data.status === 'success') { |
|
|
let contentHTML = `<h3>${data.item.title.replace(/</g,"<")}</h3><hr style="border-color:#333; margin:10px 0;">`; |
|
|
if (type === 'note') { |
|
|
contentHTML += `<pre>${data.item.content.replace(/</g,"<")}</pre>`; |
|
|
} else { |
|
|
contentHTML += '<ul>'; |
|
|
data.item.items.forEach(subItem => { |
|
|
let text = subItem.text || subItem.name; |
|
|
let checked = subItem.completed || subItem.purchased; |
|
|
contentHTML += `<li style="${checked ? 'text-decoration:line-through; color:var(--text-muted);' : ''}">${text} ${type === 'shoppinglist' ? `(${subItem.quantity})` : ''}</li>`; |
|
|
}); |
|
|
contentHTML += '</ul>'; |
|
|
} |
|
|
modalContent.innerHTML = `<div style="padding:15px; text-align:left;">${contentHTML}</div>`; |
|
|
} else { throw new Error(data.message); } |
|
|
} else if (type === 'image') { |
|
|
modalContent.innerHTML = `<img src="${srcOrUrl}">`; |
|
|
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block'; |
|
|
} else if (type === 'video') { |
|
|
modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`; |
|
|
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block'; |
|
|
} else if (type === 'pdf') { |
|
|
modalContent.innerHTML = `<iframe src="${srcOrUrl}"></iframe>`; |
|
|
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block'; |
|
|
} else if (type === 'text') { |
|
|
const response = await fetch(srcOrUrl); if (!response.ok) throw new Error(`Error: ${response.statusText}`); |
|
|
const text = await response.text(); |
|
|
modalContent.innerHTML = `<pre>${text.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}</pre>`; |
|
|
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block'; |
|
|
} else { |
|
|
initiateDownload(itemId); |
|
|
} |
|
|
} catch (error) { modalContent.innerHTML = `<p>Preview Error: ${error.message}</p>`; } |
|
|
} |
|
|
function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); } |
|
|
function closeModalManual() { |
|
|
const modal = document.getElementById('mediaModal'); |
|
|
modal.style.display = 'none'; |
|
|
const video = modal.querySelector('video'); if (video) video.pause(); |
|
|
document.getElementById('modalContent').innerHTML = ''; |
|
|
document.getElementById('modal-download-btn').style.display = 'none'; |
|
|
} |
|
|
const gridViewBtn = document.getElementById('grid-view-btn'); |
|
|
const listViewBtn = document.getElementById('list-view-btn'); |
|
|
const fileContainer = document.getElementById('file-container'); |
|
|
function setView(view) { |
|
|
fileContainer.classList.toggle('list-view', view === 'list'); |
|
|
listViewBtn.classList.toggle('active', view === 'list'); |
|
|
gridViewBtn.classList.toggle('active', view !== 'list'); |
|
|
localStorage.setItem('adminViewMode', view); |
|
|
} |
|
|
gridViewBtn.addEventListener('click', () => setView('grid')); |
|
|
listViewBtn.addEventListener('click', () => setView('list')); |
|
|
document.addEventListener('DOMContentLoaded', () => { setView(localStorage.getItem('adminViewMode') || 'grid'); }); |
|
|
</script></body></html> |
|
|
''' |
|
|
|
|
|
ADMIN_USER_REMINDERS_HTML = ''' |
|
|
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Admin - User Reminders</title> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> |
|
|
<style>''' + BASE_STYLE + ''' |
|
|
.admin-header { padding-bottom: 15px; border-bottom: 1px solid #333; margin-bottom: 20px; } |
|
|
.reminders-list { list-style: none; } |
|
|
.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; } |
|
|
.reminder-info span { display: block; } |
|
|
.reminder-info .utc-time { font-size: 0.8em; color: var(--text-muted); } |
|
|
</style></head><body> |
|
|
<div class="app-header"> |
|
|
<div class="user-info"><a href="{{ url_for('admin_panel') }}" style="color: var(--primary); text-decoration: none;">Admin Panel</a></div> |
|
|
</div> |
|
|
<div class="container"> |
|
|
<div class="admin-header"> |
|
|
<h1>Reminders for {{ user.get('first_name', 'N/A') }}</h1> |
|
|
<div class="user-details">@{{ user.get('telegram_username', 'N/A') }} (ID: {{ user_id }})</div> |
|
|
</div> |
|
|
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %} |
|
|
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %} |
|
|
{% endif %}{% endwith %} |
|
|
<ul class="reminders-list"> |
|
|
{% for reminder in reminders %} |
|
|
<li class="reminder-item-admin"> |
|
|
<div class="reminder-info"> |
|
|
<strong>{{ reminder.text }}</strong> |
|
|
<span class="utc-time">Due (UTC): {{ reminder.due_datetime_utc }}</span> |
|
|
<span class="utc-time">Notified: {{ 'Yes' if reminder.notified else 'No' }}</span> |
|
|
</div> |
|
|
<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?');"> |
|
|
<button type="submit" class="btn delete-btn" style="padding: 8px 12px;"><i class="fa fa-trash"></i></button> |
|
|
</form> |
|
|
</li> |
|
|
{% else %} |
|
|
<li>No reminders for this user.</li> |
|
|
{% endfor %} |
|
|
</ul> |
|
|
</div></body></html> |
|
|
''' |
|
|
|
|
|
@app.route('/admin') |
|
|
def admin_redirect(): |
|
|
return redirect(url_for('admin_login')) |
|
|
|
|
|
@app.route('/admhosto/login', methods=['GET', 'POST']) |
|
|
def admin_login(): |
|
|
if session.get('admin_browser_logged_in'): |
|
|
return redirect(url_for('admin_panel')) |
|
|
if request.method == 'POST': |
|
|
if request.form.get('username') == ADMIN_USERNAME and request.form.get('password') == ADMIN_PASSWORD: |
|
|
session['admin_browser_logged_in'] = True |
|
|
next_url = request.form.get('next') or url_for('admin_panel') |
|
|
return redirect(next_url) |
|
|
else: |
|
|
flash('Invalid credentials.', 'error') |
|
|
return render_template_string(ADMIN_LOGIN_HTML) |
|
|
|
|
|
@app.route('/admhosto/logout') |
|
|
def admin_logout(): |
|
|
session.pop('admin_browser_logged_in', None) |
|
|
flash('You have been logged out.') |
|
|
return redirect(url_for('admin_login')) |
|
|
|
|
|
@app.route('/admhosto') |
|
|
@admin_browser_login_required |
|
|
def admin_panel(): |
|
|
data = load_data() |
|
|
all_users = data.get('users', {}) |
|
|
search_query = request.args.get('q', '').lower() |
|
|
|
|
|
processed_users = {} |
|
|
for user_id, user_data in all_users.items(): |
|
|
user_data['item_count'] = count_items_recursive(user_data.get('filesystem')) |
|
|
processed_users[user_id] = user_data |
|
|
|
|
|
if search_query: |
|
|
filtered_users = {} |
|
|
for user_id, user_data in processed_users.items(): |
|
|
full_name = f"{user_data.get('first_name', '')} {user_data.get('last_name', '')}".lower() |
|
|
username = user_data.get('telegram_username', '').lower() |
|
|
if search_query in full_name or search_query in username: |
|
|
filtered_users[user_id] = user_data |
|
|
users_to_display = filtered_users |
|
|
else: |
|
|
users_to_display = processed_users |
|
|
|
|
|
sorted_users = sorted( |
|
|
users_to_display.items(), |
|
|
key=lambda item: item[1].get('created_at', '0000-00-00 00:00:00'), |
|
|
reverse=True |
|
|
) |
|
|
|
|
|
return render_template_string(ADMIN_PANEL_HTML, users=sorted_users, search_query=request.args.get('q', '')) |
|
|
|
|
|
@app.route('/admhosto/user/<tma_user_id_str>') |
|
|
@admin_browser_login_required |
|
|
def admin_user_files(tma_user_id_str): |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id_str) |
|
|
if not user_data: |
|
|
flash('User not found.', 'error') |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
initialize_user_filesystem_tma(user_data, tma_user_id_str) |
|
|
current_folder_id = request.args.get('folder_id', 'root') |
|
|
current_folder, _ = find_node_by_id(user_data['filesystem'], current_folder_id) |
|
|
|
|
|
if not current_folder or current_folder.get('type') != 'folder': |
|
|
flash('Folder not found!', 'error') |
|
|
current_folder_id = 'root' |
|
|
current_folder, _ = find_node_by_id(user_data['filesystem'], 'root') |
|
|
|
|
|
items_in_folder = [item for item in current_folder.get('children', []) if not item.get('is_archived')] |
|
|
items_in_folder = sorted(items_in_folder, key=lambda x: (x['type'] not in ['folder', 'note', 'todolist', 'shoppinglist'], x.get('name', x.get('original_filename', x.get('title', ''))).lower())) |
|
|
|
|
|
breadcrumbs = [] |
|
|
temp_id = current_folder_id |
|
|
while temp_id: |
|
|
node, parent_node_bc = find_node_by_id(user_data['filesystem'], temp_id) |
|
|
if not node: break |
|
|
is_link = (node['id'] != current_folder_id) |
|
|
breadcrumbs.append({'id': node['id'], 'name': node.get('name', 'Root'), 'is_link': is_link}) |
|
|
if not parent_node_bc: break |
|
|
temp_id = parent_node_bc.get('id') |
|
|
breadcrumbs.reverse() |
|
|
|
|
|
return render_template_string(ADMIN_USER_FILES_HTML, |
|
|
user_id=tma_user_id_str, |
|
|
user=user_data, |
|
|
items=items_in_folder, |
|
|
current_folder_id=current_folder_id, |
|
|
current_folder=current_folder, |
|
|
breadcrumbs=breadcrumbs, |
|
|
hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}") |
|
|
|
|
|
@app.route('/admhosto/user/<tma_user_id_str>/reminders') |
|
|
@admin_browser_login_required |
|
|
def admin_user_reminders(tma_user_id_str): |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id_str) |
|
|
if not user_data: |
|
|
flash('User not found.', 'error') |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
reminders = sorted(user_data.get('reminders', []), key=lambda r: r.get('due_datetime_utc', ''), reverse=True) |
|
|
return render_template_string(ADMIN_USER_REMINDERS_HTML, user_id=tma_user_id_str, user=user_data, reminders=reminders) |
|
|
|
|
|
@app.route('/admhosto/download/<tma_user_id_str>/<file_id>') |
|
|
@admin_browser_login_required |
|
|
def admin_download_file(tma_user_id_str, file_id): |
|
|
file_node = get_file_node_for_admin(tma_user_id_str, file_id) |
|
|
if not file_node: |
|
|
return jsonify({'status': 'error', 'message': 'File not found or access denied!'}), 404 |
|
|
|
|
|
token = uuid.uuid4().hex |
|
|
cache.set(f"download_token_{token}", file_node, timeout=300) |
|
|
public_url = url_for('public_download', token=token, _external=True) |
|
|
return jsonify({'status': 'success', 'url': public_url}) |
|
|
|
|
|
@app.route('/admhosto/text/<tma_user_id_str>/<file_id>') |
|
|
@admin_browser_login_required |
|
|
def admin_get_text_content(tma_user_id_str, file_id): |
|
|
file_node = get_file_node_for_admin(tma_user_id_str, file_id) |
|
|
if not file_node or file_node.get('file_type') != 'text': |
|
|
return Response("Text file not found", 404) |
|
|
hf_path = file_node.get('path') |
|
|
if not hf_path: |
|
|
return Response("Error: file path is missing", 500) |
|
|
file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(hf_path)}?download=true" |
|
|
try: |
|
|
req_headers = {} |
|
|
if HF_TOKEN_READ: req_headers["authorization"] = f"Bearer {HF_TOKEN_READ}" |
|
|
response = requests.get(file_url, headers=req_headers) |
|
|
response.raise_for_status() |
|
|
if len(response.content) > 1 * 1024 * 1024: |
|
|
return Response("File too large for preview.", 413) |
|
|
try: |
|
|
text_content = response.content.decode('utf-8') |
|
|
except UnicodeDecodeError: |
|
|
text_content = response.content.decode('latin-1', errors='ignore') |
|
|
return Response(text_content, mimetype='text/plain') |
|
|
except Exception as e: |
|
|
return Response(f"Download error: {e}", 502) |
|
|
|
|
|
@app.route('/admhosto/item/<tma_user_id_str>/<item_id>') |
|
|
@admin_browser_login_required |
|
|
def admin_get_item(tma_user_id_str, item_id): |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id_str) |
|
|
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404 |
|
|
node, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
if not node: |
|
|
return jsonify({'status': 'error', 'message': 'Item not found'}), 404 |
|
|
return jsonify({'status': 'success', 'item': node}) |
|
|
|
|
|
@app.route('/admhosto/delete_item/<tma_user_id_str>/<item_id>', methods=['POST']) |
|
|
@admin_browser_login_required |
|
|
def admin_delete_item(tma_user_id_str, item_id): |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id_str) |
|
|
current_folder_id = request.form.get('current_folder_id', 'root') |
|
|
if not user_data: |
|
|
flash('User not found.', 'error'); return redirect(url_for('admin_panel')) |
|
|
|
|
|
node, _ = find_node_by_id(user_data['filesystem'], item_id) |
|
|
if not node: |
|
|
flash('Item not found.', 'error') |
|
|
else: |
|
|
node_type = node.get('type') |
|
|
if node_type == 'file': |
|
|
hf_path = node.get('path') |
|
|
if not HF_TOKEN_WRITE: flash('Deletion not possible: write token not configured.', 'error') |
|
|
else: |
|
|
try: |
|
|
api = HfApi() |
|
|
if hf_path: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) |
|
|
except hf_utils.EntryNotFoundError: |
|
|
pass |
|
|
except Exception as e: |
|
|
flash(f'Deletion error from remote storage: {e}', 'error') |
|
|
return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id)) |
|
|
elif node_type == 'folder' and node.get('children'): |
|
|
flash('Folder is not empty.', 'error') |
|
|
return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id)) |
|
|
|
|
|
if remove_node(user_data['filesystem'], item_id)[0]: |
|
|
try: |
|
|
save_data(data) |
|
|
flash(f'{node_type.capitalize()} deleted.') |
|
|
except Exception as e: |
|
|
flash(f'DB update failed after deletion: {e}', 'error') |
|
|
else: |
|
|
flash('Failed to remove item from filesystem.', 'error') |
|
|
|
|
|
return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id)) |
|
|
|
|
|
@app.route('/admhosto/user/<tma_user_id_str>/delete_reminder/<reminder_id>', methods=['POST']) |
|
|
@admin_browser_login_required |
|
|
def admin_delete_reminder(tma_user_id_str, reminder_id): |
|
|
data = load_data() |
|
|
user_data = data['users'].get(tma_user_id_str) |
|
|
if not user_data: |
|
|
flash('User not found.', 'error'); return redirect(url_for('admin_panel')) |
|
|
|
|
|
reminders = user_data.get('reminders', []) |
|
|
initial_len = len(reminders) |
|
|
user_data['reminders'] = [r for r in reminders if r.get('id') != reminder_id] |
|
|
if len(user_data['reminders']) < initial_len: |
|
|
try: |
|
|
save_data(data) |
|
|
flash('Reminder deleted successfully.', 'success') |
|
|
except Exception as e: |
|
|
flash(f'Failed to save data: {e}', 'error') |
|
|
else: |
|
|
flash('Reminder not found.', 'error') |
|
|
return redirect(url_for('admin_user_reminders', tma_user_id_str=tma_user_id_str)) |
|
|
|
|
|
if __name__ == '__main__': |
|
|
if not HF_TOKEN_WRITE: logging.warning("HF_TOKEN (write) is not set. Uploads/deletions will fail.") |
|
|
if not HF_TOKEN_READ: logging.warning("HF_TOKEN_READ is not set. Downloads/previews might fail.") |
|
|
if ADMIN_TELEGRAM_ID == "YOUR_ADMIN_TELEGRAM_USER_ID_HERE": logging.warning("ADMIN_TELEGRAM_ID is not set.") |
|
|
if ADMIN_USERNAME == "admin" and ADMIN_PASSWORD == "zeusadminpass": |
|
|
logging.warning("Using default admin credentials. Please change them.") |
|
|
|
|
|
if HF_TOKEN_WRITE or HF_TOKEN_READ: |
|
|
download_db_from_hf() |
|
|
else: |
|
|
if not os.path.exists(DATA_FILE): |
|
|
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}, 'shared_links': {}, 'business_pages': {}, 'orders': {}}, f) |
|
|
|
|
|
if HF_TOKEN_WRITE: |
|
|
threading.Thread(target=periodic_backup, daemon=True).start() |
|
|
|
|
|
threading.Thread(target=check_reminders, daemon=True).start() |
|
|
|
|
|
app.run(debug=False, host='0.0.0.0', port=7860) |
|
|
|