356 lines
19 KiB
HTML
356 lines
19 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|