Initial commit

This commit is contained in:
root
2026-07-01 17:58:05 +03:00
commit 104ae25f2c
18 changed files with 1585 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Voley — вход в админку{% endblock %}
{% block header %}Админка{% endblock %}
{% block nav %}
<a class="text-slate-300 hover:text-white" href="/">На главную</a>
{% endblock %}
{% block content %}
<section class="glass rounded-2xl p-5 max-w-md">
<h2 class="text-lg font-semibold">Вход</h2>
<p class="text-sm text-slate-400 mt-1">Введите пароль администратора.</p>
{% if request.query_params.get('error') %}
<div class="mt-4 rounded-xl border border-rose-900 bg-rose-950/40 px-3 py-2 text-sm text-rose-200">
Неверный пароль.
</div>
{% endif %}
<form class="mt-4 grid gap-3" method="post" action="/admin/login">
<input
name="password"
type="password"
class="w-full rounded-xl bg-slate-950/60 border border-slate-800 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Пароль"
required
/>
<button
type="submit"
class="w-full px-4 py-2 rounded-xl text-sm font-semibold bg-indigo-600 border border-indigo-500"
>
Войти
</button>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Voley — админка{% endblock %}
{% block header %}Админка{% endblock %}
{% block nav %}
<div class="flex items-center gap-4">
<a class="text-slate-300 hover:text-white" href="/">На главную</a>
<a class="text-slate-300 hover:text-white" href="/admin/logout">Выйти</a>
</div>
{% endblock %}
{% block content %}
<section class="glass rounded-2xl p-4 sm:p-5">
<h2 class="text-lg font-semibold">Добавить игрока</h2>
<form class="mt-3 flex flex-col sm:flex-row gap-3" method="post" action="/admin/players/add">
<input
name="name"
class="flex-1 rounded-xl bg-slate-950/60 border border-slate-800 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Имя игрока"
required
/>
<button class="px-4 py-2 rounded-xl text-sm font-semibold bg-indigo-600 border border-indigo-500" type="submit">
Добавить
</button>
</form>
</section>
<section class="mt-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Игроки</h2>
<div class="text-xs text-slate-400">{{ players|length }} всего</div>
</div>
<div class="mt-3 grid gap-3">
{% for p in players %}
<div class="glass rounded-2xl p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ p.name }}
{% if not p.active %}
<span class="ml-2 text-xs font-semibold px-2 py-1 rounded-lg bg-slate-800 text-slate-200">неактивен</span>
{% endif %}
</div>
<div class="text-xs text-slate-400">ID: {{ p.id }}</div>
</div>
<form method="post" action="/admin/players/{{ p.id }}/toggle-active">
<button
class="px-3 py-2 rounded-xl text-sm font-semibold border
{% if p.active %}
bg-slate-950/40 border-slate-800 text-slate-200 hover:border-slate-600
{% else %}
bg-emerald-600 border-emerald-500 text-white
{% endif %}"
type="submit"
>
{% if p.active %}Деактивировать{% else %}Активировать{% endif %}
</button>
</form>
</div>
<form class="mt-3 flex flex-col sm:flex-row gap-3" method="post" action="/admin/players/{{ p.id }}/rename">
<input
name="name"
value="{{ p.name }}"
class="flex-1 rounded-xl bg-slate-950/60 border border-slate-800 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
<button class="px-3 py-2 rounded-xl text-sm font-semibold bg-indigo-600 border border-indigo-500" type="submit">
Переименовать
</button>
</form>
</div>
{% endfor %}
</div>
</section>
{% endblock %}

69
templates/base.html Normal file
View File

