add a lot of.... realy a lot of

This commit is contained in:
Ivan Budakov 2026-10-21 02:47:54 +03:00
parent c356166844
commit 4fac39eda5
19 changed files with 4123 additions and 456 deletions

View File

@ -9,7 +9,6 @@ POSTGRES_DB = os.getenv("POSTGRES_DB", "morozovka_portal")
POSTGRES_HOST = os.getenv("POSTGRES_HOST", "127.0.0.1") POSTGRES_HOST = os.getenv("POSTGRES_HOST", "127.0.0.1")
POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
# Добавляем psycopg2 в URL
DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000") GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000")
@ -17,12 +16,18 @@ GITEA_PUBLIC_URL = os.getenv("GITEA_PUBLIC_URL", "https://git.morozovka.space")
GITEA_CLIENT_ID = "66697efe-d88b-41fe-b3d5-6498d978e1fa" GITEA_CLIENT_ID = "66697efe-d88b-41fe-b3d5-6498d978e1fa"
GITEA_CLIENT_SECRET = "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a" GITEA_CLIENT_SECRET = "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a"
# Personal Access Token для Gitea API (для чтения приватных репо).
# Создать: https://git.morozovka.space → Settings → Applications → Generate New Token
# Scopes: read:repository, read:user
# Пусто — работает только для публичных репозиториев.
GITEA_API_TOKEN = os.getenv("GITEA_API_TOKEN", "")
WEB_SECRET = os.getenv("WEB_SECRET", "super-secret-morozovka-key-2026") WEB_SECRET = os.getenv("WEB_SECRET", "super-secret-morozovka-key-2026")
# Настройки SMTP (если пусто, письма будут падать в консоль)
SMTP_SERVER = os.getenv("SMTP_SERVER", "") SMTP_SERVER = os.getenv("SMTP_SERVER", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "") SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@morozovka.space") SMTP_FROM = os.getenv("SMTP_FROM", "noreply@morozovka.space")
GITEA_REDIRECT_URI = os.getenv("GITEA_REDIRECT_URI", "https://portal.morozovka.space/auth/callback") GITEA_REDIRECT_URI = os.getenv("GITEA_REDIRECT_URI", "https://portal.morozovka.space/auth/callback")
LLM_ENABLED = os.getenv("LLM_ENABLED", "false").lower() == "true"

156
app/gitea_api.py Normal file
View File

@ -0,0 +1,156 @@
"""Gitea API client с простым in-memory кэшем (5 минут)."""
import re
import time
import requests
from . import config
_cache = {}
CACHE_TTL = 300 # 5 минут
def _headers():
if config.GITEA_API_TOKEN:
return {"Authorization": f"token {config.GITEA_API_TOKEN}"}
return {}
def _cache_get(key):
entry = _cache.get(key)
if not entry:
return None
ts, data = entry
if time.time() - ts > CACHE_TTL:
return None
return data
def _cache_set(key, data):
_cache[key] = (time.time(), data)
def _api_get(path, params=None):
"""Возвращает (data, total) или (None, None) при ошибке."""
url = f"{config.GITEA_URL}/api/v1{path}"
try:
r = requests.get(url, headers=_headers(), params=params, timeout=5)
if r.status_code == 200:
total_header = r.headers.get("X-Total-Count")
total = int(total_header) if total_header and total_header.isdigit() else None
try:
data = r.json()
except Exception:
data = None
return data, total
return None, None
except Exception as e:
print(f"[gitea_api] {path}: {e}", flush=True)
return None, None
def extract_repo_from_url(url):
"""https://git.morozovka.space/owner/repo(.git)? → owner/repo"""
if not url:
return None
m = re.search(r"[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", url.strip())
if m:
return f"{m.group(1)}/{m.group(2)}"
return None
def get_repo_info(owner, repo):
key = f"repo:{owner}/{repo}"
cached = _cache_get(key)
if cached is not None:
return cached
data, _ = _api_get(f"/repos/{owner}/{repo}")
_cache_set(key, data)
return data
def get_latest_commits(owner, repo, limit=5):
key = f"commits:{owner}/{repo}:{limit}"
cached = _cache_get(key)
if cached is not None:
return cached
data, _ = _api_get(
f"/repos/{owner}/{repo}/commits",
params={"limit": limit, "page": 1, "stat": "false"},
)
result = []
if isinstance(data, list):
for c in data[:limit]:
commit_obj = c.get("commit") or {}
author_obj = commit_obj.get("author") or {}
result.append({
"sha_short": (c.get("sha") or "")[:7],
"message": (commit_obj.get("message") or "").split("\n")[0][:120],
"author": author_obj.get("name") or "",
"date": author_obj.get("date") or "",
"html_url": c.get("html_url") or "",
})
_cache_set(key, result)
return result
def get_open_issues_count(owner, repo):
key = f"issues:{owner}/{repo}"
cached = _cache_get(key)
if cached is not None:
return cached
_, total = _api_get(
f"/repos/{owner}/{repo}/issues",
params={"state": "open", "type": "issues", "limit": 1},
)
_cache_set(key, total or 0)
return total or 0
def get_open_prs_count(owner, repo):
key = f"prs:{owner}/{repo}"
cached = _cache_get(key)
if cached is not None:
return cached
_, total = _api_get(
f"/repos/{owner}/{repo}/pulls",
params={"state": "open", "limit": 1},
)
_cache_set(key, total or 0)
return total or 0
def get_project_stats(gitea_repo):
"""gitea_repo: 'owner/repo'. Возвращает dict для JSON-ответа."""
if not gitea_repo or "/" not in gitea_repo:
return {"ok": False, "error": "no_repo",
"message": "Не указан репозиторий проекта."}
owner, repo = gitea_repo.split("/", 1)
info = get_repo_info(owner, repo)
if info is None:
# 404 или 401 — не смогли прочитать
if not config.GITEA_API_TOKEN:
return {"ok": False, "error": "auth_required",
"message": "Репозиторий приватный или не найден. "
"Настройте GITEA_API_TOKEN в config.py."}
return {"ok": False, "error": "api_unreachable",
"message": "Не удалось получить данные из Gitea."}
commits = get_latest_commits(owner, repo, limit=5)
open_issues = get_open_issues_count(owner, repo)
open_prs = get_open_prs_count(owner, repo)
return {
"ok": True,
"repo": {
"full_name": info.get("full_name", gitea_repo),
"description": info.get("description") or "",
"html_url": info.get("html_url", ""),
"default_branch": info.get("default_branch") or "main",
"stars": info.get("stars_count", 0),
"forks": info.get("forks_count", 0),
"is_private": bool(info.get("private")),
},
"commits": commits,
"open_issues": open_issues,
"open_prs": open_prs,
}

243
app/llm_assistant.py Normal file
View File

