Files
workshop/workshop.html
2026-08-13 00:01:54 +03:00

267 lines
18 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">
<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>