@@ -0,0 +1,69 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Voley{% endblock %}</title>
<!-- Tailwind (CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Flatpickr -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css" />
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
<style>
.container-max { max-width: 980px; }
.fp-theme .flatpickr-calendar { border-radius: 14px; }
/* Glassmorphism */
.glass {
background: rgba(15, 23, 42, 0.38);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow:
0 10px 30px rgba(0,0,0,0.35),
inset 0 1px 0 rgba(255,255,255,0.06);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
}
.glass-hover:hover { border-color: rgba(148, 163, 184, 0.30); }
</style>
</head>
<body class="min-h-screen text-slate-100 relative overflow-x-hidden">
<!-- Фон-картинка + затемнение/размытие (картинку положите в templates/bg.jpg) -->
<div class="fixed inset-0 -z-10">
<div class="absolute inset-0 bg-gradient-to-b from-slate-950 via-slate-950 to-slate-900"></div>
<!-- именно <img>, чтобы браузер точно загрузил файл -->
<img
src="/assets/bg.jpg?v={{ bg_version }}"
alt=""
class="absolute inset-0 w-full h-full object-cover opacity-55"
loading="eager"
decoding="async"
/>
<!-- подложка и “дымка”, чтобы контент читался -->
<div class="absolute inset-0 bg-slate-950/45 backdrop-blur-sm"></div>
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(99,102,241,0.18),transparent_55%)]"></div>
</div>
<div class="mx-auto container-max px-4 pb-16">
<header class="pt-6 pb-4 flex items-center justify-between gap-3">
<div>
<div class="text-xs uppercase tracking-widest text-slate-400">Волейбол</div>
<h1 class="text-2xl font-semibold">{% block header %}Учет посещаемости{% endblock %}</h1>
</div>
<nav class="text-sm text-slate-300">
{% block nav %}{% endblock %}
</nav>
</header>
{% block content %}{% endblock %}
</div>
<script src="/static/app.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

BIN
templates/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

206
templates/index.html Normal file
View File

