feat: добавить веб-интерфейс админ-панели для управления модпаками
This commit is contained in:
@@ -50,6 +50,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
// Website endpoints.
|
// Website endpoints.
|
||||||
mux.HandleFunc("POST /api/web/register", h.register)
|
mux.HandleFunc("POST /api/web/register", h.register)
|
||||||
mux.HandleFunc("POST /api/web/login", h.webLogin)
|
mux.HandleFunc("POST /api/web/login", h.webLogin)
|
||||||
|
mux.HandleFunc("GET /api/web/me", h.webMe)
|
||||||
mux.HandleFunc("POST /api/web/profile/skin", h.uploadSkin)
|
mux.HandleFunc("POST /api/web/profile/skin", h.uploadSkin)
|
||||||
mux.HandleFunc("POST /api/web/profile/cape", h.uploadCape)
|
mux.HandleFunc("POST /api/web/profile/cape", h.uploadCape)
|
||||||
mux.HandleFunc("DELETE /api/web/profile/skin", h.deleteSkin)
|
mux.HandleFunc("DELETE /api/web/profile/skin", h.deleteSkin)
|
||||||
@@ -429,6 +430,29 @@ func (h *Handler) authenticateRequest(w http.ResponseWriter, r *http.Request) in
|
|||||||
return userID
|
return userID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webMe returns the current authenticated user's info (for checking admin status in UI).
|
||||||
|
func (h *Handler) webMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := h.authenticateRequest(w, r)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var username, uuid, role string
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT username, uuid, role FROM users WHERE id = $1`, userID,
|
||||||
|
).Scan(&username, &uuid, &role)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"username": username,
|
||||||
|
"uuid": uuid,
|
||||||
|
"role": role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) launcherLatest(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) launcherLatest(w http.ResponseWriter, r *http.Request) {
|
||||||
osParam := r.URL.Query().Get("os")
|
osParam := r.URL.Query().Get("os")
|
||||||
archParam := r.URL.Query().Get("arch")
|
archParam := r.URL.Query().Get("arch")
|
||||||
|
|||||||
264
internal/templates/html/admin.html
Normal file
264
internal/templates/html/admin.html
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="container">
|
||||||
|
<h1>Админ-панель: Управление модпаками</h1>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
<div id="alert-error" class="alert alert-error"></div>
|
||||||
|
<div id="alert-success" class="alert alert-success"></div>
|
||||||
|
|
||||||
|
<!-- Modpacks List -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Список модпаков</h2>
|
||||||
|
<div id="modpacks-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Версия Minecraft</th>
|
||||||
|
<th>Версия Java</th>
|
||||||
|
<th>IP сервера</th>
|
||||||
|
<th>Активен</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="modpacks-tbody">
|
||||||
|
<!-- Will be filled by JavaScript -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Form -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Загрузка нового модпака</h2>
|
||||||
|
<form id="modpack-form">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div>
|
||||||
|
<label for="slug">Slug (уникальный идентификатор)</label>
|
||||||
|
<input type="text" id="slug" name="slug" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="name">Название</label>
|
||||||
|
<input type="text" id="name" name="name" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="minecraft_version">Версия Minecraft</label>
|
||||||
|
<input type="text" id="minecraft_version" name="minecraft_version" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="java_version">Версия Java</label>
|
||||||
|
<input type="number" id="java_version" name="java_version" min="8" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="server_ip">IP сервера</label>
|
||||||
|
<input type="text" id="server_ip" name="server_ip" placeholder="Например: play.mrixs.me">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Создать модпак</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h3>Загрузка файлов для модпака</h3>
|
||||||
|
<form id="upload-form" enctype="multipart/form-data">
|
||||||
|
<div>
|
||||||
|
<label for="upload-slug">Slug модпака</label>
|
||||||
|
<input type="text" id="upload-slug" name="slug" list="modpack-slugs" required>
|
||||||
|
<datalist id="modpack-slugs"></datalist>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="files">Файлы (перетащите или выберите)</label>
|
||||||
|
<input type="file" id="files" name="files" multiple>
|
||||||
|
<p>Поддерживаются архивы .zip ( будут распакованы) и отдельные файлы.</p>
|
||||||
|
</div>
|
||||||
|
<div id="upload-progress" style="display: none; margin-top: 1rem;">
|
||||||
|
<div>Прогресс: <span id="upload-percent">0%</span></div>
|
||||||
|
<progress id="upload-bar" value="0" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" id="upload-btn">Загрузить файлы</button>
|
||||||
|
</form>
|
||||||
|
<div id="upload-result" style="margin-top: 1rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Helper functions
|
||||||
|
function showAlert(error, success) {
|
||||||
|
const errorAlert = document.getElementById('alert-error');
|
||||||
|
const successAlert = document.getElementById('alert-success');
|
||||||
|
if (error) {
|
||||||
|
errorAlert.textContent = error;
|
||||||
|
errorAlert.classList.add('show');
|
||||||
|
successAlert.classList.remove('show');
|
||||||
|
} else if (success) {
|
||||||
|
successAlert.textContent = success;
|
||||||
|
successAlert.classList.add('show');
|
||||||
|
errorAlert.classList.remove('show');
|
||||||
|
} else {
|
||||||
|
errorAlert.classList.remove('show');
|
||||||
|
successAlert.classList.remove('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and display modpacks
|
||||||
|
async function loadModpacks() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/modpacks', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch modpacks');
|
||||||
|
}
|
||||||
|
|
||||||
|
const modpacks = await response.json();
|
||||||
|
const tbody = document.getElementById('modpacks-tbody');
|
||||||
|
const datalist = document.getElementById('modpack-slugs');
|
||||||
|
|
||||||
|
// Clear existing
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
datalist.innerHTML = '';
|
||||||
|
|
||||||
|
modpacks.forEach(mp => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${mp.slug}</td>
|
||||||
|
<td>${mp.name}</td>
|
||||||
|
<td>${mp.minecraft_version}</td>
|
||||||
|
<td>${mp.java_version}</td>
|
||||||
|
<td>${mp.server_ip || '-'}</td>
|
||||||
|
<td>${mp.is_active ? 'Да' : 'Нет'}</td>
|
||||||
|
<td>
|
||||||
|
<!-- We don't have edit/delete endpoints in API yet, but we can add later -->
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="loadModpackForEdit('${mp.slug}')">Редактировать</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
|
||||||
|
// Add to datalist for upload form
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = mp.slug;
|
||||||
|
datalist.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('Ошибка загрузки модпаков: ' + err.message, null);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle modpack creation
|
||||||
|
document.getElementById('modpack-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const data = {
|
||||||
|
slug: formData.get('slug'),
|
||||||
|
name: formData.get('name'),
|
||||||
|
minecraft_version: formData.get('minecraft_version'),
|
||||||
|
java_version: parseInt(formData.get('java_version')),
|
||||||
|
server_ip: formData.get('server_ip')
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/modpacks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showAlert(null, 'Модпак успешно создан');
|
||||||
|
e.target.reset();
|
||||||
|
loadModpacks(); // Refresh list
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showAlert(error.errorMessage || 'Ошибка создания модпака', null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('Ошибка сети: ' + err.message, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle file upload
|
||||||
|
document.getElementById('upload-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const slug = formData.get('slug');
|
||||||
|
const files = document.getElementById('files').files;
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
showAlert('Пожалуйста, выберите файлы для загрузки', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show progress
|
||||||
|
const progressDiv = document.getElementById('upload-progress');
|
||||||
|
const percentSpan = document.getElementById('upload-percent');
|
||||||
|
const progressBar = document.getElementById('upload-bar');
|
||||||
|
progressDiv.style.display = 'block';
|
||||||
|
percentSpan.textContent = '0%';
|
||||||
|
progressBar.value = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We'll use the existing upload endpoint: POST /api/admin/modpacks/{slug}/upload
|
||||||
|
const response = await fetch(`/api/admin/modpacks/${slug}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
showAlert(null, `Загружено файлов: ${result.count}`);
|
||||||
|
document.getElementById('upload-form').reset();
|
||||||
|
progressDiv.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showAlert(error.errorMessage || 'Ошибка загрузки файлов', null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('Ошибка сети: ' + err.message, null);
|
||||||
|
} finally {
|
||||||
|
progressDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate upload progress (since we don't have progress events from fetch with FormData easily)
|
||||||
|
// For a real progress bar, we'd need to use XMLHttpSocket or a custom backend endpoint.
|
||||||
|
// For now, we'll just show indeterminate or fake progress.
|
||||||
|
// We'll update this if we decide to implement proper progress later.
|
||||||
|
|
||||||
|
// Placeholder for edit function (to be implemented if we add edit endpoint)
|
||||||
|
function loadModpackForEdit(slug) {
|
||||||
|
alert('Редактирование пока не реализовано. Используйте API напрямую.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadModpacks();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -357,6 +357,43 @@
|
|||||||
const username = localStorage.getItem('username');
|
const username = localStorage.getItem('username');
|
||||||
const nav = document.getElementById('mainNav');
|
const nav = document.getElementById('mainNav');
|
||||||
if (token && username) {
|
if (token && username) {
|
||||||
|
// Fetch user info to check role
|
||||||
|
fetch('/api/web/me', {
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) throw new Error('Not authenticated');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
let adminLink = '';
|
||||||
|
if (data.role === 'admin') {
|
||||||
|
adminLink = '<a href="/admin" id="nav-admin" class="btn btn-sm" style="background:var(--warning);color:#000">Админ-панель</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.innerHTML =
|
||||||
|
'<a href="/" id="nav-home">Главная</a>' +
|
||||||
|
'<a href="/profile" id="nav-profile">Профиль</a>' +
|
||||||
|
adminLink +
|
||||||
|
'<div class="nav-user"><div class="avatar"></div>' + escapeHtml(username) + '</div>' +
|
||||||
|
'<a href="#" id="nav-logout" class="btn btn-sm btn-danger" style="padding:0.35rem 0.75rem">Выйти</a>';
|
||||||
|
|
||||||
|
document.getElementById('nav-logout').addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('uuid');
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
window.location.href = '/';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight active nav link
|
||||||
|
const path = window.location.pathname;
|
||||||
|
document.querySelectorAll('header nav a').forEach(function(a) {
|
||||||
|
if (a.getAttribute('href') === path) a.classList.add('active');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
// If we can't fetch user info, still show basic nav
|
||||||
nav.innerHTML =
|
nav.innerHTML =
|
||||||
'<a href="/" id="nav-home">Главная</a>' +
|
'<a href="/" id="nav-home">Главная</a>' +
|
||||||
'<a href="/profile" id="nav-profile">Профиль</a>' +
|
'<a href="/profile" id="nav-profile">Профиль</a>' +
|
||||||
@@ -369,12 +406,14 @@
|
|||||||
localStorage.removeItem('username');
|
localStorage.removeItem('username');
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
});
|
});
|
||||||
}
|
|
||||||
// Highlight active nav link
|
// Highlight active nav link
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
document.querySelectorAll('header nav a').forEach(function(a) {
|
document.querySelectorAll('header nav a').forEach(function(a) {
|
||||||
if (a.getAttribute('href') === path) a.classList.add('active');
|
if (a.getAttribute('href') === path) a.classList.add('active');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /login", h.loginPage)
|
mux.HandleFunc("GET /login", h.loginPage)
|
||||||
mux.HandleFunc("GET /register", h.registerPage)
|
mux.HandleFunc("GET /register", h.registerPage)
|
||||||
mux.HandleFunc("GET /profile", h.profilePage)
|
mux.HandleFunc("GET /profile", h.profilePage)
|
||||||
|
mux.HandleFunc("GET /admin", h.adminPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page handlers ──────────────────────────────────────────────
|
// ── Page handlers ──────────────────────────────────────────────
|
||||||
@@ -65,6 +66,13 @@ func (h *Handler) profilePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.render(w, "profile.html", pageData{Title: "Профиль"})
|
h.render(w, "profile.html", pageData{Title: "Профиль"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) adminPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check if user is logged in via token in cookie or localStorage?
|
||||||
|
// For simplicity, we rely on the API endpoints to check auth.
|
||||||
|
// We'll just render the admin page; the JS will check for token and redirect to login if needed.
|
||||||
|
h.render(w, "admin.html", pageData{Title: "Админ-панель"})
|
||||||
|
}
|
||||||
|
|
||||||
// render executes the "base.html" template which {{template "content" .}}
|
// render executes the "base.html" template which {{template "content" .}}
|
||||||
// pulls in the per-page content block.
|
// pulls in the per-page content block.
|
||||||
func (h *Handler) render(w http.ResponseWriter, page string, data pageData) {
|
func (h *Handler) render(w http.ResponseWriter, page string, data pageData) {
|
||||||
@@ -88,7 +96,7 @@ func (h *Handler) render(w http.ResponseWriter, page string, data pageData) {
|
|||||||
// multiple {{define "content"}} blocks in wildcard-parsed files overwrite
|
// multiple {{define "content"}} blocks in wildcard-parsed files overwrite
|
||||||
// each other (last alphabetically wins).
|
// each other (last alphabetically wins).
|
||||||
func (h *Handler) parseTemplates() {
|
func (h *Handler) parseTemplates() {
|
||||||
pages := []string{"index.html", "login.html", "register.html", "profile.html"}
|
pages := []string{"index.html", "login.html", "register.html", "profile.html", "admin.html"}
|
||||||
for _, page := range pages {
|
for _, page := range pages {
|
||||||
tmpl, err := template.New("base.html").Funcs(template.FuncMap{}).ParseFS(templateFS, "html/base.html", "html/"+page)
|
tmpl, err := template.New("base.html").Funcs(template.FuncMap{}).ParseFS(templateFS, "html/base.html", "html/"+page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user