morozovka-portal/app/templates/files.html

554 lines
26 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Файлы — Morozovka Portal{% endblock %}
{% block main_class %}container{% endblock %}
{% block content %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# ROOT: пространства + файлы без пространства #}
{# ═══════════════════════════════════════════════════════════════════ #}
{% if view == 'root' %}
<div class="files-hero">
<div class="files-hero-left">
<h1>📁 Файлохранилище</h1>
<p class="subtitle">Пространства, папки, файлы с версиями и правами доступа</p>
</div>
<div class="files-hero-actions">
{% if can_create_space %}
<a href="/files/space/new" class="btn btn-primary">+ Пространство</a>
{% endif %}
<a href="/files/search" class="btn btn-default">🔍 Поиск</a>
</div>
</div>
<div class="files-section">
<div class="files-section-head">
<h2>🗂 Пространства <span class="count">{{ spaces|length }}</span></h2>
</div>
{% if spaces %}
<div class="space-grid-v2">
{% for item in spaces %}
{% set sp = item.space %}
<a href="/files/space/{{ sp.id }}" class="space-card-v2">
<div class="space-card-v2-head">
<div class="space-card-v2-icon">{% if sp.project_id %}📦{% else %}📂{% endif %}</div>
<span class="space-card-v2-vis {{ sp.visibility }}">
{% if sp.visibility == 'public' %}Публичное
{% elif sp.visibility == 'internal' %}Внутреннее
{% else %}Приватное{% endif %}
</span>
</div>
<div class="space-card-v2-title">{{ sp.name }}</div>
{% if sp.description %}
<div class="space-card-v2-desc">{{ sp.description }}</div>
{% else %}
<div class="space-card-v2-desc" style="font-style:italic;opacity:.5;">Без описания</div>
{% endif %}
<div class="space-card-v2-meta">
<span>📄 {{ item.file_count }} файлов</span>
{% if sp.project_id %}<span>🔗 Проект</span>{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="files-empty">
<div class="icon">🗂</div>
<h3>Пространств пока нет</h3>
<p>{% if can_create_space %}Создайте первое — например, «Документы команды» или «Материалы проекта».{% else %}Попросите lead/admin создать пространство.{% endif %}</p>
{% if can_create_space %}<a href="/files/space/new" class="btn btn-primary">Создать пространство</a>{% endif %}
</div>
{% endif %}
</div>
<div class="files-section">
<div class="files-section-head">
<h2>📎 Без пространства <span class="count">{{ loose_files|length }}</span></h2>
</div>
<p style="color:var(--text-muted);font-size:13px;margin:0 0 14px;">
Старые файлы и вложения постов. Можно переместить в пространство кнопкой ↔.
</p>
<div class="upload-zone"
id="upload-zone-loose"
data-space-id=""
data-folder-id=""
role="button"
tabindex="0"
aria-label="Загрузить файлы без пространства">
<input type="file" id="upload-input-loose" multiple>
<div class="upload-zone-icon">☁️</div>
<div class="upload-zone-title">Перетащите файлы сюда или кликните</div>
<div class="upload-zone-hint">Можно выбрать несколько файлов · суммарно до 100 МБ</div>
</div>
{% if loose_files %}
<div class="file-grid-v2">
{% for f, uploader in loose_files %}
<div class="file-tile-v2">
<div class="file-tile-v2-preview">
{% if f.original_name|is_image %}
<a href="/files/preview/{{ f.id }}" target="_blank">
<img src="/files/preview/{{ f.id }}" alt="{{ f.original_name }}" loading="lazy">
</a>
{% else %}
<div class="file-tile-v2-icon-big">{{ f.original_name|file_icon }}</div>
{% endif %}
</div>
<div class="file-tile-v2-body">
<div class="file-tile-v2-name" title="{{ f.original_name }}">{{ f.original_name }}</div>
<div class="file-tile-v2-meta">
<span>{{ f.size_bytes|format_size }}</span>
<span>·</span>
<span>{{ uploader }}</span>
<span>·</span>
<span>{{ f.uploaded_at|msk_strftime('%d.%m.%Y') }}</span>
</div>
</div>
<div class="file-tile-v2-actions">
<a href="/files/download/{{ f.id }}" title="Скачать">⬇</a>
<a href="/files/{{ f.id }}/move" title="Переместить">↔</a>
{% if session.get('role') == 'admin' or f.uploaded_by == session.get('user_id') %}
<form action="/files/{{ f.id }}/delete" method="POST" style="display:inline;" onsubmit="return confirm('Удалить файл?');">
<input type="hidden" name="next" value="/files">
<button type="submit" class="danger" title="Удалить">✕</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="files-empty">
<div class="icon">📎</div>
<h3>Файлов без пространства нет</h3>
<p>Перетащите файл в зону выше или создайте пространство.</p>
</div>
{% endif %}
</div>
{% endif %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# SPACE / FOLDER #}
{# ═══════════════════════════════════════════════════════════════════ #}
{% if view in ('space', 'folder') %}
<div class="breadcrumbs-bar">
{% for cr in breadcrumbs %}
{% if not loop.last %}
<a href="{{ cr.url }}">{{ cr.name }}</a>
<span class="sep">/</span>
{% else %}
<span class="current">{{ cr.name }}</span>
{% endif %}
{% endfor %}
</div>
<div class="files-hero">
<div class="files-hero-left">
<h1>{% if view == 'space' %}📂 {{ space.name }}{% else %}📁 {{ folder.name }}{% endif %}</h1>
<p class="subtitle">
{% if view == 'space' and space.description %}{{ space.description }}
{% elif view == 'space' %}Пространство · {{ space.visibility }}
{% else %}Папка в «{{ space.name }}»{% endif %}
</p>
</div>
<div class="files-hero-actions">
{% if perms.can_upload %}
<button type="button" class="btn btn-default" onclick="document.getElementById('new-folder-modal').classList.add('open')">+ Папка</button>
{% endif %}
{% if view == 'space' and perms.can_manage %}
<a href="/files/space/{{ space.id }}/acl" class="btn btn-default">🔐 Права</a>
<a href="/files/space/{{ space.id }}/edit" class="btn btn-default">⚙ Настройки</a>
{% endif %}
{% if view == 'folder' and perms.can_manage %}
<a href="/files/folder/{{ folder.id }}/acl" class="btn btn-default">🔐 Права папки</a>
{% endif %}
<a href="/files/search{% if view == 'space' %}?space_id={{ space.id }}{% endif %}" class="btn btn-default">🔍 Поиск</a>
{% if view == 'space' and (perms.is_owner or session.get('role') == 'admin') %}
<form action="/files/space/{{ space.id }}/delete" method="POST" style="display:inline;"
onsubmit="return confirm('Удалить пространство «{{ space.name }}» со всем содержимым? Это необратимо.');">
<button type="submit" class="btn btn-danger">🗑 Удалить</button>
</form>
{% endif %}
{% if view == 'folder' and perms.can_delete %}
<form action="/files/folder/{{ folder.id }}/delete" method="POST" style="display:inline;"
onsubmit="return confirm('Удалить папку «{{ folder.name }}» со всем содержимым?');">
<button type="submit" class="btn btn-danger">🗑 Удалить</button>
</form>
{% endif %}
</div>
</div>
{% if perms.can_upload %}
<div class="upload-zone"
id="upload-zone-current"
data-space-id="{{ space.id }}"
data-folder-id="{% if view == 'folder' %}{{ folder.id }}{% endif %}"
role="button"
tabindex="0"
aria-label="Загрузить файлы в {% if view == 'folder' %}{{ folder.name }}{% else %}{{ space.name }}{% endif %}">
<input type="file" id="upload-input-current" multiple>
<div class="upload-zone-icon">☁️</div>
<div class="upload-zone-title">Перетащите файлы сюда или кликните</div>
<div class="upload-zone-hint">
Загрузится в {% if view == 'folder' %}«{{ folder.name }}»{% else %}корень пространства{% endif %} · суммарно до 100 МБ
</div>
</div>
{% endif %}
{% if folders %}
<div class="files-section">
<div class="files-section-head">
<h2>📁 Папки <span class="count">{{ folders|length }}</span></h2>
</div>
<div class="folder-grid-v2">
{% for fd in folders %}
<div class="folder-tile-v2-wrap">
<a href="/files/folder/{{ fd.id }}" class="folder-tile-v2">
<span class="folder-tile-v2-icon">📁</span>
<div class="folder-tile-v2-info">
<div class="folder-tile-v2-name">{{ fd.name }}</div>
<div class="folder-tile-v2-meta">{{ fd.created_at|msk_strftime('%d.%m.%Y') }}</div>
</div>
</a>
{% if perms.can_delete or perms.can_manage %}
<form action="/files/folder/{{ fd.id }}/delete" method="POST" class="folder-delete-form"
onsubmit="return confirm('Удалить папку «{{ fd.name }}» со всем содержимым?');">
<button type="submit" class="folder-delete-btn" title="Удалить папку">✕</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="files-section">
<div class="files-section-head">
<h2>📄 Файлы <span class="count">{{ files|length }}</span></h2>
</div>
{% if files %}
<div class="file-grid-v2">
{% for f, uploader in files %}
<div class="file-tile-v2">
<div class="file-tile-v2-preview">
{% if f.original_name|is_image %}
<a href="/files/preview/{{ f.id }}" target="_blank">
<img src="/files/preview/{{ f.id }}" alt="{{ f.original_name }}" loading="lazy">
</a>
{% else %}
<div class="file-tile-v2-icon-big">{{ f.original_name|file_icon }}</div>
{% endif %}
<span class="file-tile-v2-badge">v{{ f.version }}</span>
</div>
<div class="file-tile-v2-body">
<div class="file-tile-v2-name" title="{{ f.original_name }}">{{ f.original_name }}</div>
{% if f.description %}
<div class="file-tile-v2-desc">{{ f.description }}</div>
{% endif %}
<div class="file-tile-v2-meta">
<span>{{ f.size_bytes|format_size }}</span>
<span>·</span>
<span>{{ uploader }}</span>
<span>·</span>
<span>{{ f.uploaded_at|msk_strftime('%d.%m.%Y') }}</span>
</div>
</div>
<div class="file-tile-v2-actions">
<a href="/files/download/{{ f.id }}" title="Скачать">⬇</a>
<a href="/files/{{ f.id }}/history" title="История версий">🕘</a>
<a href="/files/{{ f.id }}/move" title="Переместить">↔</a>
<a href="/files/{{ f.id }}/edit" title="Редактировать">✎</a>
{% if perms.can_delete or session.get('role') == 'admin' %}
<form action="/files/{{ f.id }}/delete" method="POST" style="display:inline;" onsubmit="return confirm('Удалить файл и все версии?');">
<input type="hidden" name="next" value="{% if view == 'folder' %}/files/folder/{{ folder.id }}{% else %}/files/space/{{ space.id }}{% endif %}">
<button type="submit" class="danger" title="Удалить">✕</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="files-empty">
<div class="icon">📄</div>
<h3>{% if folders %}В этой папке файлов нет{% else %}Пусто{% endif %}</h3>
<p>{% if perms.can_upload %}Перетащите файл в зону выше или кликните по ней.{% else %}Файлы появятся, когда их загрузят.{% endif %}</p>
</div>
{% endif %}
</div>
{% if perms.can_upload %}
<div class="modal-overlay" id="new-folder-modal">
<div class="modal">
<h2>📁 Новая папка</h2>
<form action="/files/folder/new" method="POST">
<input type="hidden" name="space_id" value="{{ space.id }}">
<input type="hidden" name="parent_id" value="{% if view == 'folder' %}{{ folder.id }}{% endif %}">
<div class="form-group">
<label>Название *</label>
<input type="text" name="name" required maxlength="200" autofocus>
</div>
<div class="form-group">
<label>Описание</label>
<input type="text" name="description" maxlength="300">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('new-folder-modal').classList.remove('open')">Отмена</button>
<button type="submit" class="btn btn-primary">Создать</button>
</div>
</form>
</div>
</div>
<style>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px; width: 100%; max-width: 450px; padding: 24px; }
.modal h2 { margin: 0 0 18px; color: var(--accent); font-size: 18px; }
.modal .form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.modal label { color: var(--text-muted); font-size: 13px; font-weight: 600; }
.modal input { padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: inherit; }
.modal input:focus { outline: none; border-color: var(--accent); }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
</style>
{% endif %}
{% endif %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# SEARCH #}
{# ═══════════════════════════════════════════════════════════════════ #}
{% if view == 'search' %}
<div class="files-hero">
<div class="files-hero-left">
<h1>🔍 Поиск файлов</h1>
<p class="subtitle">{% if space_scope %}в пространстве «{{ space_scope.name }}»{% else %}по всем пространствам{% endif %}</p>
</div>
<div class="files-hero-actions">
<a href="/files" class="btn btn-default">← Все файлы</a>
</div>
</div>
<div class="filters" style="margin-bottom:20px;">
<form method="GET" action="/files/search" style="display:flex;gap:8px;flex:1;flex-wrap:wrap;">
{% if space_scope %}<input type="hidden" name="space_id" value="{{ space_scope.id }}">{% endif %}
<input type="text" name="q" value="{{ q }}" placeholder="Имя, описание или теги..." class="search-input" style="flex:1;min-width:220px;" autofocus>
<button type="submit" class="btn btn-primary">Найти</button>
{% if q %}<a href="/files/search{% if space_scope %}?space_id={{ space_scope.id }}{% endif %}" class="filter-chip filter-chip-clear">✕ Сбросить</a>{% endif %}
</form>
</div>
{% if q %}
<div class="muted" style="font-size:13px;margin-bottom:14px;">Найдено: <strong style="color:var(--text);">{{ results|length }}</strong></div>
{% endif %}
{% if results %}
<div class="file-grid-v2">
{% for f, uploader in results %}
<div class="file-tile-v2">
<div class="file-tile-v2-preview">
{% if f.original_name|is_image %}
<a href="/files/preview/{{ f.id }}" target="_blank">
<img src="/files/preview/{{ f.id }}" alt="{{ f.original_name }}" loading="lazy">
</a>
{% else %}
<div class="file-tile-v2-icon-big">{{ f.original_name|file_icon }}</div>
{% endif %}
{% if f.version and f.version > 1 %}<span class="file-tile-v2-badge">v{{ f.version }}</span>{% endif %}
</div>
<div class="file-tile-v2-body">
<div class="file-tile-v2-name" title="{{ f.original_name }}">{{ f.original_name }}</div>
{% if f.tags %}
<div class="file-tile-v2-desc" style="color:#58a6ff;">#{{ f.tags }}</div>
{% elif f.description %}
<div class="file-tile-v2-desc">{{ f.description }}</div>
{% endif %}
<div class="file-tile-v2-meta">
<span>{{ f.size_bytes|format_size }}</span>
<span>·</span>
<span>{{ uploader }}</span>
<span>·</span>
<span>{{ f.uploaded_at|msk_strftime('%d.%m.%Y') }}</span>
</div>
</div>
<div class="file-tile-v2-actions">
<a href="/files/download/{{ f.id }}" title="Скачать">⬇</a>
{% if f.space_id %}
<a href="/files/{{ f.id }}/history" title="История">🕘</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% elif q %}
<div class="files-empty">
<div class="icon">🔍</div>
<h3>Ничего не найдено</h3>
<p>Попробуйте другой запрос или <a href="/files">вернитесь к списку</a>.</p>
</div>
{% else %}
<div class="files-empty">
<div class="icon">🔍</div>
<h3>Введите запрос</h3>
<p>Поиск по имени файла, описанию и тегам.</p>
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function() {
'use strict';
const MAX_TOTAL_BYTES = 100 * 1024 * 1024; // 100 МБ суммарно
function formatBytes(n) {
if (n < 1024) return n + ' Б';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' КБ';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' МБ';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' ГБ';
}
function uploadFiles(files, spaceId, folderId, zone) {
const arr = Array.from(files || []);
if (arr.length === 0) return;
// ── Проверка суммарного размера ──────────────────────────────
const totalBytes = arr.reduce(function(s, f) { return s + (f.size || 0); }, 0);
if (totalBytes > MAX_TOTAL_BYTES) {
alert(
'Суммарный размер выбранных файлов (' + formatBytes(totalBytes) + ') ' +
'превышает лимит 100 МБ.\n\n' +
'Выбрано файлов: ' + arr.length + '\n' +
'Уберите часть файлов или загрузите их по очереди.'
);
return;
}
// ── UI: блокируем зону и показываем прогресс ─────────────────
const titleEl = zone.querySelector('.upload-zone-title');
const hintEl = zone.querySelector('.upload-zone-hint');
const originalTitle = titleEl ? titleEl.textContent : '';
const originalHint = hintEl ? hintEl.textContent : '';
zone.classList.add('uploading');
zone.style.pointerEvents = 'none';
let done = 0;
let failed = 0;
function updateProgress() {
if (titleEl) {
titleEl.textContent = 'Загрузка ' + done + ' из ' + arr.length + '…';
}
if (hintEl) {
hintEl.textContent = 'Суммарно: ' + formatBytes(totalBytes);
}
}
updateProgress();
// ── Загружаем по одному (каждый POST ≤ 100 МБ гарантирован) ──
arr.forEach(function(file) {
const fd = new FormData();
fd.append('file', file);
if (spaceId) fd.append('space_id', spaceId);
if (folderId) fd.append('folder_id', folderId);
fetch('/files/upload', {
method: 'POST',
body: fd,
credentials: 'same-origin'
}).then(function(r) {
if (!r.ok) failed++;
}).catch(function() {
failed++;
}).finally(function() {
done++;
updateProgress();
if (done === arr.length) {
zone.classList.remove('uploading');
zone.style.pointerEvents = '';
if (failed > 0) {
alert(
'Загружено: ' + (arr.length - failed) + ' из ' + arr.length + '.\n' +
'Не удалось загрузить: ' + failed + '.'
);
}
if (titleEl) titleEl.textContent = originalTitle;
if (hintEl) hintEl.textContent = originalHint;
if (failed === 0) {
location.reload();
} else {
// даём возможность увидеть ошибку
setTimeout(function() { location.reload(); }, 1500);
}
}
});
});
}
// ── Обработчик для каждой upload-зоны ─────────────────────────────
document.querySelectorAll('.upload-zone').forEach(function(zone) {
const input = zone.querySelector('input[type="file"]');
if (!input) return;
const spaceId = zone.dataset.spaceId || '';
const folderId = zone.dataset.folderId || '';
// Клик по зоне → открыть диалог
zone.addEventListener('click', function(e) {
if (zone.classList.contains('uploading')) return;
// если клик пришёлся на input (что маловероятно — display:none) — не мешаем
if (e.target === input) return;
input.click();
});
// Клавиатура: Enter / Space
zone.addEventListener('keydown', function(e) {
if (zone.classList.contains('uploading')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
input.click();
}
});
// Выбор через диалог
input.addEventListener('change', function() {
if (input.files && input.files.length > 0) {
uploadFiles(input.files, spaceId, folderId, zone);
input.value = '';
}
});
// Drag & drop
['dragenter', 'dragover'].forEach(function(evt) {
zone.addEventListener(evt, function(e) {
e.preventDefault();
e.stopPropagation();
if (zone.classList.contains('uploading')) return;
zone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(function(evt) {
zone.addEventListener(evt, function(e) {
e.preventDefault();
e.stopPropagation();
zone.classList.remove('dragover');
});
});
zone.addEventListener('drop', function(e) {
if (zone.classList.contains('uploading')) return;
const files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length > 0) {
uploadFiles(files, spaceId, folderId, zone);
}
});
});
})();
</script>
{% endblock %}