@@ -0,0 +1,206 @@
{% extends "base.html" %}
{% block title %}Voley — отметка посещения{% endblock %}
{% block header %}Отметка посещения{% endblock %}
{% block content %}
<section class="glass rounded-2xl p-4 sm:p-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div class="text-sm text-slate-300">Выбранное воскресенье</div>
<div class="text-xl font-semibold">{{ selected_date }}</div>
</div>
<div class="w-full sm:w-[320px]">
<label class="text-sm text-slate-300">Календарь (только воскресенья)</label>
<input
id="datePicker"
class="mt-2 w-full rounded-xl bg-slate-950/60 border border-slate-800 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Выберите воскресенье"
value="{{ selected_date }}"
/>
</div>
</div>
</section>
<section class="mt-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Игроки</h2>
<div class="text-xs text-slate-400">{{ players|length }} активн.</div>
</div>
<div id="playersList" class="mt-3 grid gap-3">
{% for player in players %}
{% set present = present_map.get(player.id, False) %}
{% include "partials/player_row.html" %}
{% endfor %}
</div>
</section>
<section class="mt-8" id="teamsSection">
<div id="teamsBox">
{# По умолчанию рендерим команды детерминированно (по алфавиту): #}
{% set present_names = [] %}
{% for player in players %}
{% if present_map.get(player.id, False) %}
{% set _ = present_names.append(player.name) %}
{% endif %}
{% endfor %}
{% set count = present_names|length %}
{% set mid = (count + 1) // 2 %}
{% set team_a = present_names[:mid] %}
{% set team_b = present_names[mid:] %}
{% include "partials/teams.html" %}
</div>
</section>
<section class="mt-8 glass rounded-2xl p-4 sm:p-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-lg font-semibold">График</h2>
<div class="text-xs text-slate-400">{{ chart_title }}</div>
</div>
<div class="flex gap-2">
<a
href="/?date={{ selected_date }}&view=month"
class="px-3 py-2 rounded-xl border text-sm {{ 'bg-indigo-600 border-indigo-500' if chart_view=='month' else 'bg-slate-950/40 border-slate-800 text-slate-200' }}"
>Месяц</a>
<a
href="/?date={{ selected_date }}&view=year"
class="px-3 py-2 rounded-xl border text-sm {{ 'bg-indigo-600 border-indigo-500' if chart_view=='year' else 'bg-slate-950/40 border-slate-800 text-slate-200' }}"
>Год</a>
</div>
</div>
<div class="mt-4 h-56 sm:h-64">
{# SVG-график без JS/CDN — всегда отображается #}
{% set max_v = (chart_values|max) if chart_values else 0 %}
{% if max_v < 1 %}{% set max_v = 1 %}{% endif %}
{% set n = chart_labels|length %}
<svg viewBox="0 0 200 100" class="w-full h-full block" style="width:100%;height:100%;display:block" role="img" aria-label="{{ chart_title }}">
<defs>
<linearGradient id="barGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(129,140,248,0.95)"></stop>
<stop offset="55%" stop-color="rgba(99,102,241,0.86)"></stop>
<stop offset="100%" stop-color="rgba(59,130,246,0.62)"></stop>
</linearGradient>
<linearGradient id="bgGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="rgba(99,102,241,0.20)"></stop>
<stop offset="60%" stop-color="rgba(15,23,42,0.00)"></stop>
</linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1.2" stdDeviation="1.2" flood-color="rgba(0,0,0,0.45)"></feDropShadow>
</filter>
</defs>
<!-- карточка/фон -->
<rect x="0" y="0" width="200" height="100" rx="5" fill="rgba(2,6,23,0.22)"></rect>
<rect x="0" y="0" width="200" height="100" rx="5" fill="url(#bgGlow)"></rect>
<rect x="0.8" y="0.8" width="198.4" height="98.4" rx="5" fill="transparent" stroke="rgba(148,163,184,0.16)" stroke-width="0.7"></rect>
{# параметры области построения #}
{% set padL = 16 %}
{% set padR = 3 %}
{% set padT = 8 %}
{% set padB = 18 %}
{% set plotW = 200 - padL - padR %}
{% set plotH = 100 - padT - padB %}
<!-- оси -->
<line x1="{{ padL }}" y1="{{ padT + plotH }}" x2="{{ 200 - padR }}" y2="{{ padT + plotH }}" stroke="rgba(148,163,184,0.18)" stroke-width="0.7"></line>
<line x1="{{ padL }}" y1="{{ padT }}" x2="{{ padL }}" y2="{{ padT + plotH }}" stroke="rgba(148,163,184,0.14)" stroke-width="0.7"></line>
<!-- сетка + подписи Y -->
{% for p in [0,25,50,75,100] %}
{% set y = padT + plotH - (plotH * p / 100) %}
<line x1="{{ padL }}" y1="{{ y }}" x2="{{ 200 - padR }}" y2="{{ y }}" stroke="rgba(148,163,184,0.12)" stroke-width="0.6"></line>
<text x="5" y="{{ y + 1.7 }}" font-size="3.6" fill="rgba(226,232,240,0.70)">{{ (max_v * p / 100)|round|int }}</text>
{% endfor %}
{% if n == 0 %}
<text x="{{ padL }}" y="50" font-size="5" fill="rgba(226,232,240,0.85)">Нет данных</text>
{% else %}
{% set barGap = 1.4 %}
{% set slot = plotW / n %}
{% set barW = slot - barGap %}
{% if barW < 0.9 %}{% set barW = 0.9 %}{% endif %}
{% for lab in chart_labels %}
{% set i = loop.index0 %}
{% set v = chart_values[i] %}
{% set h = (v / max_v) * plotH %}
{% set x = padL + (i * slot) + (barGap/2) %}
{% set y = padT + plotH - h %}
{% set cx = x + (barW/2) %}
<rect
x="{{ x }}"
y="{{ y }}"
width="{{ barW }}"
height="{{ h }}"
fill="url(#barGrad)"
stroke="rgba(165,180,252,0.55)"
stroke-width="0.25"
rx="1.1"
filter="url(#softShadow)"
>
<title>{{ lab }}: {{ v }}</title>
</rect>
{# значение над столбцом (если место позволяет) #}
{% if v > 0 and barW >= 2.2 %}
<text
x="{{ cx }}"
y="{{ y - 1.4 }}"
text-anchor="middle"
font-size="3.6"
fill="rgba(226,232,240,0.88)"
>{{ v }}</text>
{% endif %}
{% if (i % chart_label_step) == 0 %}
{% if chart_view == 'month' and lab|length == 10 %}
{% set show = lab[8:10] ~ '.' ~ lab[5:7] %}
{% elif chart_view == 'year' and lab|length == 7 %}
{% set show = lab[5:7] %}
{% else %}
{% set show = lab %}
{% endif %}
<text
x="{{ cx }}"
y="96.7"
text-anchor="middle"
font-size="3.6"
fill="rgba(226,232,240,0.70)"
>{{ show }}</text>
{% endif %}
{% endfor %}
{% endif %}
</svg>
</div>
</section>
<section class="mt-8 glass rounded-2xl p-4 sm:p-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-lg font-semibold">Выгрузка данных</h2>
<div class="text-xs text-slate-400">История посещений игроков по датам</div>
</div>
<a
href="/api/export/attendance.xlsx"
class="px-4 py-2 rounded-xl bg-green-600 hover:bg-green-500 border border-green-500 text-sm font-medium transition-colors"
download
>Скачать Excel</a>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
window.__VOLEY__ = {
selectedDate: {{ selected_date|tojson }},
chartLabels: {{ chart_labels_json|safe }},
chartValues: {{ chart_values_json|safe }},
chartView: {{ chart_view|tojson }},
};
</script>
{% endblock %}

View File

@@ -0,0 +1,27 @@
<div
class="player-row glass glass-hover rounded-2xl p-4 flex items-center justify-between gap-3 transition"
data-player-id="{{ player.id }}"
data-player-name="{{ player.name|lower }}"
>
<div class="min-w-0">
<div class="text-base font-medium truncate">{{ player.name }}</div>
<div class="text-xs text-slate-400">Нажмите, чтобы {% if present %}снять отметку{% else %}отметиться{% endif %}</div>
</div>
<form class="attendance-form flex items-center gap-3" method="post" action="/api/attendance/toggle">
<input type="hidden" name="player_id" value="{{ player.id }}" />
<input type="hidden" name="session_date" value="{{ session_date if session_date is defined else selected_date }}" />
<button
type="submit"
class="min-w-[120px] sm:min-w-[140px] px-4 py-2 rounded-xl text-sm font-semibold border transition
{% if present %}
bg-emerald-600 border-emerald-500 text-white
{% else %}
bg-slate-950/50 border-slate-800 text-slate-200 hover:border-slate-600
{% endif %}"
>
{% if present %}Пришёл{% else %}Не пришёл{% endif %}
</button>
</form>
</div>

View File

@@ -0,0 +1,47 @@
<div class="glass rounded-2xl p-4 sm:p-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-lg font-semibold">Команды</h2>
<div class="text-xs text-slate-400">Отметились: {{ count }} · Дата: {{ session_date }}</div>
</div>
<form class="teams-form" method="post" action="/api/teams/random">
<input type="hidden" name="session_date" value="{{ session_date if session_date is defined else selected_date }}" />
<button
type="submit"
class="px-3 py-2 rounded-xl text-sm font-semibold bg-indigo-600 border border-indigo-500"
>
Перемешать
</button>
</form>
</div>
{% if count < 2 %}
<div class="mt-4 text-sm text-slate-300">
Нужно минимум 2 отметившихся, чтобы разделить на команды.
</div>
{% else %}
<div class="mt-4 grid sm:grid-cols-2 gap-3">
<div class="glass rounded-2xl p-4">
<div class="text-sm font-semibold text-slate-200">Команда A</div>
<ol class="mt-2 space-y-1 text-slate-100">
{% for n in team_a %}
<li class="flex items-center justify-between">
<span class="truncate">{{ loop.index }}. {{ n }}</span>
</li>
{% endfor %}
</ol>
</div>
<div class="glass rounded-2xl p-4">
<div class="text-sm font-semibold text-slate-200">Команда B</div>
<ol class="mt-2 space-y-1 text-slate-100">
{% for n in team_b %}
<li class="flex items-center justify-between">
<span class="truncate">{{ loop.index }}. {{ n }}</span>
</li>
{% endfor %}
</ol>
</div>
</div>
{% endif %}
</div>