267 lines
18 KiB
HTML
267 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Врата ADA — Мастерская</title>
|
||
<style>
|
||
:root { color-scheme: dark; --bg:#0a0a0c; --card:#111216; --muted:#1d1f25; --border:#292c34; --text:#f4f4f5; --sub:#979da8; --green:#22c55e; --green-dark:#062d15; --red:#fb7185; font-family: Inter, Arial, sans-serif; }
|
||
* { box-sizing: border-box; }
|
||
body { margin:0; min-height:100vh; color:var(--text); background:var(--bg); }
|
||
button, input, textarea, select { font:inherit; }
|
||
button { cursor:pointer; }
|
||
.sidebar { position:fixed; z-index:20; inset:0 auto 0 0; display:flex; flex-direction:column; width:256px; border-right:1px solid var(--border); background:var(--card); }
|
||
.brand { height:64px; display:flex; align-items:center; gap:11px; padding:0 20px; border-bottom:1px solid var(--border); font-size:18px; font-weight:700; color:var(--green); }
|
||
.brand-mark { display:grid; place-items:center; width:31px; height:31px; border-radius:8px; color:#021508; background:var(--green); }
|
||
.nav { flex:1; padding:18px 10px; overflow:auto; }
|
||
.nav-title { margin:0 10px 8px; color:#707784; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||
.nav-item { display:flex; align-items:center; gap:12px; width:100%; margin:2px 0; padding:10px 12px; border:0; border-radius:8px; color:var(--sub); background:transparent; text-align:left; font-size:14px; font-weight:600; }
|
||
.nav-item:hover { color:var(--text); background:var(--muted); }
|
||
.nav-item.active { color:var(--green); background:rgba(34,197,94,.1); }
|
||
.nav-icon { width:20px; text-align:center; }
|
||
.nav-bottom { padding:12px 10px; border-top:1px solid var(--border); }
|
||
.user { display:flex; align-items:center; gap:10px; padding:9px 10px; color:var(--sub); font-size:13px; }
|
||
.avatar { display:grid; place-items:center; width:31px; height:31px; border-radius:50%; background:var(--green); color:#052b12; font-weight:800; }
|
||
.header { position:fixed; z-index:10; top:0; right:0; left:256px; height:64px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:0 28px; border-bottom:1px solid var(--border); background:rgba(17,18,22,.95); backdrop-filter:blur(8px); }
|
||
.search-wrap { position:relative; width:min(430px,100%); }
|
||
.search-wrap span { position:absolute; left:12px; top:10px; color:var(--sub); }
|
||
.search { width:100%; padding:10px 36px; border:1px solid var(--border); border-radius:8px; outline:0; color:var(--text); background:var(--bg); font-size:14px; }
|
||
.search:focus { border-color:rgba(34,197,94,.8); box-shadow:0 0 0 2px rgba(34,197,94,.12); }
|
||
.header-right { display:flex; align-items:center; gap:10px; color:var(--sub); font-size:13px; }
|
||
.main { min-height:100vh; margin-left:256px; padding:96px 28px 34px; }
|
||
.page { max-width:1180px; margin:auto; }
|
||
.page-top { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; margin-bottom:26px; }
|
||
h1 { margin:0 0 7px; font-size:30px; letter-spacing:-.025em; }
|
||
.subtitle { margin:0; color:var(--sub); font-size:14px; }
|
||
.primary { display:inline-flex; align-items:center; gap:8px; padding:10px 14px; border:0; border-radius:8px; color:#04230e; background:var(--green); font-size:14px; font-weight:800; }
|
||
.primary:hover { background:#34d572; }
|
||
.layout { display:grid; grid-template-columns:240px minmax(0,1fr); gap:22px; }
|
||
.filters, .service, .upload { border:1px solid var(--border); border-radius:12px; background:var(--card); }
|
||
.filters { align-self:start; padding:18px; }
|
||
.filters h2, .upload h2 { margin:0 0 15px; font-size:15px; }
|
||
.filter-label { display:block; margin:18px 0 8px; color:var(--sub); font-size:12px; font-weight:700; text-transform:uppercase; }
|
||
.filter-label:first-of-type { margin-top:0; }
|
||
.category-list { display:flex; flex-wrap:wrap; gap:7px; }
|
||
.chip { padding:7px 9px; border:1px solid var(--border); border-radius:7px; color:var(--sub); background:transparent; font-size:12px; }
|
||
.chip:hover { color:var(--text); background:var(--muted); }
|
||
.chip.active { border-color:var(--green); color:var(--green); background:var(--green-dark); }
|
||
.selected { width:100%; margin-top:14px; padding:9px; border:1px solid var(--border); border-radius:8px; color:var(--sub); background:transparent; text-align:left; font-size:13px; }
|
||
.selected.active { border-color:var(--green); color:var(--green); background:var(--green-dark); }
|
||
.catalog-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; color:var(--sub); font-size:13px; }
|
||
.services { display:grid; gap:12px; }
|
||
.service { padding:19px; transition:border-color .15s, transform .15s; }
|
||
.service:hover { border-color:rgba(34,197,94,.55); transform:translateY(-1px); }
|
||
.service-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; }
|
||
.service h2 { margin:10px 0 7px; font-size:18px; }
|
||
.tag { display:inline-block; padding:4px 8px; border-radius:5px; color:#86efac; background:rgba(34,197,94,.12); font-size:12px; font-weight:700; }
|
||
.service p { margin:0; color:var(--sub); font-size:14px; line-height:1.5; }
|
||
.service-foot { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:17px; padding-top:14px; border-top:1px solid var(--border); }
|
||
.author { color:var(--sub); font-size:12px; }
|
||
.choose { padding:8px 10px; border:1px solid var(--border); border-radius:7px; color:var(--text); background:var(--muted); font-size:13px; font-weight:700; }
|
||
.choose:hover { border-color:var(--green); color:var(--green); }
|
||
.choose.selected-service { border-color:var(--green); color:#052b12; background:var(--green); }
|
||
.empty { display:grid; min-height:300px; place-items:center; padding:25px; border:1px dashed var(--border); border-radius:12px; color:var(--sub); text-align:center; }
|
||
.message { min-height:23px; margin:0 0 10px; color:#86efac; font-size:13px; }
|
||
.message.error { color:var(--red); }
|
||
.modal { position:fixed; z-index:30; inset:0; display:grid; place-items:center; padding:18px; background:rgba(0,0,0,.64); }
|
||
.modal[hidden] { display:none; }
|
||
.upload { width:min(520px,100%); padding:22px; box-shadow:0 24px 80px rgba(0,0,0,.5); }
|
||
.modal-head { display:flex; align-items:center; justify-content:space-between; gap:15px; }
|
||
.close { width:31px; height:31px; border:0; border-radius:7px; color:var(--sub); background:transparent; font-size:21px; }
|
||
.close:hover { color:var(--text); background:var(--muted); }
|
||
form { display:grid; gap:11px; }
|
||
.field-label { color:var(--sub); font-size:12px; font-weight:700; }
|
||
input, textarea, select { width:100%; padding:10px; border:1px solid var(--border); border-radius:8px; outline:0; color:var(--text); background:var(--bg); }
|
||
input:focus, textarea:focus, select:focus { border-color:var(--green); }
|
||
textarea { min-height:90px; resize:vertical; }
|
||
.hint { color:var(--sub); font-size:12px; line-height:1.45; }
|
||
.form-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:5px; }
|
||
.secondary { padding:10px 14px; border:1px solid var(--border); border-radius:8px; color:var(--text); background:transparent; }
|
||
@media (max-width:800px) { .sidebar { position:static; width:auto; height:auto; } .nav, .nav-bottom { display:none; } .brand { height:56px; } .header { position:static; left:auto; height:auto; padding:12px 18px; } .header-right { display:none; } .main { margin-left:0; padding:30px 18px; } .layout { grid-template-columns:1fr; } .filters { display:block; } .page-top { align-items:stretch; flex-direction:column; } .primary { justify-content:center; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<aside class="sidebar">
|
||
<div class="brand"><span class="brand-mark">⬡</span>Врата ADA</div>
|
||
<nav class="nav">
|
||
<p class="nav-title">Навигация</p>
|
||
<button class="nav-item" type="button"><span class="nav-icon">▦</span>Обзор</button>
|
||
<button class="nav-item" type="button"><span class="nav-icon">⚔</span>Матчи</button>
|
||
<button class="nav-item" type="button"><span class="nav-icon">♜</span>Рейтинг</button>
|
||
<button class="nav-item active" type="button"><span class="nav-icon">□</span>Мастерская</button>
|
||
<button class="nav-item" type="button"><span class="nav-icon">♟</span>Команды</button>
|
||
<button class="nav-item" type="button"><span class="nav-icon">◈</span>Обучение</button>
|
||
<p class="nav-title" style="margin-top:26px">Система</p>
|
||
<button class="nav-item" type="button"><span class="nav-icon">◌</span>Аналитика</button>
|
||
<button class="nav-item" type="button"><span class="nav-icon">⌁</span>Логи</button>
|
||
</nav>
|
||
<div class="nav-bottom"><div class="user"><span class="avatar">A</span><span>ADa Player</span></div></div>
|
||
</aside>
|
||
<header class="header">
|
||
<div class="search-wrap"><span>⌕</span><input id="search" class="search" type="search" placeholder="Поиск сервисов..."></div>
|
||
<div class="header-right"><span>●</span><span>Workshop</span></div>
|
||
</header>
|
||
<main class="main">
|
||
<div class="page">
|
||
<div class="page-top">
|
||
<div><h1>Мастерская сервисов</h1><p class="subtitle">Находите и публикуйте сервисы для тренировок Attack-Defense.</p></div>
|
||
<button id="open-upload" class="primary" type="button">+ Загрузить сервис</button>
|
||
</div>
|
||
<div class="layout">
|
||
<aside class="filters">
|
||
<h2>Фильтры</h2>
|
||
<span class="filter-label">Категория</span>
|
||
<div id="categories" class="category-list"></div>
|
||
<button id="selected-filter" class="selected" type="button">◉ Выбранные для игры</button>
|
||
</aside>
|
||
<section>
|
||
<div id="message" class="message" aria-live="polite"></div>
|
||
<div class="catalog-head"><span id="counter">0 сервисов</span><span>Сначала новые</span></div>
|
||
<div id="services" class="services"></div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
<div id="upload-modal" class="modal" hidden>
|
||
<section class="upload" role="dialog" aria-modal="true" aria-labelledby="upload-title">
|
||
<div class="modal-head"><h2 id="upload-title">Опубликовать сервис</h2><button id="close-upload" class="close" type="button" aria-label="Закрыть">×</button></div>
|
||
<form id="upload-form">
|
||
<label class="field-label">Название<input name="name" maxlength="100" required placeholder="Например, Secure Notes"></label>
|
||
<label class="field-label">Автор<input name="author" maxlength="100" placeholder="Ваш ник"></label>
|
||
<label class="field-label">Категория<select name="category" id="upload-category"></select></label>
|
||
<label class="field-label">Описание<textarea name="description" maxlength="2000" placeholder="Что нужно знать о сервисе?"></textarea></label>
|
||
<label class="field-label">Архив сервиса<input name="file" type="file" accept=".zip,application/zip" required></label>
|
||
<span class="hint">ZIP до 5 МБ. Обязательны: checker/checker.py, service/docker-compose.yml и service/Dockerfile.api.</span>
|
||
<div class="form-actions"><button id="cancel-upload" class="secondary" type="button">Отмена</button><button class="primary" type="submit">Опубликовать</button></div>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
<script>
|
||
var state = { category: "", selected: false };
|
||
var categoriesNode = document.getElementById("categories");
|
||
var servicesNode = document.getElementById("services");
|
||
var messageNode = document.getElementById("message");
|
||
var searchNode = document.getElementById("search");
|
||
var modal = document.getElementById("upload-modal");
|
||
var searchTimer;
|
||
|
||
function showMessage(text, error) {
|
||
messageNode.textContent = text || "";
|
||
messageNode.className = error ? "message error" : "message";
|
||
}
|
||
function setModal(open) { modal.hidden = !open; }
|
||
async function api(response) {
|
||
var data = await response.json().catch(function () { return {}; });
|
||
if (!response.ok) throw new Error(data.detail || "Не удалось выполнить запрос");
|
||
return data;
|
||
}
|
||
function makeChip(label, value) {
|
||
var button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "chip" + (state.category === value ? " active" : "");
|
||
button.textContent = label;
|
||
button.onclick = function () { state.category = value; renderCategories(window.workshopCategories); loadServices(); };
|
||
return button;
|
||
}
|
||
function renderCategories(categories) {
|
||
window.workshopCategories = categories;
|
||
categoriesNode.replaceChildren(makeChip("Все", ""));
|
||
categories.forEach(function (category) { categoriesNode.append(makeChip(category, category)); });
|
||
var select = document.getElementById("upload-category");
|
||
select.replaceChildren();
|
||
categories.forEach(function (category) {
|
||
var option = document.createElement("option");
|
||
option.value = option.textContent = category;
|
||
select.append(option);
|
||
});
|
||
}
|
||
function renderServices(services) {
|
||
servicesNode.replaceChildren();
|
||
document.getElementById("counter").textContent = services.length + " сервисов";
|
||
if (!services.length) {
|
||
var empty = document.createElement("div");
|
||
empty.className = "empty";
|
||
empty.textContent = "Сервисов по этому фильтру пока нет.";
|
||
servicesNode.append(empty);
|
||
return;
|
||
}
|
||
services.forEach(function (service) {
|
||
var card = document.createElement("article");
|
||
card.className = "service";
|
||
var head = document.createElement("div");
|
||
head.className = "service-head";
|
||
var titleBlock = document.createElement("div");
|
||
var tag = document.createElement("span");
|
||
tag.className = "tag";
|
||
tag.textContent = service.category;
|
||
var title = document.createElement("h2");
|
||
title.textContent = service.name;
|
||
titleBlock.append(tag, title);
|
||
var description = document.createElement("p");
|
||
description.textContent = service.description || "Описание не добавлено.";
|
||
head.append(titleBlock);
|
||
var foot = document.createElement("div");
|
||
foot.className = "service-foot";
|
||
var author = document.createElement("span");
|
||
author.className = "author";
|
||
author.textContent = "Автор: " + service.author;
|
||
var choose = document.createElement("button");
|
||
choose.type = "button";
|
||
choose.className = "choose" + (service.selected ? " selected-service" : "");
|
||
choose.textContent = service.selected ? "✓ Выбран для игры" : "Выбрать для игры";
|
||
choose.onclick = function () { setSelection(service.id, !service.selected); };
|
||
foot.append(author, choose);
|
||
card.append(head, description, foot);
|
||
servicesNode.append(card);
|
||
});
|
||
}
|
||
async function loadServices() {
|
||
var params = new URLSearchParams();
|
||
if (state.category) params.set("category", state.category);
|
||
if (state.selected) params.set("selected", "true");
|
||
if (searchNode.value.trim()) params.set("q", searchNode.value.trim());
|
||
try { renderServices(await api(await fetch("/api/services?" + params))); }
|
||
catch (error) { showMessage(error.message, true); }
|
||
}
|
||
async function setSelection(id, selected) {
|
||
try {
|
||
await api(await fetch("/api/services/" + id + "/selection", {
|
||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ selected: selected })
|
||
}));
|
||
showMessage(selected ? "Сервис выбран для игры." : "Сервис убран из выбора.");
|
||
loadServices();
|
||
} catch (error) { showMessage(error.message, true); }
|
||
}
|
||
document.getElementById("selected-filter").onclick = function () {
|
||
state.selected = !state.selected;
|
||
this.classList.toggle("active", state.selected);
|
||
this.textContent = state.selected ? "✓ Показаны выбранные" : "◉ Выбранные для игры";
|
||
loadServices();
|
||
};
|
||
document.getElementById("open-upload").onclick = function () { setModal(true); };
|
||
document.getElementById("close-upload").onclick = function () { setModal(false); };
|
||
document.getElementById("cancel-upload").onclick = function () { setModal(false); };
|
||
modal.onclick = function (event) { if (event.target === modal) setModal(false); };
|
||
searchNode.oninput = function () { clearTimeout(searchTimer); searchTimer = setTimeout(loadServices, 250); };
|
||
document.getElementById("upload-form").onsubmit = async function (event) {
|
||
event.preventDefault();
|
||
var form = event.currentTarget;
|
||
try {
|
||
var service = await api(await fetch("/api/services", { method: "POST", body: new FormData(form) }));
|
||
form.reset();
|
||
setModal(false);
|
||
showMessage("Опубликован сервис: " + service.name);
|
||
loadServices();
|
||
} catch (error) { showMessage(error.message, true); }
|
||
};
|
||
async function start() {
|
||
try {
|
||
renderCategories(await api(await fetch("/api/categories")));
|
||
loadServices();
|
||
} catch (error) { showMessage(error.message, true); }
|
||
}
|
||
start();
|
||
</script>
|
||
</body>
|
||
</html>
|