Add Workshop MVP
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Local secrets and configuration
|
||||||
|
.env
|
||||||
|
.evn
|
||||||
|
|
||||||
|
# Python virtual environments and cache
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Editor and OS files
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
58
database.py
Normal file
58
database.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, Integer, LargeBinary, String, Text, create_engine, text
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).with_name(".evn"), override=True)
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
if not DATABASE_URL:
|
||||||
|
raise RuntimeError("DATABASE_URL is not configured")
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Service(Base):
|
||||||
|
__tablename__ = "services"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
category: Mapped[str] = mapped_column(String(50))
|
||||||
|
author: Mapped[str] = mapped_column(String(100), default="anonymous")
|
||||||
|
selected: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
archive_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
files: Mapped[list["ServiceFile"]] = relationship(
|
||||||
|
back_populates="service", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceFile(Base):
|
||||||
|
__tablename__ = "service_files"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
service_id: Mapped[int] = mapped_column(ForeignKey("services.id"))
|
||||||
|
path: Mapped[str] = mapped_column(String(500))
|
||||||
|
content: Mapped[bytes] = mapped_column(LargeBinary)
|
||||||
|
service: Mapped[Service] = relationship(back_populates="files")
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
# Short MVP migration: existing hackathon databases may lack this new field.
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE services "
|
||||||
|
"ADD COLUMN IF NOT EXISTS selected BOOLEAN NOT NULL DEFAULT FALSE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE services ADD COLUMN IF NOT EXISTS archive_key VARCHAR(500)")
|
||||||
|
)
|
||||||
23
main.py
Normal file
23
main.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from database import init_db
|
||||||
|
from routers import router
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="ADa Workshop", lifespan=lifespan)
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=FileResponse)
|
||||||
|
def workshop():
|
||||||
|
return Path(__file__).with_name("workshop.html")
|
||||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
boto3==1.43.69
|
||||||
|
fastapi==0.141.1
|
||||||
|
psycopg[binary]==3.3.4
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
python-multipart==0.0.32
|
||||||
|
SQLAlchemy==2.0.52
|
||||||
|
uvicorn==0.52.1
|
||||||
204
routers.py
Normal file
204
routers.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from uuid import uuid4
|
||||||
|
from zipfile import BadZipFile, ZipFile
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
from botocore.exceptions import BotoCoreError, ClientError
|
||||||
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile, status
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from database import Service, engine
|
||||||
|
|
||||||
|
|
||||||
|
CATEGORIES = (
|
||||||
|
"Web",
|
||||||
|
"Binary",
|
||||||
|
"Network",
|
||||||
|
"Database",
|
||||||
|
"Authentication",
|
||||||
|
"Storage",
|
||||||
|
"Crypto",
|
||||||
|
"Other",
|
||||||
|
)
|
||||||
|
MAX_ARCHIVE_BYTES = 5_000_000
|
||||||
|
MAX_FILES = 512
|
||||||
|
MAX_UNPACKED_BYTES = 20_000_000
|
||||||
|
REQUIRED_ARCHIVE_FILES = {
|
||||||
|
"checker/checker.py",
|
||||||
|
"service/docker-compose.yml",
|
||||||
|
"service/Dockerfile.api",
|
||||||
|
}
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["workshop"])
|
||||||
|
s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
|
||||||
|
aws_access_key_id=os.getenv("S3_ACCESS_KEY"),
|
||||||
|
aws_secret_access_key=os.getenv("S3_SECRET_KEY"),
|
||||||
|
region_name=os.getenv("S3_REGION", "us-east-1"),
|
||||||
|
config=Config(
|
||||||
|
proxies={},
|
||||||
|
signature_version="s3v4",
|
||||||
|
s3={"addressing_style": "path"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_expect_header(request, **_):
|
||||||
|
request.headers.pop("Expect", None)
|
||||||
|
|
||||||
|
|
||||||
|
s3.meta.events.register("before-send.s3.PutObject", remove_expect_header)
|
||||||
|
S3_BUCKET = os.getenv("S3_BUCKET", "workshop")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
author: str
|
||||||
|
selected: bool
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectionIn(BaseModel):
|
||||||
|
selected: bool
|
||||||
|
|
||||||
|
|
||||||
|
def get_service(db: Session, service_id: int) -> Service:
|
||||||
|
service = db.get(Service, service_id)
|
||||||
|
if not service:
|
||||||
|
raise HTTPException(status_code=404, detail="Сервис не найден")
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def safe_archive_path(path: str) -> bool:
|
||||||
|
normalized = path.replace("\\", "/")
|
||||||
|
return bool(path) and not normalized.startswith("/") and ".." not in PurePosixPath(normalized).parts
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories", response_model=list[str])
|
||||||
|
def list_categories():
|
||||||
|
return CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services", response_model=list[ServiceOut])
|
||||||
|
def list_services(
|
||||||
|
category: str | None = None,
|
||||||
|
q: str | None = None,
|
||||||
|
selected: bool | None = None,
|
||||||
|
):
|
||||||
|
with Session(engine) as db:
|
||||||
|
query = db.query(Service)
|
||||||
|
if category:
|
||||||
|
query = query.filter(Service.category == category)
|
||||||
|
if q and (query_text := q.strip()):
|
||||||
|
pattern = "%" + query_text + "%"
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Service.name.ilike(pattern),
|
||||||
|
Service.description.ilike(pattern),
|
||||||
|
Service.author.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if selected is not None:
|
||||||
|
query = query.filter(Service.selected == selected)
|
||||||
|
return query.order_by(Service.id.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/services", response_model=ServiceOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_service(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
name: str = Form(..., max_length=100),
|
||||||
|
description: str = Form("", max_length=2000),
|
||||||
|
category: str = Form("Other", max_length=50),
|
||||||
|
author: str = Form("anonymous", max_length=100),
|
||||||
|
):
|
||||||
|
name, description, category, author = (
|
||||||
|
name.strip(),
|
||||||
|
description.strip(),
|
||||||
|
category.strip(),
|
||||||
|
author.strip() or "anonymous",
|
||||||
|
)
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=422, detail="Укажите название сервиса")
|
||||||
|
if category not in CATEGORIES:
|
||||||
|
raise HTTPException(status_code=422, detail="Выберите категорию из списка")
|
||||||
|
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||||||
|
raise HTTPException(status_code=415, detail="Нужен ZIP-архив сервиса")
|
||||||
|
|
||||||
|
archive_data = await file.read(MAX_ARCHIVE_BYTES + 1)
|
||||||
|
await file.close()
|
||||||
|
if len(archive_data) > MAX_ARCHIVE_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="ZIP больше 5 МБ")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with ZipFile(io.BytesIO(archive_data)) as archive:
|
||||||
|
entries = [entry for entry in archive.infolist() if not entry.is_dir()]
|
||||||
|
if not entries:
|
||||||
|
raise HTTPException(status_code=422, detail="В ZIP нет файлов")
|
||||||
|
if len(entries) > MAX_FILES:
|
||||||
|
raise HTTPException(status_code=413, detail="В ZIP больше 512 файлов")
|
||||||
|
if sum(entry.file_size for entry in entries) > MAX_UNPACKED_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Распакованный сервис больше 20 МБ")
|
||||||
|
if any(not safe_archive_path(entry.filename) for entry in entries):
|
||||||
|
raise HTTPException(status_code=422, detail="ZIP содержит небезопасный путь")
|
||||||
|
|
||||||
|
paths = {entry.filename.replace("\\", "/") for entry in entries}
|
||||||
|
missing = REQUIRED_ARCHIVE_FILES - paths
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="В ZIP нет обязательных файлов: " + ", ".join(sorted(missing)),
|
||||||
|
)
|
||||||
|
if any(not path.startswith(("checker/", "service/")) for path in paths):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Файлы ZIP должны лежать только в checker/ или service/",
|
||||||
|
)
|
||||||
|
|
||||||
|
archive_key = f"services/{uuid4()}/service.zip"
|
||||||
|
try:
|
||||||
|
await run_in_threadpool(
|
||||||
|
s3.put_object,
|
||||||
|
Bucket=S3_BUCKET,
|
||||||
|
Key=archive_key,
|
||||||
|
Body=archive_data,
|
||||||
|
ContentType="application/zip",
|
||||||
|
)
|
||||||
|
except (BotoCoreError, ClientError) as error:
|
||||||
|
print(f"S3 upload error: {error}")
|
||||||
|
raise HTTPException(status_code=503, detail="S3-хранилище недоступно")
|
||||||
|
|
||||||
|
with Session(engine) as db:
|
||||||
|
service = Service(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
category=category,
|
||||||
|
author=author,
|
||||||
|
archive_key=archive_key,
|
||||||
|
)
|
||||||
|
db.add(service)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(service)
|
||||||
|
return service
|
||||||
|
except BadZipFile:
|
||||||
|
raise HTTPException(status_code=422, detail="Файл не является корректным ZIP-архивом")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/services/{service_id}/selection", response_model=ServiceOut)
|
||||||
|
def update_selection(service_id: int, payload: SelectionIn):
|
||||||
|
with Session(engine) as db:
|
||||||
|
service = get_service(db, service_id)
|
||||||
|
service.selected = payload.selected
|
||||||
|
db.commit()
|
||||||
|
db.refresh(service)
|
||||||
|
return service
|
||||||
266
workshop.html
Normal file
266
workshop.html
Normal 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>
|
||||||
Reference in New Issue
Block a user