Add Workshop MVP

This commit is contained in:
for_rest
2026-08-13 00:01:54 +03:00
parent 71fbefad55
commit c089d5372a
6 changed files with 571 additions and 0 deletions

266
workshop.html Normal file
View File

@@ -0,0 +1,266 @@
<!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>