morozovka-portal/app/templates/project_detail.html

355 lines
24 KiB
HTML
Raw 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 %}{{ project.title }} — Morozovka Portal{% endblock %}
{% block main_class %}container{% endblock %}
{% block head %}
<style>
.breadcrumbs { color: #8b949e; font-size: 13px; margin-bottom: 16px; }
.breadcrumbs a { color: #8b949e; }
.breadcrumbs a:hover { color: #58a6ff; }
.ws-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.ws-header-left { flex: 1; }
.ws-title { font-size: 24px; font-weight: 600; margin: 0 0 12px; color: #e6edf3; }
.badges { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.badge { display: inline-block; padding: 0 7px; border-radius: 2em; font-size: 12px; font-weight: 500; line-height: 18px; border: 1px solid; }
.status-in_progress { background: rgba(31,111,235,0.15); color: #1f6feb; border-color: rgba(31,111,235,0.4); }
.status-idea { background: rgba(139,148,158,0.15); color: #8b949e; border-color: rgba(139,148,158,0.4); }
.status-completed { background: rgba(35,134,54,0.15); color: #3fb950; border-color: rgba(35,134,54,0.4); }
.status-frozen { background: rgba(218,54,51,0.15); color: #f85149; border-color: rgba(218,54,51,0.4); }
.vis-public { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.vis-internal { background: rgba(210,153,34,0.15); color: #d29922; border-color: rgba(210,153,34,0.4); }
.vis-private { background: rgba(248,81,73,0.15); color: #f85149; border-color: rgba(248,81,73,0.4); }
.ws-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.layout { display: grid; grid-template-columns: 2fr 1fr; gap: 24px; }
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } }
.section { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; margin-bottom: 16px; }
.section h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; color: #e6edf3; padding-bottom: 8px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; }
.section h2 .counter { color: #8b949e; font-size: 13px; font-weight: normal; }
.section-content { color: #c9d1d9; white-space: pre-wrap; line-height: 1.6; }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid #30363d; margin-bottom: 20px; }
.tab { padding: 8px 16px; color: #8b949e; text-decoration: none; border-bottom: 2px solid transparent; font-weight: 600; font-size: 14px; }
.tab:hover { color: #e6edf3; text-decoration: none; }
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.tab .badge-count { background: #30363d; color: #e6edf3; padding: 0 6px; border-radius: 10px; font-size: 11px; margin-left: 4px; font-weight: 500; }
.post-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 14px 16px; margin-bottom: 12px; position: relative; }
.post-card:last-child { margin-bottom: 0; }
.post-card.pinned { border-color: #d29922; background: rgba(210,153,34,0.05); }
.post-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; margin-bottom: 6px; }
.post-title { font-size: 15px; font-weight: 600; color: #e6edf3; margin: 0 0 4px; }
.post-title .pin-mark { color: #d29922; margin-right: 4px; font-size: 12px; }
.post-meta { color: #8b949e; font-size: 12px; }
.post-body { color: #c9d1d9; line-height: 1.6; white-space: pre-wrap; margin: 8px 0; }
.post-attachments { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; padding-top: 8px; border-top: 1px solid #21262d; }
.post-attachment { display: flex; align-items: center; gap: 8px; padding: 4px 8px; background: #161b22; border-radius: 4px; font-size: 12px; }
.post-attachment a { color: #58a6ff; }
.post-image-wrap { margin: 6px 0; }
.post-image-wrap img { max-width: 100%; max-height: 400px; border-radius: 6px; border: 1px solid #30363d; display: block; cursor: zoom-in; }
.post-image-name { color: #6e7681; font-size: 11px; margin-top: 3px; }
.post-actions { display: flex; gap: 4px; margin-left: auto; opacity: 0; transition: opacity .15s; }
.post-card:hover .post-actions { opacity: 1; }
.post-actions form { display: inline; }
.post-actions button { background: none; border: none; color: #8b949e; cursor: pointer; padding: 2px 6px; font-size: 12px; border-radius: 3px; }
.post-actions button:hover { background: #30363d; color: #e6edf3; }
.post-actions .del:hover { color: #f85149; }
.post-form-card { background: #0d1117; border: 1px dashed #30363d; border-radius: 6px; padding: 14px 16px; margin-top: 12px; }
.post-form-row { display: grid; grid-template-columns: 1fr; gap: 8px; margin-bottom: 8px; }
.post-form-row label { color: #8b949e; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.post-form-row input, .post-form-row textarea { padding: 5px 10px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 13px; font-family: inherit; width: 100%; }
.post-form-row textarea { min-height: 80px; resize: vertical; }
.post-form-row input:focus, .post-form-row textarea:focus { outline: none; border-color: #58a6ff; }
.post-form-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.post-form-actions input[type=file] { font-size: 12px; color: #8b949e; }
.post-form-actions button { padding: 5px 14px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 13px; }
.post-form-actions button:hover { background: #2ea043; }
.checkbox-line { display: flex; align-items: center; gap: 6px; color: #8b949e; font-size: 12px; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 20px; padding: 14px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; }
.meta-item .label { color: #8b949e; font-size: 11px; text-transform: uppercase; margin-bottom: 3px; font-weight: 600; }
.meta-item .value { color: #e6edf3; font-size: 14px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.tag { font-size: 12px; background: rgba(88,166,255,.1); color: #58a6ff; padding: 2px 10px; border-radius: 12px; border: 1px solid rgba(88,166,255,.3); }
.member-row { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px 14px; margin-bottom: 10px; }
.member-row:last-child { margin-bottom: 0; }
.member-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
.member-name { font-weight: 600; color: #e6edf3; }
.member-name .username { color: #8b949e; font-weight: normal; font-size: 12px; margin-left: 4px; }
.role-pill { font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600; text-transform: uppercase; }
.role-manager { background: #d29922; color: #000; }
.role-editor { background: #1f6feb; color: #fff; }
.role-viewer { background: #30363d; color: #e6edf3; }
.role-owner { background: #a371f7; color: #fff; }
.perm-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px; margin-top: 6px; }
.perm-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #c9d1d9; }
.perm-item input { width: 14px; height: 14px; }
.perm-actions { display: flex; gap: 8px; margin-top: 8px; align-items: center; flex-wrap: wrap; }
.add-form { background: #0d1117; border: 1px solid #30363d; padding: 12px; border-radius: 6px; margin-bottom: 12px; }
.add-form form { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; }
.add-form .form-group { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 140px; }
.add-form label { color: #8b949e; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.add-form input, .add-form select { padding: 5px 10px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 13px; font-family: inherit; }
.add-form input:focus, .add-form select:focus { outline: none; border-color: #58a6ff; }
.files-list { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; }
.file-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #21262d; }
.file-row:last-child { border-bottom: none; }
.file-row:hover { background: #161b22; }
.file-info { flex: 1; min-width: 0; }
.file-name { color: #e6edf3; font-weight: 500; font-size: 13px; overflow: hidden; text-overflow: ellipsis; }
.file-meta { color: #8b949e; font-size: 11px; margin-top: 2px; }
.file-actions { display: flex; gap: 8px; flex-shrink: 0; }
.file-actions a { color: #58a6ff; font-size: 12px; }
.sub-btn { display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%; padding: 8px; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 14px; border: 1px solid; }
.sub-btn.on { background: rgba(88,166,255,0.15); border-color: #58a6ff; color: #58a6ff; }
.sub-btn.on:hover { background: rgba(88,166,255,0.25); }
.sub-btn.off { background: #21262d; border-color: #30363d; color: #c9d1d9; }
.sub-btn.off:hover { background: #30363d; }
.empty-small { color: #8b949e; font-size: 13px; padding: 12px; text-align: center; background: #0d1117; border: 1px dashed #30363d; border-radius: 6px; }
.repo-btn { display: inline-block; background: #21262d; color: #58a6ff; text-decoration: none; padding: 8px 16px; border-radius: 5px; border: 1px solid #30363d; font-weight: 500; margin-bottom: 16px; font-size: 13px; }
.repo-btn:hover { background: #30363d; text-decoration: none; }
</style>
{% endblock %}
{% block content %}
<div class="breadcrumbs"><a href="/projects">Проекты</a> / {{ project.title }}</div>
<div class="ws-header">
<div class="ws-header-left">
<h1 class="ws-title">{{ project.title }}</h1>
<div class="badges">
<span class="badge status-{{ project.status }}">
{% if project.status == 'idea' %}Идея
{% elif project.status == 'in_progress' %}В работе
{% elif project.status == 'completed' %}Завершён
{% else %}Заморожен{% endif %}
</span>
<span class="badge vis-{{ project.visibility }}">
{% if project.visibility == 'public' %}Публичный
{% elif project.visibility == 'internal' %}Внутренний
{% else %}Приватный{% endif %}
</span>
</div>
</div>
<div class="ws-actions">
{% if perms.can_manage_members or perms.is_admin or perms.is_owner %}
<a href="/projects/{{ project.id }}/edit" class="btn btn-default">✎ Редактировать</a>
{% endif %}
{% if perms.can_delete %}
<form action="/projects/{{ project.id }}/delete" method="POST" onsubmit="return confirm('Удалить проект?');" style="display:inline;">
<button type="submit" class="btn btn-danger">Удалить</button>
</form>
{% endif %}
</div>
</div>
<div class="tabs">
<a href="/projects/{{ project.id }}" class="tab active">Обзор</a>
<a href="/projects/{{ project.id }}/board" class="tab">Доска {% if task_counts %}<span class="badge-count">{{ task_counts.values()|sum }}</span>{% endif %}</a>
<a href="#files" class="tab">Файлы <span class="badge-count">{{ files|length }}</span></a>
</div>
<div class="layout">
<div>
{% if project.tags %}
<div class="tags">{% for t in project.tags.split(',') %}<span class="tag">#{{ t.strip() }}</span>{% endfor %}</div>
{% endif %}
<div class="meta-grid">
<div class="meta-item"><div class="label">Начало</div><div class="value">{{ project.start_date|date_ru or '—' }}</div></div>
<div class="meta-item"><div class="label">Завершение</div><div class="value">{{ project.end_date|date_ru or '—' }}</div></div>
<div class="meta-item"><div class="label">Контакт</div><div class="value">{{ project.contact_person or '—' }}</div></div>
<div class="meta-item"><div class="label">Ваша роль</div><div class="value">{{ perms.project_role or '—' }}</div></div>
</div>
<div class="section"><h2>Описание</h2><div class="section-content">{{ project.description or 'Описание пока не добавлено.' }}</div></div>
{% if project.gitea_url %}
<a href="{{ project.gitea_url }}" target="_blank" class="repo-btn">📦 Репозиторий Gitea</a>
{% endif %}
<div class="section">
<h2>📢 Лента <span class="counter">{{ posts|length }}</span></h2>
{% if posts %}
{% for p in posts %}
<div class="post-card{% if p.is_pinned %} pinned{% endif %}">
<div class="post-head">
<div style="flex:1;min-width:0;">
<div class="post-title">{% if p.is_pinned %}<span class="pin-mark">📌</span>{% endif %}{{ p.title }}</div>
<div class="post-meta">
{% set au = authors_map.get(p.author_id) %}
{% if au %}{{ au.full_name or au.username }}{% endif %}
· {{ p.created_at|msk_strftime('%d.%m.%Y %H:%M') }}
</div>
</div>
{% if perms.can_edit_any_post or (perms.can_edit_own_posts and p.author_id == user_id) %}
<div class="post-actions">
<button type="button" onclick="toggleEdit({{ p.id }})">✎</button>
<form action="/projects/{{ project.id }}/posts/{{ p.id }}/delete" method="POST" onsubmit="return confirm('Удалить пост?');">
<button type="submit" class="del">✕</button>
</form>
</div>
{% endif %}
</div>
{% if p.body %}<div class="post-body">{{ p.body }}</div>{% endif %}
{% if attachments_by_post.get(p.id) %}
<div class="post-attachments">
{% for a in attachments_by_post.get(p.id) %}
{% set f = files_map.get(a.file_id) %}
{% if f %}
{% if f.original_name|is_image %}
<div class="post-image-wrap">
<a href="/files/preview/{{ f.id }}" target="_blank">
<img src="/files/preview/{{ f.id }}" alt="{{ f.original_name }}" loading="lazy">
</a>
<div class="post-image-name">{{ f.original_name }} · {{ (f.size_bytes/1024)|round(1) }} КБ</div>
</div>
{% else %}
<div class="post-attachment">{{ f.original_name|file_icon }} <a href="/files/download/{{ f.id }}">{{ f.original_name }}</a> <span style="color:#6e7681;">({{ (f.size_bytes/1024)|round(1) }} КБ)</span></div>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if perms.can_edit_any_post or (perms.can_edit_own_posts and p.author_id == user_id) %}
<form id="edit-post-{{ p.id }}" action="/projects/{{ project.id }}/posts/{{ p.id }}/edit" method="POST" style="display:none;margin-top:10px;">
<div class="post-form-row"><label>Заголовок</label><input type="text" name="title" value="{{ p.title }}" required></div>
<div class="post-form-row"><label>Текст</label><textarea name="body">{{ p.body or '' }}</textarea></div>
<div class="post-form-actions">
{% if perms.can_edit_any_post %}
<label class="checkbox-line"><input type="checkbox" name="is_pinned" {% if p.is_pinned %}checked{% endif %}> Закрепить</label>
{% endif %}
<button type="submit">Сохранить</button>
<button type="button" class="btn btn-default" onclick="toggleEdit({{ p.id }})">Отмена</button>
</div>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-small">Пока нет постов. Начните первым.</div>
{% endif %}
{% if perms.can_post_feed %}
<form action="/projects/{{ project.id }}/posts/new" method="POST" enctype="multipart/form-data" class="post-form-card">
<div class="post-form-row"><label>Заголовок *</label><input type="text" name="title" required maxlength="200"></div>
<div class="post-form-row"><label>Текст</label><textarea name="body"></textarea></div>
<div class="post-form-actions">
<input type="file" name="files" multiple>
{% if perms.can_edit_any_post %}
<label class="checkbox-line"><input type="checkbox" name="is_pinned"> Закрепить</label>
{% endif %}
<button type="submit">Опубликовать</button>
</div>
</form>
{% endif %}
</div>
<div class="section">
<h2>Участники <span class="counter">{{ members|length }}</span></h2>
{% if perms.can_manage_members %}
<div class="add-form">
<form action="/projects/{{ project.id }}/members/add" method="POST">
<div class="form-group"><label>Логин</label><input type="text" name="username" placeholder="username" required></div>
<div class="form-group" style="flex:0.5;"><label>Роль</label>
<select name="role"><option value="viewer">Viewer</option><option value="editor" selected>Editor</option><option value="manager">Manager</option></select>
</div>
<button type="submit" class="btn btn-primary">+</button>
</form>
</div>
{% endif %}
{% for member_obj, user in members %}
<div class="member-row">
<div class="member-head">
<div>
<span class="member-name">{{ user.full_name or user.username }} <span class="username">@{{ user.username }}</span></span>
{% if user.id == project.owner_id %}
<span class="role-pill role-owner">Владелец</span>
{% else %}
<span class="role-pill role-{{ member_obj.role }}">{{ member_obj.role }}</span>
{% endif %}
</div>
{% if perms.can_manage_members and user.id != project.owner_id %}
<form action="/projects/{{ project.id }}/members/{{ user.id }}/remove" method="POST" onsubmit="return confirm('Удалить участника?');">
<button type="submit" class="btn btn-danger btn-sm">✕</button>
</form>
{% endif %}
</div>
{% if perms.can_manage_members and user.id != project.owner_id %}
<form action="/projects/{{ project.id }}/members/{{ user.id }}/permissions" method="POST">
<div class="perm-grid">
<label class="perm-item"><input type="checkbox" name="can_view_files" {% if member_obj.can_view_files %}checked{% endif %}> 📁 Видеть файлы</label>
<label class="perm-item"><input type="checkbox" name="can_edit_files" {% if member_obj.can_edit_files %}checked{% endif %}> 📁✎ Загружать/удалять файлы</label>
<label class="perm-item"><input type="checkbox" name="can_view_repo" {% if member_obj.can_view_repo %}checked{% endif %}> 📦 Видеть репо</label>
<label class="perm-item"><input type="checkbox" name="can_post_feed" {% if member_obj.can_post_feed %}checked{% endif %}> 📢 Писать в ленту</label>
<label class="perm-item"><input type="checkbox" name="can_edit_own_posts" {% if member_obj.can_edit_own_posts %}checked{% endif %}> ✎ Редактировать свои посты</label>
<label class="perm-item"><input type="checkbox" name="can_edit_any_post" {% if member_obj.can_edit_any_post %}checked{% endif %}> ✎ Редактировать любые посты</label>
<label class="perm-item"><input type="checkbox" name="can_manage_members" {% if member_obj.can_manage_members %}checked{% endif %}> 👥 Управлять участниками</label>
</div>
<div class="perm-actions">
<button type="submit" class="btn btn-default btn-sm">Сохранить права</button>
</div>
</form>
{% endif %}
</div>
{% endfor %}
</div>
<div class="section" id="files">
<h2>Файлы <span class="counter">{{ files|length }}</span></h2>
{% if perms.can_upload %}
<div class="add-form">
<form action="/projects/{{ project.id }}/files/upload" method="POST" enctype="multipart/form-data">
<div class="form-group"><label>Загрузить файл (до 100 МБ)</label><input type="file" name="file" required></div>
<button type="submit" class="btn btn-primary">Загрузить</button>
</form>
</div>
{% endif %}
{% if files %}
<div class="files-list">
{% for file, uploader in files %}
<div class="file-row">
<div class="file-info">
<div class="file-name">{{ file.original_name|file_icon }} {{ file.original_name }}</div>
<div class="file-meta">{{ (file.size_bytes/1024/1024)|round(2) }} МБ · {{ uploader }} · {{ file.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }}</div>
</div>
<div class="file-actions">
<a href="/files/download/{{ file.id }}">Скачать</a>
{% if perms.can_edit_files or perms.is_admin or file.uploaded_by == user_id %}
<form action="/files/{{ file.id }}/delete" method="POST" onsubmit="return confirm('Удалить?');" style="display:inline;">
<input type="hidden" name="next" value="/projects/{{ project.id }}">
<button type="submit" class="btn btn-danger btn-sm">✕</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-small">Файлов нет.{% if perms.can_upload %} Загрузите первый.{% endif %}</div>
{% endif %}
</div>
</div>
<div>
{% if user_id %}
<div class="section">
<h2>Подписка</h2>
<form action="/projects/{{ project.id }}/subscribe" method="POST">
{% if my_sub %}
<button type="submit" class="sub-btn on">🔔 Вы подписаны</button>
{% else %}
<button type="submit" class="sub-btn off">🔔 Подписаться на новости</button>
{% endif %}
</form>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleEdit(postId) {
var el = document.getElementById('edit-post-' + postId);
if (el) el.style.display = (el.style.display === 'none' || !el.style.display) ? 'block' : 'none';
}
</script>
{% endblock %}