@ -0,0 +1,243 @@
"""LLM-ассистент расписания с tool calling (Ollama или любой OpenAI-compatible)."""
import requests
import pytz
from datetime import datetime, timezone, timedelta
from sqlmodel import Session, select
from . import config
from .db import engine
from .models import Event, GlobalStatus
MSK = pytz.timezone("Europe/Moscow")
def _msk(dt):
if dt is None: return None
if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(MSK)
def is_enabled():
if not config.LLM_ENABLED:
return False
if not config.LLM_API_BASE:
return False
return True
# ─────────────────────────────────────────────────────────────────────
# Реализации инструментов
# ─────────────────────────────────────────────────────────────────────
def _tool_get_current_status():
with Session(engine) as s:
status = s.exec(select(GlobalStatus)).first()
if not status:
return "Статус не задан."
if status.status == "on_place":
base = "Никита Сергеевич сейчас на месте."
elif status.status == "delayed":
base = f"Никита Сергеевич опаздывает на {status.delay_minutes} мин."
elif status.status == "away":
base = "Никита Сергеевич вне вуза."
elif status.status == "cancelled":
base = "Занятия отменены."
else:
base = "Никита Сергеевич свободен / вне расписания."
parts = [base]
if status.message:
parts.append(f"Сообщение: {status.message}")
if status.updated_at:
parts.append(f"Обновлено: {_msk(status.updated_at).strftime('%d.%m.%Y %H:%M')}")
return " ".join(parts)
def _tool_get_events_today():
start = datetime.now(MSK).replace(hour=0, minute=0, second=0, microsecond=0)
start_utc = start.astimezone(timezone.utc)
end_utc = (start + timedelta(days=1)).astimezone(timezone.utc)
with Session(engine) as s:
events = s.exec(
select(Event).where(Event.is_public == True)
.where(Event.start_time >= start_utc)
.where(Event.start_time < end_utc)
.order_by(Event.start_time)
).all()
if not events:
return "На сегодня событий в расписании нет."
lines = ["Расписание на сегодня:"]
for e in events:
t = _msk(e.start_time).strftime("%H:%M")
loc = f" ({e.location})" if e.location else ""
lines.append(f"- {t} — {e.title}{loc}")
return "\n".join(lines)
def _tool_get_next_event():
now = datetime.now(timezone.utc)
with Session(engine) as s:
ev = s.exec(
select(Event).where(Event.is_public == True)
.where(Event.start_time >= now)
.order_by(Event.start_time)
.limit(1)
).first()
if not ev:
return "В ближайшее время событий в расписании нет."
dt = _msk(ev.start_time).strftime("%d.%m.%Y в %H:%M")
loc = f", место: {ev.location}" if ev.location else ""
return f"Ближайшее событие: {ev.title} — {dt}{loc}."
def _tool_get_events_week():
now = datetime.now(timezone.utc)
end = now + timedelta(days=7)
with Session(engine) as s:
events = s.exec(
select(Event).where(Event.is_public == True)
.where(Event.start_time >= now)
.where(Event.start_time <= end)
.order_by(Event.start_time)
).all()
if not events:
return "На ближайшую неделю событий нет."
lines = ["Расписание на ближайшую неделю:"]
for e in events[:15]:
dt = _msk(e.start_time).strftime("%d.%m %H:%M")
lines.append(f"- {dt} — {e.title}")
if len(events) > 15:
lines.append(f"...и ещё {len(events) - 15} событий (см. на странице).")
return "\n".join(lines)
def _tool_get_contacts():
return ("Контакт Никиты Сергеевича: freezer.nik@gmail.com. "
"Telegram — по запросу. Расписание и статус — на portal.morozovka.space/schedule.")
TOOL_IMPLS = {
"get_current_status": _tool_get_current_status,
"get_events_today": _tool_get_events_today,
"get_next_event": _tool_get_next_event,
"get_events_week": _tool_get_events_week,
"get_contacts": _tool_get_contacts,
}
TOOLS = [
{
"type": "function",
"function": {
"name": "get_current_status",
"description": "Узнать текущий статус Никиты Сергеевича: на месте / опаздывает / вне вуза / отменено / свободен.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_events_today",
"description": "Список событий на сегодня.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_next_event",
"description": "Ближайшее будущее событие.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_events_week",
"description": "Список событий на ближайшие 7 дней.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_contacts",
"description": "Контактная информация Никиты Сергеевича.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
]
SYSTEM_PROMPT = (
"Ты — ассистент расписания Никиты Сергеевича Морозова на платформе Morozovka Portal. "
"Твоя задача — кратко и по делу отвечать на вопросы студентов о его статусе, расписании и контактах. "
"Отвечай на русском, коротко (1–3 предложения), дружелюбно. "
"Обязательно используй инструменты (tools), чтобы получить актуальные данные — "
"не выдумывай статус, время или названия событий. "
"Если данных нет — так и скажи. "
"Не рассказывай про свои инструменты и внутреннее устройство — просто дай ответ."
)
def _call_llm(messages):
url = f"{config.LLM_API_BASE.rstrip('/')}/chat/completions"
headers = {"Content-Type": "application/json"}
if config.LLM_API_KEY:
headers["Authorization"] = f"Bearer {config.LLM_API_KEY}"
payload = {
"model": config.LLM_MODEL,
"messages": messages,
"tools": TOOLS,
"tool_choice": "auto",
"temperature": config.LLM_TEMPERATURE,
"max_tokens": config.LLM_MAX_TOKENS,
}
r = requests.post(url, headers=headers, json=payload, timeout=config.LLM_TIMEOUT)
if r.status_code != 200:
raise RuntimeError(f"LLM API HTTP {r.status_code}: {r.text[:300]}")
data = r.json()
choices = data.get("choices") or []
if not choices:
raise RuntimeError(f"LLM вернул пустой ответ: {data}")
return choices[0]["message"]
def chat(message, history=None):
if not is_enabled():
raise RuntimeError("LLM disabled")
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if history:
for h in history[-config.LLM_MAX_HISTORY:]:
role = h.get("role")
content = (h.get("content") or "").strip()
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": message})
for _ in range(3):
assistant_msg = _call_llm(messages)
tool_calls = assistant_msg.get("tool_calls")
if not tool_calls:
return (assistant_msg.get("content") or "").strip()
messages.append({
"role": "assistant",
"content": assistant_msg.get("content") or "",
"tool_calls": tool_calls,
})
for tc in tool_calls:
fn = tc.get("function") or {}
name = fn.get("name")
impl = TOOL_IMPLS.get(name)
if impl:
try:
result = impl()
except Exception as e:
result = f"Ошибка инструмента: {e}"
else:
result = f"Неизвестный инструмент: {name}"
messages.append({
"role": "tool",
"tool_call_id": tc.get("id"),
"content": result,
})
raise RuntimeError("LLM не вернул финальный ответ после tool calling")

File diff suppressed because it is too large Load Diff

View File

@ -48,24 +48,126 @@ class Project(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
title: str title: str
description: Optional[str] = None description: Optional[str] = None
status: str = Field(default="idea") # idea, in_progress, completed, frozen status: str = Field(default="idea")
visibility: str = Field(default="public") # public, internal, private visibility: str = Field(default="public")
gitea_url: Optional[str] = None gitea_url: Optional[str] = None
gitea_repo: Optional[str] = None
owner_id: int = Field(foreign_key="user.id") owner_id: int = Field(foreign_key="user.id")
tags: Optional[str] = None
start_date: Optional[str] = None
end_date: Optional[str] = None
contact_person: Optional[str] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# --- НОВАЯ МОДЕЛЬ ---
class ProjectMember(SQLModel, table=True): class ProjectMember(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
project_id: int = Field(foreign_key="project.id") project_id: int = Field(foreign_key="project.id")
user_id: int = Field(foreign_key="user.id") user_id: int = Field(foreign_key="user.id")
role: str = Field(default="member") # lead, member role: str = Field(default="viewer")
joined_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) joined_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class ProjectTask(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
project_id: int = Field(foreign_key="project.id")
title: str
description: Optional[str] = None
status: str = Field(default="backlog")
priority: str = Field(default="medium")
assignee_id: Optional[int] = Field(default=None, foreign_key="user.id")
created_by: int = Field(foreign_key="user.id")
due_date: Optional[str] = None
position: int = Field(default=0)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class PortalFile(SQLModel, table=True): class PortalFile(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
original_name: str original_name: str
stored_name: str stored_name: str
size_bytes: int size_bytes: int
uploaded_by: int = Field(foreign_key="user.id") uploaded_by: int = Field(foreign_key="user.id")
project_id: Optional[int] = Field(default=None, foreign_key="project.id")
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class EventSubscriber(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(index=True)
user_id: Optional[int] = Field(default=None, foreign_key="user.id")
unsubscribe_token: str = Field(unique=True, index=True)
is_confirmed: bool = Field(default=False)
confirm_token: Optional[str] = Field(default=None, unique=True, index=True)
notify_status: bool = Field(default=True)
notify_events: bool = Field(default=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# ══════════════════════════════════════════════════════════════════════
# МАСТЕР-КЛАССЫ
# ══════════════════════════════════════════════════════════════════════
class Workshop(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
title: str
description: Optional[str] = None
program: Optional[str] = None
lead_id: int = Field(foreign_key="user.id")
category: str = Field(default="other")
difficulty: str = Field(default="beginner") # beginner, intermediate, advanced
partner: Optional[str] = None # ННГУ, Неймарк, Ядро, ...
status: str = Field(default="draft") # draft, published, archived
visibility: str = Field(default="public") # public, internal
location: Optional[str] = None
materials_required: Optional[str] = None
outcome: Optional[str] = None # что получит участник
has_certificate: bool = Field(default=False)
max_participants: Optional[int] = None
cover_url: Optional[str] = None
tags: Optional[str] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class WorkshopSession(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
workshop_id: int = Field(foreign_key="workshop.id")
start_time: datetime
end_time: datetime
location_override: Optional[str] = None
note: Optional[str] = None
class WorkshopRegistration(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
workshop_id: int = Field(foreign_key="workshop.id")
user_id: int = Field(foreign_key="user.id")
status: str = Field(default="registered")
notes: Optional[str] = None
registered_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class WorkshopMaterial(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
workshop_id: int = Field(foreign_key="workshop.id")
title: str
description: Optional[str] = None
file_id: Optional[int] = Field(default=None, foreign_key="portalfile.id")
external_url: Optional[str] = None
visible_to: str = Field(default="registered")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class WorkshopFeedback(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
workshop_id: int = Field(foreign_key="workshop.id")
user_id: int = Field(foreign_key="user.id")
rating: int = Field(default=5)
comment: Optional[str] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# ══════════════════════════════════════════════════════════════════════
# УВЕДОМЛЕНИЯ
# ══════════════════════════════════════════════════════════════════════
class Notification(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
title: str
body: Optional[str] = None
link_url: Optional[str] = None
kind: str = Field(default="info") # info, success, warning, error
is_read: bool = Field(default=False, index=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

131
app/static/notif_bell.js Normal file
View File

@ -0,0 +1,131 @@
(function() {
'use strict';
if (window.__notifBellLoaded) return;
window.__notifBellLoaded = true;
const style = document.createElement('style');
style.textContent = `
.notif-bell { position: fixed; top: 12px; right: 32px; z-index: 1000; }
.notif-bell-btn { position: relative; background: #21262d; border: 1px solid #30363d; border-radius: 6px; width: 32px; height: 32px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #c9d1d9; padding: 0; }
.notif-bell-btn:hover { background: #30363d; }
.notif-bell-badge { position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; padding: 0 5px; background: #f85149; color: #fff; border-radius: 10px; font-size: 11px; font-weight: 600; display: none; align-items: center; justify-content: center; }
.notif-bell-badge.visible { display: inline-flex; }
.notif-dropdown { position: absolute; top: 40px; right: 0; width: 360px; max-height: 500px; overflow-y: auto; background: #161b22; border: 1px solid #30363d; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); display: none; }
.notif-dropdown.open { display: block; }
.notif-header { padding: 12px 16px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; }
.notif-header h4 { margin: 0; font-size: 14px; font-weight: 600; color: #e6edf3; }
.notif-mark-all { color: #58a6ff; background: none; border: none; cursor: pointer; font-size: 12px; padding: 0; }
.notif-mark-all:hover { text-decoration: underline; }
.notif-list { padding: 0; }
.notif-item { display: block; padding: 12px 16px 12px 22px; border-bottom: 1px solid #21262d; color: inherit; cursor: pointer; text-decoration: none; transition: background 0.15s; position: relative; }
.notif-item:hover { background: #1c2128; text-decoration: none; }
.notif-item.unread { background: rgba(88,166,255,0.05); }
.notif-item.unread::before { content: ''; position: absolute; left: 8px; top: 18px; width: 6px; height: 6px; background: #58a6ff; border-radius: 50%; }
.notif-item-title { color: #e6edf3; font-weight: 500; font-size: 13px; margin-bottom: 2px; }
.notif-item-body { color: #8b949e; font-size: 12px; line-height: 1.4; margin-bottom: 4px; }
.notif-item-time { color: #6e7681; font-size: 11px; }
.notif-item.kind-success .notif-item-title { color: #3fb950; }
.notif-item.kind-warning .notif-item-title { color: #d29922; }
.notif-item.kind-error .notif-item-title { color: #f85149; }
.notif-empty { padding: 32px 16px; text-align: center; color: #8b949e; font-size: 13px; }
.notif-footer { padding: 8px 16px; border-top: 1px solid #30363d; text-align: center; }
.notif-footer a { color: #58a6ff; font-size: 12px; }
@media (max-width: 768px) {
.notif-bell { top: 8px; right: 16px; }
.notif-dropdown { width: calc(100vw - 32px); right: -8px; }
}`;
document.head.appendChild(style);
const container = document.createElement('div');
container.className = 'notif-bell';
container.innerHTML = `
<button class="notif-bell-btn" id="notif-bell-btn" title="Уведомления" aria-label="Уведомления">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16ZM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.516 1.516 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947Zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.017.017 0 0 0-.003.01c0 .01.006.014.015.014h11.972a.017.017 0 0 0 .015-.014.017.017 0 0 0-.003-.01l-1.703-2.556a1.745 1.745 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5Z"/></svg>
<span class="notif-bell-badge" id="notif-bell-badge"></span>
</button>
<div class="notif-dropdown" id="notif-dropdown">
<div class="notif-header">
<h4>Уведомления</h4>
<button class="notif-mark-all" id="notif-mark-all">Прочитать все</button>
</div>
<div class="notif-list" id="notif-list">
<div class="notif-empty">Загрузка...</div>
</div>
<div class="notif-footer"><a href="/notifications">Все уведомления →</a></div>
</div>`;
document.body.appendChild(container);
const btn = document.getElementById('notif-bell-btn');
const badge = document.getElementById('notif-bell-badge');
const dropdown = document.getElementById('notif-dropdown');
const list = document.getElementById('notif-list');
const markAll = document.getElementById('notif-mark-all');
function fmtTime(iso) {
const d = new Date(iso);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return 'только что';
if (diff < 3600) return Math.floor(diff/60) + ' мин назад';
if (diff < 86400) return Math.floor(diff/3600) + ' ч назад';
return d.toLocaleDateString('ru-RU');
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function render(data) {
badge.textContent = data.unread;
badge.classList.toggle('visible', data.unread > 0);
if (!data.items || data.items.length === 0) {
list.innerHTML = '<div class="notif-empty">Нет уведомлений</div>';
return;
}
list.innerHTML = data.items.map(n => `
<a class="notif-item kind-${n.kind}${n.is_read ? '' : ' unread'}" data-id="${n.id}" data-link="${n.link_url || ''}" href="${n.link_url || '#'}">
<div class="notif-item-title">${escapeHtml(n.title)}</div>
${n.body ? `<div class="notif-item-body">${escapeHtml(n.body)}</div>` : ''}
<div class="notif-item-time">${fmtTime(n.created_at)}</div>
</a>
`).join('');
list.querySelectorAll('.notif-item').forEach(el => {
el.addEventListener('click', async (e) => {
const id = el.dataset.id;
const link = el.dataset.link;
e.preventDefault();
try { await fetch(`/api/notifications/${id}/read`, { method: 'POST' }); } catch (err) {}
if (link) window.location.href = link;
else { el.classList.remove('unread'); refresh(); }
});
});
}
async function refresh() {
try {
const r = await fetch('/api/notifications', { credentials: 'same-origin' });
if (!r.ok) return;
const data = await r.json();
render(data);
} catch (e) {}
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('open');
if (dropdown.classList.contains('open')) refresh();
});
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) dropdown.classList.remove('open');
});
markAll.addEventListener('click', async (e) => {
e.stopPropagation();
try { await fetch('/api/notifications/read-all', { method: 'POST' }); } catch (err) {}
refresh();
});
refresh();
setInterval(refresh, 30000);
})();

View File

@ -1,52 +1,194 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Morozovka Portal</title> <title>Morozovka Platform</title>
<style> <style>
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; flex-direction: column; } * { box-sizing: border-box; }
h1 { font-size: 2.5rem; margin-bottom: 10px; background: linear-gradient(90deg, #58a6ff, #a371f7, #f778ba); -webkit-background-clip: text; background-clip: text; color: transparent; } body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; font-size: 14px; line-height: 1.5; min-height: 100vh; display: flex; flex-direction: column; }
p { color: #8b949e; margin-bottom: 20px; font-size: 1.1rem; } a { color: #58a6ff; text-decoration: none; }
.badge { background: #238636; color: white; padding: 5px 10px; border-radius: 5px; font-size: 0.8rem; margin-top: 30px; font-weight: bold; } a:hover { text-decoration: underline; }
.actions { display: flex; gap: 15px; margin-top: 20px; flex-wrap: wrap; justify-content: center; max-width: 800px; } .topnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 32px; display: flex; align-items: center; gap: 24px; position: sticky; top: 0; z-index: 100; }
.btn { background: #238636; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; transition: opacity 0.2s; } .topnav-logo { display: flex; align-items: center; gap: 8px; color: #e6edf3; font-weight: 600; font-size: 15px; }
.btn:hover { opacity: 0.8; } .topnav-logo:hover { text-decoration: none; color: #e6edf3; }
.btn-profile { background: #1f6feb; } .topnav-logo svg { color: #58a6ff; }
.btn-admin { background: #da3633; } .topnav-links { display: flex; gap: 20px; flex: 1; }
.btn-schedule { background: #d29922; } .topnav-links a { color: #c9d1d9; font-weight: 500; font-size: 14px; }
.btn-projects { background: #a371f7; } .topnav-links a:hover { color: #58a6ff; text-decoration: none; }
.btn-files { background: #2ea043; } .topnav-actions { display: flex; gap: 12px; align-items: center; padding-right: 80px; }
</style> .btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; line-height: 20px; border: 1px solid rgba(240,246,252,0.1); cursor: pointer; text-decoration: none; transition: all .15s; white-space: nowrap; }
.btn:hover { text-decoration: none; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; color: #fff; }
.btn-default { background: #21262d; color: #c9d1d9; }
.btn-default:hover { background: #30363d; color: #c9d1d9; }
.btn-outline { background: transparent; color: #c9d1d9; border-color: #30363d; }
.btn-outline:hover { background: #21262d; color: #e6edf3; border-color: #8b949e; }
.btn-lg { padding: 10px 24px; font-size: 15px; }
.container { max-width: 1200px; margin: 0 auto; padding: 32px; flex: 1; width: 100%; }
.hero { display: grid; grid-template-columns: 2fr 1fr; gap: 32px; margin-bottom: 32px; }
.hero-main { padding: 32px; background: linear-gradient(135deg, #161b22 0%, #1c2128 100%); border: 1px solid #30363d; border-radius: 6px; position: relative; overflow: hidden; }
.hero-main::before { content: ''; position: absolute; top: -50%; right: -20%; width: 400px; height: 400px; background: radial-gradient(circle, rgba(88,166,255,0.08) 0%, transparent 70%); pointer-events: none; }
.hero-title { font-size: 32px; font-weight: 700; margin: 0 0 12px; background: linear-gradient(90deg, #58a6ff, #a371f7); -webkit-background-clip: text; background-clip: text; color: transparent; line-height: 1.2; }
.hero-subtitle { color: #8b949e; font-size: 16px; margin: 0 0 24px; line-height: 1.6; max-width: 500px; }
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.hero-side { display: flex; flex-direction: column; gap: 16px; }
.status-card { padding: 20px; border: 1px solid #30363d; border-radius: 6px; background: #161b22; }
.status-label { color: #8b949e; font-size: 12px; text-transform: uppercase; font-weight: 600; margin-bottom: 8px; }
.status-value { font-size: 18px; font-weight: 600; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.status-dot-green { background: #3fb950; box-shadow: 0 0 0 3px rgba(63,185,80,0.2); }
.status-dot-yellow { background: #d29922; box-shadow: 0 0 0 3px rgba(210,153,34,0.2); }
.status-dot-red { background: #f85149; box-shadow: 0 0 0 3px rgba(248,81,73,0.2); }
.status-dot-grey { background: #8b949e; box-shadow: 0 0 0 3px rgba(139,148,158,0.2); }
.status-meta { color: #8b949e; font-size: 13px; }
.status-link { display: inline-flex; align-items: center; gap: 4px; margin-top: 12px; font-size: 13px; font-weight: 500; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; margin-top: 32px; }
.section-title { font-size: 18px; font-weight: 600; color: #e6edf3; margin: 0; }
.cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.feature-card { padding: 20px; border: 1px solid #30363d; border-radius: 6px; background: #161b22; transition: all .15s; display: flex; flex-direction: column; text-decoration: none; color: inherit; }
.feature-card:hover { border-color: #58a6ff; transform: translateY(-2px); text-decoration: none; }
.feature-icon { font-size: 32px; margin-bottom: 12px; }
.feature-title { font-size: 16px; font-weight: 600; color: #e6edf3; margin: 0 0 6px; }
.feature-desc { color: #8b949e; font-size: 13px; line-height: 1.5; margin: 0; flex: 1; }
.feature-badge { display: inline-block; margin-top: 12px; padding: 0 8px; border-radius: 2em; font-size: 11px; font-weight: 500; line-height: 18px; border: 1px solid; }
.feature-badge-new { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.footer { border-top: 1px solid #30363d; padding: 24px 32px; text-align: center; color: #8b949e; font-size: 13px; }
.footer a { color: #8b949e; }
.footer a:hover { color: #58a6ff; }
@media (max-width: 900px) { .hero { grid-template-columns: 1fr; } .hero-side { flex-direction: row; flex-wrap: wrap; } .hero-side > * { flex: 1; min-width: 200px; } }
@media (max-width: 768px) { .topnav { padding: 12px 16px; gap: 12px; } .topnav-links { display: none; } .topnav-actions { padding-right: 60px; } .container { padding: 16px; } .hero-main { padding: 20px; } .hero-title { font-size: 24px; } .section-header { flex-direction: column; align-items: flex-start; gap: 8px; } }
</style>
</head> </head>
<body> <body>
<h1>Morozovka Portal</h1> <nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
</a>
<div class="topnav-links">
<a href="/schedule">Расписание</a>
<a href="/projects">Проекты</a>
<a href="/workshops">Мастер-классы</a>
<a href="/files">Файлы</a>
</div>
<div class="topnav-actions">
{% if username %} {% if username %}
<p>Привет, <b>{{ username }}</b>!</p> <a href="/profile" class="btn btn-default">{{ username }}</a>
<div class="actions"> {% if session.get('role') == 'admin' %}<a href="/admin/users" class="btn btn-outline">Админ</a>{% endif %}
<a href="/profile" class="btn btn-profile">Мой профиль</a> <a href="/logout" class="btn btn-outline">Выйти</a>
<a href="/schedule" class="btn btn-schedule">Расписание</a>
<a href="/projects" class="btn btn-projects">Проекты</a>
<a href="/files" class="btn btn-files">Файлы</a>
{% if session.get('role') in ['admin', 'professor'] %}
<a href="/admin/schedule" class="btn btn-schedule">Управление расписанием</a>
{% endif %}
{% if session.get('role') == 'admin' %}
<a href="/admin/users" class="btn btn-admin">Админ-панель</a>
{% endif %}
</div>
{% else %} {% else %}
<p>Платформа для команды и проектов</p> <a href="/login" class="btn btn-default">Войти</a>
<div class="actions"> <a href="/register" class="btn btn-primary">Регистрация</a>
<a href="/login" class="btn">Войти / Регистрация</a>
<a href="/schedule" class="btn btn-schedule">Расписание</a>
<a href="/projects" class="btn btn-projects">Проекты</a>
</div>
{% endif %} {% endif %}
</div>
</nav>
<div class="badge">Stage 1 (Identity): Complete</div> <div class="container">
<div class="hero">
<div class="hero-main">
{% if username %}
<h1 class="hero-title">Привет, {{ username }}!</h1>
<p class="hero-subtitle">Добро пожаловать на платформу команды Морозовки. Расписание, проекты, мастер-классы и файлы — всё в одном месте.</p>
{% else %}
<h1 class="hero-title">Morozovka Platform</h1>
<p class="hero-subtitle">Единая платформа команды: расписание, проекты, мастер-классы и файлы. Войдите, чтобы получить полный доступ.</p>
{% endif %}
<div class="hero-actions">
<a href="/schedule" class="btn btn-primary btn-lg">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M3.75 1.5a.75.75 0 0 1 .75.75V3h7V2.25a.75.75 0 0 1 1.5 0V3h.5A1.75 1.75 0 0 1 15 4.75v9A1.75 1.75 0 0 1 13.5 15.5h-11A1.75 1.75 0 0 1 1 13.75v-9A1.75 1.75 0 0 1 2.5 3H3V2.25a.75.75 0 0 1 .75-.75Zm10.75 5.5h-13v6.75c0 .138.112.25.25.25h11a.25.25 0 0 0 .25-.25Z"/></svg>
Расписание
</a>
<a href="/projects" class="btn btn-outline btn-lg">Проекты</a>
<a href="/workshops" class="btn btn-outline btn-lg">Мастер-классы</a>
</div>
</div>
<div class="hero-side">
<div class="status-card">
<div class="status-label">Статус Никиты Сергеевича</div>
<div class="status-value" id="status-value"><span class="status-dot status-dot-grey"></span><span>Загрузка...</span></div>
<div class="status-meta" id="status-meta">Проверяем расписание...</div>
<a href="/schedule" class="status-link">Открыть расписание →</a>
</div>
</div>
</div>
<div class="section-header">
<h2 class="section-title">Разделы платформы</h2>
</div>
<div class="cards-grid">
<a href="/schedule" class="feature-card">
<div class="feature-icon">📅</div>
<h3 class="feature-title">Расписание</h3>
<p class="feature-desc">Календарь событий, статус преподавателя, встроенный ассистент</p>
</a>
<a href="/projects" class="feature-card">
<div class="feature-icon">🗂</div>
<h3 class="feature-title">Проекты</h3>
<p class="feature-desc">Каталог проектов команды, канбан-доски, участники и роли</p>
</a>
<a href="/workshops" class="feature-card">
<div class="feature-icon">🎓</div>
<h3 class="feature-title">Мастер-классы</h3>
<p class="feature-desc">Пайка, embedded, Linux и творческие занятия. Регистрация и материалы</p>
<span class="feature-badge feature-badge-new">NEW</span>
</a>
<a href="/files" class="feature-card">
<div class="feature-icon">📁</div>
<h3 class="feature-title">Файлы</h3>
<p class="feature-desc">Единое хранилище документов, схем и материалов</p>
</a>
{% if session.get('role') in ['admin', 'professor'] %}
<a href="/admin/schedule" class="feature-card">
<div class="feature-icon">⚙️</div>
<h3 class="feature-title">Управление расписанием</h3>
<p class="feature-desc">Быстрые действия, события, импорт/экспорт .ics</p>
</a>
{% endif %}
{% if session.get('role') == 'admin' %}
<a href="/admin/users" class="feature-card">
<div class="feature-icon">👥</div>
<h3 class="feature-title">Пользователи</h3>
<p class="feature-desc">Управление ролями и доступом</p>
</a>
{% endif %}
</div>
</div>
<div class="footer">
Morozovka Platform · <a href="/schedule">Расписание</a> · <a href="/projects">Проекты</a> · <a href="/workshops">Мастер-классы</a> · <a href="/files">Файлы</a>
</div>
<script>
(function() {
fetch('/api/public-status')
.then(r => r.ok ? r.json() : null)
.then(data => {
const valEl = document.getElementById('status-value');
const metaEl = document.getElementById('status-meta');
if (!data || !data.status) {
valEl.innerHTML = '<span class="status-dot status-dot-grey"></span><span>Нет данных</span>';
metaEl.textContent = '';
return;
}
const map = {
'on_place': { text: 'На месте', dot: 'green' },
'delayed': { text: 'Опаздывает на ' + (data.delay_minutes || 0) + ' мин', dot: 'yellow' },
'away': { text: 'Вне вуза', dot: 'red' },
'cancelled': { text: 'Отменено', dot: 'red' },
'free': { text: 'Вне расписания', dot: 'grey' }
};
const info = map[data.status] || map.free;
valEl.innerHTML = '<span class="status-dot status-dot-' + info.dot + '"></span><span>' + info.text + '</span>';
metaEl.textContent = data.message || '';
})
.catch(() => {
document.getElementById('status-value').innerHTML = '<span class="status-dot status-dot-grey"></span><span>Нет данных</span>';
});
})();
</script>
<script src="/static/notif_bell.js"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Уведомления — Morozovka Portal</title>
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; font-size: 14px; line-height: 1.5; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.topnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 32px; display: flex; align-items: center; gap: 24px; }
.topnav-logo { display: flex; align-items: center; gap: 8px; color: #e6edf3; font-weight: 600; font-size: 15px; }
.topnav-logo svg { color: #58a6ff; }
.topnav-links { display: flex; gap: 20px; flex: 1; }
.topnav-links a { color: #c9d1d9; font-weight: 500; }
.topnav-links a:hover { color: #58a6ff; text-decoration: none; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; line-height: 20px; border: 1px solid rgba(240,246,252,0.1); cursor: pointer; text-decoration: none; }
.btn-default { background: #21262d; color: #c9d1d9; }
.container { max-width: 900px; margin: 0 auto; padding: 24px 32px; }
h1 { font-size: 24px; font-weight: 600; margin: 0 0 24px; color: #e6edf3; }
.notif-list { background: #161b22; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; }
.notif-row { display: block; padding: 14px 20px; border-bottom: 1px solid #21262d; color: inherit; text-decoration: none; transition: background 0.15s; position: relative; }
.notif-row:last-child { border-bottom: none; }
.notif-row:hover { background: #1c2128; text-decoration: none; }
.notif-row.unread { background: rgba(88,166,255,0.05); }
.notif-row.unread::before { content: ''; position: absolute; left: 8px; top: 20px; width: 6px; height: 6px; background: #58a6ff; border-radius: 50%; }
.notif-row-title { color: #e6edf3; font-weight: 500; font-size: 14px; margin-bottom: 4px; }
.notif-row-body { color: #8b949e; font-size: 13px; line-height: 1.4; margin-bottom: 6px; }
.notif-row-time { color: #6e7681; font-size: 12px; }
.notif-row.kind-success .notif-row-title { color: #3fb950; }
.notif-row.kind-warning .notif-row-title { color: #d29922; }
.notif-row.kind-error .notif-row-title { color: #f85149; }
.empty { text-align: center; padding: 60px 20px; color: #8b949e; font-size: 14px; background: #161b22; border: 1px dashed #30363d; border-radius: 6px; }
</style>
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
</a>
<div class="topnav-links">
<a href="/schedule">Расписание</a>
<a href="/projects">Проекты</a>
<a href="/workshops">Мастер-классы</a>
<a href="/files">Файлы</a>
</div>
{% if session.get('username') %}
<a href="/profile" class="btn btn-default">{{ session.get('username') }}</a>
{% endif %}
</nav>
<div class="container">
<h1>Уведомления</h1>
{% if notifications %}
<div class="notif-list">
{% for n in notifications %}
<a class="notif-row kind-{{ n.kind }}{% if not n.is_read %} unread{% endif %}" href="{{ n.link_url or '#' }}">
<div class="notif-row-title">{{ n.title }}</div>
{% if n.body %}<div class="notif-row-body">{{ n.body }}</div>{% endif %}
<div class="notif-row-time">{{ n.created_at|msk_strftime('%d.%m.%Y %H:%M') }}</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty">Уведомлений пока нет</div>
{% endif %}
</div>
<script src="/static/notif_bell.js"></script>
</body>
</html>

View File

@ -5,53 +5,113 @@
<title>Профиль — Morozovka Portal</title> <title>Профиль — Morozovka Portal</title>
<style> <style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; } body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 20px; max-width: 500px; margin: 0 auto; } .card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 20px; max-width: 560px; margin: 0 auto 20px; }
h2 { color: #58a6ff; margin-top: 0; } h2 { color: #58a6ff; margin-top: 0; }
h3 { color: #e6edf3; margin-top: 0; font-size: 1.1rem; }
.field { margin-bottom: 15px; border-bottom: 1px solid #30363d; padding-bottom: 10px; } .field { margin-bottom: 15px; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
.label { color: #8b949e; font-size: 0.8rem; text-transform: uppercase; } .label { color: #8b949e; font-size: 0.8rem; text-transform: uppercase; }
.value { font-size: 1.1rem; font-family: monospace; } .value { font-size: 1.1rem; font-family: monospace; }
a { color: #58a6ff; text-decoration: none; } a { color: #58a6ff; text-decoration: none; }
.btn-sessions { display: inline-block; margin-top: 15px; padding: 8px 15px; background: #21262d; border: 1px solid #30363d; border-radius: 5px; } .btn-sessions { display: inline-block; margin-top: 15px; padding: 8px 15px; background: #21262d; border: 1px solid #30363d; border-radius: 5px; color: #e6edf3; }
.btn-sessions:hover { background: #30363d; } .btn-sessions:hover { background: #30363d; }
.sub-status { padding: 12px 15px; border-radius: 6px; margin-bottom: 15px; font-size: .9rem; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.sub-active { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; }
.sub-none { background: rgba(139,148,158,.15); border: 1px solid #8b949e; color: #8b949e; }
.sub-badge { font-size: .75rem; padding: 2px 8px; border-radius: 10px; background: rgba(63,185,80,.25); border: 1px solid #238636; color: #3fb950; }
.sub-form { display: flex; flex-direction: column; gap: 12px; }
.sub-checkbox { display: flex; align-items: center; gap: 8px; color: #c9d1d9; font-size: .9rem; cursor: pointer; }
.sub-checkbox input { width: 16px; height: 16px; }
.btn-primary { background: #238636; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; }
.btn-primary:hover { background: #2ea043; }
.btn-danger { background: #da3633; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: 600; }
.btn-danger:hover { background: #f85149; }
.muted { color: #8b949e; font-size: .85rem; line-height: 1.5; }
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<h2>Личный кабинет</h2> <h2>Личный кабинет</h2>
<div class="field"> <div class="field">
<div class="label">Логин</div> <div class="label">Логин</div>
<div class="value">{{ user.username }}</div> <div class="value">{{ user.username }}</div>
</div> </div>
<div class="field"> <div class="field">
<div class="label">ФИО</div> <div class="label">ФИО</div>
<div class="value">{{ user.full_name or 'Не указано' }}</div> <div class="value">{{ user.full_name or 'Не указано' }}</div>
</div> </div>
<div class="field"> <div class="field">
<div class="label">Email</div> <div class="label">Email</div>
<div class="value">{{ user.email or 'Не указано' }} <div class="value">{{ user.email or 'Не указано' }}
{% if user.is_verified %}<span style="color: #3fb950; font-size: 0.8rem;">✓ Подтвержден</span>{% endif %} {% if user.is_verified %}<span style="color: #3fb950; font-size: 0.8rem;">✓ Подтвержден</span>{% endif %}
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="label">Организация</div> <div class="label">Организация</div>
<div class="value">{{ user.organization or 'Не указана' }}</div> <div class="value">{{ user.organization or 'Не указана' }}</div>
</div> </div>
<div class="field" style="border-bottom: none;">
<div class="field">
<div class="label">Роль в системе</div> <div class="label">Роль в системе</div>
<div class="value">{{ user.role }}</div> <div class="value">{{ user.role }}</div>
</div> </div>
<div style="margin-top: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<div style="margin-top: 20px; display: flex; justify-content: space-between; align-items: center;">
<div> <div>
<a href="/">← На главную</a> | <a href="/logout" style="color: #f85149;">Выйти</a> <a href="/">← На главную</a> | <a href="/logout" style="color: #f85149;">Выйти</a>
</div> </div>
<a href="/sessions" class="btn-sessions">Безопасность (сессии)</a> <a href="/sessions" class="btn-sessions">Безопасность (сессии)</a>
</div> </div>
</div> </div>
<div class="card">
<h3>📬 Уведомления о расписании</h3>
{% if not user.email %}
<div class="sub-status sub-none">⚠ Укажите email в профиле, чтобы получать уведомления.</div>
{% elif subscription and subscription.is_confirmed %}
<div class="sub-status sub-active">
<span>✅ Подписка активна</span>
<span class="sub-badge">{{ user.email }}</span>
</div>
<form method="POST" action="/subscribe/toggle" class="sub-form">
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="next" value="/profile">
<label class="sub-checkbox">
<input type="checkbox" name="notify_status" {% if subscription.notify_status %}checked{% endif %} disabled>
Изменения статуса (опаздываю / на месте / отменено)
</label>
<label class="sub-checkbox">
<input type="checkbox" name="notify_events" {% if subscription.notify_events %}checked{% endif %} disabled>
Изменения событий в расписании
</label>
<div>
<button type="submit" class="btn-danger">Отписаться</button>
</div>
</form>
<p class="muted" style="margin-top: 12px;">
Отписаться можно в один клик. Повторная подписка тоже в один клик — никаких подтверждений по почте.
</p>
{% else %}
<div class="sub-status sub-none">Вы пока не подписаны на уведомления</div>
<form method="POST" action="/subscribe/toggle" class="sub-form">
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="next" value="/profile">
<label class="sub-checkbox">
<input type="checkbox" name="notify_status" checked>
Уведомлять об изменениях статуса
</label>
<label class="sub-checkbox">
<input type="checkbox" name="notify_events" checked>
Уведомлять об изменениях в расписании
</label>
<div>
<button type="submit" class="btn-primary">Подписаться на {{ user.email }}</button>
</div>
</form>
<p class="muted" style="margin-top: 12px;">
Email берётся из вашего аккаунта — подтверждение не требуется.
</p>
{% endif %}
</div>
</body> </body>
</html> </html>

View File

@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ project.title }} — Доска — Morozovka Portal</title>
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 20px; margin: 0; }
.container { max-width: 1400px; margin: 0 auto; }
.back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; flex-wrap: wrap; gap: 15px; }
h1 { color: #58a6ff; margin: 0; font-size: 1.8rem; }
.tabs { display: flex; gap: 5px; border-bottom: 1px solid #30363d; margin-bottom: 25px; }
.tab { padding: 10px 20px; color: #8b949e; text-decoration: none; border-bottom: 2px solid transparent; font-weight: 600; }
.tab:hover { color: #e6edf3; }
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.tab .badge { background: #30363d; color: #e6edf3; padding: 2px 8px; border-radius: 10px; font-size: .75rem; margin-left: 6px; }
.board { display: grid; grid-template-columns: repeat(5, minmax(240px, 1fr)); gap: 15px; align-items: flex-start; overflow-x: auto; padding-bottom: 15px; }
.column { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 12px; min-height: 200px; }
.column-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #30363d; }
.column-title { font-weight: 700; color: #e6edf3; font-size: .9rem; text-transform: uppercase; letter-spacing: .5px; }
.column-count { color: #8b949e; font-size: .8rem; background: #0d1117; padding: 2px 8px; border-radius: 10px; }
.column.drop-target { border-color: #58a6ff; background: rgba(88,166,255,.05); }
.task-list { min-height: 50px; display: flex; flex-direction: column; gap: 8px; }
.task-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 10px 12px; cursor: grab; position: relative; transition: all .15s; }
.task-card:hover { border-color: #58a6ff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,.3); }
.task-card.dragging { opacity: .5; cursor: grabbing; }
.task-card::before { content: ''; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 2px; }
.priority-low::before { background: #8b949e; }
.priority-medium::before { background: #58a6ff; }
.priority-high::before { background: #d29922; }
.priority-critical::before { background: #f85149; }
.task-title { color: #e6edf3; font-weight: 600; font-size: .9rem; margin-left: 6px; margin-bottom: 6px; word-wrap: break-word; }
.task-desc { color: #8b949e; font-size: .8rem; margin-left: 6px; margin-bottom: 8px; line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.task-meta { display: flex; justify-content: space-between; align-items: center; gap: 6px; margin-left: 6px; font-size: .75rem; }
.task-assignee { color: #58a6ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.task-due { color: #8b949e; }
.task-due.overdue { color: #f85149; }
.task-actions { display: flex; gap: 4px; margin-left: auto; opacity: 0; transition: opacity .15s; }
.task-card:hover .task-actions { opacity: 1; }
.task-action-btn { background: none; border: none; color: #8b949e; cursor: pointer; padding: 2px 4px; font-size: .85rem; border-radius: 3px; }
.task-action-btn:hover { background: #30363d; color: #e6edf3; }
.task-action-btn.danger:hover { color: #f85149; }
.add-task-btn { width: 100%; padding: 8px; margin-top: 8px; background: transparent; border: 1px dashed #30363d; color: #8b949e; border-radius: 6px; cursor: pointer; font-size: .85rem; transition: all .15s; }
.add-task-btn:hover { border-color: #58a6ff; color: #58a6ff; }
/* Модалка */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px; width: 100%; max-width: 550px; padding: 25px; max-height: 90vh; overflow-y: auto; }
.modal h2 { margin: 0 0 20px; color: #58a6ff; }
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 15px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
label { color: #8b949e; font-size: .85rem; font-weight: 600; }
input, select, textarea { padding: 10px; background: #0d1117; color: #fff; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; font-size: .95rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; }
textarea { resize: vertical; min-height: 80px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.btn { padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; font-weight: 600; font-size: .9rem; text-decoration: none; display: inline-block; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
@media (max-width: 1100px) {
.board { grid-template-columns: repeat(5, 280px); }
}
</style>
</head>
<body>
<div class="container">
<a href="/projects/{{ project.id }}" class="back-link">← К проекту</a>
<div class="header">
<h1>{{ project.title }} — Доска</h1>
</div>
<div class="tabs">
<a href="/projects/{{ project.id }}" class="tab">Обзор</a>
<a href="/projects/{{ project.id }}/board" class="tab active">
Доска
</a>
<a href="/projects/{{ project.id }}#files" class="tab">
Файлы
</a>
</div>
<div class="board" id="board">
{% for status in TASK_STATUSES %}
<div class="column" data-status="{{ status }}">
<div class="column-header">
<span class="column-title">{{ TASK_STATUS_LABELS[status] }}</span>
<span class="column-count">{{ columns[status]|length }}</span>
</div>
<div class="task-list" data-status="{{ status }}">
{% for item in columns[status] %}
{% set task = item.task %}
<div class="task-card priority-{{ task.priority }}"
draggable="true"
data-task-id="{{ task.id }}"
data-status="{{ task.status }}">
<div class="task-title">{{ task.title }}</div>
{% if task.description %}
<div class="task-desc">{{ task.description }}</div>
{% endif %}
<div class="task-meta">
{% if item.assignee %}
<span class="task-assignee">👤 {{ item.assignee.full_name or item.assignee.username }}</span>
{% else %}
<span class="task-assignee" style="color:#6e7681;">— не назначено</span>
{% endif %}
{% if task.due_date %}
<span class="task-due">{{ task.due_date|date_ru }}</span>
{% endif %}
{% if perms.can_edit %}
<div class="task-actions">
<a href="/projects/{{ project.id }}/tasks/{{ task.id }}/edit" class="task-action-btn" title="Редактировать">✎</a>
{% if perms.can_manage %}
<form action="/projects/{{ project.id }}/tasks/{{ task.id }}/delete" method="POST" onsubmit="return confirm('Удалить задачу?');" style="display:inline;">
<button type="submit" class="task-action-btn danger" title="Удалить">✕</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if perms.can_edit %}
<button type="button" class="add-task-btn" onclick="openTaskModal('{{ status }}')">+ Добавить задачу</button>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- Модалка создания задачи -->
<div class="modal-overlay" id="task-modal">
<div class="modal">
<h2>Новая задача</h2>
<form id="task-form" method="POST" action="/projects/{{ project.id }}/tasks/new">
<input type="hidden" name="status" id="task-status" value="backlog">
<div class="form-group">
<label>Название *</label>
<input type="text" name="title" required maxlength="200">
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description" rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Приоритет</label>
<select name="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="form-group">
<label>Дедлайн</label>
<input type="date" name="due_date">
</div>
</div>
<div class="form-group">
<label>Исполнитель</label>
<select name="assignee_id">
<option value="">— не назначено —</option>
{% for member_obj, user in members %}
<option value="{{ user.id }}">{{ user.full_name or user.username }}</option>
{% endfor %}
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">Отмена</button>
<button type="submit" class="btn btn-primary">Создать</button>
</div>
</form>
</div>
</div>
<script>
const PROJECT_ID = {{ project.id }};
const CAN_EDIT = {{ 'true' if perms.can_edit else 'false' }};
function openTaskModal(status) {
document.getElementById('task-status').value = status;
document.getElementById('task-modal').classList.add('open');
setTimeout(() => document.querySelector('#task-form input[name="title"]').focus(), 100);
}
function closeTaskModal() {
document.getElementById('task-modal').classList.remove('open');
document.getElementById('task-form').reset();
}
document.getElementById('task-modal').addEventListener('click', function(e) {
if (e.target === this) closeTaskModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeTaskModal();
});
// Drag & drop
let draggedCard = null;
let draggedFromStatus = null;
document.querySelectorAll('.task-card').forEach(card => {
card.addEventListener('dragstart', e => {
if (!CAN_EDIT) { e.preventDefault(); return; }
draggedCard = card;
draggedFromStatus = card.dataset.status;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', card.dataset.taskId);
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
draggedCard = null;
document.querySelectorAll('.column').forEach(c => c.classList.remove('drop-target'));
});
});
document.querySelectorAll('.column').forEach(column => {
column.addEventListener('dragover', e => {
if (!draggedCard) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
column.classList.add('drop-target');
});
column.addEventListener('dragleave', e => {
if (!column.contains(e.relatedTarget)) column.classList.remove('drop-target');
});
column.addEventListener('drop', async e => {
e.preventDefault();
column.classList.remove('drop-target');
if (!draggedCard || !CAN_EDIT) return;
const newStatus = column.dataset.status;
const taskId = draggedCard.dataset.taskId;
const list = column.querySelector('.task-list');
// Определяем позицию: относительно других карточек
let position = list.children.length;
const cards = Array.from(list.querySelectorAll('.task-card'));
const mouseY = e.clientY;
for (let i = 0; i < cards.length; i++) {
const rect = cards[i].getBoundingClientRect();
if (mouseY < rect.top + rect.height / 2) { position = i; break; }
}
// Оптимистичное обновление UI
const insertBefore = position < cards.length ? cards[position] : null;
list.insertBefore(draggedCard, insertBefore);
draggedCard.dataset.status = newStatus;
updateCounts();
// Отправка на сервер
try {
const res = await fetch(`/projects/${PROJECT_ID}/tasks/${taskId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus, position: position })
});
if (!res.ok) {
console.error('Move failed', await res.text());
location.reload();
}
} catch (err) {
console.error(err);
location.reload();
}
});
});
function updateCounts() {
document.querySelectorAll('.column').forEach(col => {
const count = col.querySelectorAll('.task-card').length;
col.querySelector('.column-count').textContent = count;
});
}
</script>
</body>
</html>

View File

@ -1,14 +1,23 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{{ project.title }} — Morozovka Portal</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <title>{{ project.title }} — Morozovka Portal</title>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; } <style>
.container { max-width: 800px; margin: 0 auto; } * { box-sizing: border-box; }
h1 { color: #58a6ff; margin-top: 0; margin-bottom: 10px; } body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px 20px; }
.badges { display: flex; gap: 10px; margin-bottom: 20px; } .container { max-width: 900px; margin: 0 auto; }
.badge { padding: 4px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: bold; text-transform: uppercase; } h1 { color: #58a6ff; margin: 0 0 10px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; margin-bottom: 20px; }
.header-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.tabs { display: flex; gap: 5px; border-bottom: 1px solid #30363d; margin-bottom: 25px; }
.tab { padding: 10px 20px; color: #8b949e; text-decoration: none; border-bottom: 2px solid transparent; font-weight: 600; }
.tab:hover { color: #e6edf3; }
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.tab .badge { background: #30363d; color: #e6edf3; padding: 2px 8px; border-radius: 10px; font-size: .75rem; margin-left: 6px; }
.badges { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
.badge { padding: 4px 10px; border-radius: 4px; font-size: 0.8rem; font-weight: bold; text-transform: uppercase; }
.status-in_progress { background: #1f6feb; color: white; } .status-in_progress { background: #1f6feb; color: white; }
.status-idea { background: #30363d; color: white; } .status-idea { background: #30363d; color: white; }
.status-completed { background: #238636; color: white; } .status-completed { background: #238636; color: white; }
@ -16,83 +25,331 @@
.vis-public { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid #3fb950; } .vis-public { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid #3fb950; }
.vis-internal { background: rgba(210,153,34,0.2); color: #d29922; border: 1px solid #d29922; } .vis-internal { background: rgba(210,153,34,0.2); color: #d29922; border: 1px solid #d29922; }
.vis-private { background: rgba(248,81,73,0.2); color: #f85149; border: 1px solid #f85149; } .vis-private { background: rgba(248,81,73,0.2); color: #f85149; border: 1px solid #f85149; }
.tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.desc { font-size: 1.1rem; line-height: 1.6; color: #c9d1d9; margin-bottom: 30px; white-space: pre-wrap; } .tag { font-size: .8rem; background: rgba(88,166,255,.1); color: #58a6ff; padding: 3px 10px; border-radius: 12px; border: 1px solid rgba(88,166,255,.3); }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-bottom: 25px; padding: 15px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; }
.meta-item .label { color: #8b949e; font-size: .75rem; text-transform: uppercase; margin-bottom: 4px; }
.meta-item .value { color: #e6edf3; font-size: 1rem; }
.desc { font-size: 1.05rem; line-height: 1.7; color: #c9d1d9; margin-bottom: 30px; white-space: pre-wrap; }
.repo-btn { display: inline-block; background: #21262d; color: #58a6ff; text-decoration: none; padding: 10px 20px; border-radius: 5px; border: 1px solid #30363d; font-weight: bold; margin-bottom: 30px; } .repo-btn { display: inline-block; background: #21262d; color: #58a6ff; text-decoration: none; padding: 10px 20px; border-radius: 5px; border: 1px solid #30363d; font-weight: bold; margin-bottom: 30px; }
.repo-btn:hover { background: #30363d; }
.section-title { border-bottom: 1px solid #30363d; padding-bottom: 10px; margin-bottom: 20px; color: #8b949e; font-size: 1.2rem; } .section-title { border-bottom: 1px solid #30363d; padding-bottom: 10px; margin-bottom: 20px; color: #e6edf3; font-size: 1.2rem; display: flex; justify-content: space-between; align-items: center; }
.section-title .count { color: #8b949e; font-size: .85rem; font-weight: normal; }
.members-list { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 30px; } .members-list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 25px; }
.member { background: #161b22; border: 1px solid #30363d; padding: 15px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; } .member { background: #161b22; border: 1px solid #30363d; padding: 12px 15px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.member-name { font-weight: bold; } .member-info { flex: 1; min-width: 0; }
.member-role { font-size: 0.75rem; color: #8b949e; text-transform: uppercase; } .member-name { font-weight: bold; overflow: hidden; text-overflow: ellipsis; }
.member-role.lead { color: #d29922; } .member-role-badge { font-size: 0.7rem; text-transform: uppercase; padding: 2px 6px; border-radius: 3px; display: inline-block; margin-top: 4px; }
.role-manager { background: #d29922; color: #000; }
.add-member-form { display: flex; gap: 10px; margin-bottom: 40px; } .role-editor { background: #1f6feb; color: #fff; }
.add-member-form input { flex: 1; padding: 10px; background: #0d1117; border: 1px solid #30363d; border-radius: 5px; color: white; } .role-viewer { background: #30363d; color: #e6edf3; }
.btn { background: #238636; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; text-decoration: none; } .member-actions { display: flex; gap: 5px; align-items: center; flex-shrink: 0; }
.role-select { padding: 4px 6px; background: #0d1117; border: 1px solid #30363d; color: #e6edf3; border-radius: 4px; font-size: .8rem; }
.btn { background: #238636; color: white; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer; font-weight: bold; text-decoration: none; display: inline-block; font-size: .9rem; }
.btn:hover { background: #2ea043; } .btn:hover { background: #2ea043; }
.btn-secondary { background: #21262d; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
.btn-danger { background: #da3633; } .btn-danger { background: #da3633; }
.btn-danger:hover { background: #f85149; } .btn-danger:hover { background: #f85149; }
.btn-sm { padding: 5px 10px; font-size: .8rem; }
.add-form { background: #161b22; border: 1px solid #30363d; padding: 15px; border-radius: 8px; margin-bottom: 30px; }
.add-form form { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
.add-form .form-group { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 140px; }
.add-form label { color: #8b949e; font-size: .8rem; }
.add-form input, .add-form select { padding: 8px 10px; background: #0d1117; border: 1px solid #30363d; border-radius: 5px; color: #fff; font-size: .9rem; }
.add-form input:focus, .add-form select:focus { outline: none; border-color: #58a6ff; }
.files-list { background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
.file-row { display: flex; align-items: center; gap: 15px; padding: 12px 15px; border-bottom: 1px solid #30363d; }
.file-row:last-child { border-bottom: none; }
.file-row:hover { background: #1c2128; }
.file-info { flex: 1; min-width: 0; }
.file-name { color: #e6edf3; font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.file-meta { color: #8b949e; font-size: .8rem; margin-top: 2px; }
.file-actions { display: flex; gap: 8px; flex-shrink: 0; }
.file-actions a { color: #58a6ff; text-decoration: none; font-size: .9rem; }
.file-actions a:hover { text-decoration: underline; }
.empty-state { text-align: center; padding: 30px; color: #8b949e; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; } .back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.actions { display: flex; gap: 10px; margin-top: 20px; } .back-link:hover { color: #58a6ff; }
</style>
/* Gitea block */
.gitea-block { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 30px; }
.gitea-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; flex-wrap: wrap; gap: 10px; }
.gitea-title { color: #e6edf3; font-weight: 700; font-size: 1.05rem; }
.gitea-title a { color: #58a6ff; text-decoration: none; }
.gitea-stats-row { display: flex; gap: 20px; margin-bottom: 15px; flex-wrap: wrap; }
.gitea-stat { display: flex; align-items: center; gap: 6px; color: #8b949e; font-size: .9rem; }
.gitea-stat strong { color: #e6edf3; }
.commits-list { display: flex; flex-direction: column; gap: 8px; }
.commit-row { display: flex; gap: 12px; padding: 8px 12px; background: #0d1117; border-radius: 6px; border: 1px solid #30363d; align-items: center; }
.commit-sha { color: #58a6ff; font-family: monospace; font-size: .8rem; text-decoration: none; flex-shrink: 0; }
.commit-sha:hover { text-decoration: underline; }
.commit-msg { flex: 1; color: #c9d1d9; font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.commit-author { color: #8b949e; font-size: .8rem; flex-shrink: 0; }
.gitea-loading { color: #8b949e; padding: 15px 0; font-size: .9rem; }
.gitea-error { color: #f85149; padding: 15px 0; font-size: .9rem; }
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<a href="/projects" class="back-link">← К проектам</a> <a href="/projects" class="back-link">← К проектам</a>
<div style="display: flex; justify-content: space-between; align-items: start;"> <div class="header">
<div> <div>
<h1>{{ project.title }}</h1> <h1>{{ project.title }}</h1>
<div class="badges"> <div class="badges">
<span class="badge status-{{ project.status }}">{{ project.status }}</span> <span class="badge status-{{ project.status }}">
<span class="badge vis-{{ project.visibility }}">{{ project.visibility }}</span> {% if project.status == 'idea' %}Идея
{% elif project.status == 'in_progress' %}В работе
{% elif project.status == 'completed' %}Завершён
{% else %}Заморожен{% endif %}
</span>
<span class="badge vis-{{ project.visibility }}">
{% if project.visibility == 'public' %}Публичный
{% elif project.visibility == 'internal' %}Внутренний
{% else %}Приватный{% endif %}
</span>
</div> </div>
</div> </div>
<div class="actions"> <div class="header-actions">
{% if role == 'admin' or project.owner_id == user_id or (is_member and role == 'lead') %} {% if perms.can_edit %}
<a href="/projects/{{ project.id }}/edit" class="btn" style="background: #21262d; border: 1px solid #30363d;">Редактировать</a> <a href="/projects/{{ project.id }}/edit" class="btn btn-secondary">Редактировать</a>
{% endif %} {% endif %}
{% if perms.can_delete %}
{% if role == 'admin' or project.owner_id == user_id %} <form action="/projects/{{ project.id }}/delete" method="POST" onsubmit="return confirm('Удалить проект?');" style="display:inline;">
<form action="/projects/{{ project.id }}/delete" method="POST" onsubmit="return confirm('Удалить проект?');">
<button type="submit" class="btn btn-danger">Удалить</button> <button type="submit" class="btn btn-danger">Удалить</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="tabs">
<a href="/projects/{{ project.id }}" class="tab active">Обзор</a>
<a href="/projects/{{ project.id }}/board" class="tab">
Доска
{% if task_counts %}
<span class="badge">{{ task_counts.values()|sum }}</span>
{% endif %}
</a>
<a href="#files" class="tab">Файлы <span class="badge">{{ files|length }}</span></a>
</div>
{% if project.tags %}
<div class="tags">
{% for t in project.tags.split(',') %}<span class="tag">#{{ t.strip() }}</span>{% endfor %}
</div>
{% endif %}
<div class="meta-grid">
<div class="meta-item">
<div class="label">Начало</div>
<div class="value">{{ project.start_date|date_ru or '—' }}</div>
</div>
<div class="meta-item">
<div class="label">Завершение</div>
<div class="value">{{ project.end_date|date_ru or '—' }}</div>
</div>
<div class="meta-item">
<div class="label">Контакт</div>
<div class="value">{{ project.contact_person or '—' }}</div>
</div>
<div class="meta-item">
<div class="label">Ваша роль</div>
<div class="value">
{% if perms.project_role == 'manager' %}Manager
{% elif perms.project_role == 'editor' %}Editor
{% elif perms.project_role == 'viewer' %}Viewer
{% else %}—{% endif %}
</div>
</div>
</div>
<div class="desc">{{ project.description or 'Описание пока не добавлено.' }}</div> <div class="desc">{{ project.description or 'Описание пока не добавлено.' }}</div>
{% if project.gitea_url %} {% if project.gitea_url %}
<a href="{{ project.gitea_url }}" target="_blank" class="repo-btn">📦 Перейти в репозиторий Gitea</a> <a href="{{ project.gitea_url }}" target="_blank" class="repo-btn">📦 Перейти в репозиторий Gitea</a>
{% endif %} {% endif %}
<h2 class="section-title">Участники ({{ members|length }})</h2> <!-- Gitea stats block (lazy-loaded) -->
{% if project.gitea_url or project.gitea_repo %}
<div class="gitea-block" id="gitea-block">
<div class="gitea-loading">Загружаю статистику из Gitea...</div>
</div>
{% endif %}
<h2 class="section-title">Участники <span class="count">{{ members|length }}</span></h2>
{% if perms.can_manage %}
<div class="add-form">
<form action="/projects/{{ project.id }}/members/add" method="POST">
<div class="form-group">
<label>Логин пользователя</label>
<input type="text" name="username" placeholder="например, testuser" required>
</div>
<div class="form-group" style="flex: 0.5;">
<label>Роль</label>
<select name="role">
<option value="viewer">Viewer (только чтение)</option>
<option value="editor" selected>Editor (может редактировать)</option>
<option value="manager">Manager (полные права)</option>
</select>
</div>
<button type="submit" class="btn">Добавить</button>
</form>
</div>
{% endif %}
<div class="members-list"> <div class="members-list">
{% for member_obj, user in members %} {% for member_obj, user in members %}
<div class="member"> <div class="member">
<div> <div class="member-info">
<div class="member-name">{{ user.full_name or user.username }}</div> <div class="member-name">{{ user.full_name or user.username }} <span style="color: #6e7681; font-weight: normal; font-size: .85rem;">@{{ user.username }}</span></div>
<div class="member-role {{ member_obj.role }}">{{ member_obj.role }}</div> {% if user.id == project.owner_id %}
<span class="member-role-badge role-manager">Владелец</span>
{% else %}
<span class="member-role-badge role-{{ member_obj.role }}">
{% if member_obj.role == 'manager' %}Manager
{% elif member_obj.role == 'editor' %}Editor
{% else %}Viewer{% endif %}
</span>
{% endif %}
</div> </div>
{% if (role == 'admin' or project.owner_id == user_id or (is_member and role == 'lead')) and user.id != project.owner_id %} {% if perms.can_manage and user.id != project.owner_id %}
<form action="/projects/{{ project.id }}/members/{{ user.id }}/remove" method="POST" onsubmit="return confirm('Удалить участника?');"> <div class="member-actions">
<button type="submit" style="background:none; border:none; color:#da3633; cursor:pointer;">✕</button> <form action="/projects/{{ project.id }}/members/{{ user.id }}/role" method="POST" style="display:flex; gap: 5px;">
<select name="role" class="role-select">
<option value="viewer" {% if member_obj.role == 'viewer' %}selected{% endif %}>Viewer</option>
<option value="editor" {% if member_obj.role == 'editor' %}selected{% endif %}>Editor</option>
<option value="manager" {% if member_obj.role == 'manager' %}selected{% endif %}>Manager</option>
</select>
<button type="submit" class="btn btn-sm btn-secondary">OK</button>
</form> </form>
<form action="/projects/{{ project.id }}/members/{{ user.id }}/remove" method="POST" onsubmit="return confirm('Удалить участника?');">
<button type="submit" class="btn btn-sm btn-danger">✕</button>
</form>
</div>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% if role == 'admin' or project.owner_id == user_id or (is_member and role == 'lead') %} <h2 class="section-title" id="files">Файлы проекта <span class="count">{{ files|length }}</span></h2>
<h3 style="color:#8b949e; margin-bottom: 10px;">Добавить участника</h3>
<form action="/projects/{{ project.id }}/members/add" method="POST" class="add-member-form"> {% if perms.can_upload %}
<input type="text" name="username" placeholder="Введите логин пользователя (например, testuser)" required> <div class="add-form">
<button type="submit" class="btn">Добавить</button> <form action="/projects/{{ project.id }}/files/upload" method="POST" enctype="multipart/form-data">
<div class="form-group">
<label>Загрузить файл (макс 100 МБ)</label>
<input type="file" name="file" required>
</div>
<button type="submit" class="btn">Загрузить</button>
</form>
</div>
{% endif %}
{% if files %}
<div class="files-list">
{% for file, uploader in files %}
<div class="file-row">
<div class="file-info">
<div class="file-name">{{ file.original_name }}</div>
<div class="file-meta">{{ (file.size_bytes / 1024 / 1024) | round(2) }} МБ · {{ uploader }} · {{ file.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }}</div>
</div>
<div class="file-actions">
<a href="/files/download/{{ file.id }}">Скачать</a>
{% if perms.can_manage or file.uploaded_by == user_id %}
<form action="/files/{{ file.id }}/delete" method="POST" onsubmit="return confirm('Удалить файл?');" style="display:inline;">
<input type="hidden" name="next" value="/projects/{{ project.id }}">
<button type="submit" class="btn btn-sm btn-danger">Удалить</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
</div>
{% endfor %}
</div>
{% else %}
<div class="files-list">
<div class="empty-state">Файлов в проекте пока нет.{% if perms.can_upload %} Загрузите первый файл выше.{% endif %}</div>
</div>
{% endif %}
</div>
<script>
(function() {
const block = document.getElementById('gitea-block');
if (!block) return;
const PROJECT_ID = {{ project.id }};
async function loadGiteaStats() {
try {
const res = await fetch(`/projects/${PROJECT_ID}/gitea-stats`, { headers: { 'Accept': 'application/json' } });
const data = await res.json();
renderGitea(data);
} catch (e) {
block.innerHTML = '<div class="gitea-error">Ошибка загрузки статистики Gitea</div>';
}
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
const dd = String(d.getDate()).padStart(2,'0');
const mm = String(d.getMonth()+1).padStart(2,'0');
const yy = String(d.getFullYear()).slice(-2);
const hh = String(d.getHours()).padStart(2,'0');
const mn = String(d.getMinutes()).padStart(2,'0');
return `${dd}.${mm}.${yy} ${hh}:${mn}`;
}
function renderGitea(data) {
if (!data.ok) {
block.innerHTML = `<div class="gitea-error">${escapeHtml(data.message || 'Не удалось получить данные')}</div>`;
return;
}
const repo = data.repo;
let html = '';
html += '<div class="gitea-header">';
html += '<div class="gitea-title">📦 ';
if (repo.html_url) {
html += `<a href="${escapeHtml(repo.html_url)}" target="_blank">${escapeHtml(repo.full_name)}</a>`;
} else {
html += escapeHtml(repo.full_name);
}
if (repo.is_private) html += ' <span class="tag" style="font-size:.7rem;">private</span>';
html += '</div></div>';
html += '<div class="gitea-stats-row">';
html += `<div class="gitea-stat">🐛 Open issues: <strong>${data.open_issues}</strong></div>`;
html += `<div class="gitea-stat">🔀 Open PRs: <strong>${data.open_prs}</strong></div>`;
html += `<div class="gitea-stat">🌿 Branch: <strong>${escapeHtml(repo.default_branch)}</strong></div>`;
if (repo.stars) html += `<div class="gitea-stat">⭐ <strong>${repo.stars}</strong></div>`;
if (repo.forks) html += `<div class="gitea-stat">🍴 <strong>${repo.forks}</strong></div>`;
html += '</div>';
if (data.commits && data.commits.length) {
html += '<div style="color:#8b949e;font-size:.8rem;text-transform:uppercase;margin-bottom:8px;font-weight:600;">Последние коммиты</div>';
html += '<div class="commits-list">';
for (const c of data.commits) {
const shaLink = c.html_url ? `<a href="${escapeHtml(c.html_url)}" target="_blank" class="commit-sha">${escapeHtml(c.sha_short)}</a>` : `<span class="commit-sha">${escapeHtml(c.sha_short)}</span>`;
html += '<div class="commit-row">';
html += shaLink;
html += `<div class="commit-msg" title="${escapeHtml(c.message)}">${escapeHtml(c.message)}</div>`;
html += `<div class="commit-author">${escapeHtml(c.author)} · ${fmtDate(c.date)}</div>`;
html += '</div>';
}
html += '</div>';
}
block.innerHTML = html;
}
loadGiteaStats();
})();
</script>
</body> </body>
</html> </html>

View File

@ -1,26 +1,32 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{{ 'Редактирование' if is_edit else 'Новый проект' }} — Morozovka Portal</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <title>{{ 'Редактирование' if is_edit else 'Новый проект' }} — Morozovka Portal</title>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; } <style>
.container { max-width: 600px; margin: 0 auto; } body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px 20px; }
.container { max-width: 640px; margin: 0 auto; }
h2 { color: #58a6ff; } h2 { color: #58a6ff; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; } .form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.9rem; } label { margin-bottom: 5px; color: #8b949e; font-size: 0.9rem; }
input, select, textarea { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; } input, select, textarea { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; font-size: 1rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; }
textarea { resize: vertical; min-height: 100px; } textarea { resize: vertical; min-height: 100px; }
button { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; } .hint { color: #6e7681; font-size: .8rem; margin-top: 4px; }
button:hover { background: #2ea043; } button { padding: 12px 24px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 1rem; margin-top: 10px; }
button:hover:not(:disabled) { background: #2ea043; }
button:disabled { background: #30363d; color: #8b949e; cursor: not-allowed; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; } .back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style> .back-link:hover { color: #58a6ff; }
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<a href="{{ '/projects/' + project.id|string if is_edit else '/projects' }}" class="back-link">← Назад</a> <a href="{{ '/projects/' + project.id|string if is_edit else '/projects' }}" class="back-link">← Назад</a>
<h2>{{ 'Редактирование проекта' if is_edit else 'Создание нового проекта' }}</h2> <h2>{{ 'Редактирование проекта' if is_edit else 'Создание нового проекта' }}</h2>
<form method="POST"> <form method="POST" id="project-form">
<div class="form-group"> <div class="form-group">
<label>Название проекта *</label> <label>Название проекта *</label>
<input type="text" name="title" value="{{ project.title if is_edit else '' }}" required> <input type="text" name="title" value="{{ project.title if is_edit else '' }}" required>
@ -29,6 +35,8 @@
<label>Описание</label> <label>Описание</label>
<textarea name="description">{{ project.description if is_edit else '' }}</textarea> <textarea name="description">{{ project.description if is_edit else '' }}</textarea>
</div> </div>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label>Статус</label> <label>Статус</label>
<select name="status"> <select name="status">
@ -46,12 +54,63 @@
<option value="private" {% if is_edit and project.visibility == 'private' %}selected{% endif %}>Приватный (только участники)</option> <option value="private" {% if is_edit and project.visibility == 'private' %}selected{% endif %}>Приватный (только участники)</option>
</select> </select>
</div> </div>
</div>
<div class="form-row">
<div class="form-group">
<label>Дата начала</label>
<input type="date" name="start_date" value="{{ project.start_date if is_edit and project.start_date else '' }}">
</div>
<div class="form-group">
<label>Дата завершения</label>
<input type="date" name="end_date" value="{{ project.end_date if is_edit and project.end_date else '' }}">
</div>
</div>
<div class="form-group">
<label>Контактное лицо</label>
<input type="text" name="contact_person" placeholder="Имя, email или Telegram" value="{{ project.contact_person if is_edit and project.contact_person else '' }}">
</div>
<div class="form-group">
<label>Теги технологий</label>
<input type="text" name="tags" placeholder="python, flask, iot, embedded" value="{{ project.tags if is_edit and project.tags else '' }}">
<div class="hint">Через запятую. Сохраняются в нижнем регистре, без дублей.</div>
</div>
<div class="form-group"> <div class="form-group">
<label>Ссылка на репозиторий Gitea</label> <label>Ссылка на репозиторий Gitea</label>
<input type="url" name="gitea_url" value="{{ project.gitea_url if is_edit and project.gitea_url else '' }}" placeholder="https://git.morozovka.space/user/repo"> <input type="url" name="gitea_url" placeholder="https://git.morozovka.space/user/repo" value="{{ project.gitea_url if is_edit and project.gitea_url else '' }}">
</div> </div>
<button type="submit">{{ 'Сохранить изменения' if is_edit else 'Создать проект' }}</button>
<div class="form-group">
<label>Репозиторий для Gitea API (owner/repo)</label>
<input type="text" name="gitea_repo" placeholder="например, IvanBud123/morozovka-portal" value="{{ project.gitea_repo if is_edit and project.gitea_repo else '' }}">
<div class="hint">Можно оставить пустым — автоматически извлечётся из URL выше. Заполняйте вручную, если URL нестандартный.</div>
</div>
<button type="submit" id="submit-btn">{{ 'Сохранить изменения' if is_edit else 'Создать проект' }}</button>
</form> </form>
</div> </div>
<script>
(function() {
const form = document.getElementById('project-form');
const btn = document.getElementById('submit-btn');
const originalText = btn.textContent;
let submitted = false;
form.addEventListener('submit', function(e) {
if (submitted) { e.preventDefault(); return false; }
submitted = true;
btn.disabled = true;
btn.textContent = '{{ "Сохранение..." if is_edit else "Создание..." }}';
setTimeout(function() {
submitted = false;
btn.disabled = false;
btn.textContent = originalText;
}, 30000);
});
})();
</script>
</body> </body>
</html> </html>

View File

@ -1,21 +1,32 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Проекты — Morozovka Portal</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <title>Проекты — Morozovka Portal</title>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; } <style>
.container { max-width: 1000px; margin: 0 auto; } body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px 20px; }
.container { max-width: 1100px; margin: 0 auto; }
h1 { color: #58a6ff; margin-top: 0; } h1 { color: #58a6ff; margin-top: 0; }
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.btn-add { background: #238636; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; } .btn-add { background: #238636; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; }
.btn-add:hover { background: #2ea043; } .btn-add:hover { background: #2ea043; }
.back-link { display: block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; } .filters { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 15px 20px; margin-bottom: 20px; display: flex; flex-wrap: wrap; gap: 20px; align-items: center; }
.filter-group { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.filter-group-label { color: #8b949e; font-size: .8rem; text-transform: uppercase; font-weight: 600; margin-right: 5px; }
.filter-chip { display: inline-block; padding: 6px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 20px; color: #8b949e; text-decoration: none; font-size: .85rem; transition: all .15s; }
.filter-chip:hover { border-color: #58a6ff; color: #e6edf3; }
.filter-chip.active { background: rgba(88,166,255,.15); border-color: #58a6ff; color: #58a6ff; }
.filter-chip.clear { background: transparent; border-color: #f85149; color: #f85149; }
.filter-chip.clear:hover { background: rgba(248,81,73,.15); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; text-decoration: none; color: inherit; transition: border-color 0.2s; } .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; text-decoration: none; color: inherit; transition: border-color 0.2s; }
.card:hover { border-color: #58a6ff; } .card:hover { border-color: #58a6ff; }
.card h3 { margin: 0 0 10px 0; color: #e6edf3; } .card h3 { margin: 0 0 10px 0; color: #e6edf3; font-size: 1.15rem; }
.card p { color: #8b949e; font-size: 0.9rem; flex-grow: 1; margin-bottom: 15px; } .card p { color: #8b949e; font-size: 0.9rem; flex-grow: 1; margin-bottom: 15px; line-height: 1.5; }
.badges { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; } .badges { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; text-transform: uppercase; } .badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; text-transform: uppercase; }
@ -23,20 +34,20 @@
.status-in_progress { background: #1f6feb; color: white; } .status-in_progress { background: #1f6feb; color: white; }
.status-completed { background: #238636; color: white; } .status-completed { background: #238636; color: white; }
.status-frozen { background: #da3633; color: white; } .status-frozen { background: #da3633; color: white; }
.vis-public { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid #3fb950; } .vis-public { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid #3fb950; }
.vis-internal { background: rgba(210,153,34,0.2); color: #d29922; border: 1px solid #d29922; } .vis-internal { background: rgba(210,153,34,0.2); color: #d29922; border: 1px solid #d29922; }
.vis-private { background: rgba(248,81,73,0.2); color: #f85149; border: 1px solid #f85149; } .vis-private { background: rgba(248,81,73,0.2); color: #f85149; border: 1px solid #f85149; }
.card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; border-top: 1px solid #30363d; padding-top: 15px; font-size: 0.85rem; } .tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.repo-link { color: #58a6ff; text-decoration: none; } .tag { font-size: .7rem; background: rgba(88,166,255,.1); color: #58a6ff; padding: 2px 8px; border-radius: 10px; border: 1px solid rgba(88,166,255,.3); }
.delete-btn { background: none; border: none; color: #da3633; cursor: pointer; font-size: 0.85rem; padding: 0; }
.delete-btn:hover { text-decoration: underline; } .card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; border-top: 1px solid #30363d; padding-top: 15px; font-size: 0.85rem; color: #8b949e; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; } .repo-link { color: #58a6ff; }
</style> .empty { color: #8b949e; padding: 40px; text-align: center; background: #161b22; border-radius: 8px; border: 1px dashed #30363d; grid-column: 1 / -1; }
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<a href="/" class="back-link">← На главную</a> <a href="/" class="back-link">← На главную</a>
<div class="header-actions"> <div class="header-actions">
<h1>Каталог проектов</h1> <h1>Каталог проектов</h1>
@ -45,6 +56,32 @@
{% endif %} {% endif %}
</div> </div>
<div class="filters">
<div class="filter-group">
<span class="filter-group-label">Статус:</span>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=" class="filter-chip {% if not current_status %}active{% endif %}">Все</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=idea" class="filter-chip {% if current_status == 'idea' %}active{% endif %}">Идея</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=in_progress" class="filter-chip {% if current_status == 'in_progress' %}active{% endif %}">В работе</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=completed" class="filter-chip {% if current_status == 'completed' %}active{% endif %}">Завершён</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=frozen" class="filter-chip {% if current_status == 'frozen' %}active{% endif %}">Заморожен</a>
</div>
{% if all_tags %}
<div class="filter-group">
<span class="filter-group-label">Теги:</span>
{% for t in all_tags %}
<a href="?{% if current_status %}status={{ current_status }}&{% endif %}tag={{ t }}" class="filter-chip {% if current_tag == t %}active{% endif %}">#{{ t }}</a>
{% endfor %}
</div>
{% endif %}
{% if current_tag or current_status %}
<div class="filter-group">
<a href="/projects" class="filter-chip clear">✕ Сбросить</a>
</div>
{% endif %}
</div>
<div class="grid"> <div class="grid">
{% for proj in projects %} {% for proj in projects %}
<a href="/projects/{{ proj.id }}" class="card"> <a href="/projects/{{ proj.id }}" class="card">
@ -63,17 +100,20 @@
</div> </div>
<h3>{{ proj.title }}</h3> <h3>{{ proj.title }}</h3>
<p>{{ proj.description or 'Описание отсутствует.' }}</p> <p>{{ proj.description or 'Описание отсутствует.' }}</p>
<div class="card-footer"> {% if proj.tags %}
<span style="color: #8b949e;">Подробнее →</span> <div class="tags">
{% if proj.gitea_url %} {% for t in proj.tags.split(',') %}<span class="tag">#{{ t.strip() }}</span>{% endfor %}
<span class="repo-link">📦 Есть репозиторий</span> </div>
{% endif %} {% endif %}
<div class="card-footer">
<span>Подробнее →</span>
{% if proj.gitea_url %}<span class="repo-link">📦 Репозиторий</span>{% endif %}
</div> </div>
</a> </a>
{% else %} {% else %}
<p style="color: #8b949e;">Доступных проектов пока нет.</p> <div class="empty">Проектов, соответствующих фильтрам, пока нет.</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@ -11,7 +11,9 @@ body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMac
h1 { color: #58a6ff; margin: 0 0 15px; } h1 { color: #58a6ff; margin: 0 0 15px; }
.back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; } .back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; } .back-link:hover { color: #58a6ff; }
.banner { padding: 12px 18px; border-radius: 8px; margin-bottom: 15px; font-size: .9rem; }
.banner-msg { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; }
.banner-error { background: rgba(248,81,73,.15); border: 1px solid #f85149; color: #f85149; }
.status-banner { padding: 18px 20px; border-radius: 10px; text-align: center; margin-bottom: 20px; } .status-banner { padding: 18px 20px; border-radius: 10px; text-align: center; margin-bottom: 20px; }
.status-on_place { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; } .status-on_place { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; }
.status-delayed { background: rgba(210,153,34,.15); border: 1px solid #d29922; color: #d29922; } .status-delayed { background: rgba(210,153,34,.15); border: 1px solid #d29922; color: #d29922; }
@ -19,7 +21,6 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
.status-free { background: rgba(139,148,158,.15); border: 1px solid #8b949e; color: #8b949e; } .status-free { background: rgba(139,148,158,.15); border: 1px solid #8b949e; color: #8b949e; }
.status-title { font-size: 1.3rem; font-weight: bold; margin-bottom: 4px; } .status-title { font-size: 1.3rem; font-weight: bold; margin-bottom: 4px; }
.status-message { font-size: .95rem; opacity: .9; } .status-message { font-size: .95rem; opacity: .9; }
.calendar { background: #161b22; border: 1px solid #30363d; border-radius: 12px; overflow: hidden; } .calendar { background: #161b22; border: 1px solid #30363d; border-radius: 12px; overflow: hidden; }
.cal-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid #30363d; gap: 15px; flex-wrap: wrap; } .cal-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid #30363d; gap: 15px; flex-wrap: wrap; }
.cal-nav { display: flex; gap: 8px; align-items: center; } .cal-nav { display: flex; gap: 8px; align-items: center; }
@ -32,13 +33,11 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
.view-switcher button { background: transparent; border: none; color: #8b949e; padding: 8px 14px; cursor: pointer; font-weight: 600; font-size: .9rem; transition: all .15s; } .view-switcher button { background: transparent; border: none; color: #8b949e; padding: 8px 14px; cursor: pointer; font-weight: 600; font-size: .9rem; transition: all .15s; }
.view-switcher button:hover { color: #e6edf3; } .view-switcher button:hover { color: #e6edf3; }
.view-switcher button.active { background: #58a6ff; color: #fff; } .view-switcher button.active { background: #58a6ff; color: #fff; }
.cal-filters { display: flex; gap: 10px; padding: 12px 20px; border-bottom: 1px solid #30363d; flex-wrap: wrap; background: #0d1117; } .cal-filters { display: flex; gap: 10px; padding: 12px 20px; border-bottom: 1px solid #30363d; flex-wrap: wrap; background: #0d1117; }
.filter-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 20px; cursor: pointer; font-size: .85rem; color: #8b949e; user-select: none; transition: all .15s; } .filter-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 20px; cursor: pointer; font-size: .85rem; color: #8b949e; user-select: none; transition: all .15s; }
.filter-chip:hover { border-color: #58a6ff; } .filter-chip:hover { border-color: #58a6ff; }
.filter-chip input { display: none; } .filter-chip input { display: none; }
.filter-chip:has(input:checked) { background: rgba(88,166,255,.1); border-color: #58a6ff; color: #e6edf3; } .filter-chip:has(input:checked) { background: rgba(88,166,255,.1); border-color: #58a6ff; color: #e6edf3; }
.month-header { display: grid; grid-template-columns: repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; } .month-header { display: grid; grid-template-columns: repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; }
.month-header-cell { padding: 10px; text-align: center; color: #8b949e; font-weight: 600; font-size: .75rem; text-transform: uppercase; } .month-header-cell { padding: 10px; text-align: center; color: #8b949e; font-weight: 600; font-size: .75rem; text-transform: uppercase; }
.month-grid { display: grid; grid-template-columns: repeat(7,1fr); grid-auto-rows: minmax(110px,auto); } .month-grid { display: grid; grid-template-columns: repeat(7,1fr); grid-auto-rows: minmax(110px,auto); }
@ -54,7 +53,6 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
.month-event .event-time { font-weight: 600; opacity: .75; } .month-event .event-time { font-weight: 600; opacity: .75; }
.month-event .event-title { overflow: hidden; text-overflow: ellipsis; } .month-event .event-title { overflow: hidden; text-overflow: ellipsis; }
.month-more { font-size: .7rem; color: #58a6ff; padding: 2px 4px; } .month-more { font-size: .7rem; color: #58a6ff; padding: 2px 4px; }
.week-view, .day-view { display: flex; flex-direction: column; } .week-view, .day-view { display: flex; flex-direction: column; }
.week-header { display: grid; grid-template-columns: 50px repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; } .week-header { display: grid; grid-template-columns: 50px repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; }
.week-day-header { padding: 10px 4px; text-align: center; border-left: 1px solid #30363d; } .week-day-header { padding: 10px 4px; text-align: center; border-left: 1px solid #30363d; }
@ -76,7 +74,6 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
.we-desc { font-size: .7rem; opacity: .8; margin-top: 4px; line-height: 1.3; } .we-desc { font-size: .7rem; opacity: .8; margin-top: 4px; line-height: 1.3; }
.now-line { position: absolute; left: 0; right: 0; height: 2px; background: #f85149; z-index: 20; pointer-events: none; } .now-line { position: absolute; left: 0; right: 0; height: 2px; background: #f85149; z-index: 20; pointer-events: none; }
.now-line::before { content: ''; position: absolute; left: -4px; top: -3px; width: 8px; height: 8px; background: #f85149; border-radius: 50%; } .now-line::before { content: ''; position: absolute; left: -4px; top: -3px; width: 8px; height: 8px; background: #f85149; border-radius: 50%; }
.event-type-lecture { background: rgba(88,166,255,.18); border-left-color: #58a6ff; } .event-type-lecture { background: rgba(88,166,255,.18); border-left-color: #58a6ff; }
.event-type-workshop { background: rgba(163,113,247,.18); border-left-color: #a371f7; } .event-type-workshop { background: rgba(163,113,247,.18); border-left-color: #a371f7; }
.event-type-meeting { background: rgba(210,153,34,.18); border-left-color: #d29922; } .event-type-meeting { background: rgba(210,153,34,.18); border-left-color: #d29922; }
@ -85,7 +82,6 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
.month-event.event-type-workshop { background: rgba(163,113,247,.15); color: #a371f7; } .month-event.event-type-workshop { background: rgba(163,113,247,.15); color: #a371f7; }
.month-event.event-type-meeting { background: rgba(210,153,34,.15); color: #d29922; } .month-event.event-type-meeting { background: rgba(210,153,34,.15); color: #d29922; }
.month-event.event-type-personal { background: rgba(139,148,158,.15); color: #8b949e; } .month-event.event-type-personal { background: rgba(139,148,158,.15); color: #8b949e; }
.event-popup { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; z-index: 1000; box-shadow: 0 20px 50px rgba(0,0,0,.6); } .event-popup { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; z-index: 1000; box-shadow: 0 20px 50px rgba(0,0,0,.6); }
.event-popup.hidden { display: none; } .event-popup.hidden { display: none; }
.event-popup h3 { margin: 0 0 12px; color: #58a6ff; } .event-popup h3 { margin: 0 0 12px; color: #58a6ff; }
@ -94,6 +90,18 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
#popup-meta { color: #8b949e; font-size: .9rem; line-height: 1.8; } #popup-meta { color: #8b949e; font-size: .9rem; line-height: 1.8; }
#popup-desc { margin-top: 15px; color: #c9d1d9; line-height: 1.5; white-space: pre-wrap; } #popup-desc { margin-top: 15px; color: #c9d1d9; line-height: 1.5; white-space: pre-wrap; }
.subscribe-box { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px 25px; margin-top: 20px; }
.subscribe-box h3 { margin: 0 0 8px; color: #58a6ff; font-size: 1.1rem; }
.subscribe-box p { color: #8b949e; margin: 0 0 15px; font-size: .9rem; line-height: 1.5; }
.sub-form { display: flex; flex-direction: column; gap: 12px; max-width: 500px; }
.sub-checkbox { display: flex; align-items: center; gap: 8px; color: #c9d1d9; font-size: .9rem; cursor: pointer; }
.sub-checkbox input { width: 16px; height: 16px; }
.btn-primary { background: #238636; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: .95rem; display: inline-block; text-decoration: none; }
.btn-primary:hover { background: #2ea043; }
.btn-danger { background: #da3633; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: .95rem; }
.btn-danger:hover { background: #f85149; }
.sub-active-badge { display: inline-block; padding: 4px 10px; background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; border-radius: 20px; font-size: .8rem; margin-bottom: 10px; }
.chat-fab { position: fixed; bottom: 25px; right: 25px; width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg,#58a6ff,#a371f7); color: #fff; border: none; font-size: 1.5rem; cursor: pointer; box-shadow: 0 6px 20px rgba(88,166,255,.4); z-index: 999; transition: transform .2s; display: flex; align-items: center; justify-content: center; } .chat-fab { position: fixed; bottom: 25px; right: 25px; width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg,#58a6ff,#a371f7); color: #fff; border: none; font-size: 1.5rem; cursor: pointer; box-shadow: 0 6px 20px rgba(88,166,255,.4); z-index: 999; transition: transform .2s; display: flex; align-items: center; justify-content: center; }
.chat-fab:hover { transform: scale(1.08); } .chat-fab:hover { transform: scale(1.08); }
.chat-window { position: fixed; bottom: 95px; right: 25px; width: 360px; height: 500px; background: #161b22; border: 1px solid #30363d; border-radius: 12px; display: flex; flex-direction: column; z-index: 998; box-shadow: 0 20px 50px rgba(0,0,0,.6); overflow: hidden; } .chat-window { position: fixed; bottom: 95px; right: 25px; width: 360px; height: 500px; background: #161b22; border: 1px solid #30363d; border-radius: 12px; display: flex; flex-direction: column; z-index: 998; box-shadow: 0 20px 50px rgba(0,0,0,.6); overflow: hidden; }
@ -127,6 +135,9 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
<a href="/" class="back-link">← На главную</a> <a href="/" class="back-link">← На главную</a>
<h1>Расписание</h1> <h1>Расписание</h1>
{% if msg %}<div class="banner banner-msg">{{ msg }}</div>{% endif %}
{% if error %}<div class="banner banner-error">{{ error }}</div>{% endif %}
{% if status %} {% if status %}
<div class="status-banner status-{{ status.status }}"> <div class="status-banner status-{{ status.status }}">
<div class="status-title"> <div class="status-title">
@ -162,6 +173,41 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
</div> </div>
<div class="cal-body" id="cal-body"></div> <div class="cal-body" id="cal-body"></div>
</div> </div>
<!-- Подписка на уведомления (аккаунт-центричная) -->
<div class="subscribe-box">
{% if not current_user %}
<h3>📬 Уведомления о расписании</h3>
<p>Войдите в аккаунт, чтобы подписаться на уведомления об изменениях статуса и событий.</p>
<a href="/login" class="btn-primary">Войти</a>
{% elif not current_user.email %}
<h3>📬 Уведомления о расписании</h3>
<p>В вашем профиле не указан email — укажите его, чтобы получать уведомления.</p>
<a href="/profile" class="btn-primary">Открыть профиль</a>
{% elif my_subscription and my_subscription.is_confirmed %}
<h3>📬 Вы подписаны на уведомления</h3>
<p>
<span class="sub-active-badge">✅ {{ current_user.email }}</span>
{% if my_subscription.notify_status %}<span style="margin-left: 8px;">✓ статус</span>{% endif %}
{% if my_subscription.notify_events %}<span style="margin-left: 8px;">✓ события</span>{% endif %}
</p>
<form method="POST" action="/subscribe/toggle">
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="next" value="/schedule">
<button type="submit" class="btn-danger">Отписаться</button>
</form>
{% else %}
<h3>📬 Подписаться на уведомления</h3>
<p>Получайте email, когда меняется статус или расписание. Email берётся из аккаунта — подтверждение не нужно.</p>
<form method="POST" action="/subscribe/toggle" class="sub-form">
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="next" value="/schedule">
<label class="sub-checkbox"><input type="checkbox" name="notify_status" checked> Изменения статуса</label>
<label class="sub-checkbox"><input type="checkbox" name="notify_events" checked> Изменения событий</label>
<div><button type="submit" class="btn-primary">Подписаться на {{ current_user.email }}</button></div>
</form>
{% endif %}
</div>
</div> </div>
<div id="event-popup" class="event-popup hidden"> <div id="event-popup" class="event-popup hidden">
@ -190,7 +236,6 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
const EVENTS = {{ events_json|tojson }}; const EVENTS = {{ events_json|tojson }};
const MSK_OFFSET_MS = 3 * 60 * 60 * 1000; const MSK_OFFSET_MS = 3 * 60 * 60 * 1000;
const state = { view: 'week', cursor: new Date(), filters: new Set(['lecture','workshop','meeting','personal']) }; const state = { view: 'week', cursor: new Date(), filters: new Set(['lecture','workshop','meeting','personal']) };
function inMSK(d) { return new Date(d.getTime() + MSK_OFFSET_MS); } function inMSK(d) { return new Date(d.getTime() + MSK_OFFSET_MS); }
function fromMSK(y, m, d, h, mn) { return new Date(Date.UTC(y, m, d, h||0, mn||0) - MSK_OFFSET_MS); } function fromMSK(y, m, d, h, mn) { return new Date(Date.UTC(y, m, d, h||0, mn||0) - MSK_OFFSET_MS); }
function mskParts(d) { function mskParts(d) {
@ -220,7 +265,6 @@ function fmtDateFull(d) {
} }
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']; const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
const MONTHS_GEN = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря']; const MONTHS_GEN = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
function getFilteredEvents() { function getFilteredEvents() {
return EVENTS.filter(e => state.filters.has(e.type)) return EVENTS.filter(e => state.filters.has(e.type))
.map(e => Object.assign({}, e, { _start: new Date(e.start), _end: new Date(e.end) })); .map(e => Object.assign({}, e, { _start: new Date(e.start), _end: new Date(e.end) }));
@ -250,7 +294,6 @@ function layoutOverlaps(events, dayStart, dayEnd) {
function escapeHtml(s) { function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
} }
function render() { function render() {
const body = document.getElementById('cal-body'); const body = document.getElementById('cal-body');
document.getElementById('cal-title').textContent = getTitle(); document.getElementById('cal-title').textContent = getTitle();
@ -269,7 +312,6 @@ function getTitle() {
} }
return fmtDateFull(state.cursor); return fmtDateFull(state.cursor);
} }
function renderMonth(container) { function renderMonth(container) {
const first = startOfMSKMonth(state.cursor); const first = startOfMSKMonth(state.cursor);
const gridStart = startOfMSKWeek(first); const gridStart = startOfMSKWeek(first);
@ -297,7 +339,6 @@ function renderMonth(container) {
container.innerHTML = html; container.innerHTML = html;
attachEventHandlers(); attachEventHandlers();
} }
function renderWeek(container) { function renderWeek(container) {
const weekStart = startOfMSKWeek(state.cursor); const weekStart = startOfMSKWeek(state.cursor);
const today = startOfMSKDay(new Date()); const today = startOfMSKDay(new Date());
@ -348,7 +389,6 @@ function renderWeek(container) {
container.dataset.scrolled = '1'; container.dataset.scrolled = '1';
} }
} }
function renderDay(container) { function renderDay(container) {
const ds = startOfMSKDay(state.cursor); const ds = startOfMSKDay(state.cursor);
const de = addDays(ds, 1); const de = addDays(ds, 1);
@ -378,7 +418,6 @@ function renderDay(container) {
container.innerHTML = html; container.innerHTML = html;
attachEventHandlers(); attachEventHandlers();
} }
function attachEventHandlers() { function attachEventHandlers() {
document.querySelectorAll('[data-event-id]').forEach(el => { document.querySelectorAll('[data-event-id]').forEach(el => {
el.addEventListener('click', e => { e.stopPropagation(); showEventPopup(parseInt(el.dataset.eventId)); }); el.addEventListener('click', e => { e.stopPropagation(); showEventPopup(parseInt(el.dataset.eventId)); });
@ -397,7 +436,6 @@ function showEventPopup(id) {
document.getElementById('popup-desc').textContent = ev.description || ''; document.getElementById('popup-desc').textContent = ev.description || '';
document.getElementById('event-popup').classList.remove('hidden'); document.getElementById('event-popup').classList.remove('hidden');
} }
document.getElementById('cal-prev').addEventListener('click', () => { document.getElementById('cal-prev').addEventListener('click', () => {
if (state.view === 'month') state.cursor = addMonths(state.cursor, -1); if (state.view === 'month') state.cursor = addMonths(state.cursor, -1);
else if (state.view === 'week') state.cursor = addDays(state.cursor, -7); else if (state.view === 'week') state.cursor = addDays(state.cursor, -7);
@ -426,7 +464,6 @@ document.querySelectorAll('#cal-filters input').forEach(cb => {
render(); render();
}); });
}); });
document.getElementById('popup-close').addEventListener('click', () => { document.getElementById('popup-close').addEventListener('click', () => {
document.getElementById('event-popup').classList.add('hidden'); document.getElementById('event-popup').classList.add('hidden');
}); });
@ -436,7 +473,6 @@ document.addEventListener('click', e => {
popup.classList.add('hidden'); popup.classList.add('hidden');
} }
}); });
const chatWindow = document.getElementById('chat-window'); const chatWindow = document.getElementById('chat-window');
document.getElementById('chat-toggle').addEventListener('click', () => chatWindow.classList.toggle('hidden')); document.getElementById('chat-toggle').addEventListener('click', () => chatWindow.classList.toggle('hidden'));
document.getElementById('chat-close').addEventListener('click', () => chatWindow.classList.add('hidden')); document.getElementById('chat-close').addEventListener('click', () => chatWindow.classList.add('hidden'));
@ -462,7 +498,6 @@ function addMsg(text, sender) {
c.appendChild(div); c.scrollTop = c.scrollHeight; c.appendChild(div); c.scrollTop = c.scrollHeight;
return div; return div;
} }
render(); render();
</script> </script>
</body> </body>

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 20px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 40px 35px; max-width: 480px; width: 100%; text-align: center; }
.icon { font-size: 3.5rem; margin-bottom: 15px; }
.icon.ok { color: #3fb950; }
.icon.err { color: #f85149; }
h1 { margin: 0 0 15px; font-size: 1.5rem; }
h1.ok { color: #3fb950; }
h1.err { color: #f85149; }
p { color: #8b949e; line-height: 1.6; margin: 0 0 25px; }
.btn { display: inline-block; padding: 10px 20px; border-radius: 6px; text-decoration: none; font-weight: 600; margin: 5px; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-danger { background: #da3633; color: #fff; border: none; cursor: pointer; font-family: inherit; font-size: inherit; }
.btn-danger:hover { background: #f85149; }
.btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
form { display: inline; }
</style>
</head>
<body>
<div class="card">
<div class="icon {% if ok %}ok{% else %}err{% endif %}">{% if ok %}✓{% else %}⚠{% endif %}</div>
<h1 class="{% if ok %}ok{% else %}err{% endif %}">{{ title }}</h1>
<p>{{ message }}</p>
{% if show_confirm_form %}
<form method="POST" action="{{ confirm_action }}">
<button type="submit" class="btn btn-danger">Да, отписаться</button>
<a href="/schedule" class="btn btn-secondary">Отмена</a>
</form>
{% else %}
<a href="/schedule" class="btn btn-primary">← К расписанию</a>
{% if unsubscribe_token %}
<a href="/unsubscribe/{{ unsubscribe_token }}" class="btn btn-secondary">Управление подпиской</a>
{% endif %}
{% endif %}
</div>
</body>
</html>

View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Редактирование задачи — {{ project.title }}</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px 20px; }
.container { max-width: 600px; margin: 0 auto; }
h2 { color: #58a6ff; }
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 15px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
label { color: #8b949e; font-size: .85rem; font-weight: 600; }
input, select, textarea { padding: 10px; background: #0d1117; color: #fff; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; font-size: .95rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; }
textarea { resize: vertical; min-height: 100px; }
.actions { display: flex; gap: 10px; margin-top: 20px; }
.btn { padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; font-weight: 600; font-size: .9rem; text-decoration: none; display: inline-block; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
</style>
</head>
<body>
<div class="container">
<a href="/projects/{{ project.id }}/board" class="back-link">← К доске</a>
<h2>Редактирование задачи</h2>
<form method="POST">
<div class="form-group">
<label>Название *</label>
<input type="text" name="title" value="{{ task.title }}" required maxlength="200">
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description">{{ task.description or '' }}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Статус</label>
<select name="status">
{% for s in TASK_STATUSES %}
<option value="{{ s }}" {% if task.status == s %}selected{% endif %}>{{ TASK_STATUS_LABELS[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Приоритет</label>
<select name="priority">
{% for p in TASK_PRIORITIES %}
<option value="{{ p }}" {% if task.priority == p %}selected{% endif %}>{{ p|capitalize }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Дедлайн</label>
<input type="date" name="due_date" value="{{ task.due_date or '' }}">
</div>
<div class="form-group">
<label>Исполнитель</label>
<select name="assignee_id">
<option value="">— не назначено —</option>
{% for member_obj, user in members %}
<option value="{{ user.id }}" {% if task.assignee_id == user.id %}selected{% endif %}>{{ user.full_name or user.username }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="/projects/{{ project.id }}/board" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ workshop.title }} — Morozovka Portal</title>
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; font-size: 14px; line-height: 1.5; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.topnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 32px; display: flex; align-items: center; gap: 24px; }
.topnav-logo { display: flex; align-items: center; gap: 8px; color: #e6edf3; font-weight: 600; font-size: 15px; }
.topnav-logo svg { color: #58a6ff; }
.topnav-links { display: flex; gap: 20px; flex: 1; }
.topnav-links a { color: #c9d1d9; font-weight: 500; }
.topnav-links a:hover { color: #58a6ff; text-decoration: none; }
.topnav-actions { display: flex; gap: 12px; align-items: center; padding-right: 80px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; line-height: 20px; border: 1px solid rgba(240,246,252,0.1); cursor: pointer; text-decoration: none; transition: all .15s; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-danger { background: #da3633; color: #fff; }
.btn-danger:hover { background: #f85149; }
.btn-default { background: #21262d; color: #c9d1d9; }
.btn-default:hover { background: #30363d; }
.container { max-width: 1000px; margin: 0 auto; padding: 24px 32px; }
.breadcrumbs { color: #8b949e; font-size: 13px; margin-bottom: 16px; }
.breadcrumbs a { color: #8b949e; }
.breadcrumbs a:hover { color: #58a6ff; }
.ws-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.ws-header-left { flex: 1; }
.ws-title { font-size: 24px; font-weight: 600; margin: 0 0 12px; color: #e6edf3; }
.badges { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.badge { display: inline-block; padding: 0 7px; border-radius: 2em; font-size: 12px; font-weight: 500; line-height: 18px; border: 1px solid; }
.badge-cat-soldering { background: rgba(88,166,255,0.15); color: #58a6ff; border-color: rgba(88,166,255,0.4); }
.badge-cat-embroidery { background: rgba(163,113,247,0.15); color: #a371f7; border-color: rgba(163,113,247,0.4); }
.badge-cat-embedded { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.badge-cat-linux { background: rgba(210,153,34,0.15); color: #d29922; border-color: rgba(210,153,34,0.4); }
.badge-cat-other { background: rgba(139,148,158,0.15); color: #8b949e; border-color: rgba(139,148,158,0.4); }
.badge-status-draft { background: rgba(139,148,158,0.15); color: #8b949e; border-color: rgba(139,148,158,0.4); }
.badge-status-published { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.badge-status-archived { background: rgba(210,153,34,0.15); color: #d29922; border-color: rgba(210,153,34,0.4); }
.badge-vis-internal { background: rgba(248,81,73,0.15); color: #f85149; border-color: rgba(248,81,73,0.4); }
.badge-difficulty-beginner { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.badge-difficulty-intermediate { background: rgba(210,153,34,0.15); color: #d29922; border-color: rgba(210,153,34,0.4); }
.badge-difficulty-advanced { background: rgba(248,81,73,0.15); color: #f85149; border-color: rgba(248,81,73,0.4); }
.ws-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.layout { display: grid; grid-template-columns: 2fr 1fr; gap: 24px; }
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } .container { padding: 16px; } .topnav { padding: 12px 16px; } .topnav-links { display: none; } .topnav-actions { padding-right: 60px; } }
.section { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; margin-bottom: 16px; }
.section h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; color: #e6edf3; padding-bottom: 8px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; }
.section h2 .counter { color: #8b949e; font-size: 13px; font-weight: normal; }
.section-content { color: #c9d1d9; white-space: pre-wrap; line-height: 1.6; }
.session-row { display: flex; gap: 12px; align-items: center; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; margin-bottom: 8px; }
.session-row:last-child { margin-bottom: 0; }
.session-date { color: #58a6ff; font-weight: 600; font-family: monospace; flex-shrink: 0; }
.session-info { flex: 1; color: #c9d1d9; font-size: 13px; }
.session-note { color: #8b949e; font-size: 12px; margin-top: 2px; }
.material-row { display: flex; gap: 12px; align-items: center; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; margin-bottom: 8px; }
.material-row:last-child { margin-bottom: 0; }
.material-icon { font-size: 20px; flex-shrink: 0; }
.material-info { flex: 1; }
.material-title { color: #e6edf3; font-weight: 500; font-size: 13px; }
.material-desc { color: #8b949e; font-size: 12px; margin-top: 2px; }
.material-row a { color: #58a6ff; font-size: 13px; }
.add-form { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; padding: 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; margin-top: 12px; }
.add-form .form-group { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 140px; }
.add-form label { color: #8b949e; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.add-form input, .add-form select { padding: 5px 10px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 13px; font-family: inherit; }
.add-form input:focus, .add-form select:focus { outline: none; border-color: #58a6ff; }
.sidebar-item { display: flex; flex-direction: column; gap: 4px; margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #30363d; }
.sidebar-item:last-child { border-bottom: none; padding-bottom: 0; margin-bottom: 0; }
.sidebar-label { color: #8b949e; font-size: 12px; text-transform: uppercase; font-weight: 600; }
.sidebar-value { color: #e6edf3; font-size: 14px; }
.sidebar-value .badge { font-size: 12px; }
.reg-info { padding: 12px; border-radius: 6px; font-size: 13px; margin-bottom: 12px; }
.reg-info.registered { background: rgba(63,185,80,0.1); border: 1px solid rgba(63,185,80,0.4); color: #3fb950; }
.reg-info.full { background: rgba(210,153,34,0.1); border: 1px solid rgba(210,153,34,0.4); color: #d29922; }
.reg-info.none { background: #0d1117; border: 1px solid #30363d; color: #8b949e; }
.empty-small { color: #8b949e; font-size: 13px; padding: 12px; text-align: center; background: #0d1117; border: 1px dashed #30363d; border-radius: 6px; }
.participant-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; font-size: 13px; border-bottom: 1px solid #21262d; }
.participant-row:last-child { border-bottom: none; }
.participant-status { font-size: 11px; padding: 2px 6px; border-radius: 10px; }
.status-registered { background: rgba(63,185,80,0.15); color: #3fb950; }
.status-attended { background: rgba(88,166,255,0.15); color: #58a6ff; }
.status-cancelled { background: rgba(139,148,158,0.15); color: #8b949e; }
.status-no_show { background: rgba(248,81,73,0.15); color: #f85149; }
</style>
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
</a>
<div class="topnav-links">
<a href="/schedule">Расписание</a>
<a href="/projects">Проекты</a>
<a href="/workshops" style="color:#58a6ff;">Мастер-классы</a>
<a href="/files">Файлы</a>
</div>
<div class="topnav-actions">
{% if session.get('username') %}<a href="/profile" class="btn btn-default">{{ session.get('username') }}</a>{% endif %}
</div>
</nav>
<div class="container">
<div class="breadcrumbs"><a href="/workshops">Мастер-классы</a> / {{ workshop.title }}</div>
<div class="ws-header">
<div class="ws-header-left">
<h1 class="ws-title">{{ workshop.title }}</h1>
<div class="badges">
<span class="badge badge-cat-{{ workshop.category }}">{{ WORKSHOP_CATEGORY_LABELS[workshop.category] }}</span>
<span class="badge badge-difficulty-{{ workshop.difficulty }}">{{ WORKSHOP_DIFFICULTY_LABELS[workshop.difficulty] }}</span>
<span class="badge badge-status-{{ workshop.status }}">{{ WORKSHOP_STATUS_LABELS[workshop.status] }}</span>
<span class="badge badge-vis-{{ workshop.visibility }}">{{ WORKSHOP_VISIBILITY_LABELS[workshop.visibility] }}</span>
</div>
</div>
<div class="ws-actions">
{% if perms.can_manage %}
<a href="/workshops/{{ workshop.id }}/edit" class="btn btn-default">✎ Редактировать</a>
{% endif %}
{% if perms.can_delete %}
<form action="/workshops/{{ workshop.id }}/delete" method="POST" onsubmit="return confirm('Удалить мастер-класс?');" style="display:inline;">
<button type="submit" class="btn btn-danger">Удалить</button>
</form>
{% endif %}
</div>
</div>
<div class="layout">
<div>
{% if workshop.description %}
<div class="section"><h2>Описание</h2><div class="section-content">{{ workshop.description }}</div></div>
{% endif %}
{% if workshop.program %}
<div class="section"><h2>Программа</h2><div class="section-content">{{ workshop.program }}</div></div>
{% endif %}
{% if workshop.materials_required %}
<div class="section"><h2>Требуемые материалы</h2><div class="section-content">{{ workshop.materials_required }}</div></div>
{% endif %}
{% if workshop.outcome %}
<div class="section"><h2>Что получит участник</h2><div class="section-content">{{ workshop.outcome }}</div></div>
{% endif %}
<div class="section">
<h2>Сессии <span class="counter">{{ sessions|length }}</span></h2>
{% if sessions %}
{% for s in sessions %}
<div class="session-row">
<div class="session-date">
{{ s.start_time|msk_strftime('%d.%m.%Y') }}<br>
<span style="color:#8b949e;font-weight:normal;font-size:12px;">{{ s.start_time|msk_strftime('%H:%M') }}–{{ s.end_time|msk_strftime('%H:%M') }}</span>
</div>
<div class="session-info">
{% if s.location_override %}📍 {{ s.location_override }}{% elif workshop.location %}📍 {{ workshop.location }}{% endif %}
{% if s.note %}<div class="session-note">{{ s.note }}</div>{% endif %}
</div>
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/sessions/{{ s.id }}/delete" method="POST" onsubmit="return confirm('Удалить сессию?');">
<button type="submit" class="btn btn-default" style="padding:2px 8px;font-size:12px;">✕</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-small">Пока нет запланированных сессий.</div>
{% endif %}
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/sessions/add" method="POST" class="add-form">
<div class="form-group"><label>Начало</label><input type="datetime-local" name="start_time" required></div>
<div class="form-group"><label>Конец</label><input type="datetime-local" name="end_time" required></div>
<div class="form-group"><label>Место (опц.)</label><input type="text" name="location_override"></div>
<div class="form-group"><label>Заметка</label><input type="text" name="note"></div>
<button type="submit" class="btn btn-primary">+ Добавить</button>
</form>
{% endif %}
</div>
<div class="section">
<h2>Материалы <span class="counter">{{ materials|length }}</span></h2>
{% if materials %}
{% for m in materials %}
<div class="material-row">
<div class="material-icon">{% if m.external_url %}🔗{% else %}📎{% endif %}</div>
<div class="material-info">
<div class="material-title">{{ m.title }}</div>
{% if m.description %}<div class="material-desc">{{ m.description }}</div>{% endif %}
</div>
{% if m.external_url %}<a href="{{ m.external_url }}" target="_blank">Открыть →</a>
{% elif m.file_id and material_files_map.get(m.file_id) %}<a href="/files/download/{{ m.file_id }}">Скачать →</a>{% endif %}
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/materials/{{ m.id }}/delete" method="POST" onsubmit="return confirm('Удалить материал?');">
<button type="submit" class="btn btn-default" style="padding:2px 8px;font-size:12px;">✕</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-small">{% if is_registered or perms.can_manage %}Материалы появятся после публикации.{% else %}Материалы доступны после записи.{% endif %}</div>
{% endif %}
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/materials/add" method="POST" class="add-form">
<div class="form-group"><label>Название *</label><input type="text" name="title" required></div>
<div class="form-group"><label>Ссылка (URL)</label><input type="url" name="external_url"></div>
<div class="form-group"><label>Описание</label><input type="text" name="description"></div>
<div class="form-group"><label>Кому видно</label>
<select name="visible_to"><option value="registered">Записавшимся</option><option value="all">Всем</option></select>
</div>
<button type="submit" class="btn btn-primary">+ Добавить</button>
</form>
{% endif %}
</div>
{% if perms.can_manage %}
<div class="section">
<h2>Участники <span class="counter">{{ participants|length }}</span></h2>
{% if participants %}
{% for reg, usr in participants %}
<div class="participant-row">
<div>{{ usr.full_name or usr.username }} <span style="color:#8b949e;">@{{ usr.username }}</span></div>
<span class="participant-status status-{{ reg.status }}">
{% if reg.status == 'registered' %}Записан
{% elif reg.status == 'attended' %}Посетил
{% elif reg.status == 'cancelled' %}Отменил
{% elif reg.status == 'no_show' %}Не пришёл
{% else %}{{ reg.status }}{% endif %}
</span>
</div>
{% endfor %}
{% else %}
<div class="empty-small">Пока никто не записался.</div>
{% endif %}
</div>
{% endif %}
</div>
<div>
<div class="section">
<h2>Детали</h2>
<div class="sidebar-item">
<span class="sidebar-label">Ведущий</span>
<span class="sidebar-value">{% if lead %}{{ lead.full_name or lead.username }}{% else %}—{% endif %}</span>
</div>
{% if workshop.partner %}
<div class="sidebar-item">
<span class="sidebar-label">Партнёр</span>
<span class="sidebar-value">{{ workshop.partner }}</span>
</div>
{% endif %}
{% if workshop.location %}
<div class="sidebar-item">
<span class="sidebar-label">Место</span>
<span class="sidebar-value">📍 {{ workshop.location }}</span>
</div>
{% endif %}
<div class="sidebar-item">
<span class="sidebar-label">Уровень</span>
<span class="sidebar-value"><span class="badge badge-difficulty-{{ workshop.difficulty }}">{{ WORKSHOP_DIFFICULTY_LABELS[workshop.difficulty] }}</span></span>
</div>
{% if workshop.has_certificate %}
<div class="sidebar-item">
<span class="sidebar-label">По окончании</span>
<span class="sidebar-value">🏅 Сертификат</span>
</div>
{% endif %}
<div class="sidebar-item">
<span class="sidebar-label">Участники</span>
<span class="sidebar-value">{{ registered_count }}{% if workshop.max_participants %} / {{ workshop.max_participants }}{% endif %}</span>
</div>
{% if workshop.tags %}
<div class="sidebar-item">
<span class="sidebar-label">Теги</span>
<span class="sidebar-value" style="font-size:13px;">{% for t in workshop.tags.split(',') %}#{{ t.strip() }} {% endfor %}</span>
</div>
{% endif %}
</div>
{% if workshop.status == 'published' %}
<div class="section">
<h2>Регистрация</h2>
{% if not user_id %}
<div class="reg-info none">Войдите, чтобы записаться.</div>
<a href="/login" class="btn btn-primary" style="width:100%;justify-content:center;">Войти</a>
{% elif is_registered %}
<div class="reg-info registered">✅ Вы записаны на этот мастер-класс.</div>
<form action="/workshops/{{ workshop.id }}/unregister" method="POST">
<button type="submit" class="btn btn-default" style="width:100%;justify-content:center;">Отменить запись</button>
</form>
{% elif is_full %}
<div class="reg-info full">⚠ Мест больше нет ({{ registered_count }}/{{ workshop.max_participants }}).</div>
{% else %}
<div class="reg-info none">Свободно мест: {% if workshop.max_participants %}{{ workshop.max_participants - registered_count }}{% else %}не ограничено{% endif %}.</div>
<form action="/workshops/{{ workshop.id }}/register" method="POST">
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;">Записаться</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<script src="/static/notif_bell.js"></script>
</body>
</html>

View File

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ 'Редактирование' if is_edit else 'Новый мастер-класс' }} — Morozovka Portal</title>
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; font-size: 14px; line-height: 1.5; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.topnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 32px; display: flex; align-items: center; gap: 24px; }
.topnav-logo { display: flex; align-items: center; gap: 8px; color: #e6edf3; font-weight: 600; font-size: 15px; }
.topnav-logo svg { color: #58a6ff; }
.topnav-links { display: flex; gap: 20px; flex: 1; }
.topnav-links a { color: #c9d1d9; font-weight: 500; }
.topnav-links a:hover { color: #58a6ff; text-decoration: none; }
.topnav-actions { display: flex; gap: 12px; align-items: center; padding-right: 80px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; line-height: 20px; border: 1px solid rgba(240,246,252,0.1); cursor: pointer; text-decoration: none; transition: all .15s; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-default { background: #21262d; color: #c9d1d9; }
.btn-default:hover { background: #30363d; }
.container { max-width: 800px; margin: 0 auto; padding: 24px 32px; }
.page-title { font-size: 24px; font-weight: 600; margin: 0 0 24px; color: #e6edf3; }
.form-card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 24px; }
.form-group { margin-bottom: 16px; display: flex; flex-direction: column; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
label { color: #e6edf3; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
label .req { color: #f85149; }
input, select, textarea { padding: 5px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 14px; font-family: inherit; line-height: 20px; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; box-shadow: 0 0 0 3px rgba(88,166,255,0.15); }
textarea { min-height: 100px; resize: vertical; }
.hint { color: #8b949e; font-size: 12px; margin-top: 4px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.checkbox-row input { width: 16px; height: 16px; }
.checkbox-row label { font-weight: 500; margin: 0; cursor: pointer; }
.form-actions { display: flex; gap: 8px; margin-top: 24px; padding-top: 16px; border-top: 1px solid #30363d; }
@media (max-width: 768px) {
.topnav { padding: 12px 16px; gap: 12px; }
.topnav-links { display: none; }
.topnav-actions { padding-right: 60px; }
.container { padding: 16px; }
.form-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
</a>
<div class="topnav-links">
<a href="/schedule">Расписание</a>
<a href="/projects">Проекты</a>
<a href="/workshops">Мастер-классы</a>
<a href="/files">Файлы</a>
</div>
<div class="topnav-actions">
{% if session.get('username') %}
<a href="/profile" class="btn btn-default">{{ session.get('username') }}</a>
{% endif %}
</div>
</nav>
<div class="container">
<h1 class="page-title">{{ 'Редактирование мастер-класса' if is_edit else 'Новый мастер-класс' }}</h1>
<form method="POST" class="form-card">
<div class="form-group">
<label>Название <span class="req">*</span></label>
<input type="text" name="title" value="{{ workshop.title if is_edit else '' }}" required maxlength="200">
</div>
<div class="form-group">
<label>Краткое описание</label>
<textarea name="description">{{ workshop.description if is_edit else '' }}</textarea>
<span class="hint">2–3 предложения — появится в каталоге.</span>
</div>
<div class="form-group">
<label>Программа</label>
<textarea name="program" style="min-height: 150px;">{{ workshop.program if is_edit else '' }}</textarea>
<span class="hint">Подробный план занятия.</span>
</div>
<div class="form-row">
<div class="form-group">
<label>Категория</label>
<select name="category">
{% for cat in WORKSHOP_CATEGORIES %}
<option value="{{ cat }}" {% if is_edit and workshop.category == cat %}selected{% endif %}>{{ WORKSHOP_CATEGORY_LABELS[cat] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Уровень сложности</label>
<select name="difficulty">
{% for d in WORKSHOP_DIFFICULTIES %}
<option value="{{ d }}" {% if is_edit and workshop.difficulty == d %}selected{% endif %}>{{ WORKSHOP_DIFFICULTY_LABELS[d] }}</option>
{% endfor %}
</select>
<span class="hint">Новичок — без опыта, Продвинутый — нужна база.</span>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Партнёр / организатор</label>
<input type="text" name="partner" value="{{ workshop.partner if is_edit and workshop.partner else '' }}" placeholder="ННГУ, Неймарк, Ядро, ...">
<span class="hint">Опционально. Покажется плашкой на карточке.</span>
</div>
<div class="form-group">
<label>Место</label>
<input type="text" name="location" value="{{ workshop.location if is_edit and workshop.location else '' }}" placeholder="Ауд. 405">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Статус</label>
<select name="status">
{% for s in WORKSHOP_STATUSES %}
<option value="{{ s }}" {% if is_edit and workshop.status == s %}selected{% endif %}>{{ WORKSHOP_STATUS_LABELS[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Видимость</label>
<select name="visibility">
{% for v in WORKSHOP_VISIBILITIES %}
<option value="{{ v }}" {% if is_edit and workshop.visibility == v %}selected{% endif %}>{{ WORKSHOP_VISIBILITY_LABELS[v] }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Максимум участников</label>
<input type="number" name="max_participants" value="{{ workshop.max_participants if is_edit and workshop.max_participants else '' }}" placeholder="Без ограничения">
<span class="hint">Пусто — без лимита</span>
</div>
<div class="form-group">
<label>Ссылка на обложку</label>
<input type="url" name="cover_url" value="{{ workshop.cover_url if is_edit and workshop.cover_url else '' }}" placeholder="https://...">
</div>
</div>
<div class="form-group">
<label>Требуемые материалы</label>
<textarea name="materials_required" style="min-height: 80px;">{{ workshop.materials_required if is_edit else '' }}</textarea>
<span class="hint">Что принести с собой или что предоставим.</span>
</div>
<div class="form-group">
<label>Что получит участник</label>
<textarea name="outcome" style="min-height: 80px;">{{ workshop.outcome if is_edit else '' }}</textarea>
<span class="hint">Например: «Собранный светодиодный индикатор и базовые навыки пайки».</span>
<div class="checkbox-row">
<input type="checkbox" name="has_certificate" id="has_certificate" {% if is_edit and workshop.has_certificate %}checked{% endif %}>
<label for="has_certificate">🏅 Выдаётся сертификат</label>
</div>
</div>
<div class="form-group">
<label>Теги</label>
<input type="text" name="tags" value="{{ workshop.tags if is_edit and workshop.tags else '' }}" placeholder="через, запятую">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Сохранить' if is_edit else 'Создать' }}</button>
<a href="{{ '/workshops/' + workshop.id|string if is_edit else '/workshops' }}" class="btn btn-default">Отмена</a>
</div>
</form>
</div>
<script src="/static/notif_bell.js"></script>
</body>
</html>

View File

@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Мастер-классы — Morozovka Portal</title>
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; font-size: 14px; line-height: 1.5; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.topnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 32px; display: flex; align-items: center; gap: 24px; }
.topnav-logo { display: flex; align-items: center; gap: 8px; color: #e6edf3; font-weight: 600; font-size: 15px; }
.topnav-logo svg { color: #58a6ff; }
.topnav-links { display: flex; gap: 20px; flex: 1; }
.topnav-links a { color: #c9d1d9; font-weight: 500; }
.topnav-links a:hover { color: #58a6ff; text-decoration: none; }
.topnav-actions { display: flex; gap: 12px; align-items: center; padding-right: 80px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; line-height: 20px; border: 1px solid rgba(240,246,252,0.1); cursor: pointer; text-decoration: none; white-space: nowrap; transition: all .15s; }
.btn:hover { text-decoration: none; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-default { background: #21262d; color: #c9d1d9; }
.btn-default:hover { background: #30363d; }
.container { max-width: 1100px; margin: 0 auto; padding: 24px 32px; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; gap: 16px; flex-wrap: wrap; }
.page-title { font-size: 24px; font-weight: 600; margin: 0 0 4px; color: #e6edf3; }
.page-subtitle { color: #8b949e; font-size: 14px; margin: 0; }
.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; padding: 16px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
.filter-chip { display: inline-flex; align-items: center; padding: 4px 10px; font-size: 12px; font-weight: 500; border-radius: 2em; border: 1px solid #30363d; background: #21262d; color: #c9d1d9; cursor: pointer; }
.filter-chip:hover { background: #30363d; text-decoration: none; color: #e6edf3; }
.filter-chip.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
.filter-chip.active:hover { background: #388bfd; color: #fff; }
.filter-chip-clear { border-color: #f85149; color: #f85149; background: transparent; }
.filter-chip-clear:hover { background: rgba(248,81,73,0.1); color: #f85149; }
.filter-label { color: #8b949e; font-size: 12px; font-weight: 600; text-transform: uppercase; align-self: center; margin-right: 6px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.ws-card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; display: flex; flex-direction: column; transition: border-color .15s; }
.ws-card:hover { border-color: #58a6ff; }
.ws-card-cover { height: 120px; background: linear-gradient(135deg, #1f6feb 0%, #a371f7 100%); display: flex; align-items: center; justify-content: center; font-size: 2.5rem; position: relative; }
.ws-card-cover-partner { position: absolute; top: 8px; right: 10px; background: rgba(0,0,0,0.5); color: #fff; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.ws-card-body { padding: 16px; flex: 1; display: flex; flex-direction: column; }
.ws-card h3 { color: #e6edf3; font-size: 16px; font-weight: 600; margin: 0 0 8px; line-height: 1.4; }
.ws-card h3 a { color: inherit; }
.ws-card h3 a:hover { color: #58a6ff; text-decoration: none; }
.ws-card-desc { color: #8b949e; font-size: 13px; line-height: 1.5; margin: 0 0 12px; flex: 1; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.badges { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.badge { display: inline-block; padding: 0 7px; border-radius: 2em; font-size: 12px; font-weight: 500; line-height: 18px; border: 1px solid; white-space: nowrap; }
.badge-cat-soldering { background: rgba(88,166,255,0.15); color: #58a6ff; border-color: rgba(88,166,255,0.4); }
.badge-cat-embroidery { background: rgba(163,113,247,0.15); color: #a371f7; border-color: rgba(163,113,247,0.4); }
.badge-cat-embedded { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.badge-cat-linux { background: rgba(210,153,34,0.15); color: #d29922; border-color: rgba(210,153,34,0.4); }
.badge-cat-other { background: rgba(139,148,158,0.15); color: #8b949e; border-color: rgba(139,148,158,0.4); }
.badge-status-draft { background: rgba(139,148,158,0.15); color: #8b949e; border-color: rgba(139,148,158,0.4); }
.badge-status-published { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.badge-status-archived { background: rgba(210,153,34,0.15); color: #d29922; border-color: rgba(210,153,34,0.4); }
.badge-vis-internal { background: rgba(248,81,73,0.15); color: #f85149; border-color: rgba(248,81,73,0.4); }
.badge-difficulty-beginner { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.badge-difficulty-intermediate { background: rgba(210,153,34,0.15); color: #d29922; border-color: rgba(210,153,34,0.4); }
.badge-difficulty-advanced { background: rgba(248,81,73,0.15); color: #f85149; border-color: rgba(248,81,73,0.4); }
.ws-meta { display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; color: #8b949e; padding-top: 12px; border-top: 1px solid #30363d; margin-top: auto; }
.ws-meta-item { display: inline-flex; align-items: center; gap: 4px; }
.empty-state { padding: 64px 24px; text-align: center; color: #8b949e; background: #161b22; border: 1px dashed #30363d; border-radius: 6px; }
.empty-state h3 { color: #e6edf3; margin-bottom: 8px; font-size: 16px; }
.empty-state p { margin-bottom: 16px; }
@media (max-width: 768px) {
.topnav { padding: 12px 16px; gap: 12px; }
.topnav-links { display: none; }
.topnav-actions { padding-right: 60px; }
.container { padding: 16px; }
.page-title { font-size: 20px; }
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
</a>
<div class="topnav-links">
<a href="/schedule">Расписание</a>
<a href="/projects">Проекты</a>
<a href="/workshops" style="color:#58a6ff;">Мастер-классы</a>
<a href="/files">Файлы</a>
</div>
<div class="topnav-actions">
{% if session.get('username') %}
<a href="/profile" class="btn btn-default">{{ session.get('username') }}</a>
{% else %}
<a href="/login" class="btn btn-default">Войти</a>
{% endif %}
</div>
</nav>
<div class="container">
<div class="page-header">
<div>
<h1 class="page-title">Мастер-классы</h1>
<p class="page-subtitle">Практические занятия: пайка, embedded, Linux, творчество</p>
</div>
{% if can_create %}
<a href="/workshops/new" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 2a.75.75 0 0 1 .75.75v4.5h4.5a.75.75 0 0 1 0 1.5h-4.5v4.5a.75.75 0 0 1-1.5 0v-4.5h-4.5a.75.75 0 0 1 0-1.5h4.5v-4.5A.75.75 0 0 1 8 2Z"/></svg>
Создать мастер-класс
</a>
{% endif %}
</div>
<div class="filters">
<span class="filter-label">Категория:</span>
<a href="?{% if current_status %}status={{ current_status }}&{% endif %}category=" class="filter-chip {% if not current_cat %}active{% endif %}">Все</a>
{% for cat in WORKSHOP_CATEGORIES %}
<a href="?{% if current_status %}status={{ current_status }}&{% endif %}category={{ cat }}" class="filter-chip {% if current_cat == cat %}active{% endif %}">{{ WORKSHOP_CATEGORY_LABELS[cat] }}</a>
{% endfor %}
<span style="width:16px;"></span>
<span class="filter-label">Статус:</span>
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=" class="filter-chip {% if not current_status %}active{% endif %}">Все</a>
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=published" class="filter-chip {% if current_status == 'published' %}active{% endif %}">Опубликован</a>
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=archived" class="filter-chip {% if current_status == 'archived' %}active{% endif %}">В архиве</a>
{% if session.get('role') in ['admin', 'professor', 'lead'] %}
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=draft" class="filter-chip {% if current_status == 'draft' %}active{% endif %}">Черновик</a>
{% endif %}
{% if current_cat or current_status %}
<a href="/workshops" class="filter-chip filter-chip-clear">✕ Сбросить</a>
{% endif %}
</div>
{% if workshops %}
<div class="grid">
{% for ws in workshops %}
<div class="ws-card">
<div class="ws-card-cover">
{% if ws.category == 'soldering' %}🔥
{% elif ws.category == 'embroidery' %}🎨
{% elif ws.category == 'embedded' %}⚙️
{% elif ws.category == 'linux' %}🐧
{% else %}🎓{% endif %}
{% if ws.partner %}<span class="ws-card-cover-partner">{{ ws.partner }}</span>{% endif %}
</div>
<div class="ws-card-body">
<div class="badges">
<span class="badge badge-cat-{{ ws.category }}">{{ WORKSHOP_CATEGORY_LABELS[ws.category] }}</span>
<span class="badge badge-difficulty-{{ ws.difficulty }}">{{ WORKSHOP_DIFFICULTY_LABELS[ws.difficulty] }}</span>
<span class="badge badge-status-{{ ws.status }}">{{ WORKSHOP_STATUS_LABELS[ws.status] }}</span>
{% if ws.visibility == 'internal' %}<span class="badge badge-vis-internal">Внутренний</span>{% endif %}
</div>
<h3><a href="/workshops/{{ ws.id }}">{{ ws.title }}</a></h3>
<p class="ws-card-desc">{{ ws.description or 'Без описания' }}</p>
<div class="ws-meta">
{% set lead = leads_map.get(ws.lead_id) %}
{% if lead %}<span class="ws-meta-item">👤 {{ lead.full_name or lead.username }}</span>{% endif %}
{% set sess = next_sessions.get(ws.id) %}
{% if sess %}
<span class="ws-meta-item">📅 {{ sess.start_time|msk_strftime('%d.%m %H:%M') }}–{{ sess.end_time|msk_strftime('%H:%M') }}</span>
{% endif %}
<span class="ws-meta-item">🎫 {{ reg_counts.get(ws.id, 0) }}{% if ws.max_participants %}/{{ ws.max_participants }}{% endif %}</span>
{% if ws.location %}<span class="ws-meta-item">📍 {{ ws.location }}</span>{% endif %}
{% if ws.has_certificate %}<span class="ws-meta-item">🏅 Сертификат</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<h3>Пока нет мастер-классов</h3>
<p>{% if can_create %}Создайте первый — он появится здесь.{% else %}Загляните позже.{% endif %}</p>
{% if can_create %}<a href="/workshops/new" class="btn btn-primary">Создать мастер-класс</a>{% endif %}
</div>
{% endif %}
</div>
<script src="/static/notif_bell.js"></script>
</body>
</html>