morozovka-portal/app/templates/project_detail.html

356 lines
19 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.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ project.title }} — Morozovka Portal</title>
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px 20px; }
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #58a6ff; margin: 0 0 10px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; margin-bottom: 20px; }
.header-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.tabs { display: flex; gap: 5px; border-bottom: 1px solid #30363d; margin-bottom: 25px; }
.tab { padding: 10px 20px; color: #8b949e; text-decoration: none; border-bottom: 2px solid transparent; font-weight: 600; }
.tab:hover { color: #e6edf3; }
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.tab .badge { background: #30363d; color: #e6edf3; padding: 2px 8px; border-radius: 10px; font-size: .75rem; margin-left: 6px; }
.badges { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
.badge { padding: 4px 10px; border-radius: 4px; font-size: 0.8rem; font-weight: bold; text-transform: uppercase; }
.status-in_progress { background: #1f6feb; color: white; }
.status-idea { background: #30363d; color: white; }
.status-completed { background: #238636; color: white; }
.status-frozen { background: #da3633; color: white; }
.vis-public { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid #3fb950; }
.vis-internal { background: rgba(210,153,34,0.2); color: #d29922; border: 1px solid #d29922; }
.vis-private { background: rgba(248,81,73,0.2); color: #f85149; border: 1px solid #f85149; }
.tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.tag { font-size: .8rem; background: rgba(88,166,255,.1); color: #58a6ff; padding: 3px 10px; border-radius: 12px; border: 1px solid rgba(88,166,255,.3); }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-bottom: 25px; padding: 15px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; }
.meta-item .label { color: #8b949e; font-size: .75rem; text-transform: uppercase; margin-bottom: 4px; }
.meta-item .value { color: #e6edf3; font-size: 1rem; }
.desc { font-size: 1.05rem; line-height: 1.7; color: #c9d1d9; margin-bottom: 30px; white-space: pre-wrap; }
.repo-btn { display: inline-block; background: #21262d; color: #58a6ff; text-decoration: none; padding: 10px 20px; border-radius: 5px; border: 1px solid #30363d; font-weight: bold; margin-bottom: 30px; }
.repo-btn:hover { background: #30363d; }
.section-title { border-bottom: 1px solid #30363d; padding-bottom: 10px; margin-bottom: 20px; color: #e6edf3; font-size: 1.2rem; display: flex; justify-content: space-between; align-items: center; }
.section-title .count { color: #8b949e; font-size: .85rem; font-weight: normal; }
.members-list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 25px; }
.member { background: #161b22; border: 1px solid #30363d; padding: 12px 15px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.member-info { flex: 1; min-width: 0; }
.member-name { font-weight: bold; overflow: hidden; text-overflow: ellipsis; }
.member-role-badge { font-size: 0.7rem; text-transform: uppercase; padding: 2px 6px; border-radius: 3px; display: inline-block; margin-top: 4px; }
.role-manager { background: #d29922; color: #000; }
.role-editor { background: #1f6feb; color: #fff; }
.role-viewer { background: #30363d; color: #e6edf3; }
.member-actions { display: flex; gap: 5px; align-items: center; flex-shrink: 0; }
.role-select { padding: 4px 6px; background: #0d1117; border: 1px solid #30363d; color: #e6edf3; border-radius: 4px; font-size: .8rem; }
.btn { background: #238636; color: white; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer; font-weight: bold; text-decoration: none; display: inline-block; font-size: .9rem; }
.btn:hover { background: #2ea043; }
.btn-secondary { background: #21262d; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
.btn-danger { background: #da3633; }
.btn-danger:hover { background: #f85149; }
.btn-sm { padding: 5px 10px; font-size: .8rem; }
.add-form { background: #161b22; border: 1px solid #30363d; padding: 15px; border-radius: 8px; margin-bottom: 30px; }
.add-form form { display: flex; gap: 10px; 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: .8rem; }
.add-form input, .add-form select { padding: 8px 10px; background: #0d1117; border: 1px solid #30363d; border-radius: 5px; color: #fff; font-size: .9rem; }
.add-form input:focus, .add-form select:focus { outline: none; border-color: #58a6ff; }
.files-list { background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
.file-row { display: flex; align-items: center; gap: 15px; padding: 12px 15px; border-bottom: 1px solid #30363d; }
.file-row:last-child { border-bottom: none; }
.file-row:hover { background: #1c2128; }
.file-info { flex: 1; min-width: 0; }
.file-name { color: #e6edf3; font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.file-meta { color: #8b949e; font-size: .8rem; margin-top: 2px; }
.file-actions { display: flex; gap: 8px; flex-shrink: 0; }
.file-actions a { color: #58a6ff; text-decoration: none; font-size: .9rem; }
.file-actions a:hover { text-decoration: underline; }
.empty-state { text-align: center; padding: 30px; color: #8b949e; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
/* Gitea block */
.gitea-block { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 30px; }
.gitea-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; flex-wrap: wrap; gap: 10px; }
.gitea-title { color: #e6edf3; font-weight: 700; font-size: 1.05rem; }
.gitea-title a { color: #58a6ff; text-decoration: none; }
.gitea-stats-row { display: flex; gap: 20px; margin-bottom: 15px; flex-wrap: wrap; }
.gitea-stat { display: flex; align-items: center; gap: 6px; color: #8b949e; font-size: .9rem; }
.gitea-stat strong { color: #e6edf3; }
.commits-list { display: flex; flex-direction: column; gap: 8px; }
.commit-row { display: flex; gap: 12px; padding: 8px 12px; background: #0d1117; border-radius: 6px; border: 1px solid #30363d; align-items: center; }
.commit-sha { color: #58a6ff; font-family: monospace; font-size: .8rem; text-decoration: none; flex-shrink: 0; }
.commit-sha:hover { text-decoration: underline; }
.commit-msg { flex: 1; color: #c9d1d9; font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.commit-author { color: #8b949e; font-size: .8rem; flex-shrink: 0; }
.gitea-loading { color: #8b949e; padding: 15px 0; font-size: .9rem; }
.gitea-error { color: #f85149; padding: 15px 0; font-size: .9rem; }
</style>
</head>
<body>
<div class="container">
<a href="/projects" class="back-link">← К проектам</a>
<div class="header">
<div>
<h1>{{ 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="header-actions">
{% if perms.can_edit %}
<a href="/projects/{{ project.id }}/edit" class="btn btn-secondary">Редактировать</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">{{ task_counts.values()|sum }}</span>
{% endif %}
</a>
<a href="#files" class="tab">Файлы <span class="badge">{{ files|length }}</span></a>
</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">
{% if perms.project_role == 'manager' %}Manager
{% elif perms.project_role == 'editor' %}Editor
{% elif perms.project_role == 'viewer' %}Viewer
{% else %}—{% endif %}
</div>
</div>
</div>
<div class="desc">{{ project.description or 'Описание пока не добавлено.' }}</div>
{% if project.gitea_url %}
<a href="{{ project.gitea_url }}" target="_blank" class="repo-btn">📦 Перейти в репозиторий Gitea</a>
{% endif %}
<!-- Gitea stats block (lazy-loaded) -->
{% if project.gitea_url or project.gitea_repo %}
<div class="gitea-block" id="gitea-block">
<div class="gitea-loading">Загружаю статистику из Gitea...</div>
</div>
{% endif %}
<h2 class="section-title">Участники <span class="count">{{ members|length }}</span></h2>
{% if perms.can_manage %}
<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="например, testuser" 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">Добавить</button>
</form>
</div>
{% endif %}
<div class="members-list">
{% for member_obj, user in members %}
<div class="member">
<div class="member-info">
<div class="member-name">{{ user.full_name or user.username }} <span style="color: #6e7681; font-weight: normal; font-size: .85rem;">@{{ user.username }}</span></div>
{% if user.id == project.owner_id %}
<span class="member-role-badge role-manager">Владелец</span>
{% else %}
<span class="member-role-badge role-{{ member_obj.role }}">
{% if member_obj.role == 'manager' %}Manager
{% elif member_obj.role == 'editor' %}Editor
{% else %}Viewer{% endif %}
</span>
{% endif %}
</div>
{% if perms.can_manage and user.id != project.owner_id %}
<div class="member-actions">
<form action="/projects/{{ project.id }}/members/{{ user.id }}/role" method="POST" style="display:flex; gap: 5px;">
<select name="role" class="role-select">
<option value="viewer" {% if member_obj.role == 'viewer' %}selected{% endif %}>Viewer</option>
<option value="editor" {% if member_obj.role == 'editor' %}selected{% endif %}>Editor</option>
<option value="manager" {% if member_obj.role == 'manager' %}selected{% endif %}>Manager</option>
</select>
<button type="submit" class="btn btn-sm btn-secondary">OK</button>
</form>
<form action="/projects/{{ project.id }}/members/{{ user.id }}/remove" method="POST" onsubmit="return confirm('Удалить участника?');">
<button type="submit" class="btn btn-sm btn-danger">✕</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
</div>
<h2 class="section-title" id="files">Файлы проекта <span class="count">{{ 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">Загрузить</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 }}</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_manage 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-sm btn-danger">Удалить</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="files-list">
<div class="empty-state">Файлов в проекте пока нет.{% if perms.can_upload %} Загрузите первый файл выше.{% endif %}</div>
</div>
{% endif %}
</div>
<script>
(function() {
const block = document.getElementById('gitea-block');
if (!block) return;
const PROJECT_ID = {{ project.id }};
async function loadGiteaStats() {
try {
const res = await fetch(`/projects/${PROJECT_ID}/gitea-stats`, { headers: { 'Accept': 'application/json' } });
const data = await res.json();
renderGitea(data);
} catch (e) {
block.innerHTML = '<div class="gitea-error">Ошибка загрузки статистики Gitea</div>';
}
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
const dd = String(d.getDate()).padStart(2,'0');
const mm = String(d.getMonth()+1).padStart(2,'0');
const yy = String(d.getFullYear()).slice(-2);
const hh = String(d.getHours()).padStart(2,'0');
const mn = String(d.getMinutes()).padStart(2,'0');
return `${dd}.${mm}.${yy} ${hh}:${mn}`;
}
function renderGitea(data) {
if (!data.ok) {
block.innerHTML = `<div class="gitea-error">${escapeHtml(data.message || 'Не удалось получить данные')}</div>`;
return;
}
const repo = data.repo;
let html = '';
html += '<div class="gitea-header">';
html += '<div class="gitea-title">📦 ';
if (repo.html_url) {
html += `<a href="${escapeHtml(repo.html_url)}" target="_blank">${escapeHtml(repo.full_name)}</a>`;
} else {
html += escapeHtml(repo.full_name);
}
if (repo.is_private) html += ' <span class="tag" style="font-size:.7rem;">private</span>';
html += '</div></div>';
html += '<div class="gitea-stats-row">';
html += `<div class="gitea-stat">🐛 Open issues: <strong>${data.open_issues}</strong></div>`;
html += `<div class="gitea-stat">🔀 Open PRs: <strong>${data.open_prs}</strong></div>`;
html += `<div class="gitea-stat">🌿 Branch: <strong>${escapeHtml(repo.default_branch)}</strong></div>`;
if (repo.stars) html += `<div class="gitea-stat">⭐ <strong>${repo.stars}</strong></div>`;
if (repo.forks) html += `<div class="gitea-stat">🍴 <strong>${repo.forks}</strong></div>`;
html += '</div>';
if (data.commits && data.commits.length) {
html += '<div style="color:#8b949e;font-size:.8rem;text-transform:uppercase;margin-bottom:8px;font-weight:600;">Последние коммиты</div>';
html += '<div class="commits-list">';
for (const c of data.commits) {
const shaLink = c.html_url ? `<a href="${escapeHtml(c.html_url)}" target="_blank" class="commit-sha">${escapeHtml(c.sha_short)}</a>` : `<span class="commit-sha">${escapeHtml(c.sha_short)}</span>`;
html += '<div class="commit-row">';
html += shaLink;
html += `<div class="commit-msg" title="${escapeHtml(c.message)}">${escapeHtml(c.message)}</div>`;
html += `<div class="commit-author">${escapeHtml(c.author)} · ${fmtDate(c.date)}</div>`;
html += '</div>';
}
html += '</div>';
}
block.innerHTML = html;
}
loadGiteaStats();
})();
</script>
</body>
</html>