mirror of
https://github.com/EDeev/tablo.git
synced 2026-10-07 20:49:31 +03:00
Full Project
This commit is contained in:
commit
3946aeb81e
65 changed files with 7399 additions and 0 deletions
17
.env.example
Normal file
17
.env.example
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# ── OpenAI API ──────────────────────────────────────────────────
|
||||
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
OPENAI_MODEL=
|
||||
|
||||
# ── PostgreSQL ─────────────────────────────────────────────────
|
||||
|
||||
DB_HOST=
|
||||
DB_PORT=
|
||||
DB_NAME=
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
|
||||
# ── Переменные окружения ───────────────────────────────────────
|
||||
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
84
README.md
Normal file
84
README.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Tablo
|
||||
|
||||
Веб-приложение для работы с учебным расписанием: распознавание расписания из изображения с помощью ИИ, персональные трекеры успеваемости и совместный доступ по ссылке.
|
||||
|
||||
Сайт: [tablo.deev.su](https://tablo.deev.su)
|
||||
|
||||
## Функциональность
|
||||
|
||||
- Загрузка фотографии или скана расписания и автоматическое извлечение структурированных данных через AI Vision API
|
||||
- Полный CRUD для расписаний, предметов и слотов занятий
|
||||
- 11 типов виджетов трекеров успеваемости (посещаемость, оценки, дедлайны, серии дней и др.)
|
||||
- Дашборд с перетаскиваемыми виджетами на сетке 12 колонок
|
||||
- Совместный доступ по токен-ссылке с тремя режимами: просмотр, редактирование, клонирование как шаблон
|
||||
- Объединение нескольких расписаний с разрешением конфликтов по предметам
|
||||
|
||||
## Технологический стек
|
||||
|
||||
- **Backend:** Python, Flask, SQLAlchemy, Flask-Migrate, Flask-Login
|
||||
- **База данных:** PostgreSQL (JSONB для хранения структуры расписания)
|
||||
- **ИИ:** Gemini 3.1 Flash через OpenAI-совместимый API (Timeweb Cloud AI)
|
||||
- **Frontend:** Jinja2, Bootstrap 5.3, Vanilla JS, Fetch API
|
||||
|
||||
## Установка
|
||||
|
||||
**Требования:** Python 3.11+, PostgreSQL
|
||||
|
||||
```bash
|
||||
git clone https://github.com/EDeev/tablo.git
|
||||
cd tablo
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
# source venv/bin/activate # Linux/macOS
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Создайте файл `.env` в корне проекта:
|
||||
|
||||
```env
|
||||
SECRET_KEY=your_secret_key
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/tablo
|
||||
OPENAI_API_KEY=your_api_key
|
||||
OPENAI_BASE_URL=https://api.your-provider.com/v1
|
||||
```
|
||||
|
||||
Примените миграции и запустите приложение:
|
||||
|
||||
```bash
|
||||
flask db upgrade
|
||||
python run.py
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу `http://localhost:5000`.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
tablo/
|
||||
├── app/
|
||||
│ ├── models/ # Модели SQLAlchemy
|
||||
│ ├── routes/ # Блюпринты (auth, schedules, subjects, shares)
|
||||
│ ├── services/ # Бизнес-логика (AI-распознавание, AI-генерация метрик)
|
||||
│ └── templates/ # HTML-шаблоны Jinja2
|
||||
├── migrations/ # Файлы миграций Flask-Migrate
|
||||
├── requirements.txt
|
||||
└── run.py
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
Этот проект является некоммерческим и распространяется под лицензией MIT.
|
||||
|
||||
## Автор
|
||||
|
||||
**Деев Егор Викторович** - Backend Developer
|
||||
- GitHub: [@EDeev](https://github.com/EDeev)
|
||||
- Email: egor@deev.space
|
||||
- Telegram: [@Egor_Deev](https://t.me/Egor_Deev)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>⭐ Если проект оказался полезным, поставьте звездочку на GitHub!</sub>
|
||||
<p><sub>Создано с ❤️ от вашего дорогого - deev.space ©</sub></p>
|
||||
</div>
|
||||
31
app/__init__.py
Normal file
31
app/__init__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
csrf = CSRFProtect()
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object('app.config.Config')
|
||||
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
csrf.init_app(app)
|
||||
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Войдите для доступа'
|
||||
login_manager.login_message_category = 'warning'
|
||||
|
||||
from app.routes import auth, schedules, subjects, shares
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(schedules.bp)
|
||||
app.register_blueprint(subjects.bp)
|
||||
app.register_blueprint(shares.bp)
|
||||
|
||||
return app
|
||||
14
app/config.py
Normal file
14
app/config.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'change-me-in-production')
|
||||
SQLALCHEMY_DATABASE_URI = (
|
||||
f"postgresql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}"
|
||||
f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
MAX_CONTENT_LENGTH = 20 * 1024 * 1024
|
||||
WTF_CSRF_ENABLED = True
|
||||
4
app/models/__init__.py
Normal file
4
app/models/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from app.models.user import User
|
||||
from app.models.schedule import Schedule, SubjectConfig
|
||||
from app.models.metric import Metric
|
||||
from app.models.share import Share, ShareEditor
|
||||
37
app/models/metric.py
Normal file
37
app/models/metric.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from app import db
|
||||
|
||||
METRIC_TYPES = [
|
||||
'checkpoints', 'progress_bar', 'counter', 'stages',
|
||||
'checklist', 'attendance', 'grades', 'deadlines',
|
||||
'streak', 'effort_hours', 'rating_history',
|
||||
]
|
||||
|
||||
METRIC_LABELS = {
|
||||
'checkpoints': 'Контрольные точки',
|
||||
'progress_bar': 'Накопление баллов',
|
||||
'counter': 'Счётчик',
|
||||
'stages': 'Этапы',
|
||||
'checklist': 'Чеклист',
|
||||
'attendance': 'Посещаемость',
|
||||
'grades': 'Оценки',
|
||||
'deadlines': 'Дедлайны',
|
||||
'streak': 'Серия',
|
||||
'effort_hours': 'Трудозатраты',
|
||||
'rating_history': 'История оценок',
|
||||
}
|
||||
|
||||
|
||||
class Metric(db.Model):
|
||||
__tablename__ = 'metrics'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
subject_config_id = db.Column(db.Integer, db.ForeignKey('subject_configs.id'), nullable=False)
|
||||
type = db.Column(db.String(32), nullable=False)
|
||||
label = db.Column(db.String(256), nullable=False)
|
||||
config = db.Column(db.JSON, nullable=False, default=dict)
|
||||
progress = db.Column(db.JSON, nullable=False, default=dict)
|
||||
grid_col = db.Column(db.Integer, nullable=False, default=0)
|
||||
grid_row = db.Column(db.Integer, nullable=False, default=0)
|
||||
grid_w = db.Column(db.Integer, nullable=False, default=1)
|
||||
grid_h = db.Column(db.Integer, nullable=False, default=1)
|
||||
ai_prompt = db.Column(db.Text, nullable=True)
|
||||
40
app/models/schedule.py
Normal file
40
app/models/schedule.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Schedule(db.Model):
|
||||
__tablename__ = 'schedules'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
data = db.Column(db.JSON, nullable=False, default=list)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
subject_configs = db.relationship('SubjectConfig', backref='schedule',
|
||||
lazy='dynamic', cascade='all, delete-orphan')
|
||||
shares = db.relationship('Share', backref='schedule', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
|
||||
COMPLETION_TYPES = ['Зачёт', 'Диф. зачёт', 'Экзамен']
|
||||
|
||||
|
||||
class SubjectConfig(db.Model):
|
||||
__tablename__ = 'subject_configs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
schedule_id = db.Column(db.Integer, db.ForeignKey('schedules.id'), nullable=False)
|
||||
subject_name = db.Column(db.String(256), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
hidden = db.Column(db.Boolean, nullable=False, default=False)
|
||||
completion_type = db.Column(db.String(32), nullable=True)
|
||||
completed = db.Column(db.Boolean, nullable=False, default=False)
|
||||
sdo_url = db.Column(db.Text, nullable=True)
|
||||
|
||||
metrics = db.relationship('Metric', backref='subject_config',
|
||||
order_by='Metric.grid_row, Metric.grid_col, Metric.id',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('schedule_id', 'subject_name', name='uq_schedule_subject'),
|
||||
)
|
||||
30
app/models/share.py
Normal file
30
app/models/share.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from app import db
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
class Share(db.Model):
|
||||
__tablename__ = 'shares'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
schedule_id = db.Column(db.Integer, db.ForeignKey('schedules.id'), nullable=False)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
# view = только просмотр, edit = совместное редактирование, template = клонирование
|
||||
share_type = db.Column(db.String(16), nullable=False, default='view')
|
||||
token = db.Column(db.String(36), unique=True, nullable=False,
|
||||
default=lambda: str(uuid.uuid4()))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
editors = db.relationship('ShareEditor', backref='share', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
|
||||
class ShareEditor(db.Model):
|
||||
__tablename__ = 'share_editors'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
share_id = db.Column(db.Integer, db.ForeignKey('shares.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('share_id', 'user_id', name='uq_share_editor'),
|
||||
)
|
||||
28
app/models/user.py
Normal file
28
app/models/user.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from app import db, login_manager
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
login = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(256), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
schedules = db.relationship('Schedule', backref='owner', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
shares = db.relationship('Share', backref='owner', lazy='dynamic',
|
||||
foreign_keys='Share.owner_id')
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
0
app/routes/__init__.py
Normal file
0
app/routes/__init__.py
Normal file
55
app/routes/auth.py
Normal file
55
app/routes/auth.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('schedules.profile'))
|
||||
if request.method == 'POST':
|
||||
login = request.form.get('login', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
confirm = request.form.get('confirm', '')
|
||||
if not login or not password:
|
||||
flash('Заполните все поля', 'danger')
|
||||
elif len(login) < 3:
|
||||
flash('Логин не менее 3 символов', 'danger')
|
||||
elif password != confirm:
|
||||
flash('Пароли не совпадают', 'danger')
|
||||
elif User.query.filter_by(login=login).first():
|
||||
flash('Логин уже занят', 'danger')
|
||||
else:
|
||||
user = User(login=login)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
login_user(user)
|
||||
return redirect(url_for('schedules.profile'))
|
||||
return render_template('auth/register.html')
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('schedules.profile'))
|
||||
if request.method == 'POST':
|
||||
login = request.form.get('login', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
user = User.query.filter_by(login=login).first()
|
||||
if user and user.check_password(password):
|
||||
login_user(user, remember=bool(request.form.get('remember')))
|
||||
next_page = request.args.get('next')
|
||||
return redirect(next_page or url_for('schedules.profile'))
|
||||
flash('Неверный логин или пароль', 'danger')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
||||
471
app/routes/schedules.py
Normal file
471
app/routes/schedules.py
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
import copy
|
||||
from flask import (Blueprint, render_template, redirect, url_for,
|
||||
flash, request, jsonify)
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, csrf
|
||||
from app.models.schedule import Schedule, SubjectConfig
|
||||
from app.models.metric import Metric
|
||||
from app.services.ai_scan import scan_image
|
||||
from app.services.merge import merge_schedules_data
|
||||
from app.services.schedule_helpers import (
|
||||
build_week_view, get_week_dates, DAYS_DISPLAY, DAYS_RU
|
||||
)
|
||||
from datetime import date
|
||||
|
||||
bp = Blueprint('schedules', __name__)
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def profile():
|
||||
schedules = current_user.schedules.order_by(Schedule.created_at.desc()).all()
|
||||
return render_template('profile/index.html', schedules=schedules)
|
||||
|
||||
|
||||
@bp.route('/schedules/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload():
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
extra_prompt = request.form.get('extra_prompt', '').strip()
|
||||
file = request.files.get('image')
|
||||
|
||||
if not name:
|
||||
flash('Введите название расписания', 'danger')
|
||||
return render_template('schedule/upload.html')
|
||||
if not file or file.filename == '':
|
||||
flash('Выберите картинку', 'danger')
|
||||
return render_template('schedule/upload.html')
|
||||
|
||||
ext = '.' + file.filename.rsplit('.', 1)[-1].lower()
|
||||
allowed = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'}
|
||||
if ext not in allowed:
|
||||
flash('Неподдерживаемый формат изображения', 'danger')
|
||||
return render_template('schedule/upload.html')
|
||||
|
||||
try:
|
||||
image_bytes = file.read()
|
||||
data = scan_image(image_bytes, ext, extra_prompt)
|
||||
except Exception as e:
|
||||
flash(f'Ошибка сканирования: {e}', 'danger')
|
||||
return render_template('schedule/upload.html')
|
||||
|
||||
schedule = Schedule(user_id=current_user.id, name=name, data=data)
|
||||
db.session.add(schedule)
|
||||
db.session.commit()
|
||||
flash('Расписание успешно создано', 'success')
|
||||
return redirect(url_for('schedules.view', schedule_id=schedule.id))
|
||||
|
||||
return render_template('schedule/upload.html')
|
||||
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>')
|
||||
@login_required
|
||||
def view(schedule_id):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_view(schedule):
|
||||
flash('Нет доступа', 'danger')
|
||||
return redirect(url_for('schedules.profile'))
|
||||
|
||||
week_dates = get_week_dates()
|
||||
week_view = build_week_view(schedule.data or [])
|
||||
today_idx = date.today().weekday()
|
||||
|
||||
configs = {sc.subject_name: sc for sc in schedule.subject_configs}
|
||||
can_edit = _can_edit(schedule)
|
||||
|
||||
# Все трекеры для дэшборда — сортируем по сохранённой позиции в сетке
|
||||
all_metrics = []
|
||||
for sc in schedule.subject_configs:
|
||||
for m in sc.metrics:
|
||||
all_metrics.append({'subject': sc.subject_name, 'metric': m})
|
||||
all_metrics.sort(key=lambda e: (e['metric'].grid_row, e['metric'].grid_col, e['metric'].id))
|
||||
|
||||
return render_template('schedule/view.html',
|
||||
schedule=schedule,
|
||||
week_view=week_view,
|
||||
week_dates=week_dates,
|
||||
days_display=DAYS_DISPLAY,
|
||||
days_ru=DAYS_RU,
|
||||
today_idx=today_idx,
|
||||
configs=configs,
|
||||
can_edit=can_edit,
|
||||
is_shared_view=False,
|
||||
all_metrics=all_metrics)
|
||||
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete(schedule_id):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if schedule.user_id != current_user.id:
|
||||
flash('Нет доступа', 'danger')
|
||||
return redirect(url_for('schedules.profile'))
|
||||
db.session.delete(schedule)
|
||||
db.session.commit()
|
||||
flash('Расписание удалено', 'success')
|
||||
return redirect(url_for('schedules.profile'))
|
||||
|
||||
|
||||
@bp.route('/schedules/merge', methods=['POST'])
|
||||
@login_required
|
||||
def merge():
|
||||
ids = request.form.getlist('schedule_ids')
|
||||
name = request.form.get('name', '').strip()
|
||||
if len(ids) < 2:
|
||||
flash('Выберите хотя бы 2 расписания', 'danger')
|
||||
return redirect(url_for('schedules.profile'))
|
||||
if not name:
|
||||
flash('Введите название', 'danger')
|
||||
return redirect(url_for('schedules.profile'))
|
||||
|
||||
schedules = Schedule.query.filter(
|
||||
Schedule.id.in_(ids),
|
||||
Schedule.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
if len(schedules) < 2:
|
||||
flash('Расписания не найдены', 'danger')
|
||||
return redirect(url_for('schedules.profile'))
|
||||
|
||||
merged_data = merge_schedules_data([s.data for s in schedules])
|
||||
merged = Schedule(user_id=current_user.id, name=name, data=merged_data)
|
||||
db.session.add(merged)
|
||||
db.session.commit()
|
||||
flash(f'Расписание "{name}" создано', 'success')
|
||||
return redirect(url_for('schedules.view', schedule_id=merged.id))
|
||||
|
||||
|
||||
# ─── Переименование расписания ────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/rename', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def rename_schedule(schedule_id):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_edit(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
name = request.json.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Пустое имя'}), 400
|
||||
schedule.name = name
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Глобальное переименование поля предмета ─────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/field',
|
||||
methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def rename_subject_field(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_edit(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
body = request.json or {}
|
||||
field = body.get('field') # name | classroom | teacher | type
|
||||
new_value = body.get('value', '').strip()
|
||||
old_type = body.get('old_type', '') # только для field=type
|
||||
|
||||
data = copy.deepcopy(schedule.data or [])
|
||||
for item in data:
|
||||
if item.get('subject') != subject_name:
|
||||
continue
|
||||
if field == 'name':
|
||||
item['subject'] = new_value
|
||||
elif field == 'classroom':
|
||||
_replace_slot_field(item, idx=2, value=new_value or None)
|
||||
elif field == 'teacher':
|
||||
_replace_slot_field(item, idx=3, value=new_value or None)
|
||||
elif field == 'type' and old_type:
|
||||
types = item.get('types', {})
|
||||
if old_type in types:
|
||||
types[new_value] = types.pop(old_type)
|
||||
|
||||
schedule.data = data
|
||||
|
||||
if field == 'name':
|
||||
cfg = SubjectConfig.query.filter_by(
|
||||
schedule_id=schedule_id, subject_name=subject_name).first()
|
||||
if cfg:
|
||||
cfg.subject_name = new_value
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
def _replace_slot_field(item: dict, idx: int, value):
|
||||
"""Меняет поле слота по индексу во всех типах и датах"""
|
||||
for tn, type_data in item.get('types', {}).items():
|
||||
date_ranges = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data
|
||||
for dr, slots in date_ranges.items():
|
||||
for slot in slots:
|
||||
while len(slot) <= idx:
|
||||
slot.append(None)
|
||||
slot[idx] = value
|
||||
|
||||
|
||||
# ─── Изменение времени конкретного слота ─────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/slot-time',
|
||||
methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def edit_slot_time(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_edit(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
body = request.json or {}
|
||||
day = (body.get('day') or '').lower()
|
||||
old_time = body.get('old_time', '')
|
||||
new_time = body.get('new_time', '').strip()
|
||||
type_name = body.get('type_name', '')
|
||||
dr_key = body.get('date_range', '')
|
||||
|
||||
data = copy.deepcopy(schedule.data or [])
|
||||
for item in data:
|
||||
if item.get('subject') != subject_name:
|
||||
continue
|
||||
for tn, type_data in item.get('types', {}).items():
|
||||
if type_name and tn != type_name:
|
||||
continue
|
||||
drs = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data
|
||||
for dr, slots in drs.items():
|
||||
if dr_key and dr != dr_key:
|
||||
continue
|
||||
for slot in slots:
|
||||
if slot[0].lower() == day and (len(slot) < 2 or slot[1] == old_time):
|
||||
if len(slot) > 1:
|
||||
slot[1] = new_time
|
||||
|
||||
schedule.data = data
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Полное редактирование одного слота ──────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/slot',
|
||||
methods=['PATCH'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def edit_slot(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_edit(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
body = request.json or {}
|
||||
old_day = (body.get('old_day') or '').lower()
|
||||
old_time = body.get('old_time', '')
|
||||
type_name = body.get('type_name', '')
|
||||
dr_key = body.get('date_range', '')
|
||||
|
||||
new_day = (body.get('day') or '').lower()
|
||||
new_time = body.get('time', '').strip()
|
||||
new_classroom = body.get('classroom', '').strip() or None
|
||||
new_teacher = body.get('teacher', '').strip() or None
|
||||
|
||||
data = copy.deepcopy(schedule.data or [])
|
||||
changed = False
|
||||
for item in data:
|
||||
if item.get('subject') != subject_name:
|
||||
continue
|
||||
for tn, type_data in item.get('types', {}).items():
|
||||
if type_name and tn != type_name:
|
||||
continue
|
||||
drs = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data
|
||||
for dr, slots in drs.items():
|
||||
if dr_key and dr != dr_key:
|
||||
continue
|
||||
for slot in slots:
|
||||
if slot[0].lower() == old_day and (not old_time or (len(slot) > 1 and slot[1] == old_time)):
|
||||
while len(slot) < 4:
|
||||
slot.append(None)
|
||||
slot[0] = new_day
|
||||
slot[1] = new_time
|
||||
slot[2] = new_classroom
|
||||
slot[3] = new_teacher
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
schedule.data = data
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Добавление нового слота ──────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/slot',
|
||||
methods=['PUT'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def add_slot(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_edit(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
body = request.json or {}
|
||||
type_name = body.get('type_name', '').strip()
|
||||
dr_key = body.get('date_range', '').strip()
|
||||
day = (body.get('day') or '').lower().strip()
|
||||
time_val = body.get('time', '').strip()
|
||||
classroom = body.get('classroom', '').strip() or None
|
||||
teacher = body.get('teacher', '').strip() or None
|
||||
|
||||
if not type_name or not dr_key or not day:
|
||||
return jsonify({'error': 'Укажите тип, период и день'}), 400
|
||||
|
||||
data = copy.deepcopy(schedule.data or [])
|
||||
for item in data:
|
||||
if item.get('subject') != subject_name:
|
||||
continue
|
||||
types = item.setdefault('types', {})
|
||||
if type_name not in types:
|
||||
types[type_name] = {'color': 'gray', 'dates': {}}
|
||||
type_data = types[type_name]
|
||||
if isinstance(type_data, dict) and 'dates' in type_data:
|
||||
drs = type_data['dates']
|
||||
else:
|
||||
drs = type_data
|
||||
slot_list = drs.setdefault(dr_key, [])
|
||||
slot_list.append([day, time_val, classroom, teacher])
|
||||
schedule.data = data
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
return jsonify({'error': 'Предмет не найден'}), 404
|
||||
|
||||
|
||||
# ─── Переименование периода (date_range ключа) ────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/period',
|
||||
methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def rename_period(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_edit(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
body = request.json or {}
|
||||
type_name = body.get('type_name', '')
|
||||
old_dr = body.get('old_period', '').strip()
|
||||
new_dr = body.get('new_period', '').strip()
|
||||
|
||||
if not old_dr or not new_dr:
|
||||
return jsonify({'error': 'Укажите старый и новый период'}), 400
|
||||
|
||||
data = copy.deepcopy(schedule.data or [])
|
||||
for item in data:
|
||||
if item.get('subject') != subject_name:
|
||||
continue
|
||||
for tn, type_data in item.get('types', {}).items():
|
||||
if type_name and tn != type_name:
|
||||
continue
|
||||
drs = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data
|
||||
if old_dr in drs:
|
||||
drs[new_dr] = drs.pop(old_dr)
|
||||
|
||||
schedule.data = data
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Удаление предмета полностью ─────────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>',
|
||||
methods=['DELETE'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def delete_subject(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_edit(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
schedule.data = [item for item in (schedule.data or [])
|
||||
if item.get('subject') != subject_name]
|
||||
|
||||
cfg = SubjectConfig.query.filter_by(
|
||||
schedule_id=schedule_id, subject_name=subject_name).first()
|
||||
if cfg:
|
||||
db.session.delete(cfg)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Удаление конкретного слота ───────────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/slot',
|
||||
methods=['DELETE'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def delete_slot(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _can_edit(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
body = request.json or {}
|
||||
day = (body.get('day') or '').lower()
|
||||
time_val = body.get('time', '')
|
||||
type_name = body.get('type_name', '')
|
||||
dr_key = body.get('date_range', '')
|
||||
|
||||
data = copy.deepcopy(schedule.data or [])
|
||||
for item in data:
|
||||
if item.get('subject') != subject_name:
|
||||
continue
|
||||
for tn in list(item.get('types', {}).keys()):
|
||||
if type_name and tn != type_name:
|
||||
continue
|
||||
type_data = item['types'][tn]
|
||||
new_format = isinstance(type_data, dict) and 'dates' in type_data
|
||||
drs = type_data['dates'] if new_format else type_data
|
||||
for dr in list(drs.keys()):
|
||||
if dr_key and dr != dr_key:
|
||||
continue
|
||||
drs[dr] = [
|
||||
s for s in drs[dr]
|
||||
if not (s[0].lower() == day and
|
||||
(len(s) < 2 or s[1] == time_val))
|
||||
]
|
||||
if not drs[dr]:
|
||||
del drs[dr]
|
||||
if not drs:
|
||||
del item['types'][tn]
|
||||
|
||||
# Убираем предметы без слотов
|
||||
schedule.data = [item for item in data if item.get('types')]
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Вспомогательные функции доступа ─────────────────────────────────────────
|
||||
|
||||
def _can_view(schedule: Schedule) -> bool:
|
||||
if schedule.user_id == current_user.id:
|
||||
return True
|
||||
from app.models.share import ShareEditor, Share
|
||||
edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
|
||||
if edit_share:
|
||||
editor = ShareEditor.query.filter_by(
|
||||
share_id=edit_share.id, user_id=current_user.id).first()
|
||||
if editor:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _can_edit(schedule: Schedule) -> bool:
|
||||
if schedule.user_id == current_user.id:
|
||||
return True
|
||||
from app.models.share import ShareEditor, Share
|
||||
edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
|
||||
if edit_share:
|
||||
editor = ShareEditor.query.filter_by(
|
||||
share_id=edit_share.id, user_id=current_user.id).first()
|
||||
if editor:
|
||||
return True
|
||||
return False
|
||||
111
app/routes/shares.py
Normal file
111
app/routes/shares.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
from flask import (Blueprint, render_template, redirect, url_for,
|
||||
flash, request, jsonify)
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, csrf
|
||||
from app.models.schedule import Schedule, SubjectConfig
|
||||
from app.models.share import Share, ShareEditor
|
||||
from app.models.user import User
|
||||
from app.services.schedule_helpers import build_week_view, get_week_dates, DAYS_DISPLAY, DAYS_RU
|
||||
from app.services.merge import merge_schedules_data
|
||||
from datetime import date
|
||||
|
||||
bp = Blueprint('shares', __name__)
|
||||
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/share', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def create_share(schedule_id):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if schedule.user_id != current_user.id:
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
share_type = request.json.get('type', 'view')
|
||||
if share_type not in ('view', 'edit', 'template'):
|
||||
return jsonify({'error': 'Неизвестный тип'}), 400
|
||||
|
||||
# Один тип = одна ссылка на расписание
|
||||
existing = Share.query.filter_by(
|
||||
schedule_id=schedule_id, share_type=share_type).first()
|
||||
if existing:
|
||||
return jsonify({'ok': True, 'token': existing.token})
|
||||
|
||||
share = Share(schedule_id=schedule_id, owner_id=current_user.id,
|
||||
share_type=share_type)
|
||||
db.session.add(share)
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True, 'token': share.token})
|
||||
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/share/editors', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def add_editor(schedule_id):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if schedule.user_id != current_user.id:
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
login = request.json.get('login', '').strip()
|
||||
user = User.query.filter_by(login=login).first()
|
||||
if not user:
|
||||
return jsonify({'error': 'Пользователь не найден'}), 404
|
||||
|
||||
edit_share = Share.query.filter_by(
|
||||
schedule_id=schedule_id, share_type='edit').first()
|
||||
if not edit_share:
|
||||
edit_share = Share(schedule_id=schedule_id, owner_id=current_user.id,
|
||||
share_type='edit')
|
||||
db.session.add(edit_share)
|
||||
db.session.flush()
|
||||
|
||||
existing = ShareEditor.query.filter_by(
|
||||
share_id=edit_share.id, user_id=user.id).first()
|
||||
if not existing:
|
||||
db.session.add(ShareEditor(share_id=edit_share.id, user_id=user.id))
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True, 'token': edit_share.token})
|
||||
|
||||
|
||||
@bp.route('/shared/<token>')
|
||||
def view_shared(token):
|
||||
share = Share.query.filter_by(token=token).first_or_404()
|
||||
schedule = share.schedule
|
||||
|
||||
if share.share_type == 'template':
|
||||
# Клонирование — только для авторизованных
|
||||
from flask_login import current_user
|
||||
if not current_user.is_authenticated:
|
||||
flash('Войдите чтобы использовать шаблон', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
cloned = Schedule(
|
||||
user_id=current_user.id,
|
||||
name=f'{schedule.name} (копия)',
|
||||
data=schedule.data,
|
||||
)
|
||||
db.session.add(cloned)
|
||||
db.session.commit()
|
||||
flash('Расписание добавлено в ваш профиль', 'success')
|
||||
return redirect(url_for('schedules.view', schedule_id=cloned.id))
|
||||
|
||||
week_dates = get_week_dates()
|
||||
week_view = build_week_view(schedule.data or [])
|
||||
today_idx = date.today().weekday()
|
||||
configs = {sc.subject_name: sc for sc in schedule.subject_configs}
|
||||
|
||||
all_metrics = []
|
||||
for sc in schedule.subject_configs:
|
||||
for m in sc.metrics:
|
||||
all_metrics.append({'subject': sc.subject_name, 'metric': m})
|
||||
all_metrics.sort(key=lambda e: (e['metric'].grid_row, e['metric'].grid_col, e['metric'].id))
|
||||
|
||||
return render_template('schedule/view.html',
|
||||
schedule=schedule,
|
||||
week_view=week_view,
|
||||
week_dates=week_dates,
|
||||
days_display=DAYS_DISPLAY,
|
||||
days_ru=DAYS_RU,
|
||||
today_idx=today_idx,
|
||||
configs=configs,
|
||||
can_edit=False,
|
||||
is_shared_view=True,
|
||||
all_metrics=all_metrics)
|
||||
301
app/routes/subjects.py
Normal file
301
app/routes/subjects.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
from flask import Blueprint, render_template, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from app import db, csrf
|
||||
from app.models.schedule import Schedule, SubjectConfig, COMPLETION_TYPES
|
||||
from app.models.metric import Metric, METRIC_TYPES, METRIC_LABELS
|
||||
from app.services.ai_metrics import generate_metric_from_prompt
|
||||
|
||||
bp = Blueprint('subjects', __name__)
|
||||
|
||||
|
||||
# ─── Страница предметов ───────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects')
|
||||
@login_required
|
||||
def subjects_page(schedule_id):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
can_edit = _check_edit_access(schedule)
|
||||
configs = {sc.subject_name: sc for sc in schedule.subject_configs}
|
||||
return render_template('schedule/subjects.html',
|
||||
schedule=schedule,
|
||||
configs=configs,
|
||||
can_edit=can_edit,
|
||||
completion_types=COMPLETION_TYPES)
|
||||
|
||||
|
||||
# ─── Описание предмета ────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/description',
|
||||
methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def update_description(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _check_edit_access(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
config = _get_or_create_config(schedule_id, subject_name)
|
||||
config.description = request.json.get('description', '')
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Добавить метрику (вручную) ───────────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/metrics',
|
||||
methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def add_metric(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _check_edit_access(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
body = request.json or {}
|
||||
metric_type = body.get('type', '')
|
||||
label = body.get('label', '').strip()
|
||||
config = body.get('config', {})
|
||||
|
||||
if metric_type not in METRIC_TYPES:
|
||||
return jsonify({'error': 'Неизвестный тип'}), 400
|
||||
if not label:
|
||||
label = METRIC_LABELS.get(metric_type, 'Метрика')
|
||||
|
||||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||||
metric = Metric(subject_config_id=cfg.id, type=metric_type,
|
||||
label=label, config=config, progress={})
|
||||
db.session.add(metric)
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True, 'html': _render_metric_html(metric)})
|
||||
|
||||
|
||||
# ─── Добавить метрику через AI ────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/metrics/ai',
|
||||
methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def add_metric_ai(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _check_edit_access(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
body = request.json or {}
|
||||
prompt = body.get('prompt', '').strip()
|
||||
if not prompt:
|
||||
return jsonify({'error': 'Пустой промт'}), 400
|
||||
|
||||
try:
|
||||
result = generate_metric_from_prompt(subject_name, prompt)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||||
metric = Metric(subject_config_id=cfg.id,
|
||||
type=result.get('type', 'checkpoints'),
|
||||
label=result.get('label', 'Метрика'),
|
||||
config=result.get('config', {}),
|
||||
progress={},
|
||||
ai_prompt=prompt)
|
||||
db.session.add(metric)
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True, 'html': _render_metric_html(metric)})
|
||||
|
||||
|
||||
# ─── Сохранить прогресс метрики ───────────────────────────────────────────────
|
||||
# Принимает весь объект progress целиком и перезаписывает его в БД.
|
||||
|
||||
@bp.route('/api/metrics/<int:metric_id>/progress', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def save_progress(metric_id):
|
||||
metric = Metric.query.get_or_404(metric_id)
|
||||
if not _check_edit_access(metric.subject_config.schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
|
||||
metric.progress = request.json or {}
|
||||
flag_modified(metric, 'progress')
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Сохранить позицию/размер метрики в сетке ────────────────────────────────
|
||||
|
||||
@bp.route('/api/metrics/<int:metric_id>/layout', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def save_layout(metric_id):
|
||||
metric = Metric.query.get_or_404(metric_id)
|
||||
if not _check_edit_access(metric.subject_config.schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
body = request.json or {}
|
||||
for field in ('grid_col', 'grid_row', 'grid_w', 'grid_h'):
|
||||
if field in body:
|
||||
setattr(metric, field, int(body[field]))
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Переименовать метрику ────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/api/metrics/<int:metric_id>/rename', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def rename_metric(metric_id):
|
||||
metric = Metric.query.get_or_404(metric_id)
|
||||
if not _check_edit_access(metric.subject_config.schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
label = (request.json or {}).get('label', '').strip()
|
||||
if not label:
|
||||
return jsonify({'error': 'Пустое название'}), 400
|
||||
metric.label = label
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Скрыть / показать предмет в расписании ──────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/visibility',
|
||||
methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def toggle_subject_visibility(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _check_edit_access(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||||
cfg.hidden = not cfg.hidden
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True, 'hidden': cfg.hidden})
|
||||
|
||||
|
||||
# ─── Тип сдачи предмета ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/completion_type',
|
||||
methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def set_completion_type(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _check_edit_access(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
value = (request.json or {}).get('completion_type')
|
||||
if value and value not in COMPLETION_TYPES:
|
||||
return jsonify({'error': 'Неверный тип'}), 400
|
||||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||||
cfg.completion_type = value or None
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True, 'completion_type': cfg.completion_type})
|
||||
|
||||
|
||||
# ─── Завершить / возобновить предмет ─────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/completed',
|
||||
methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def toggle_completed(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _check_edit_access(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||||
cfg.completed = not cfg.completed
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True, 'completed': cfg.completed})
|
||||
|
||||
|
||||
# ─── Ссылка на СДО ───────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/sdo_url',
|
||||
methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def set_sdo_url(schedule_id, subject_name):
|
||||
schedule = Schedule.query.get_or_404(schedule_id)
|
||||
if not _check_edit_access(schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
url = (request.json or {}).get('sdo_url', '').strip()
|
||||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||||
cfg.sdo_url = url or None
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Получить промт AI-трекера ────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/api/metrics/<int:metric_id>/ai_prompt', methods=['GET'])
|
||||
@login_required
|
||||
def get_metric_ai_prompt(metric_id):
|
||||
metric = Metric.query.get_or_404(metric_id)
|
||||
if not _check_edit_access(metric.subject_config.schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
return jsonify({'ok': True, 'prompt': metric.ai_prompt or ''})
|
||||
|
||||
|
||||
# ─── Сохранить порядок/размеры метрик батчем ─────────────────────────────────
|
||||
|
||||
@bp.route('/api/metrics/layout_batch', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def save_layout_batch():
|
||||
items = (request.json or {}).get('items', [])
|
||||
if not items:
|
||||
return jsonify({'ok': True})
|
||||
ids = [int(item['id']) for item in items]
|
||||
metrics_map = {m.id: m for m in Metric.query.filter(Metric.id.in_(ids)).all()}
|
||||
for item in items:
|
||||
metric = metrics_map.get(int(item['id']))
|
||||
if not metric:
|
||||
continue
|
||||
if not _check_edit_access(metric.subject_config.schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
for field in ('grid_col', 'grid_row', 'grid_w', 'grid_h'):
|
||||
if field in item:
|
||||
setattr(metric, field, int(item[field]))
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Удалить метрику ──────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/api/metrics/<int:metric_id>', methods=['DELETE'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
def delete_metric(metric_id):
|
||||
metric = Metric.query.get_or_404(metric_id)
|
||||
if not _check_edit_access(metric.subject_config.schedule):
|
||||
return jsonify({'error': 'Нет доступа'}), 403
|
||||
db.session.delete(metric)
|
||||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Вспомогательные функции ──────────────────────────────────────────────────
|
||||
|
||||
def _get_or_create_config(schedule_id: int, subject_name: str) -> SubjectConfig:
|
||||
config = SubjectConfig.query.filter_by(
|
||||
schedule_id=schedule_id, subject_name=subject_name).first()
|
||||
if not config:
|
||||
config = SubjectConfig(schedule_id=schedule_id, subject_name=subject_name)
|
||||
db.session.add(config)
|
||||
db.session.flush()
|
||||
return config
|
||||
|
||||
|
||||
def _check_edit_access(schedule: Schedule) -> bool:
|
||||
if schedule.user_id == current_user.id:
|
||||
return True
|
||||
from app.models.share import Share, ShareEditor
|
||||
share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
|
||||
if share:
|
||||
return ShareEditor.query.filter_by(
|
||||
share_id=share.id, user_id=current_user.id).first() is not None
|
||||
return False
|
||||
|
||||
|
||||
def _render_metric_html(metric: Metric) -> str:
|
||||
from flask import render_template_string
|
||||
return render_template_string(
|
||||
'{% from "macros/metrics.html" import render_metric %}{{ render_metric(metric, True) }}',
|
||||
metric=metric
|
||||
)
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
80
app/services/ai_metrics.py
Normal file
80
app/services/ai_metrics.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import json
|
||||
import os
|
||||
from openai import OpenAI
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Схема всех типов метрик для AI
|
||||
METRICS_SCHEMA_PROMPT = """Доступные типы метрик:
|
||||
|
||||
1. checkpoints — N независимых точек для отметки (лабы, задания, тесты)
|
||||
config: { "total": число, "labels": ["Лаба 1", ...] или null }
|
||||
|
||||
2. progress_bar — Накопление баллов/очков до максимума
|
||||
config: { "max": число, "unit": "баллов", "step": шаг_или_null }
|
||||
|
||||
3. counter — Простой счётчик до цели
|
||||
config: { "max": число, "step": 1, "unit": "единица" }
|
||||
|
||||
4. stages — Последовательные этапы работы
|
||||
config: { "stages": ["Тема", "План", "Черновик", "Защита"] }
|
||||
|
||||
5. checklist — Именованный список задач
|
||||
config: { "items": ["Задача 1", "Задача 2", ...] }
|
||||
|
||||
6. attendance — Отметки посещаемости (был/не был/уважительная)
|
||||
config: { "total_classes": число_или_null }
|
||||
|
||||
7. grades — Оценки за работы с весами и средним
|
||||
config: { "works": [{ "name": "Работа 1", "weight": 1 }], "max_grade": 5 }
|
||||
|
||||
8. deadlines — Задачи с датами дедлайнов
|
||||
config: { "items": [{ "name": "Задача", "date": "ДД.ММ" }] }
|
||||
|
||||
9. streak — Серия непрерывных дней активности
|
||||
config: {}
|
||||
|
||||
10. effort_hours — Учёт трудозатрат в часах
|
||||
config: { "goal_hours": число_или_null }
|
||||
|
||||
11. rating_history — История оценок в динамике
|
||||
config: { "scale": 5 }
|
||||
"""
|
||||
|
||||
|
||||
def generate_metric_from_prompt(subject_name: str, user_prompt: str) -> dict:
|
||||
client = OpenAI(
|
||||
api_key=os.getenv('OPENAI_API_KEY'),
|
||||
base_url=os.getenv('OPENAI_BASE_URL') or None,
|
||||
)
|
||||
model = os.getenv('OPENAI_MODEL', 'gpt-4o')
|
||||
|
||||
system = (
|
||||
'Ты — помощник по созданию метрик успеваемости. '
|
||||
'Выбери подходящий тип метрики по описанию пользователя и верни ТОЛЬКО JSON без лишнего текста.\n\n'
|
||||
+ METRICS_SCHEMA_PROMPT
|
||||
+ '\nОтвет строго в формате: { "type": "тип", "label": "короткое название", "config": {...} }'
|
||||
)
|
||||
|
||||
user_msg = (
|
||||
f'Предмет: {subject_name}\n'
|
||||
f'Описание от пользователя: {user_prompt}'
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{'role': 'system', 'content': system},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
],
|
||||
temperature=0,
|
||||
)
|
||||
|
||||
raw = response.choices[0].message.content.strip()
|
||||
if raw.startswith('```'):
|
||||
lines = raw.splitlines()
|
||||
end = len(lines) - 1 if lines[-1].strip() == '```' else len(lines)
|
||||
raw = '\n'.join(lines[1:end])
|
||||
|
||||
return json.loads(raw)
|
||||
134
app/services/ai_scan.py
Normal file
134
app/services/ai_scan.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import base64
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from openai import OpenAI
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
TYPE_COLOR_POOL = """
|
||||
Доступные цвета для типов занятий (выбери наиболее подходящий для каждого типа):
|
||||
"blue" — лекции, теоретические занятия
|
||||
"green" — практики, семинары
|
||||
"yellow" — лабораторные работы, практикумы
|
||||
"purple" — семинары, коллоквиумы
|
||||
"orange" — консультации, факультативы
|
||||
"red" — экзамены, зачёты, контрольные
|
||||
"teal" — проектная работа, курсовые
|
||||
"gray" — прочее, не указано
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """Ты — точный парсер расписания учебных занятий.
|
||||
Тебе дадут картинку с расписанием. Твоя задача — извлечь ВСЕ занятия и вернуть ТОЛЬКО валидный JSON, без лишнего текста, пояснений и markdown.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
ВАЖНО: ОДИН ПРЕДМЕТ — ОДИН ОБЪЕКТ В МАССИВЕ
|
||||
═══════════════════════════════════════════════
|
||||
Один и тот же предмет может встречаться в расписании много раз:
|
||||
— в разные дни недели
|
||||
— в разное время
|
||||
— с разными типами (Лекция, Практика, Лаб. работа)
|
||||
— в разные периоды дат
|
||||
— в разных аудиториях
|
||||
— у разных преподавателей
|
||||
|
||||
ВСЕ вхождения одного предмета должны быть объединены в ОДИН объект!
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
СТРУКТУРА ОТВЕТА
|
||||
═══════════════════════════════════════════════
|
||||
[
|
||||
{
|
||||
"subject": "Название предмета",
|
||||
"types": {
|
||||
"Тип занятия": {
|
||||
"color": "blue",
|
||||
"dates": {
|
||||
"ДД.ММ-ДД.ММ": [
|
||||
["день", "ЧЧ:ММ-ЧЧ:ММ", "кабинет_или_null", "преподаватель_или_null"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
ПРАВИЛА
|
||||
═══════════════════════════════════════════════
|
||||
subject — обязательно, точное название
|
||||
types — обязательно, словарь типов
|
||||
|
||||
Ключ типа — "Лекция", "Практика", "Лаб. работа" и т.п.
|
||||
Если тип не указан — "Не указано"
|
||||
|
||||
color — цвет типа из пула ниже, выбери по смыслу типа
|
||||
""" + TYPE_COLOR_POOL + """
|
||||
Ключ дат — формат "ДД.ММ-ДД.ММ", например "02.02-05.04"
|
||||
Месяцы: янв=01 фев=02 мар=03 апр=04 май=05 июн=06
|
||||
июл=07 авг=08 сен=09 окт=10 ноя=11 дек=12
|
||||
Если период не указан — "Весь период"
|
||||
|
||||
Строка слота:
|
||||
[0] день — нижний регистр (понедельник, вторник...)
|
||||
[1] время — "ЧЧ:ММ-ЧЧ:ММ"
|
||||
[2] кабинет — строка или null
|
||||
[3] преподаватель — строка (несколько через запятую) или null
|
||||
|
||||
ВАЖНО: поле types изменилось — теперь каждый тип содержит объект с полями "color" и "dates" (вместо прямого словаря дат).
|
||||
"""
|
||||
|
||||
|
||||
def _encode_image(image_bytes: bytes, ext: str) -> tuple[str, str]:
|
||||
mime_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png', '.webp': 'image/webp', '.bmp': 'image/bmp'}
|
||||
mime = mime_map.get(ext.lower(), 'image/jpeg')
|
||||
return base64.standard_b64encode(image_bytes).decode('utf-8'), mime
|
||||
|
||||
|
||||
def _strip_markdown(raw: str) -> str:
|
||||
raw = raw.strip()
|
||||
if raw.startswith('```'):
|
||||
lines = raw.splitlines()
|
||||
end = len(lines) - 1 if lines[-1].strip() == '```' else len(lines)
|
||||
raw = '\n'.join(lines[1:end])
|
||||
return raw
|
||||
|
||||
|
||||
def scan_image(image_bytes: bytes, ext: str, extra_prompt: str = '') -> list[dict]:
|
||||
client = OpenAI(
|
||||
api_key=os.getenv('OPENAI_API_KEY'),
|
||||
base_url=os.getenv('OPENAI_BASE_URL') or None,
|
||||
)
|
||||
model = os.getenv('OPENAI_MODEL', 'gpt-4o')
|
||||
image_data, mime_type = _encode_image(image_bytes, ext)
|
||||
|
||||
user_text = 'Извлеки расписание из этого изображения и верни JSON строго по инструкции.'
|
||||
if extra_prompt:
|
||||
user_text += f'\n\nДополнительные указания от пользователя: {extra_prompt}'
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{'role': 'system', 'content': SYSTEM_PROMPT},
|
||||
{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {
|
||||
'url': f'data:{mime_type};base64,{image_data}',
|
||||
'detail': 'high',
|
||||
},
|
||||
},
|
||||
{'type': 'text', 'text': user_text},
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature=0,
|
||||
)
|
||||
|
||||
raw = response.choices[0].message.content
|
||||
return json.loads(_strip_markdown(raw))
|
||||
23
app/services/merge.py
Normal file
23
app/services/merge.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
def merge_schedules_data(schedules_data: list[list]) -> list[dict]:
|
||||
"""Объединяет данные нескольких расписаний"""
|
||||
subjects: dict[str, dict] = {}
|
||||
|
||||
for schedule in schedules_data:
|
||||
for item in schedule:
|
||||
name = item.get('subject', '').strip()
|
||||
if not name:
|
||||
continue
|
||||
entry = subjects.setdefault(name, {'subject': name, 'types': {}})
|
||||
for type_name, type_data in item.get('types', {}).items():
|
||||
new_fmt = isinstance(type_data, dict) and 'dates' in type_data
|
||||
color = type_data.get('color', 'gray') if new_fmt else 'gray'
|
||||
date_ranges = type_data['dates'] if new_fmt else type_data
|
||||
typ = entry['types'].setdefault(type_name, {'color': color, 'dates': {}})
|
||||
typ_dates = typ['dates']
|
||||
for dr, slots in date_ranges.items():
|
||||
slot_list = typ_dates.setdefault(dr, [])
|
||||
for slot in slots:
|
||||
if slot not in slot_list:
|
||||
slot_list.append(slot)
|
||||
|
||||
return list(subjects.values())
|
||||
119
app/services/schedule_helpers.py
Normal file
119
app/services/schedule_helpers.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
from datetime import date, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
DAYS_RU = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье']
|
||||
DAYS_DISPLAY = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']
|
||||
|
||||
|
||||
def _parse_date_range(dr: str):
|
||||
"""Парсим 'ДД.ММ-ДД.ММ' в даты"""
|
||||
if not dr or dr == 'Весь период':
|
||||
return None, None
|
||||
try:
|
||||
start_str, end_str = dr[:5], dr[6:]
|
||||
year = date.today().year
|
||||
start = date(year, int(start_str[3:5]), int(start_str[0:2]))
|
||||
end = date(year, int(end_str[3:5]), int(end_str[0:2]))
|
||||
return start, end
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def get_week_dates():
|
||||
"""Даты текущей недели Пн-Вс"""
|
||||
today = date.today()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
return [monday + timedelta(days=i) for i in range(7)]
|
||||
|
||||
|
||||
def _merge_same_subject(slots: list) -> dict:
|
||||
"""Несколько слотов одного предмета в одно время — объединяем различающиеся поля через /"""
|
||||
merged = dict(slots[0])
|
||||
for field in ('classroom', 'teacher', 'type'):
|
||||
vals = list(dict.fromkeys(str(s.get(field) or '') for s in slots))
|
||||
vals = [v for v in vals if v]
|
||||
merged[field] = ' / '.join(vals) if vals else None
|
||||
return merged
|
||||
|
||||
|
||||
def _group_by_time(slots: list) -> list:
|
||||
"""Группирует слоты по времени; один предмет — мёрджит поля; разные предметы — группа"""
|
||||
by_time = defaultdict(list)
|
||||
for slot in slots:
|
||||
by_time[slot.get('time') or ''].append(slot)
|
||||
|
||||
result = []
|
||||
for time_key in sorted(by_time.keys()):
|
||||
group = by_time[time_key]
|
||||
subjects = {s['subject'] for s in group}
|
||||
if len(subjects) == 1:
|
||||
result.append(_merge_same_subject(group))
|
||||
else:
|
||||
result.append({'_group': True, 'time': time_key, 'slots': group})
|
||||
return result
|
||||
|
||||
|
||||
def build_day_view(schedule_data: list, target_date: date) -> list:
|
||||
"""Занятия на конкретную дату с группировкой по времени"""
|
||||
day_name = DAYS_RU[target_date.weekday()]
|
||||
raw = []
|
||||
|
||||
for item in schedule_data:
|
||||
subject = item.get('subject', '')
|
||||
for type_name, type_data in item.get('types', {}).items():
|
||||
# Support both old format (dates directly) and new format ({color, dates})
|
||||
if isinstance(type_data, dict) and 'dates' in type_data:
|
||||
type_color = type_data.get('color', 'gray')
|
||||
date_ranges = type_data['dates']
|
||||
else:
|
||||
type_color = 'gray'
|
||||
date_ranges = type_data
|
||||
for dr, slots in date_ranges.items():
|
||||
start, end = _parse_date_range(dr)
|
||||
if start and end and not (start <= target_date <= end):
|
||||
continue
|
||||
for slot in slots:
|
||||
if not slot or slot[0].lower() != day_name:
|
||||
continue
|
||||
raw.append({
|
||||
'subject': subject,
|
||||
'type': type_name,
|
||||
'type_name': type_name,
|
||||
'color': type_color,
|
||||
'time': slot[1] if len(slot) > 1 else None,
|
||||
'classroom': slot[2] if len(slot) > 2 else None,
|
||||
'teacher': slot[3] if len(slot) > 3 else None,
|
||||
'date_range': dr,
|
||||
'day': day_name,
|
||||
})
|
||||
|
||||
raw.sort(key=lambda x: x.get('time') or '')
|
||||
return _group_by_time(raw)
|
||||
|
||||
|
||||
def build_week_view(schedule_data: list) -> dict:
|
||||
"""Расписание на всю неделю"""
|
||||
week_dates = get_week_dates()
|
||||
return {i: build_day_view(schedule_data, d) for i, d in enumerate(week_dates)}
|
||||
|
||||
|
||||
def is_now(time_str: str) -> bool:
|
||||
"""Идёт ли занятие прямо сейчас"""
|
||||
if not time_str or '-' not in time_str:
|
||||
return False
|
||||
from datetime import datetime
|
||||
try:
|
||||
parts = time_str.split('-')
|
||||
now = datetime.now()
|
||||
start = datetime.strptime(parts[0].strip(), '%H:%M').replace(
|
||||
year=now.year, month=now.month, day=now.day)
|
||||
end = datetime.strptime(parts[1].strip(), '%H:%M').replace(
|
||||
year=now.year, month=now.month, day=now.day)
|
||||
return start <= now <= end
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_all_subjects(schedule_data: list) -> list:
|
||||
"""Список уникальных названий предметов"""
|
||||
return [item.get('subject', '') for item in schedule_data if item.get('subject')]
|
||||
128
app/static/css/base.css
Normal file
128
app/static/css/base.css
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/* ═══ CSS-переменные ════════════════════════════════════════════════════════ */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--green: #3D7A5F;
|
||||
--mint: #E8F5EE;
|
||||
--bg: #F9F9F8;
|
||||
--surface: #FFFFFF;
|
||||
--surface2: #F4F4F3;
|
||||
--ink: #1A1A1A;
|
||||
--muted: #6B7280;
|
||||
--border: #E5E7EB;
|
||||
--mono: 'JetBrains Mono', monospace;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--green: #4A9B77;
|
||||
--mint: #1A2E25;
|
||||
--bg: #111110;
|
||||
--surface: #1C1C1B;
|
||||
--surface2: #242423;
|
||||
--ink: #EDEDED;
|
||||
--muted: #71717A;
|
||||
--border: #2E2E2C;
|
||||
--mono: 'JetBrains Mono', monospace;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ═══ Сброс ══════════════════════════════════════════════════════════════════ */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
/* ═══ Страница ════════════════════════════════════════════════════════════════ */
|
||||
.t-page { max-width: 1080px; width: 100%; margin: 0 auto; padding: 28px 24px; flex: 1; }
|
||||
|
||||
/* ═══ Заголовок раздела ═══════════════════════════════════════════════════════ */
|
||||
.t-section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -.01em;
|
||||
margin-bottom: 14px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.t-section-title-inline { margin: 0; }
|
||||
|
||||
/* ═══ Шапка страницы (PH) ════════════════════════════════════════════════════ */
|
||||
.t-ph { display: flex; align-items: center; gap: 10px; margin-bottom: 22px; flex-wrap: wrap; }
|
||||
.t-ph-title { font-size: 19px; font-weight: 700; letter-spacing: -.02em; }
|
||||
.t-ph-week { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
||||
.t-ph-right { margin-left: auto; display: flex; gap: 8px; }
|
||||
.t-ph-name-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ═══ Утилиты ════════════════════════════════════════════════════════════════ */
|
||||
.min-w-0 { min-width: 0; }
|
||||
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.text-muted { color: var(--muted); }
|
||||
.text-green { color: var(--green); }
|
||||
.t-link { color: var(--green); text-decoration: none; }
|
||||
.t-link:hover { text-decoration: underline; }
|
||||
.mono { font-family: var(--mono); }
|
||||
.fw-bold { font-weight: 700; }
|
||||
.fw-semi { font-weight: 600; }
|
||||
.fs-small { font-size: 11.5px; }
|
||||
.hidden { display: none !important; }
|
||||
.t-muted { color: var(--muted); font-size: 13px; margin-left: 6px; }
|
||||
|
||||
/* Layout-помощники */
|
||||
.row-center { display: flex; align-items: center; }
|
||||
.row-gap-8 { display: flex; align-items: center; gap: 8px; }
|
||||
.row-gap-6 { display: flex; align-items: center; gap: 6px; }
|
||||
.row-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.col-gap-6 { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ml-auto { margin-left: auto; }
|
||||
|
||||
/* ═══ Empty state ════════════════════════════════════════════════════════════ */
|
||||
.t-empty {
|
||||
text-align: center;
|
||||
padding: 64px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.t-empty-icon { font-size: 40px; margin-bottom: 14px; }
|
||||
.t-empty p { font-size: 13px; margin-bottom: 14px; }
|
||||
|
||||
/* ═══ Разделитель ════════════════════════════════════════════════════════════ */
|
||||
.t-divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
|
||||
|
||||
/* ═══ Футер ══════════════════════════════════════════════════════════════════ */
|
||||
.t-footer {
|
||||
max-width: 1080px;
|
||||
width: 100%;
|
||||
margin: 40px auto 24px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.t-footer-sep { opacity: .4; }
|
||||
.t-footer-link { color: var(--muted); text-decoration: none; }
|
||||
.t-footer-link:hover { color: var(--ink); }
|
||||
.t-footer-brand {
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
letter-spacing: -.04em;
|
||||
color: var(--ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
.t-footer-brand .t-brand-lo {
|
||||
font-size: 12px;
|
||||
padding: 1px 4px 2px;
|
||||
}
|
||||
258
app/static/css/components.css
Normal file
258
app/static/css/components.css
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/* ═══ Кнопки ══════════════════════════════════════════════════════════════════ */
|
||||
.t-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 6px 14px;
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: opacity .15s, background .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-btn-primary { background: var(--green); color: #fff; }
|
||||
.t-btn-primary:hover { opacity: .88; }
|
||||
.t-btn-outline {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink);
|
||||
}
|
||||
.t-btn-outline:hover { border-color: var(--muted); background: var(--surface2); }
|
||||
.t-btn-ghost { background: none; border: none; color: var(--muted); padding: 6px 10px; }
|
||||
.t-btn-ghost:hover { color: var(--ink); background: var(--surface2); }
|
||||
.t-btn-danger { background: none; border: 1px solid var(--border); color: #f87171; }
|
||||
[data-theme="light"] .t-btn-danger { border-color: #fecaca; color: #b91c1c; }
|
||||
.t-btn-danger:hover { background: var(--surface2); border-color: #f87171; }
|
||||
[data-theme="light"] .t-btn-danger:hover { background: #fee2e2; border-color: #fecaca; }
|
||||
|
||||
/* Размеры */
|
||||
.t-btn-sm { font-size: 12px; padding: 4px 10px; }
|
||||
.t-btn-xs { font-size: 11px; padding: 3px 8px; }
|
||||
.t-btn-full { width: 100%; justify-content: center; }
|
||||
.t-btn-group-end { border-radius: 0 8px 8px 0; white-space: nowrap; }
|
||||
|
||||
/* Кнопка-иконка */
|
||||
.t-btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.t-btn-icon:hover { background: var(--surface2); color: var(--ink); }
|
||||
.t-btn-icon.danger:hover { background: var(--surface2); color: #f87171; }
|
||||
[data-theme="light"] .t-btn-icon.danger:hover { background: #fee2e2; color: #b91c1c; }
|
||||
.t-btn-icon-ml { margin-left: auto; }
|
||||
|
||||
/* Кнопка режима редактирования */
|
||||
.t-edit-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all .15s;
|
||||
}
|
||||
.t-edit-btn.on { border-color: var(--green); color: var(--green); background: var(--mint); }
|
||||
|
||||
/* ═══ Алерты ══════════════════════════════════════════════════════════════════ */
|
||||
.t-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.t-alert-close { background: none; border: none; font-size: 16px; color: inherit; opacity: .6; margin-left: auto; cursor: pointer; line-height: 1; }
|
||||
.t-alert-success { background: var(--mint); border-color: var(--green); color: #6ee7a0; }
|
||||
[data-theme="light"] .t-alert-success { color: #166534; border-color: #a7f3d0; }
|
||||
.t-alert-danger, .t-alert-error { background: #3b0f0f; border-color: #7f1d1d; color: #f87171; }
|
||||
[data-theme="light"] .t-alert-danger, [data-theme="light"] .t-alert-error { background: #fee2e2; border-color: #fecaca; color: #b91c1c; }
|
||||
.t-alert-warning { background: #332600; border-color: #78350f; color: #f5d06a; }
|
||||
[data-theme="light"] .t-alert-warning { background: #fef9c3; border-color: #fde68a; color: #854d0e; }
|
||||
.t-alert-info { background: #1e3a5f; border-color: #1e40af; color: #93c5fd; }
|
||||
[data-theme="light"] .t-alert-info { background: #dbeafe; border-color: #bfdbfe; color: #1d4ed8; }
|
||||
|
||||
/* ═══ Бейджи типов ════════════════════════════════════════════════════════════ */
|
||||
.t-type {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-type-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.t-type-green { background: #dcfce7; color: #166534; }
|
||||
.t-type-yellow { background: #fef9c3; color: #854d0e; }
|
||||
.t-type-purple { background: #ede9fe; color: #5b21b6; }
|
||||
.t-type-orange { background: #ffedd5; color: #9a3412; }
|
||||
.t-type-red { background: #fee2e2; color: #b91c1c; }
|
||||
.t-type-teal { background: #ccfbf1; color: #0f766e; }
|
||||
.t-type-gray { background: #f1f5f9; color: #475569; }
|
||||
|
||||
[data-theme="dark"] .t-type-blue { background: #1e3a5f; color: #93c5fd; }
|
||||
[data-theme="dark"] .t-type-green { background: #14352a; color: #6ee7b7; }
|
||||
[data-theme="dark"] .t-type-yellow { background: #3a2e0a; color: #fde68a; }
|
||||
[data-theme="dark"] .t-type-purple { background: #2d1f5e; color: #c4b5fd; }
|
||||
[data-theme="dark"] .t-type-orange { background: #3d1f0a; color: #fdba74; }
|
||||
[data-theme="dark"] .t-type-red { background: #3d1212; color: #fca5a5; }
|
||||
[data-theme="dark"] .t-type-teal { background: #0a2e2a; color: #5eead4; }
|
||||
[data-theme="dark"] .t-type-gray { background: #1e2530; color: #94a3b8; }
|
||||
|
||||
/* Бейдж «Сегодня» */
|
||||
.t-badge-today {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
background: var(--mint);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* ═══ Поля ввода ══════════════════════════════════════════════════════════════ */
|
||||
.t-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.t-input:focus { border-color: var(--green); }
|
||||
.t-input-sm { padding: 5px 10px; font-size: 12px; border-radius: 6px; }
|
||||
.t-input-group { display: flex; gap: 0; }
|
||||
.t-input-group .t-input { border-radius: 8px 0 0 8px; }
|
||||
.t-input-group .t-btn { border-radius: 0 8px 8px 0; border-left: none; }
|
||||
.t-textarea { resize: vertical; min-height: 72px; }
|
||||
.t-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.t-select:focus { border-color: var(--green); }
|
||||
.t-select-sm { padding: 5px 10px; font-size: 12px; border-radius: 6px; }
|
||||
.t-label { display: block; font-size: 12px; font-weight: 600; color: var(--ink); margin-bottom: 5px; }
|
||||
.t-field { margin-bottom: 14px; }
|
||||
.t-hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||
.t-form-check { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.t-form-check input[type=checkbox] { accent-color: var(--green); width: 14px; height: 14px; cursor: pointer; }
|
||||
|
||||
/* ═══ Модальные окна ══════════════════════════════════════════════════════════ */
|
||||
.t-modal-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.35);
|
||||
z-index: 300;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.t-modal-backdrop.open { display: flex; }
|
||||
.t-modal {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
.t-modal-sm { width: 300px; }
|
||||
.t-modal-header {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.t-modal-title { font-size: 14px; font-weight: 700; }
|
||||
.t-modal-body { padding: 16px; }
|
||||
.t-modal-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.t-modal-close-btn { margin-left: auto; }
|
||||
|
||||
/* ═══ Спиннер ════════════════════════════════════════════════════════════════ */
|
||||
.t-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--green);
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
display: inline-block;
|
||||
will-change: transform;
|
||||
}
|
||||
@keyframes spin { to { rotate: 360deg; } }
|
||||
|
||||
/* ═══ Режим редактирования ════════════════════════════════════════════════════ */
|
||||
.edit-ctrl { display: none !important; }
|
||||
body.edit-mode .edit-ctrl { display: flex !important; }
|
||||
body.edit-mode button.edit-ctrl { display: inline-flex !important; }
|
||||
body.edit-mode a.edit-ctrl { display: inline-flex !important; }
|
||||
|
||||
/* ═══ Offcanvas (предметы — legacy, оставлено для обратной совместимости) ══ */
|
||||
.t-offcanvas-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.3);
|
||||
z-index: 200;
|
||||
}
|
||||
.t-offcanvas-backdrop.open { display: block; }
|
||||
.t-offcanvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 480px;
|
||||
max-width: 100vw;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform .25s ease;
|
||||
}
|
||||
.t-offcanvas.open { transform: translateX(0); }
|
||||
.t-offcanvas-header {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-offcanvas-title { font-size: 15px; font-weight: 700; }
|
||||
.t-offcanvas-body { flex: 1; overflow-y: auto; padding: 0; }
|
||||
47
app/static/css/forms.css
Normal file
47
app/static/css/forms.css
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* ═══ Страница загрузки расписания ══════════════════════════════════════════ */
|
||||
.t-upload-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.t-file-drop {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s;
|
||||
display: block;
|
||||
}
|
||||
.t-file-drop:hover { border-color: var(--green); background: var(--mint); }
|
||||
.t-file-drop input { display: none; }
|
||||
.t-upload-drop-text { font-size: 13px; font-weight: 500; margin-top: 10px; margin-bottom: 4px; }
|
||||
.t-preview-img { max-height: 280px; border-radius: 8px; border: 1px solid var(--border); margin-top: 10px; display: block; }
|
||||
.t-change-file-btn { margin-top: 6px; }
|
||||
.t-image-preview { display: none; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.t-image-preview.visible { display: flex; }
|
||||
|
||||
/* ═══ Страница входа / регистрации ══════════════════════════════════════════ */
|
||||
.t-auth-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 48px;
|
||||
}
|
||||
.t-auth-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
width: 380px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.t-auth-title { font-size: 20px; font-weight: 700; letter-spacing: -.02em; text-align: center; margin-bottom: 24px; }
|
||||
.t-auth-footer { text-align: center; font-size: 12.5px; color: var(--muted); margin-top: 16px; }
|
||||
.t-auth-footer a { color: var(--green); font-weight: 600; }
|
||||
|
||||
/* ═══ Кнопка шаринга ══════════════════════════════════════════════════════════ */
|
||||
.t-share-copy-btn { border-radius: 0 8px 8px 0; border-left: none; }
|
||||
1
app/static/css/metrics.css
Normal file
1
app/static/css/metrics.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Этот файл заменён на app/static/css/metrics/_*.css */
|
||||
114
app/static/css/metrics/_attendance.css
Normal file
114
app/static/css/metrics/_attendance.css
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
ATTENDANCE — посещаемость
|
||||
Акцент: #ef4444 (red)
|
||||
|
||||
gh-1: три числа горизонтально — присут / пропуск / уваж. Форма скрыта.
|
||||
gh-2: статы + точки-история + форма.
|
||||
gh-3/4: всё с воздухом, точки крупнее.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Три статы */
|
||||
.t-att-stats {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-att-stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 3px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface2);
|
||||
min-height: 44px;
|
||||
}
|
||||
.t-att-num {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
line-height: 1;
|
||||
}
|
||||
.t-att-lbl {
|
||||
font-size: 8.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-att-stat--present .t-att-num { color: #16a34a; }
|
||||
.t-att-stat--absent .t-att-num { color: #ef4444; }
|
||||
.t-att-stat--excused .t-att-num { color: #d97706; }
|
||||
[data-theme="dark"] .t-att-stat--present .t-att-num { color: #4ade80; }
|
||||
[data-theme="dark"] .t-att-stat--absent .t-att-num { color: #f87171; }
|
||||
[data-theme="dark"] .t-att-stat--excused .t-att-num { color: #fbbf24; }
|
||||
|
||||
/* Точки-история */
|
||||
.t-att-dots {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
align-content: flex-start;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.t-att-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
cursor: default;
|
||||
}
|
||||
.t-att-dot--present { background: #16a34a; }
|
||||
.t-att-dot--absent { background: #ef4444; }
|
||||
.t-att-dot--excused { background: #d97706; }
|
||||
[data-theme="dark"] .t-att-dot--present { background: #4ade80; }
|
||||
[data-theme="dark"] .t-att-dot--absent { background: #f87171; }
|
||||
[data-theme="dark"] .t-att-dot--excused { background: #fbbf24; }
|
||||
|
||||
/* Форма */
|
||||
.t-att-form {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-att-form .t-input,
|
||||
.t-att-form .t-select { flex: 1; min-width: 0; }
|
||||
|
||||
/* ── gh-1: только статы ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 8px 8px; }
|
||||
.t-att-stats { flex: 1; gap: 6px; }
|
||||
.t-att-stat { flex-direction: row; justify-content: center; gap: 6px; min-height: unset; padding: 6px 8px; }
|
||||
.t-att-num { font-size: 22px; }
|
||||
.t-att-lbl { font-size: 9px; margin-top: 0; }
|
||||
.t-att-dots { display: none; }
|
||||
.t-att-form { display: none; }
|
||||
}
|
||||
|
||||
/* ── gh-2 ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-att-stat { min-height: 38px; }
|
||||
.t-att-num { font-size: 18px; }
|
||||
.t-att-dot { width: 9px; height: 9px; }
|
||||
.t-att-form .t-input,
|
||||
.t-att-form .t-select { font-size: 11px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4 ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-att-stat { min-height: 60px; border-radius: 10px; }
|
||||
.t-att-num { font-size: 28px; }
|
||||
.t-att-lbl { font-size: 10px; }
|
||||
.t-att-dot { width: 13px; height: 13px; }
|
||||
.t-att-dots { gap: 4px; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3: статы крупнее ── */
|
||||
@container metric (min-width: 280px) {
|
||||
.t-att-num { font-size: 24px; }
|
||||
.t-att-dot { width: 11px; height: 11px; }
|
||||
}
|
||||
227
app/static/css/metrics/_base.css
Normal file
227
app/static/css/metrics/_base.css
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
METRICS BASE — общая обёртка, grid-контейнер, drag/resize, утилиты
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Виджет-обёртка ────────────────────────────────────────────────────────── */
|
||||
.t-metric {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
container-type: size;
|
||||
container-name: metric;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
|
||||
/* Цветные акценты — тонкая полоска сверху */
|
||||
.t-metric--checkpoints { border-top: 3px solid #6366f1; }
|
||||
.t-metric--progress_bar { border-top: 3px solid #0ea5e9; }
|
||||
.t-metric--counter { border-top: 3px solid #f59e0b; }
|
||||
.t-metric--stages { border-top: 3px solid #8b5cf6; }
|
||||
.t-metric--checklist { border-top: 3px solid #10b981; }
|
||||
.t-metric--attendance { border-top: 3px solid #ef4444; }
|
||||
.t-metric--grades { border-top: 3px solid #f97316; }
|
||||
.t-metric--deadlines { border-top: 3px solid #ec4899; }
|
||||
.t-metric--streak { border-top: 3px solid #f59e0b; }
|
||||
.t-metric--effort_hours { border-top: 3px solid #14b8a6; }
|
||||
.t-metric--rating_history { border-top: 3px solid #6366f1; }
|
||||
|
||||
/* ── Заголовок ─────────────────────────────────────────────────────────────── */
|
||||
.t-metric-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px 4px;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
.t-metric-title {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Кнопка переименования в заголовке ────────────────────────────────────── */
|
||||
.t-metric-rename-btn {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
transition: color .12s;
|
||||
}
|
||||
.t-metric:hover .t-metric-rename-btn { color: var(--muted); }
|
||||
.t-metric-rename-btn:hover { color: var(--ink) !important; }
|
||||
/* Скрыть кнопку переименования когда активен режим перетаскивания (там появляется крестик удаления) */
|
||||
.t-metrics-grid.drag-mode .t-metric-rename-btn { color: transparent !important; pointer-events: none; }
|
||||
|
||||
/* ── Тело ──────────────────────────────────────────────────────────────────── */
|
||||
.t-metric-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 10px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Пустое состояние ──────────────────────────────────────────────────────── */
|
||||
.t-metric-empty {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Крестик удаления — только в drag-mode ────────────────────────────────── */
|
||||
.t-metric .delete-metric-btn {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 10;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
transition: all .1s;
|
||||
}
|
||||
.t-metric .delete-metric-btn:hover { background: #fef2f2; border-color: #ef4444; color: #ef4444; }
|
||||
[data-theme="dark"] .t-metric .delete-metric-btn:hover { background: rgba(239,68,68,.15); }
|
||||
.t-metrics-grid.drag-mode .delete-metric-btn { display: flex; }
|
||||
|
||||
/* ── Resize-ручка ──────────────────────────────────────────────────────────── */
|
||||
.t-metric-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: se-resize;
|
||||
display: none;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: 4px;
|
||||
color: var(--border);
|
||||
z-index: 5;
|
||||
transition: color .12s;
|
||||
}
|
||||
.t-metric-resize:hover { color: var(--muted); }
|
||||
.t-metrics-grid.drag-mode .t-metric-resize { display: flex; }
|
||||
.t-metric-resize svg { pointer-events: none; }
|
||||
|
||||
/* ── Drag ──────────────────────────────────────────────────────────────────── */
|
||||
.t-metrics-grid.drag-mode .t-metric { cursor: grab; }
|
||||
.t-metrics-grid.drag-mode .t-metric:active { cursor: grabbing; }
|
||||
.t-metric.drag-ghost {
|
||||
opacity: .3;
|
||||
outline: 2px dashed var(--green);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.t-metric.drag-over {
|
||||
outline: 2px solid var(--green);
|
||||
outline-offset: -2px;
|
||||
background: var(--mint);
|
||||
}
|
||||
|
||||
/* ── Подсветка навигации ───────────────────────────────────────────────────── */
|
||||
@keyframes metric-highlight {
|
||||
0%,60% { box-shadow: 0 0 0 3px var(--green); }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
}
|
||||
.t-metric.highlight { animation: metric-highlight 1.8s ease-out forwards; }
|
||||
|
||||
/* ═══ Grid-контейнер ══════════════════════════════════════════════════════════ */
|
||||
.t-metrics-grid {
|
||||
--mg-cols: 3;
|
||||
--mg-gap: 12px;
|
||||
--row-h: 120px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--mg-cols), 1fr);
|
||||
grid-auto-rows: var(--row-h);
|
||||
gap: var(--mg-gap);
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (max-width: 900px) { .t-metrics-grid { --mg-cols: 2; } }
|
||||
@media (max-width: 560px) { .t-metrics-grid { --mg-cols: 1; } }
|
||||
|
||||
.t-metric.gw-2 { grid-column: span 2; }
|
||||
.t-metric.gw-3 { grid-column: span 3; }
|
||||
.t-metric.gh-1 { grid-row: span 1; }
|
||||
.t-metric.gh-2 { grid-row: span 2; }
|
||||
.t-metric.gh-3 { grid-row: span 3; }
|
||||
.t-metric.gh-4 { grid-row: span 4; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.t-metric.gw-2,
|
||||
.t-metric.gw-3 { grid-column: span 2; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.t-metric.gw-2,
|
||||
.t-metric.gw-3 { grid-column: span 1; }
|
||||
}
|
||||
|
||||
/* ── Кнопка "Расположение" ─────────────────────────────────────────────────── */
|
||||
.t-grid-edit-btn {
|
||||
font-size: 11.5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all .12s;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-grid-edit-btn:hover,
|
||||
.t-grid-edit-btn.active {
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
background: var(--mint);
|
||||
}
|
||||
|
||||
/* ── Общий мини-бар (переиспользуется в типах) ─────────────────────────────── */
|
||||
.t-m-bar-track {
|
||||
background: var(--surface2);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-m-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
transition: width .35s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
115
app/static/css/metrics/_checklist.css
Normal file
115
app/static/css/metrics/_checklist.css
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
CHECKLIST — чеклист
|
||||
Акцент: #10b981 (emerald)
|
||||
|
||||
gh-1: бар прогресса + дробь. Список скрыт.
|
||||
gh-2+: бар + список со скроллом.
|
||||
gh-3/4: строки выше, чекбоксы крупнее.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.t-cl-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.t-cl-track {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: var(--surface2);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.t-cl-fill {
|
||||
height: 100%;
|
||||
background: #10b981;
|
||||
border-radius: 99px;
|
||||
transition: width .35s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.t-cl-frac {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--mono);
|
||||
color: #10b981;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Список */
|
||||
.t-cl-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.t-cl-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 2px;
|
||||
border-radius: 6px;
|
||||
transition: background .1s;
|
||||
flex-shrink: 0;
|
||||
min-height: 28px;
|
||||
}
|
||||
.t-cl-item:hover { background: var(--surface2); }
|
||||
.t-cl-item input[type=checkbox] { display: none; }
|
||||
|
||||
.t-cl-box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 5px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all .12s;
|
||||
color: transparent;
|
||||
}
|
||||
.t-cl-item.checked .t-cl-box { background: #10b981; border-color: #10b981; color: #fff; }
|
||||
|
||||
.t-cl-text {
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
line-height: 1.3;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
transition: all .12s;
|
||||
}
|
||||
.t-cl-item.checked .t-cl-text { color: var(--muted); text-decoration: line-through; }
|
||||
|
||||
/* ── gh-1: только бар ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 10px 8px; }
|
||||
.t-cl-bar-row { flex: 1; align-items: center; margin-bottom: 0; }
|
||||
.t-cl-track { height: 8px; }
|
||||
.t-cl-frac { font-size: 16px; }
|
||||
.t-cl-list { display: none; }
|
||||
}
|
||||
|
||||
/* ── gh-2 ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-cl-item { min-height: 24px; padding: 2px 2px; }
|
||||
.t-cl-text { font-size: 11.5px; }
|
||||
.t-cl-box { width: 14px; height: 14px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4 ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-cl-item { min-height: 34px; padding: 5px 4px; }
|
||||
.t-cl-box { width: 18px; height: 18px; border-radius: 6px; }
|
||||
.t-cl-text { font-size: 13px; }
|
||||
.t-cl-track { height: 6px; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3: двухколоночный список ── */
|
||||
@container metric (min-width: 300px) and (min-height: 240px) {
|
||||
.t-cl-list { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 10px; align-content: start; }
|
||||
}
|
||||
104
app/static/css/metrics/_checkpoints.css
Normal file
104
app/static/css/metrics/_checkpoints.css
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
CHECKPOINTS — контрольные точки
|
||||
Акцент: #6366f1 (indigo)
|
||||
|
||||
gh-1 (120px): бар + дробь X/N. Точки скрыты.
|
||||
gh-2 (240px): бар + дробь + кнопки-точки в grid, заполняют пространство.
|
||||
gh-3/4 (360-480px): то же + подписи под точками видны полностью.
|
||||
Ширина: gw-1 компактно, gw-2/3 — точки крупнее, в несколько колонок.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Строка прогресса ── */
|
||||
.t-cp-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.t-cp-fraction {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
color: #6366f1;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.t-cp-fraction-max { font-size: 10px; font-weight: 400; color: var(--muted); }
|
||||
.t-cp-track {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: var(--surface2);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.t-cp-fill {
|
||||
height: 100%;
|
||||
background: #6366f1;
|
||||
border-radius: 99px;
|
||||
transition: width .35s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
|
||||
/* ── Сетка кнопок ── */
|
||||
.t-cps {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
|
||||
gap: 5px;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.t-cp {
|
||||
aspect-ratio: 1;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all .12s;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px;
|
||||
}
|
||||
.t-cp:hover { border-color: #6366f1; color: #6366f1; background: rgba(99,102,241,.06); }
|
||||
.t-cp.done { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
|
||||
/* ── gh-1: только бар, точки скрыты ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 10px 8px; }
|
||||
.t-cp-bar-row { padding-bottom: 0; flex: 1; align-items: center; }
|
||||
.t-cp-fraction { font-size: 20px; }
|
||||
.t-cps { display: none; }
|
||||
}
|
||||
|
||||
/* ── gh-2 (240px): стандарт ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-cp-fraction { font-size: 14px; }
|
||||
.t-cps { grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 4px; }
|
||||
.t-cp { font-size: 10px; border-radius: 6px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4: подписи ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-cp-fraction { font-size: 16px; }
|
||||
.t-cps { grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); gap: 6px; }
|
||||
.t-cp { aspect-ratio: unset; padding: 6px 4px; font-size: 12px; min-height: 40px; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3: точки крупнее ── */
|
||||
@container metric (min-width: 260px) {
|
||||
.t-cps { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); }
|
||||
}
|
||||
@container metric (min-width: 380px) {
|
||||
.t-cps { grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); }
|
||||
.t-cp { font-size: 12px; }
|
||||
}
|
||||
118
app/static/css/metrics/_counter.css
Normal file
118
app/static/css/metrics/_counter.css
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
COUNTER — счётчик
|
||||
Акцент: #f59e0b (amber)
|
||||
|
||||
gh-1: − цифра + кнопки в одну строку, компактно.
|
||||
gh-2: кнопки круглые по краям, цифра крупная по центру.
|
||||
gh-3/4: кнопки огромные, цифра гигантская, бар снизу.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.t-counter-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.t-counter-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.t-counter-btn {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
background: none;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
transition: all .12s;
|
||||
}
|
||||
.t-counter-btn:hover {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
background: rgba(245,158,11,.08);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.t-counter-btn:active { transform: scale(.95); }
|
||||
|
||||
.t-counter-display {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.t-counter-val {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
color: #f59e0b;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-counter-sub {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Прогресс-бар (если есть макс) */
|
||||
.t-counter-bar-row {
|
||||
flex-shrink: 0;
|
||||
height: 5px;
|
||||
background: var(--surface2);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.t-counter-bar-fill {
|
||||
height: 100%;
|
||||
background: #f59e0b;
|
||||
border-radius: 99px;
|
||||
transition: width .35s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
|
||||
/* ── gh-1: компактная строка ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 8px 8px; }
|
||||
.t-counter-main { gap: 6px; }
|
||||
.t-counter-btn { width: 28px; height: 28px; font-size: 16px; border-width: 1.5px; }
|
||||
.t-counter-val { font-size: 26px; }
|
||||
.t-counter-sub { display: none; }
|
||||
.t-counter-bar-row { height: 3px; }
|
||||
}
|
||||
|
||||
/* ── gh-2: стандарт ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-counter-btn { width: 36px; height: 36px; font-size: 20px; }
|
||||
.t-counter-val { font-size: 38px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4: всё крупно ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-counter-btn { width: 60px; height: 60px; font-size: 30px; border-width: 2.5px; }
|
||||
.t-counter-val { font-size: 72px; }
|
||||
.t-counter-sub { font-size: 14px; }
|
||||
.t-counter-bar-row { height: 8px; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3: больше места — кнопки крупнее ── */
|
||||
@container metric (min-width: 260px) {
|
||||
.t-counter-btn { width: 48px; height: 48px; font-size: 26px; }
|
||||
.t-counter-val { font-size: 52px; }
|
||||
}
|
||||
128
app/static/css/metrics/_deadlines.css
Normal file
128
app/static/css/metrics/_deadlines.css
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
DEADLINES — дедлайны
|
||||
Акцент: #ec4899 (pink)
|
||||
|
||||
gh-1: дробь + бар. Список скрыт.
|
||||
gh-2+: бар + список с датами.
|
||||
gh-3/4: строки просторнее, даты видны.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.t-dl-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.t-dl-track {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: var(--surface2);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.t-dl-fill {
|
||||
height: 100%;
|
||||
background: #ec4899;
|
||||
border-radius: 99px;
|
||||
transition: width .35s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.t-dl-frac {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--mono);
|
||||
color: #ec4899;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.t-dl-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.t-dl-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
cursor: pointer;
|
||||
padding: 4px 2px;
|
||||
border-radius: 6px;
|
||||
transition: background .1s;
|
||||
flex-shrink: 0;
|
||||
min-height: 28px;
|
||||
}
|
||||
.t-dl-item:hover { background: var(--surface2); }
|
||||
.t-dl-item input[type=checkbox] { display: none; }
|
||||
|
||||
.t-dl-box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 5px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all .12s;
|
||||
color: transparent;
|
||||
}
|
||||
.t-dl-item.done .t-dl-box { background: #ec4899; border-color: #ec4899; color: #fff; }
|
||||
|
||||
.t-dl-name {
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.3;
|
||||
transition: all .12s;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-dl-item.done .t-dl-name { color: var(--muted); text-decoration: line-through; }
|
||||
|
||||
.t-dl-date {
|
||||
font-size: 9.5px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── gh-1: только бар ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 10px 8px; }
|
||||
.t-dl-bar-row { flex: 1; align-items: center; margin-bottom: 0; }
|
||||
.t-dl-frac { font-size: 18px; }
|
||||
.t-dl-track { height: 8px; }
|
||||
.t-dl-list { display: none; }
|
||||
}
|
||||
|
||||
/* ── gh-2 ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-dl-date { display: none; }
|
||||
.t-dl-item { min-height: 24px; }
|
||||
.t-dl-name { font-size: 11.5px; }
|
||||
.t-dl-box { width: 14px; height: 14px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4 ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-dl-date { display: block; font-size: 10px; }
|
||||
.t-dl-item { min-height: 32px; padding: 5px 4px; }
|
||||
.t-dl-box { width: 18px; height: 18px; border-radius: 6px; }
|
||||
.t-dl-name { font-size: 13px; }
|
||||
.t-dl-track { height: 6px; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3 ── */
|
||||
@container metric (min-width: 280px) {
|
||||
.t-dl-date { display: block; }
|
||||
.t-dl-name { font-size: 12.5px; }
|
||||
}
|
||||
107
app/static/css/metrics/_effort_hours.css
Normal file
107
app/static/css/metrics/_effort_hours.css
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
EFFORT HOURS — трудозатраты
|
||||
Акцент: #14b8a6 (teal)
|
||||
|
||||
gh-1: "X ч" крупно по центру, инпут компактно справа.
|
||||
gh-2: число слева, бар прогресса + инпут справа.
|
||||
gh-3/4: число огромное, бар толстый, инпут широкий.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.t-ef-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Левая часть — число */
|
||||
.t-ef-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-ef-num {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
color: #14b8a6;
|
||||
line-height: 1;
|
||||
}
|
||||
.t-ef-unit { font-size: 18px; color: #14b8a6; }
|
||||
.t-ef-goal { font-size: 10px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
/* Правая часть — бар + инпут */
|
||||
.t-ef-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
.t-ef-bar-wrap {
|
||||
position: relative;
|
||||
height: 18px;
|
||||
background: var(--surface2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.t-ef-bar-fill {
|
||||
height: 100%;
|
||||
background: #14b8a6;
|
||||
border-radius: 8px;
|
||||
transition: width .35s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.t-ef-bar-pct {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
mix-blend-mode: overlay;
|
||||
pointer-events: none;
|
||||
}
|
||||
.t-ef-input-row { display: flex; gap: 5px; }
|
||||
.t-ef-input-row .t-input { flex: 1; min-width: 0; }
|
||||
|
||||
/* ── gh-1: число + инпут горизонтально ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 10px 8px; }
|
||||
.t-ef-wrap { gap: 10px; }
|
||||
.t-ef-num { font-size: 28px; }
|
||||
.t-ef-unit { font-size: 14px; }
|
||||
.t-ef-goal { display: none; }
|
||||
.t-ef-bar-wrap { height: 14px; }
|
||||
.t-ef-bar-pct { font-size: 9px; }
|
||||
.t-ef-input-row .t-input { font-size: 11px; height: 26px; padding: 2px 6px; }
|
||||
.t-ef-input-row .t-btn { height: 26px; padding: 0 8px; }
|
||||
}
|
||||
|
||||
/* ── gh-2: стандарт ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-ef-num { font-size: 36px; }
|
||||
.t-ef-unit { font-size: 16px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4 ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-ef-wrap { flex-direction: column; align-items: center; gap: 14px; }
|
||||
.t-ef-left { align-items: center; }
|
||||
.t-ef-num { font-size: 64px; }
|
||||
.t-ef-unit { font-size: 28px; }
|
||||
.t-ef-goal { font-size: 12px; }
|
||||
.t-ef-right { width: 100%; }
|
||||
.t-ef-bar-wrap { height: 24px; border-radius: 12px; }
|
||||
.t-ef-bar-pct { font-size: 11px; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3 ── */
|
||||
@container metric (min-width: 280px) and (max-height: 359px) {
|
||||
.t-ef-num { font-size: 48px; }
|
||||
.t-ef-unit { font-size: 22px; }
|
||||
}
|
||||
88
app/static/css/metrics/_grades.css
Normal file
88
app/static/css/metrics/_grades.css
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
GRADES — оценки
|
||||
Акцент: #f97316 (orange)
|
||||
|
||||
gh-1: только мини-бары с названиями, инпуты скрыты.
|
||||
gh-2+: бары + инпуты + кнопка сохранить.
|
||||
gh-3/4: строки просторнее.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.t-gr-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.t-gr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-gr-name {
|
||||
font-size: 10.5px;
|
||||
color: var(--muted);
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.t-gr-track {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: var(--surface2);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
.t-gr-fill {
|
||||
height: 100%;
|
||||
background: #f97316;
|
||||
border-radius: 99px;
|
||||
transition: width .35s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.t-gr-input {
|
||||
width: 46px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
/* Кнопка */
|
||||
.t-gr-save {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── gh-1: только бары без инпутов ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 10px 8px; }
|
||||
.t-gr-input { display: none; }
|
||||
.t-gr-save { display: none; }
|
||||
.t-gr-name { font-size: 10px; }
|
||||
.t-gr-track { height: 7px; }
|
||||
.t-gr-row { gap: 8px; }
|
||||
}
|
||||
|
||||
/* ── gh-2 ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-gr-row { gap: 5px; }
|
||||
.t-gr-name { font-size: 10px; width: 32px; }
|
||||
.t-gr-input { width: 40px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4 ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-gr-row { gap: 8px; min-height: 30px; }
|
||||
.t-gr-name { font-size: 12px; width: 48px; }
|
||||
.t-gr-track { height: 7px; }
|
||||
.t-gr-input { width: 54px; font-size: 12px !important; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3 ── */
|
||||
@container metric (min-width: 300px) {
|
||||
.t-gr-name { width: 52px; font-size: 11px; }
|
||||
.t-gr-input { width: 52px; }
|
||||
}
|
||||
110
app/static/css/metrics/_progress_bar.css
Normal file
110
app/static/css/metrics/_progress_bar.css
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
PROGRESS BAR — накопление баллов
|
||||
Акцент: #0ea5e9 (sky)
|
||||
|
||||
gh-1 (120px): кольцо SVG по центру, инпут и кнопка под ним компактно.
|
||||
gh-2 (240px): кольцо слева, справа — значение + инпут + кнопка.
|
||||
gh-3/4: кольцо крупнее, инпут шире, всё с воздухом.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Layout-обёртка ── */
|
||||
.t-pb-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Кольцо ── */
|
||||
.t-pb-ring-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
.t-pb-ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
overflow: visible;
|
||||
}
|
||||
.t-pb-ring-bg { fill: none; stroke: var(--surface2); stroke-width: 8; }
|
||||
.t-pb-ring-fill {
|
||||
fill: none;
|
||||
stroke: #0ea5e9;
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset .45s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.t-pb-pct {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
color: #0ea5e9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Правая панель ── */
|
||||
.t-pb-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
.t-pb-val {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
color: #0ea5e9;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.t-pb-val-max { font-size: 12px; color: var(--muted); font-weight: 400; }
|
||||
.t-pb-input-row { display: flex; gap: 5px; }
|
||||
.t-pb-input-row .t-input { flex: 1; min-width: 0; }
|
||||
|
||||
/* ── gh-1: кольцо + инпут горизонтально на одной строке ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 8px 8px; }
|
||||
.t-pb-wrap { gap: 8px; }
|
||||
.t-pb-ring-wrap { width: 56px; height: 56px; }
|
||||
.t-pb-pct { font-size: 11px; }
|
||||
.t-pb-val { font-size: 14px; }
|
||||
.t-pb-val-max { font-size: 10px; }
|
||||
.t-pb-input-row .t-input { font-size: 11px; padding: 3px 6px; height: 26px; }
|
||||
.t-pb-input-row .t-btn { height: 26px; padding: 0 7px; }
|
||||
}
|
||||
|
||||
/* ── gh-2: стандарт ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-pb-ring-wrap { width: 72px; height: 72px; }
|
||||
.t-pb-pct { font-size: 13px; }
|
||||
.t-pb-val { font-size: 18px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4: просторно, кольцо крупнее ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-pb-wrap { flex-direction: column; align-items: center; gap: 14px; }
|
||||
.t-pb-ring-wrap { width: 110px; height: 110px; }
|
||||
.t-pb-pct { font-size: 18px; }
|
||||
.t-pb-val { font-size: 22px; text-align: center; }
|
||||
.t-pb-right { width: 100%; align-items: center; }
|
||||
.t-pb-input-row { width: 100%; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3: кольцо ещё крупнее ── */
|
||||
@container metric (min-width: 300px) and (min-height: 120px) and (max-height: 359px) {
|
||||
.t-pb-ring-wrap { width: 90px; height: 90px; }
|
||||
.t-pb-pct { font-size: 15px; }
|
||||
.t-pb-val { font-size: 22px; }
|
||||
}
|
||||
107
app/static/css/metrics/_rating_history.css
Normal file
107
app/static/css/metrics/_rating_history.css
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
RATING HISTORY — история оценок
|
||||
Акцент: #6366f1 (indigo)
|
||||
|
||||
gh-1: последняя оценка крупно по центру.
|
||||
gh-2: оценка слева + список строк с барами справа + форма снизу.
|
||||
gh-3/4: список длиннее, бары толще, форма просторнее.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* gh-1 layout: центр */
|
||||
.t-rh-center {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.t-rh-big-val { font-size: 48px; font-weight: 700; font-family: var(--mono); color: #6366f1; line-height: 1; }
|
||||
.t-rh-big-scale { font-size: 14px; color: var(--muted); }
|
||||
.t-rh-big-avg { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
/* gh-2+ layout: левый столбец + правый */
|
||||
.t-rh-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.t-rh-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 46px;
|
||||
border-right: 1px solid var(--border);
|
||||
padding-right: 10px;
|
||||
}
|
||||
.t-rh-last-val { font-size: 28px; font-weight: 700; font-family: var(--mono); color: #6366f1; line-height: 1; }
|
||||
.t-rh-last-scale { font-size: 11px; color: var(--muted); }
|
||||
.t-rh-avg { font-size: 9.5px; color: var(--muted); margin-top: 4px; white-space: nowrap; }
|
||||
.t-rh-avg-lbl { font-size: 8.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
|
||||
|
||||
.t-rh-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.t-rh-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.t-rh-row { display: flex; align-items: center; gap: 5px; flex-shrink: 0; }
|
||||
.t-rh-date { font-size: 9px; color: var(--muted); font-family: var(--mono); width: 58px; flex-shrink: 0; }
|
||||
.t-rh-track { flex: 1; height: 4px; background: var(--surface2); border-radius: 99px; overflow: hidden; }
|
||||
.t-rh-fill { height: 100%; background: #6366f1; border-radius: 99px; transition: width .35s cubic-bezier(.4,0,.2,1); }
|
||||
.t-rh-val { font-size: 10px; font-weight: 600; font-family: var(--mono); width: 18px; text-align: right; flex-shrink: 0; color: #6366f1; }
|
||||
|
||||
.t-rh-form { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.t-rh-form .t-input:first-child { flex: 1.4; min-width: 0; }
|
||||
.t-rh-form .t-input:nth-child(2) { flex: 1; min-width: 0; }
|
||||
|
||||
/* ── gh-1: только большая оценка ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 10px 8px; }
|
||||
.t-rh-wrap { display: none; }
|
||||
.t-rh-center { display: flex; }
|
||||
}
|
||||
/* ── gh-2+: скрыть центр, показать wrap ── */
|
||||
@container metric (min-height: 120px) {
|
||||
.t-rh-center { display: none; }
|
||||
.t-rh-wrap { display: flex; }
|
||||
}
|
||||
|
||||
/* ── gh-2 ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-rh-last-val { font-size: 22px; }
|
||||
.t-rh-left { min-width: 40px; padding-right: 8px; }
|
||||
.t-rh-date { width: 50px; }
|
||||
.t-rh-form .t-input { font-size: 11px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4 ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-rh-last-val { font-size: 36px; }
|
||||
.t-rh-avg { font-size: 11px; }
|
||||
.t-rh-left { min-width: 58px; }
|
||||
.t-rh-track { height: 6px; }
|
||||
.t-rh-val { font-size: 11px; width: 22px; }
|
||||
.t-rh-date { width: 64px; font-size: 10px; }
|
||||
.t-rh-row { gap: 6px; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3 ── */
|
||||
@container metric (min-width: 280px) {
|
||||
.t-rh-last-val { font-size: 32px; }
|
||||
.t-rh-date { width: 64px; font-size: 10px; }
|
||||
}
|
||||
130
app/static/css/metrics/_stages.css
Normal file
130
app/static/css/metrics/_stages.css
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
STAGES — этапы
|
||||
Акцент: #8b5cf6 (violet)
|
||||
|
||||
gh-1: только точки горизонтально без подписей, активная — цветная.
|
||||
gh-2: точки + подписи горизонтально, при overflow wrap в 2 строки.
|
||||
gh-3/4: точки крупнее, подписи полные, коннекторы толще.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.t-stages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: center;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.t-stage-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Кнопка-этап: точка + подпись */
|
||||
.t-stage-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 3px 4px;
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.t-stage-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
transition: all .15s;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.t-stage-lbl {
|
||||
font-size: 9.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
max-width: 52px;
|
||||
white-space: normal;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
transition: color .15s;
|
||||
}
|
||||
|
||||
/* Горизонтальный коннектор между точками */
|
||||
.t-stage-conn {
|
||||
height: 2px;
|
||||
width: 16px;
|
||||
min-width: 8px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
/* центрируется по точке: padding-top кнопки (4px) + (28px/2) = 18px */
|
||||
margin-top: 18px;
|
||||
transition: background .15s;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.t-stage-conn.done { background: #8b5cf6; }
|
||||
|
||||
/* Состояния */
|
||||
.t-stage-btn.done .t-stage-dot { background: #8b5cf6; border-color: #8b5cf6; color: #fff; }
|
||||
.t-stage-btn.done .t-stage-lbl { color: var(--ink); }
|
||||
.t-stage-btn.active .t-stage-dot {
|
||||
border-color: #8b5cf6;
|
||||
color: #8b5cf6;
|
||||
background: rgba(139,92,246,.1);
|
||||
box-shadow: 0 0 0 3px rgba(139,92,246,.15);
|
||||
}
|
||||
.t-stage-btn.active .t-stage-lbl { color: var(--ink); font-weight: 600; }
|
||||
.t-stage-btn:hover .t-stage-dot { border-color: #8b5cf6; }
|
||||
|
||||
/* ── gh-1: только точки, без подписей ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 8px 8px; }
|
||||
.t-stages { align-content: center; justify-content: center; }
|
||||
.t-stage-lbl { display: none; }
|
||||
.t-stage-btn { padding: 4px 2px; }
|
||||
.t-stage-dot { width: 22px; height: 22px; font-size: 9px; }
|
||||
.t-stage-conn { width: 12px; margin-top: 15px; }
|
||||
}
|
||||
|
||||
/* ── gh-2: подписи видны ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-stage-dot { width: 26px; height: 26px; font-size: 10px; }
|
||||
.t-stage-conn { width: 14px; margin-top: 17px; }
|
||||
.t-stage-lbl { max-width: 48px; font-size: 9px; white-space: normal; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4: крупно ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-stage-dot { width: 36px; height: 36px; font-size: 12px; }
|
||||
.t-stage-conn { width: 22px; height: 3px; margin-top: 22px; }
|
||||
.t-stage-lbl { font-size: 11px; max-width: 64px; white-space: normal; }
|
||||
.t-stage-btn { gap: 5px; padding: 6px 4px; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3: коннекторы шире ── */
|
||||
@container metric (min-width: 280px) {
|
||||
.t-stage-conn { width: 24px; }
|
||||
.t-stage-dot { width: 30px; height: 30px; }
|
||||
.t-stage-conn { margin-top: 19px; }
|
||||
.t-stage-lbl { max-width: 60px; font-size: 10px; white-space: normal; }
|
||||
}
|
||||
@container metric (min-width: 400px) {
|
||||
.t-stage-conn { width: 32px; }
|
||||
.t-stage-lbl { max-width: 72px; }
|
||||
}
|
||||
72
app/static/css/metrics/_streak.css
Normal file
72
app/static/css/metrics/_streak.css
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
STREAK — серия
|
||||
Акцент: #f59e0b (amber)
|
||||
|
||||
gh-1: 🔥 + цифра горизонтально, кнопка компактная.
|
||||
gh-2: огонь + цифра вертикально по центру + кнопка снизу.
|
||||
gh-3/4: всё огромное.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.t-streak-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.t-streak-flame { font-size: 32px; line-height: 1; user-select: none; }
|
||||
.t-streak-num {
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
color: #f59e0b;
|
||||
line-height: 1;
|
||||
}
|
||||
.t-streak-lbl {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.t-streak-btn {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* ── gh-1: горизонтально ── */
|
||||
@container metric (max-height: 119px) {
|
||||
.t-metric-body { padding: 0 10px 8px; }
|
||||
.t-streak-body { flex-direction: row; gap: 8px; justify-content: center; }
|
||||
.t-streak-flame { font-size: 24px; }
|
||||
.t-streak-num { font-size: 34px; }
|
||||
.t-streak-lbl { display: none; }
|
||||
.t-streak-btn { width: auto; font-size: 10.5px; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
/* ── gh-2 ── */
|
||||
@container metric (min-height: 120px) and (max-height: 239px) {
|
||||
.t-streak-flame { font-size: 28px; }
|
||||
.t-streak-num { font-size: 44px; }
|
||||
.t-streak-lbl { font-size: 10px; }
|
||||
}
|
||||
|
||||
/* ── gh-3/4 ── */
|
||||
@container metric (min-height: 360px) {
|
||||
.t-streak-flame { font-size: 48px; }
|
||||
.t-streak-num { font-size: 72px; }
|
||||
.t-streak-lbl { font-size: 14px; }
|
||||
.t-streak-btn { font-size: 13px; padding: 8px; }
|
||||
}
|
||||
|
||||
/* ── gw-2/3 ── */
|
||||
@container metric (min-width: 260px) {
|
||||
.t-streak-flame { font-size: 36px; }
|
||||
.t-streak-num { font-size: 60px; }
|
||||
}
|
||||
63
app/static/css/nav.css
Normal file
63
app/static/css/nav.css
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/* ═══ Навбар ════════════════════════════════════════════════════════════════ */
|
||||
.t-nav {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 28px;
|
||||
height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.t-brand {
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
letter-spacing: -.04em;
|
||||
color: var(--ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.t-brand-tab {
|
||||
color: var(--ink);
|
||||
}
|
||||
.t-brand-lo {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 2px 5px 3px;
|
||||
margin-left: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.t-nav-r {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
/* Все кнопки в правой части навбара — одинаковая высота */
|
||||
.t-nav-r .t-btn,
|
||||
.t-nav-r .t-btn-icon {
|
||||
height: 32px;
|
||||
}
|
||||
.t-nav-r .t-btn {
|
||||
padding: 0 14px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.t-nav-r .t-btn-icon {
|
||||
padding: 0 8px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.t-nav-user { font-size: 12px; color: var(--muted); }
|
||||
|
||||
/* Переключатель темы — иконки */
|
||||
.t-theme-toggle { font-size: 15px; }
|
||||
[data-theme="dark"] .t-icon-sun { display: block; }
|
||||
[data-theme="dark"] .t-icon-moon { display: none; }
|
||||
[data-theme="light"] .t-icon-sun { display: none; }
|
||||
[data-theme="light"] .t-icon-moon { display: block; }
|
||||
.t-icon-sun, .t-icon-moon { display: none; }
|
||||
279
app/static/css/schedule.css
Normal file
279
app/static/css/schedule.css
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/* ═══ Сетка дней ══════════════════════════════════════════════════════════════ */
|
||||
.t-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 900px) { .t-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 580px) { .t-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ═══ Карточка дня ═══════════════════════════════════════════════════════════ */
|
||||
.t-day-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.t-day-card.today { border-color: var(--green); }
|
||||
.t-day-card-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.t-day-card-head.today { background: var(--mint); }
|
||||
.t-day-name { font-size: 13px; font-weight: 600; }
|
||||
.t-day-date { margin-left: auto; font-size: 11.5px; color: var(--muted); font-family: var(--mono); }
|
||||
|
||||
/* ═══ Слот расписания ════════════════════════════════════════════════════════ */
|
||||
.t-slot {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
transition: background .1s;
|
||||
}
|
||||
.t-slot:last-of-type { border-bottom: none; }
|
||||
.t-slot:hover { background: var(--surface2); }
|
||||
.t-slot.open { background: var(--surface2); }
|
||||
|
||||
.t-slot-time {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
min-width: 66px;
|
||||
padding-top: 2px;
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-slot-body { flex: 1; min-width: 0; overflow: hidden; }
|
||||
.t-slot-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
.t-slot-room {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.t-slot-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-slot-edit-controls { display: none; gap: 2px; }
|
||||
body.edit-mode .t-slot-edit-controls { display: flex; }
|
||||
|
||||
/* Стрелка разворота */
|
||||
.t-chevron {
|
||||
font-size: 10px;
|
||||
color: #ccc;
|
||||
transition: transform .2s;
|
||||
line-height: 1;
|
||||
}
|
||||
.t-slot.open .t-chevron { transform: rotate(180deg); }
|
||||
|
||||
/* ═══ Детали слота ════════════════════════════════════════════════════════════ */
|
||||
.t-slot-detail {
|
||||
display: none;
|
||||
padding: 10px 14px 12px 90px;
|
||||
background: var(--surface2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.t-slot.open + .t-slot-detail { display: block; }
|
||||
.t-slot-teacher {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Группа слотов — все предметы сразу видны */
|
||||
.t-slot-group {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.t-slot-group-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.t-slot-group-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-slot-group-items {
|
||||
border-left: 2px solid var(--border);
|
||||
margin-left: 7px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.t-slot-group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0 5px 10px;
|
||||
transition: background .1s;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.t-slot-group-item:hover { background: var(--surface2); }
|
||||
.flex-1 { flex: 1; }
|
||||
.t-slot-time-mono { font-size: 11px; font-family: var(--mono); }
|
||||
|
||||
/* ═══ Детали слота — описание и трекеры ══════════════════════════════════════ */
|
||||
.t-slot-desc {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.t-slot-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.t-slot-metric-link {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition: border-color .12s, color .12s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.t-slot-metric-link:hover {
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Кнопка СДО в детали слота */
|
||||
.t-slot-sdo-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
text-decoration: none;
|
||||
transition: border-color .12s, color .12s;
|
||||
}
|
||||
.t-slot-sdo-btn:hover { border-color: var(--accent, #6366f1); color: var(--accent, #6366f1); }
|
||||
|
||||
/* Бейдж «Завершён» в детали слота */
|
||||
.t-slot-completed-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
border-radius: 99px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: rgba(34, 197, 94, .12);
|
||||
color: #16a34a;
|
||||
border: 1px solid rgba(34, 197, 94, .3);
|
||||
margin-left: 4px;
|
||||
}
|
||||
[data-theme="dark"] .t-slot-completed-badge {
|
||||
color: #4ade80;
|
||||
background: rgba(34, 197, 94, .18);
|
||||
border-color: rgba(34, 197, 94, .35);
|
||||
}
|
||||
|
||||
/* ═══ Дэшборд ═════════════════════════════════════════════════════════════════ */
|
||||
/* Использует общий .t-metrics-grid из metrics.css */
|
||||
|
||||
.t-dash-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.t-dash-title-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Заполненные трекеры — скрыты по умолчанию, показываются по кнопке. */
|
||||
.t-metric.metric-filled-hidden { display: none !important; }
|
||||
|
||||
/* Промт AI-трекера: заголовок кликабельный */
|
||||
.t-metric-title-clickable {
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.t-metric-title-clickable:hover { opacity: .8; }
|
||||
|
||||
/* Блок отображения промта в модальном окне */
|
||||
.t-ai-prompt-display {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-top: 8px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* ═══ Карточки расписаний (профиль) ══════════════════════════════════════════ */
|
||||
.t-sched-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 900px) { .t-sched-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 580px) { .t-sched-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.t-sched-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: box-shadow .15s, border-color .15s;
|
||||
}
|
||||
.t-sched-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.15); border-color: var(--muted); }
|
||||
.t-sched-card-title { font-size: 15px; font-weight: 700; }
|
||||
.t-sched-card-meta { font-size: 11.5px; color: var(--muted); display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.t-sched-card-meta span { display: flex; align-items: center; gap: 3px; }
|
||||
.t-sched-card-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
|
||||
.t-sched-card-footer { border-top: 1px solid var(--border); padding-top: 10px; margin-top: 4px; }
|
||||
|
||||
/* Объединение расписаний */
|
||||
.t-merge-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.t-merge-title { margin-bottom: 12px; }
|
||||
235
app/static/css/subjects.css
Normal file
235
app/static/css/subjects.css
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
/* ═══ Сетка карточек предметов ══════════════════════════════════════════════ */
|
||||
.t-subj-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ═══ Карточка предмета ═════════════════════════════════════════════════════ */
|
||||
.t-subj-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Шапка карточки — в стиле t-day-card-head */
|
||||
.t-subj-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.t-subj-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.t-subj-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
.t-subj-type-dots { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.t-subj-head-actions { display: flex; gap: 2px; flex-shrink: 0; }
|
||||
|
||||
/* Скрытый предмет — приглушённый вид карточки */
|
||||
.t-subj-card.subject-hidden { opacity: .5; }
|
||||
.t-subj-card.subject-hidden .t-subj-head { background: repeating-linear-gradient(
|
||||
135deg, transparent, transparent 4px, rgba(128,128,128,.04) 4px, rgba(128,128,128,.04) 8px); }
|
||||
|
||||
/* Кнопка видимости: активная (скрыто) — красноватый оттенок */
|
||||
.toggle-visibility-btn.subject-hidden { color: #ef4444 !important; }
|
||||
|
||||
/* Секция внутри карточки */
|
||||
.t-subj-section {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.t-subj-section:last-child { border-bottom: none; }
|
||||
.t-subj-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.t-subj-section-head .t-subj-section-title { margin-bottom: 0; }
|
||||
.t-subj-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Описание */
|
||||
.t-subj-desc { font-size: 13px; margin-bottom: 6px; line-height: 1.5; }
|
||||
.t-desc-italic { font-style: italic; }
|
||||
.t-subject-desc-form { display: none; margin-top: 6px; }
|
||||
.t-subject-desc-form.open { display: block; }
|
||||
.t-subj-desc-actions { display: flex; gap: 6px; margin-top: 6px; }
|
||||
|
||||
/* Кнопки полей (переименование) */
|
||||
.t-subj-field-btns { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
|
||||
|
||||
/* Добавить трекер */
|
||||
.t-subj-add-metric { margin-top: 8px; }
|
||||
.t-add-metric-btns { display: flex; gap: 6px; }
|
||||
.t-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||||
|
||||
/* Заголовок секции слотов (тип + период) */
|
||||
.t-subj-slots-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.t-subj-period-form { display: flex; }
|
||||
.t-subj-period-form .t-input-group { width: 200px; }
|
||||
|
||||
/* Строка редактирования слота */
|
||||
.t-slot-edit-row td { padding: 3px 6px; }
|
||||
.t-slot-edit-row .t-input-sm { padding: 3px 7px; font-size: 12px; }
|
||||
.t-slot-edit-save-row { display: flex; gap: 2px; }
|
||||
|
||||
/* Таблица слотов — нет верхнего отступа */
|
||||
.t-subj-slot-table { margin-top: 0; }
|
||||
|
||||
/* Legacy-классы (используются в JS) */
|
||||
.t-subject-card { border-bottom: 1px solid var(--border); }
|
||||
.t-subject-name { font-weight: 600; font-size: 13px; }
|
||||
.t-desc-text { font-size: 12.5px; margin-bottom: 6px; }
|
||||
|
||||
/* ═══ Страница предметов (legacy-обёртка) ════════════════════════════════════ */
|
||||
.t-subjects-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
.t-subject-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--surface2);
|
||||
cursor: pointer;
|
||||
transition: background .12s;
|
||||
}
|
||||
.t-subject-card-head:hover { background: var(--bg); }
|
||||
.t-subject-body { padding: 12px 16px; }
|
||||
.t-subject-collapse { display: none; }
|
||||
.t-subject-collapse.open { display: block; }
|
||||
.t-subject-close-row { margin-left: auto; }
|
||||
.t-subject-fields-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
|
||||
.t-subject-desc-edit { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.t-add-metric-btns { display: flex; gap: 6px; }
|
||||
|
||||
/* ═══ Завершённость предмета ════════════════════════════════════════════════ */
|
||||
.t-subj-card.subject-completed { border-color: var(--color-green, #22c55e); }
|
||||
.t-subj-card.subject-completed .t-subj-head {
|
||||
background: color-mix(in srgb, var(--color-green, #22c55e) 8%, var(--surface2));
|
||||
}
|
||||
|
||||
.t-completed-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 7px;
|
||||
border-radius: 99px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: rgba(34, 197, 94, .12);
|
||||
color: #16a34a;
|
||||
border: 1px solid rgba(34, 197, 94, .3);
|
||||
}
|
||||
[data-theme="dark"] .t-completed-badge {
|
||||
color: #4ade80;
|
||||
background: rgba(34, 197, 94, .18);
|
||||
border-color: rgba(34, 197, 94, .35);
|
||||
}
|
||||
|
||||
.toggle-completed-btn.is-completed { color: #22c55e !important; }
|
||||
|
||||
/* ═══ Тип сдачи: бейдж ══════════════════════════════════════════════════════ */
|
||||
.t-completion-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 7px;
|
||||
border-radius: 99px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: rgba(167, 139, 250, .12);
|
||||
color: #7c3aed;
|
||||
border: 1px solid rgba(167, 139, 250, .3);
|
||||
}
|
||||
[data-theme="dark"] .t-completion-badge {
|
||||
color: #c4b5fd;
|
||||
background: rgba(167, 139, 250, .18);
|
||||
border-color: rgba(167, 139, 250, .35);
|
||||
}
|
||||
|
||||
/* ═══ Мета-блок (тип сдачи + СДО) ══════════════════════════════════════════ */
|
||||
.t-subj-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.t-subj-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 130px;
|
||||
}
|
||||
.t-subj-meta-item.t-subj-meta-sdo { flex: 1; }
|
||||
.t-subj-meta-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.t-subj-meta-value { font-size: 13px; }
|
||||
.t-subj-sdo-edit { display: flex; gap: 4px; align-items: center; }
|
||||
.t-subj-sdo-edit .sdo-url-input { flex: 1; min-width: 0; }
|
||||
.t-subj-sdo-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--accent, #6366f1);
|
||||
text-decoration: none;
|
||||
}
|
||||
.t-subj-sdo-link:hover { text-decoration: underline; }
|
||||
|
||||
/* ═══ Таблица слотов ════════════════════════════════════════════════════════ */
|
||||
.t-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.t-table th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.t-table td { padding: 5px 6px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
.t-table tr:last-child td { border-bottom: none; }
|
||||
.t-table-actions { width: 52px; }
|
||||
.t-table-actions-cell { display: none; gap: 2px; }
|
||||
body.edit-mode .t-table-actions-cell { display: flex; }
|
||||
|
||||
/* Добавить трекер */
|
||||
.t-add-metric-row { margin-top: 8px; display: flex; gap: 6px; }
|
||||
.t-metric-form { margin-top: 8px; display: none; }
|
||||
.t-metric-form.open { display: block; }
|
||||
.t-metric-input-row { display: flex; gap: 6px; }
|
||||
.t-ai-submit { border-radius: 0 8px 8px 0; }
|
||||
BIN
app/static/favicon.ico
Normal file
BIN
app/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
4
app/static/favicon.svg
Normal file
4
app/static/favicon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="5" fill="#10b981"/>
|
||||
<text x="16" y="16" font-family="system-ui, -apple-system, sans-serif" font-weight="600" font-size="24" fill="#ffffff" letter-spacing="-0.04em" text-anchor="middle" dominant-baseline="central">lo</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
86
app/static/js/core.js
Normal file
86
app/static/js/core.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// ─── CSRF ────────────────────────────────────────────────────────────────────
|
||||
const csrfToken = () =>
|
||||
document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
// ─── Fetch-обёртка ───────────────────────────────────────────────────────────
|
||||
async function apiFetch(url, method = 'GET', body = null) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() },
|
||||
};
|
||||
if (body !== null) opts.body = JSON.stringify(body);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, opts);
|
||||
} catch (e) {
|
||||
return { error: 'Нет соединения' };
|
||||
}
|
||||
if (res.redirected || res.status === 401 || res.status === 302) {
|
||||
return { error: 'Сессия истекла, обновите страницу' };
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
return { error: `Ошибка сервера (${res.status})` };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Модальные окна ──────────────────────────────────────────────────────────
|
||||
function openModal(id) {
|
||||
document.getElementById(id)?.classList.add('open');
|
||||
}
|
||||
function closeModal(id) {
|
||||
document.getElementById(id)?.classList.remove('open');
|
||||
}
|
||||
|
||||
// ─── URL-encode ──────────────────────────────────────────────────────────────
|
||||
function enc(s) { return encodeURIComponent(s); }
|
||||
|
||||
// ─── Режим редактирования ────────────────────────────────────────────────────
|
||||
function initEditMode() {
|
||||
const btn = document.getElementById('edit-mode-btn');
|
||||
if (!btn) return;
|
||||
const key = 'edit-mode-' + (typeof SCHEDULE_ID !== 'undefined' ? SCHEDULE_ID : '');
|
||||
if (sessionStorage.getItem(key) === '1') {
|
||||
document.body.classList.add('edit-mode');
|
||||
btn.classList.add('on');
|
||||
}
|
||||
btn.addEventListener('click', () => {
|
||||
const active = document.body.classList.toggle('edit-mode');
|
||||
btn.classList.toggle('on', active);
|
||||
sessionStorage.setItem(key, active ? '1' : '0');
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Флеш «сохранено» на кнопке ─────────────────────────────────────────────
|
||||
function flashSaved(btn) {
|
||||
if (!btn) return;
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => btn.textContent = orig, 1200);
|
||||
}
|
||||
|
||||
// ─── Универсальный модал переименования ─────────────────────────────────────
|
||||
let _renameCb = null;
|
||||
function openRenameModal(title, currentVal, cb) {
|
||||
_renameCb = cb;
|
||||
document.getElementById('rename-modal-title').textContent = title;
|
||||
const input = document.getElementById('rename-input');
|
||||
input.value = currentVal;
|
||||
openModal('renameModalBackdrop');
|
||||
setTimeout(() => input.select(), 50);
|
||||
}
|
||||
function initRenameModal() {
|
||||
const confirmBtn = document.getElementById('rename-confirm-btn');
|
||||
if (!confirmBtn) return;
|
||||
const doSave = async () => {
|
||||
const val = document.getElementById('rename-input').value.trim();
|
||||
if (!val) return;
|
||||
closeModal('renameModalBackdrop');
|
||||
if (_renameCb) { await _renameCb(val); _renameCb = null; }
|
||||
};
|
||||
confirmBtn.addEventListener('click', doSave);
|
||||
document.getElementById('rename-input')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doSave();
|
||||
});
|
||||
}
|
||||
631
app/static/js/metrics.js
Normal file
631
app/static/js/metrics.js
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
// ═══ Ядро: чтение/запись прогресса через data-атрибут ════════════════════════
|
||||
|
||||
function getProgress(widget) {
|
||||
try { return JSON.parse(widget.dataset.progress || '{}'); } catch { return {}; }
|
||||
}
|
||||
|
||||
function setProgress(widget, prog) {
|
||||
widget.dataset.progress = JSON.stringify(prog);
|
||||
}
|
||||
|
||||
async function saveProgress(widget, prog) {
|
||||
setProgress(widget, prog);
|
||||
const id = widget.dataset.metricId;
|
||||
await apiFetch(`/api/metrics/${id}/progress`, 'POST', prog);
|
||||
}
|
||||
|
||||
// ═══ Инициализация всех виджетов ═════════════════════════════════════════════
|
||||
|
||||
function initMetricWidgets() {
|
||||
document.querySelectorAll('.t-metric').forEach(w => initWidget(w));
|
||||
}
|
||||
|
||||
function initWidget(w) {
|
||||
if (w._init) return;
|
||||
w._init = true;
|
||||
|
||||
const type = w.dataset.metricType;
|
||||
const canEdit = typeof CAN_EDIT !== 'undefined' ? CAN_EDIT : true;
|
||||
|
||||
w.querySelector('.delete-metric-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm('Удалить трекер?')) return;
|
||||
const res = await apiFetch(`/api/metrics/${w.dataset.metricId}`, 'DELETE');
|
||||
if (res.ok) w.remove();
|
||||
});
|
||||
|
||||
if (!canEdit) return;
|
||||
|
||||
if (type === 'checkpoints') {
|
||||
const total = w.querySelectorAll('.t-cp').length;
|
||||
w.querySelectorAll('button.t-cp').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const prog = getProgress(w);
|
||||
const checked = prog.checked || [];
|
||||
const idx = parseInt(btn.dataset.index);
|
||||
const pos = checked.indexOf(idx);
|
||||
if (pos === -1) checked.push(idx); else checked.splice(pos, 1);
|
||||
prog.checked = checked;
|
||||
btn.classList.toggle('done', checked.includes(idx));
|
||||
const done = checked.length;
|
||||
const fracEl = w.querySelector('.t-cp-fraction');
|
||||
if (fracEl) fracEl.firstChild.textContent = done;
|
||||
const fill = w.querySelector('.t-cp-fill');
|
||||
if (fill && total) fill.style.width = Math.round(done / total * 100) + '%';
|
||||
await saveProgress(w, prog);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'progress_bar') {
|
||||
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
|
||||
w.querySelector('.update-progress-btn')?.addEventListener('click', async () => {
|
||||
const input = w.querySelector('.progress-input');
|
||||
const val = parseFloat(input.value) || 0;
|
||||
const mx = parseFloat(cfg.max || 100);
|
||||
const pct = Math.min(100, Math.round(val / mx * 100));
|
||||
const ring = w.querySelector('.t-pb-ring-fill');
|
||||
if (ring) ring.style.strokeDashoffset = ((100 - pct) / 100 * 213.6).toFixed(1);
|
||||
const pctEl = w.querySelector('.t-pb-pct');
|
||||
if (pctEl) pctEl.textContent = pct + '%';
|
||||
const valEl = w.querySelector('.t-pb-val');
|
||||
if (valEl) valEl.firstChild.textContent = val;
|
||||
await saveProgress(w, { current: val });
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'counter') {
|
||||
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
|
||||
const valEl = w.querySelector('.t-counter-val');
|
||||
const step = parseFloat(cfg.step) || 1;
|
||||
const update = async delta => {
|
||||
const prog = getProgress(w);
|
||||
const max = cfg.max ? parseFloat(cfg.max) : Infinity;
|
||||
const raw = (prog.current || 0) + delta;
|
||||
const next = Math.max(0, Math.min(max, Math.round(raw * 1e9) / 1e9));
|
||||
prog.current = next;
|
||||
valEl.textContent = next;
|
||||
if (cfg.max) {
|
||||
const bar = w.querySelector('.t-counter-bar-fill');
|
||||
if (bar) bar.style.width = Math.min(100, Math.round(next / parseFloat(cfg.max) * 100)) + '%';
|
||||
}
|
||||
await saveProgress(w, prog);
|
||||
};
|
||||
w.querySelector('.counter-dec')?.addEventListener('click', () => update(-step));
|
||||
w.querySelector('.counter-inc')?.addEventListener('click', () => update(+step));
|
||||
}
|
||||
|
||||
if (type === 'stages') {
|
||||
w.querySelectorAll('.stage-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const idx = parseInt(btn.dataset.index);
|
||||
w.querySelectorAll('.stage-btn').forEach((b, i) => {
|
||||
b.classList.toggle('done', i < idx);
|
||||
b.classList.toggle('active', i === idx);
|
||||
if (i > idx) b.classList.remove('done', 'active');
|
||||
});
|
||||
w.querySelectorAll('.t-stage-conn').forEach((c, i) => {
|
||||
c.classList.toggle('done', i < idx);
|
||||
});
|
||||
await saveProgress(w, { current_stage: idx });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'checklist') {
|
||||
w.querySelectorAll('.checklist-item').forEach(cb => {
|
||||
cb.addEventListener('change', async () => {
|
||||
const prog = getProgress(w);
|
||||
const checked = prog.checked || [];
|
||||
const idx = parseInt(cb.dataset.index);
|
||||
const pos = checked.indexOf(idx);
|
||||
if (cb.checked && pos === -1) checked.push(idx);
|
||||
if (!cb.checked && pos !== -1) checked.splice(pos, 1);
|
||||
prog.checked = checked;
|
||||
cb.closest('.t-cl-item').classList.toggle('checked', cb.checked);
|
||||
await saveProgress(w, prog);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'attendance') {
|
||||
w.querySelector('.add-att-btn')?.addEventListener('click', async () => {
|
||||
const dateVal = w.querySelector('.att-date').value;
|
||||
const status = w.querySelector('.att-status').value;
|
||||
if (!dateVal) return;
|
||||
const prog = getProgress(w);
|
||||
const records = prog.records || [];
|
||||
records.push({ date: dateVal, status });
|
||||
prog.records = records;
|
||||
// Добавить точку в историю
|
||||
const dot = document.createElement('span');
|
||||
dot.className = `t-att-dot t-att-dot--${status}`;
|
||||
dot.title = dateVal;
|
||||
w.querySelector('.t-att-dots')?.appendChild(dot);
|
||||
// Обновить счётчик нужного стата
|
||||
const statMap = { present: 't-att-stat--present', absent: 't-att-stat--absent', excused: 't-att-stat--excused' };
|
||||
const statEl = w.querySelector(`.${statMap[status]} .t-att-num`);
|
||||
if (statEl) statEl.textContent = parseInt(statEl.textContent || '0') + 1;
|
||||
w.querySelector('.att-date').value = '';
|
||||
await saveProgress(w, prog);
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'grades') {
|
||||
w.querySelector('.save-grades-btn')?.addEventListener('click', async () => {
|
||||
const prog = getProgress(w);
|
||||
const grades = {};
|
||||
w.querySelectorAll('.grade-input').forEach(inp => {
|
||||
if (inp.value !== '') grades[inp.dataset.work] = parseFloat(inp.value);
|
||||
});
|
||||
prog.grades = grades;
|
||||
await saveProgress(w, prog);
|
||||
flashSaved(w.querySelector('.save-grades-btn'));
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'deadlines') {
|
||||
w.querySelectorAll('.deadline-item').forEach(cb => {
|
||||
cb.addEventListener('change', async () => {
|
||||
const prog = getProgress(w);
|
||||
const completed = prog.completed || [];
|
||||
const idx = parseInt(cb.dataset.index);
|
||||
const pos = completed.indexOf(idx);
|
||||
if (cb.checked && pos === -1) completed.push(idx);
|
||||
if (!cb.checked && pos !== -1) completed.splice(pos, 1);
|
||||
prog.completed = completed;
|
||||
cb.closest('.t-dl-item').classList.toggle('done', cb.checked);
|
||||
await saveProgress(w, prog);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'streak') {
|
||||
w.querySelector('.mark-today-btn')?.addEventListener('click', async () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const prog = getProgress(w);
|
||||
const records = prog.records || [];
|
||||
if (records.includes(today)) return;
|
||||
records.push(today);
|
||||
prog.records = records;
|
||||
w.querySelector('.t-streak-num').textContent = records.length;
|
||||
w.querySelector('.mark-today-btn').disabled = true;
|
||||
await saveProgress(w, prog);
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'effort_hours') {
|
||||
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
|
||||
w.querySelector('.add-effort-btn')?.addEventListener('click', async () => {
|
||||
const add = parseFloat(w.querySelector('.effort-input').value);
|
||||
if (!add || add <= 0) return;
|
||||
const prog = getProgress(w);
|
||||
const total = +(prog.hours || 0) + add;
|
||||
prog.hours = total;
|
||||
const numEl = w.querySelector('.t-ef-num');
|
||||
if (numEl) numEl.childNodes[0].textContent = total;
|
||||
const goal = parseFloat(cfg.goal_hours);
|
||||
if (goal) {
|
||||
const pct = Math.min(100, Math.round(total / goal * 100));
|
||||
const bar = w.querySelector('.t-ef-bar-fill');
|
||||
if (bar) bar.style.width = pct + '%';
|
||||
const pctEl = w.querySelector('.t-ef-bar-pct');
|
||||
if (pctEl) pctEl.textContent = pct + '%';
|
||||
}
|
||||
w.querySelector('.effort-input').value = '';
|
||||
await saveProgress(w, prog);
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'rating_history') {
|
||||
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
|
||||
w.querySelector('.add-rating-btn')?.addEventListener('click', async () => {
|
||||
const dateVal = w.querySelector('.rating-date').value;
|
||||
const grade = parseFloat(w.querySelector('.rating-grade').value);
|
||||
if (!dateVal || isNaN(grade)) return;
|
||||
const prog = getProgress(w);
|
||||
const entries = prog.entries || [];
|
||||
entries.push({ date: dateVal, grade });
|
||||
prog.entries = entries;
|
||||
const scale = cfg.scale || 5;
|
||||
const list = w.querySelector('.t-rh-list');
|
||||
const pct = Math.round(grade / scale * 100);
|
||||
const row = document.createElement('div');
|
||||
row.className = 't-rh-row';
|
||||
row.innerHTML = `<span class="t-rh-date">${dateVal}</span>`
|
||||
+ `<div class="t-rh-track"><div class="t-rh-fill" style="width:${pct}%"></div></div>`
|
||||
+ `<span class="t-rh-val">${grade}</span>`;
|
||||
// Удалить «Нет записей» если было
|
||||
list.querySelector('.t-metric-empty')?.remove();
|
||||
list.prepend(row);
|
||||
// Обновить summary — последняя оценка и среднее
|
||||
const avg = (entries.reduce((s, e) => s + e.grade, 0) / entries.length).toFixed(1);
|
||||
['.t-rh-last-val', '.t-rh-big-val'].forEach(sel => {
|
||||
const el = w.querySelector(sel);
|
||||
if (el) el.textContent = grade;
|
||||
});
|
||||
['.t-rh-avg', '.t-rh-big-avg'].forEach(sel => {
|
||||
const el = w.querySelector(sel);
|
||||
if (el) el.textContent = el.textContent.replace(/[\d.]+/, avg);
|
||||
});
|
||||
w.querySelector('.rating-date').value = '';
|
||||
w.querySelector('.rating-grade').value = '';
|
||||
await saveProgress(w, prog);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Форма добавления трекера ════════════════════════════════════════════════
|
||||
|
||||
const METRIC_SCHEMA = {
|
||||
checkpoints: [{ name: 'total', label: 'Количество точек', type: 'number', ph: '10' },
|
||||
{ name: 'labels', label: 'Подписи через запятую (опц.)', type: 'text', ph: 'КТ1,КТ2' }],
|
||||
progress_bar: [{ name: 'max', label: 'Максимум', type: 'number', ph: '100' },
|
||||
{ name: 'unit', label: 'Единица', type: 'text', ph: 'баллов' },
|
||||
{ name: 'step', label: 'Шаг', type: 'number', ph: '1' }],
|
||||
counter: [{ name: 'max', label: 'Максимум (0 = ∞)', type: 'number', ph: '0' },
|
||||
{ name: 'step', label: 'Шаг', type: 'number', ph: '1' },
|
||||
{ name: 'unit', label: 'Единица', type: 'text', ph: 'раз' }],
|
||||
stages: [{ name: 'stages', label: 'Этапы через запятую', type: 'text', ph: 'Старт,В работе,Сдано' }],
|
||||
checklist: [{ name: 'items', label: 'Пункты, каждый с новой строки', type: 'textarea', ph: 'Пункт 1\nПункт 2' }],
|
||||
attendance: [],
|
||||
grades: [{ name: 'works', label: 'Работы: имя=вес, через запятую', type: 'text', ph: 'ЛР1=1,ЛР2=2' },
|
||||
{ name: 'max_grade', label: 'Макс. балл', type: 'number', ph: '5' }],
|
||||
deadlines: [{ name: 'items', label: 'Дедлайны: имя|ГГГГ-ММ-ДД, по строке', type: 'textarea', ph: 'Курсовая|2026-05-20' }],
|
||||
streak: [],
|
||||
effort_hours: [{ name: 'goal_hours', label: 'Цель (часов)', type: 'number', ph: '40' }],
|
||||
rating_history: [{ name: 'scale', label: 'Шкала (макс. балл)', type: 'number', ph: '5' }],
|
||||
};
|
||||
|
||||
function buildConfig(type, form) {
|
||||
const schema = METRIC_SCHEMA[type] || [];
|
||||
const cfg = {};
|
||||
for (const f of schema) {
|
||||
const el = form.querySelector(`[data-cfg="${f.name}"]`);
|
||||
const val = el?.value.trim();
|
||||
if (!val) continue;
|
||||
if (type === 'checkpoints' && f.name === 'labels') { cfg.labels = val.split(',').map(s => s.trim()).filter(Boolean); continue; }
|
||||
if (type === 'stages' && f.name === 'stages') { cfg.stages = val.split(',').map(s => s.trim()).filter(Boolean); continue; }
|
||||
if (type === 'checklist' && f.name === 'items') { cfg.items = val.split('\n').map(s => s.trim()).filter(Boolean); continue; }
|
||||
if (type === 'grades' && f.name === 'works') {
|
||||
cfg.works = val.split(',').map(s => { const [n, w] = s.split('=').map(x => x.trim()); return { name: n, weight: parseFloat(w) || 1 }; });
|
||||
continue;
|
||||
}
|
||||
if (type === 'deadlines' && f.name === 'items') {
|
||||
cfg.items = val.split('\n').map(s => { const [n, d] = s.split('|').map(x => x.trim()); return { name: n, date: d || '' }; }).filter(x => x.name);
|
||||
continue;
|
||||
}
|
||||
cfg[f.name] = f.type === 'number' ? parseFloat(val) || 0 : val;
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function renderConfigFields(type, area) {
|
||||
area.innerHTML = '';
|
||||
for (const f of METRIC_SCHEMA[type] || []) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 't-field';
|
||||
const lbl = document.createElement('label');
|
||||
lbl.className = 't-label';
|
||||
lbl.textContent = f.label;
|
||||
lbl.htmlFor = `cfg-${f.name}`;
|
||||
wrap.appendChild(lbl);
|
||||
let el;
|
||||
if (f.type === 'textarea') {
|
||||
el = document.createElement('textarea');
|
||||
el.rows = 3;
|
||||
el.className = 't-input t-textarea';
|
||||
} else {
|
||||
el = document.createElement('input');
|
||||
el.type = f.type;
|
||||
el.className = 't-input t-input-sm';
|
||||
}
|
||||
el.placeholder = f.ph || '';
|
||||
el.dataset.cfg = f.name;
|
||||
el.id = `cfg-${f.name}`;
|
||||
wrap.appendChild(el);
|
||||
area.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function initAddMetric() {
|
||||
document.querySelectorAll('.add-metric-section').forEach(section => {
|
||||
const sid = section.dataset.schedule;
|
||||
const subject = section.dataset.subject;
|
||||
const slotId = section.dataset.slot;
|
||||
const container = document.getElementById(`metrics-${slotId}`);
|
||||
const aiForm = section.querySelector('.ai-metric-form');
|
||||
const manForm = section.querySelector('.manual-metric-form');
|
||||
|
||||
section.querySelector('.ai-metric-btn')?.addEventListener('click', () => {
|
||||
aiForm.classList.toggle('open');
|
||||
manForm.classList.remove('open');
|
||||
});
|
||||
section.querySelector('.manual-metric-btn')?.addEventListener('click', () => {
|
||||
manForm.classList.toggle('open');
|
||||
aiForm.classList.remove('open');
|
||||
});
|
||||
|
||||
const typeSelect = section.querySelector('.metric-type-select');
|
||||
const configArea = section.querySelector('.metric-config-area');
|
||||
typeSelect?.addEventListener('change', () => renderConfigFields(typeSelect.value, configArea));
|
||||
|
||||
// AI
|
||||
section.querySelector('.ai-prompt-submit')?.addEventListener('click', async () => {
|
||||
const prompt = section.querySelector('.ai-prompt-input').value.trim();
|
||||
if (!prompt) return;
|
||||
const submitBtn = section.querySelector('.ai-prompt-submit');
|
||||
submitBtn.innerHTML = '<span class="t-spinner"></span>';
|
||||
submitBtn.disabled = true;
|
||||
const res = await apiFetch(
|
||||
`/schedules/${sid}/subjects/${enc(subject)}/metrics/ai`, 'POST', { prompt }
|
||||
);
|
||||
submitBtn.innerHTML = '<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
|
||||
submitBtn.disabled = false;
|
||||
if (res.ok && container) {
|
||||
container.insertAdjacentHTML('beforeend', res.html);
|
||||
const newEl = container.lastElementChild;
|
||||
initWidget(newEl);
|
||||
gridRegisterNew(newEl, container);
|
||||
aiForm.classList.remove('open');
|
||||
section.querySelector('.ai-prompt-input').value = '';
|
||||
} else alert(res.error || 'Ошибка');
|
||||
});
|
||||
|
||||
// Вручную
|
||||
section.querySelector('.manual-metric-submit')?.addEventListener('click', async () => {
|
||||
const type = typeSelect?.value;
|
||||
const label = section.querySelector('.metric-label-input').value.trim();
|
||||
if (!type) { alert('Выберите тип'); return; }
|
||||
const cfg = buildConfig(type, section);
|
||||
const res = await apiFetch(
|
||||
`/schedules/${sid}/subjects/${enc(subject)}/metrics`, 'POST',
|
||||
{ type, label, config: cfg }
|
||||
);
|
||||
if (res.ok && container) {
|
||||
container.insertAdjacentHTML('beforeend', res.html);
|
||||
const newEl = container.lastElementChild;
|
||||
initWidget(newEl);
|
||||
gridRegisterNew(newEl, container);
|
||||
manForm.classList.remove('open');
|
||||
section.querySelector('.metric-label-input').value = '';
|
||||
if (typeSelect) typeSelect.value = '';
|
||||
configArea.innerHTML = '';
|
||||
} else alert(res.error || 'Ошибка');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Grid-редактор: размер и перетаскивание трекеров ═════════════════════════
|
||||
|
||||
const GRID_COLS = 3; // колонки (совпадает с --mg-cols в CSS)
|
||||
const GRID_MAX_W = 3; // макс. ширина в колонках
|
||||
const GRID_MAX_H = 4; // макс. высота в полустроках (4 = 2 строки)
|
||||
|
||||
// Применить размерные классы gw-N / gh-N (N = полустроки для высоты)
|
||||
function applyGridSize(el) {
|
||||
const w = Math.max(1, Math.min(GRID_MAX_W, parseInt(el.dataset.gridW) || 1));
|
||||
const h = Math.max(1, Math.min(GRID_MAX_H, parseInt(el.dataset.gridH) || 2));
|
||||
el.classList.remove('gw-2', 'gw-3', 'gh-1', 'gh-2', 'gh-3', 'gh-4');
|
||||
if (w > 1) el.classList.add(`gw-${w}`);
|
||||
el.classList.add(`gh-${h}`);
|
||||
}
|
||||
|
||||
// ─── Сохранение позиции/размера в БД ─────────────────────────────────────────
|
||||
async function saveLayout(metricId, col, row, w, h) {
|
||||
await apiFetch(`/api/metrics/${metricId}/layout`, 'POST',
|
||||
{ grid_col: col, grid_row: row, grid_w: w, grid_h: h });
|
||||
}
|
||||
|
||||
// ─── Resize (правый нижний угол) ──────────────────────────────────────────────
|
||||
function initResize(el, grid) {
|
||||
const handle = el.querySelector('.t-metric-resize');
|
||||
if (!handle) return;
|
||||
|
||||
handle.addEventListener('pointerdown', e => {
|
||||
if (!grid.classList.contains('drag-mode')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startW = parseInt(el.dataset.gridW) || 1;
|
||||
const startH = parseInt(el.dataset.gridH) || 2;
|
||||
|
||||
// Ширина одной колонки; высота одной полустроки из CSS-переменной
|
||||
const gap = parseFloat(getComputedStyle(grid).getPropertyValue('--mg-gap')) || 12;
|
||||
const colW = (grid.offsetWidth - gap * (GRID_COLS - 1)) / GRID_COLS;
|
||||
const rowHpx = parseFloat(getComputedStyle(grid).getPropertyValue('--row-h')) || 120;
|
||||
|
||||
function onMove(ev) {
|
||||
const dx = ev.clientX - startX;
|
||||
const dy = ev.clientY - startY;
|
||||
const col = parseInt(el.dataset.gridCol) || 0;
|
||||
const maxW = Math.min(GRID_MAX_W, GRID_COLS - col);
|
||||
const newW = Math.max(1, Math.min(maxW, Math.round(startW + dx / (colW + gap))));
|
||||
const newH = Math.max(1, Math.min(GRID_MAX_H, Math.round(startH + dy / (rowHpx + gap))));
|
||||
el.dataset.gridW = newW;
|
||||
el.dataset.gridH = newH;
|
||||
applyGridSize(el);
|
||||
}
|
||||
|
||||
async function onUp() {
|
||||
handle.removeEventListener('pointermove', onMove);
|
||||
handle.removeEventListener('pointerup', onUp);
|
||||
const mid = el.dataset.metricId;
|
||||
const col = parseInt(el.dataset.gridCol) || 0;
|
||||
const row = parseInt(el.dataset.gridRow) || 0;
|
||||
const w = parseInt(el.dataset.gridW) || 1;
|
||||
const h = parseInt(el.dataset.gridH) || 2;
|
||||
await saveLayout(mid, col, row, w, h);
|
||||
}
|
||||
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Drag-and-drop ────────────────────────────────────────────────────────────
|
||||
function initDrag(grid) {
|
||||
let dragged = null;
|
||||
|
||||
const items = () => [...grid.querySelectorAll(':scope > .t-metric')];
|
||||
|
||||
// Сдвиг элементов вправо/вниз, освобождая место перед target
|
||||
function shiftBefore(target) {
|
||||
if (!dragged || target === dragged) return;
|
||||
const all = items();
|
||||
const fromIdx = all.indexOf(dragged);
|
||||
const toIdx = all.indexOf(target);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
if (fromIdx < toIdx) {
|
||||
target.after(dragged);
|
||||
} else {
|
||||
target.before(dragged);
|
||||
}
|
||||
}
|
||||
|
||||
grid.addEventListener('dragstart', e => {
|
||||
const metric = e.target.closest('.t-metric');
|
||||
if (!metric || !grid.classList.contains('drag-mode')) { e.preventDefault(); return; }
|
||||
dragged = metric;
|
||||
metric.classList.add('drag-ghost');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', metric.dataset.metricId);
|
||||
});
|
||||
|
||||
grid.addEventListener('dragend', e => {
|
||||
const metric = e.target.closest('.t-metric');
|
||||
if (!metric) return;
|
||||
metric.classList.remove('drag-ghost');
|
||||
grid.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
dragged = null;
|
||||
// Сохранить новый порядок: обновляем grid_row / grid_col по позиции в DOM
|
||||
persistOrder(grid);
|
||||
});
|
||||
|
||||
grid.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const target = e.target.closest('.t-metric');
|
||||
if (!target || target === dragged) return;
|
||||
grid.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
target.classList.add('drag-over');
|
||||
shiftBefore(target);
|
||||
});
|
||||
|
||||
grid.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
grid.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
});
|
||||
}
|
||||
|
||||
// Пересчитать col/row по DOM-порядку и сохранить в БД одним батч-запросом
|
||||
async function persistOrder(grid) {
|
||||
let col = 0, row = 0, rowMaxH = 2;
|
||||
const items = [];
|
||||
|
||||
grid.querySelectorAll(':scope > .t-metric').forEach(el => {
|
||||
const w = parseInt(el.dataset.gridW) || 1;
|
||||
const h = parseInt(el.dataset.gridH) || 2;
|
||||
|
||||
if (col + w > GRID_COLS) {
|
||||
col = 0;
|
||||
row += rowMaxH;
|
||||
rowMaxH = 2;
|
||||
}
|
||||
|
||||
el.dataset.gridCol = col;
|
||||
el.dataset.gridRow = row;
|
||||
rowMaxH = Math.max(rowMaxH, h);
|
||||
col += w;
|
||||
|
||||
items.push({ id: el.dataset.metricId, grid_col: col - w, grid_row: row, grid_w: w, grid_h: h });
|
||||
});
|
||||
|
||||
await apiFetch('/api/metrics/layout_batch', 'POST', { items });
|
||||
}
|
||||
|
||||
// ─── Переход к трекеру на дашборде с подсветкой ───────────────────────────────
|
||||
function initMetricLinks() {
|
||||
document.querySelectorAll('.t-slot-metric-link').forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const mid = link.dataset.metricId;
|
||||
const target = document.getElementById(`metric-${mid}`);
|
||||
if (!target) return;
|
||||
// Закрыть слот если открыт
|
||||
const detail = link.closest('.t-slot-detail');
|
||||
if (detail) {
|
||||
const slot = detail.previousElementSibling;
|
||||
if (slot?.classList.contains('open')) {
|
||||
slot.classList.remove('open');
|
||||
detail.style.display = 'none';
|
||||
}
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.remove('highlight');
|
||||
// Перезапуск анимации через reflow
|
||||
void target.offsetWidth;
|
||||
target.classList.add('highlight');
|
||||
target.addEventListener('animationend', () => target.classList.remove('highlight'), { once: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Кнопки включения режима редактирования сетки ────────────────────────────
|
||||
function initGridEditButtons() {
|
||||
document.querySelectorAll('.t-grid-edit-btn').forEach(btn => {
|
||||
const gridId = btn.dataset.grid;
|
||||
const grid = document.getElementById(gridId);
|
||||
if (!grid) return;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const active = grid.classList.toggle('drag-mode');
|
||||
btn.classList.toggle('active', active);
|
||||
// draggable выставляем/снимаем на всех метриках
|
||||
grid.querySelectorAll(':scope > .t-metric').forEach(el => {
|
||||
el.draggable = active;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Добавить resize-ручку к элементу ────────────────────────────────────────
|
||||
function addResizeHandle(el) {
|
||||
if (el.querySelector('.t-metric-resize')) return;
|
||||
const h = document.createElement('div');
|
||||
h.className = 't-metric-resize';
|
||||
h.title = 'Изменить размер';
|
||||
h.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M9 1L1 9M5 1L1 5M9 5L5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
|
||||
el.appendChild(h);
|
||||
}
|
||||
|
||||
// ─── Инициализация всех grid-контейнеров ─────────────────────────────────────
|
||||
function initMetricsGrids() {
|
||||
document.querySelectorAll('.t-metrics-grid').forEach(grid => {
|
||||
grid.querySelectorAll(':scope > .t-metric').forEach(el => {
|
||||
applyGridSize(el);
|
||||
addResizeHandle(el);
|
||||
initResize(el, grid);
|
||||
});
|
||||
initDrag(grid);
|
||||
});
|
||||
initGridEditButtons();
|
||||
}
|
||||
|
||||
// После динамической вставки нового виджета — подключить его к сетке
|
||||
function gridRegisterNew(el, grid) {
|
||||
applyGridSize(el);
|
||||
addResizeHandle(el);
|
||||
initResize(el, grid);
|
||||
if (grid.classList.contains('drag-mode')) el.draggable = true;
|
||||
}
|
||||
|
||||
// ─── Инициализация ────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMetricWidgets();
|
||||
initAddMetric();
|
||||
initMetricsGrids();
|
||||
initMetricLinks();
|
||||
});
|
||||
20
app/static/js/schedule.js
Normal file
20
app/static/js/schedule.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// ═══ Переименование расписания ═══════════════════════════════════════════════
|
||||
function initRenameSchedule() {
|
||||
document.querySelector('.rename-schedule-btn')?.addEventListener('click', () => {
|
||||
const current = document.querySelector('.schedule-name-display')?.textContent.trim() || '';
|
||||
openRenameModal('Переименовать расписание', current, async (val) => {
|
||||
const res = await apiFetch(`/schedules/${SCHEDULE_ID}/rename`, 'POST', { name: val });
|
||||
if (res.ok) {
|
||||
document.querySelector('.schedule-name-display').textContent = val;
|
||||
document.title = val;
|
||||
} else alert(res.error || 'Ошибка');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Инициализация страницы расписания ───────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initRenameModal();
|
||||
initEditMode();
|
||||
initRenameSchedule();
|
||||
});
|
||||
439
app/static/js/subjects.js
Normal file
439
app/static/js/subjects.js
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
// ═══ Переименование предмета ══════════════════════════════════════════════════
|
||||
function initSubjectRename() {
|
||||
document.querySelectorAll('.rename-subject-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const card = btn.closest('[data-subject]');
|
||||
const current = card?.querySelector('.subject-name-display')?.textContent.trim() || '';
|
||||
openRenameModal('Переименовать предмет', current, async (val) => {
|
||||
const res = await apiFetch(
|
||||
`/schedules/${btn.dataset.schedule}/subjects/${enc(btn.dataset.subject)}/field`,
|
||||
'POST', { field: 'name', value: val }
|
||||
);
|
||||
if (res.ok) location.reload(); else alert(res.error || 'Ошибка');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Удаление предмета ═══════════════════════════════════════════════════════
|
||||
function initDeleteSubject() {
|
||||
document.querySelectorAll('.delete-subject-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(`Удалить «${btn.dataset.subject}» полностью?`)) return;
|
||||
const res = await apiFetch(
|
||||
`/schedules/${btn.dataset.schedule}/subjects/${enc(btn.dataset.subject)}`, 'DELETE');
|
||||
if (res.ok) location.reload(); else alert(res.error || 'Ошибка');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Inline-редактирование строк слотов ══════════════════════════════════════
|
||||
function initSlotInlineEdit() {
|
||||
document.querySelectorAll('.slot-inline-edit-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const viewRow = btn.closest('.t-slot-row');
|
||||
const editRow = viewRow?.nextElementSibling;
|
||||
if (!editRow?.classList.contains('t-slot-edit-row')) return;
|
||||
viewRow.classList.add('hidden');
|
||||
editRow.classList.remove('hidden');
|
||||
editRow.querySelector('.edit-day')?.focus();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.slot-cancel-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const editRow = btn.closest('.t-slot-edit-row');
|
||||
const viewRow = editRow?.previousElementSibling;
|
||||
editRow.classList.add('hidden');
|
||||
viewRow?.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.slot-save-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => saveSlotEdit(btn.closest('.t-slot-edit-row')));
|
||||
});
|
||||
|
||||
document.querySelectorAll('.t-slot-edit-row input').forEach(input => {
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') saveSlotEdit(input.closest('.t-slot-edit-row'));
|
||||
if (e.key === 'Escape') input.closest('.t-slot-edit-row').querySelector('.slot-cancel-btn').click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSlotEdit(editRow) {
|
||||
if (!editRow) return;
|
||||
const d = editRow.dataset;
|
||||
const newDay = editRow.querySelector('.edit-day').value.trim().toLowerCase();
|
||||
const newTime = editRow.querySelector('.edit-time').value.trim();
|
||||
const newRoom = editRow.querySelector('.edit-room').value.trim();
|
||||
const newTeach = editRow.querySelector('.edit-teach').value.trim();
|
||||
|
||||
const res = await apiFetch(
|
||||
`/schedules/${d.schedule}/subjects/${enc(d.subject)}/slot`,
|
||||
'PATCH',
|
||||
{ old_day: d.day, old_time: d.time, type_name: d.type, date_range: d.period,
|
||||
day: newDay, time: newTime, classroom: newRoom, teacher: newTeach }
|
||||
);
|
||||
if (res.ok) {
|
||||
// Обновляем ячейки строки просмотра без перезагрузки
|
||||
const viewRow = editRow.previousElementSibling;
|
||||
viewRow.querySelector('.slot-view-day').textContent = newDay ? newDay.charAt(0).toUpperCase() + newDay.slice(1) : '—';
|
||||
viewRow.querySelector('.slot-view-time').textContent = newTime || '—';
|
||||
viewRow.querySelector('.slot-view-room').textContent = newRoom || '—';
|
||||
viewRow.querySelector('.slot-view-teach').textContent = newTeach || '—';
|
||||
// Обновляем data-атрибуты обеих строк
|
||||
for (const row of [viewRow, editRow]) {
|
||||
row.dataset.day = newDay;
|
||||
row.dataset.time = newTime;
|
||||
}
|
||||
// Обновляем data на кнопке удаления
|
||||
const delBtn = viewRow.querySelector('.slot-delete-btn');
|
||||
if (delBtn) { delBtn.dataset.day = newDay; delBtn.dataset.time = newTime; }
|
||||
editRow.classList.add('hidden');
|
||||
viewRow.classList.remove('hidden');
|
||||
} else {
|
||||
alert(res.error || 'Ошибка сохранения');
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Удаление слота ══════════════════════════════════════════════════════════
|
||||
function initDeleteSlot() {
|
||||
document.querySelectorAll('.slot-delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Удалить эту строку?')) return;
|
||||
const res = await apiFetch(
|
||||
`/schedules/${btn.dataset.schedule}/subjects/${enc(btn.dataset.subject)}/slot`,
|
||||
'DELETE',
|
||||
{ day: btn.dataset.day, time: btn.dataset.time,
|
||||
type_name: btn.dataset.type, date_range: btn.dataset.daterange }
|
||||
);
|
||||
if (res.ok) {
|
||||
const viewRow = btn.closest('.t-slot-row');
|
||||
const editRow = viewRow?.nextElementSibling;
|
||||
viewRow?.remove();
|
||||
if (editRow?.classList.contains('t-slot-edit-row')) editRow.remove();
|
||||
} else {
|
||||
alert(res.error || 'Ошибка');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Добавление новой строки (слота) ════════════════════════════════════════
|
||||
function initAddSlot() {
|
||||
document.querySelectorAll('.add-slot-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const section = btn.closest('.t-subj-slots-section');
|
||||
const tbody = section.querySelector('.slots-tbody');
|
||||
const schedule = btn.dataset.schedule;
|
||||
const subject = btn.dataset.subject;
|
||||
const typeName = btn.dataset.type;
|
||||
const period = btn.dataset.period;
|
||||
|
||||
// Вставляем временную строку редактирования
|
||||
const tempId = 'newrow-' + Date.now();
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 't-slot-edit-row';
|
||||
tr.dataset.day = '';
|
||||
tr.dataset.time = '';
|
||||
tr.dataset.type = typeName;
|
||||
tr.dataset.period = period;
|
||||
tr.dataset.subject = subject;
|
||||
tr.dataset.schedule = schedule;
|
||||
tr.id = tempId;
|
||||
tr.innerHTML = `
|
||||
<td><input type="text" class="t-input t-input-sm edit-day" placeholder="пн" aria-label="День"></td>
|
||||
<td><input type="text" class="t-input t-input-sm edit-time" placeholder="09:00-10:30" aria-label="Время"></td>
|
||||
<td><input type="text" class="t-input t-input-sm edit-room" placeholder="Кабинет" aria-label="Кабинет"></td>
|
||||
<td><input type="text" class="t-input t-input-sm edit-teach" placeholder="Преподаватель" aria-label="Преподаватель"></td>
|
||||
<td>
|
||||
<div class="t-slot-edit-save-row">
|
||||
<button type="button" class="t-btn-icon new-slot-save-btn" title="Сохранить">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
<button type="button" class="t-btn-icon new-slot-cancel-btn" title="Отмена">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
tr.querySelector('.edit-day').focus();
|
||||
|
||||
const doSave = async () => {
|
||||
const day = tr.querySelector('.edit-day').value.trim().toLowerCase();
|
||||
const time = tr.querySelector('.edit-time').value.trim();
|
||||
const room = tr.querySelector('.edit-room').value.trim();
|
||||
const teach = tr.querySelector('.edit-teach').value.trim();
|
||||
if (!day) { alert('Укажите день'); return; }
|
||||
|
||||
const res = await apiFetch(
|
||||
`/schedules/${schedule}/subjects/${enc(subject)}/slot`,
|
||||
'PUT',
|
||||
{ type_name: typeName, date_range: period, day, time, classroom: room, teacher: teach }
|
||||
);
|
||||
if (res.ok) location.reload();
|
||||
else alert(res.error || 'Ошибка');
|
||||
};
|
||||
|
||||
tr.querySelector('.new-slot-save-btn').addEventListener('click', doSave);
|
||||
tr.querySelector('.new-slot-cancel-btn').addEventListener('click', () => tr.remove());
|
||||
tr.querySelectorAll('input').forEach(inp => {
|
||||
inp.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doSave();
|
||||
if (e.key === 'Escape') tr.remove();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Редактирование периода (date_range) ═════════════════════════════════════
|
||||
function initEditPeriod() {
|
||||
document.querySelectorAll('.edit-period-btn').forEach(btn => {
|
||||
const section = btn.closest('.t-subj-slots-section');
|
||||
const form = section.querySelector('.t-subj-period-form');
|
||||
const display = section.querySelector('.t-subj-period-display');
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
form.classList.toggle('hidden');
|
||||
if (!form.classList.contains('hidden')) {
|
||||
form.querySelector('.period-input').select();
|
||||
}
|
||||
});
|
||||
|
||||
const doSave = async () => {
|
||||
const newPeriod = form.querySelector('.period-input').value.trim();
|
||||
if (!newPeriod) return;
|
||||
const d = section.dataset;
|
||||
const res = await apiFetch(
|
||||
`/schedules/${d.schedule}/subjects/${enc(d.subject)}/period`,
|
||||
'POST',
|
||||
{ type_name: d.type, old_period: d.period, new_period: newPeriod }
|
||||
);
|
||||
if (res.ok) {
|
||||
display.textContent = newPeriod;
|
||||
section.dataset.period = newPeriod;
|
||||
// Обновляем data-period на всех строках в секции
|
||||
section.querySelectorAll('[data-period]').forEach(el => el.dataset.period = newPeriod);
|
||||
section.querySelectorAll('.add-slot-btn').forEach(el => el.dataset.period = newPeriod);
|
||||
form.classList.add('hidden');
|
||||
} else {
|
||||
alert(res.error || 'Ошибка');
|
||||
}
|
||||
};
|
||||
|
||||
section.querySelector('.save-period-btn')?.addEventListener('click', doSave);
|
||||
form.querySelector('.period-input')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doSave();
|
||||
if (e.key === 'Escape') form.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Описание предмета ════════════════════════════════════════════════════════
|
||||
function initDescriptions() {
|
||||
document.querySelectorAll('.subject-description').forEach(block => {
|
||||
const { schedule: sid, subject } = block.dataset;
|
||||
const editBtn = block.querySelector('.edit-desc-btn');
|
||||
const form = block.querySelector('.edit-desc-form');
|
||||
const textEl = block.querySelector('.description-text');
|
||||
const input = block.querySelector('.desc-input');
|
||||
const saveBtn = block.querySelector('.save-desc-btn');
|
||||
const cancelBtn = block.querySelector('.cancel-desc-btn');
|
||||
if (!editBtn) return;
|
||||
editBtn.addEventListener('click', () => {
|
||||
form.classList.add('open');
|
||||
editBtn.hidden = true;
|
||||
});
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
form.classList.remove('open');
|
||||
editBtn.hidden = false;
|
||||
});
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const res = await apiFetch(
|
||||
`/schedules/${sid}/subjects/${enc(subject)}/description`,
|
||||
'POST', { description: input.value }
|
||||
);
|
||||
if (res.ok) {
|
||||
textEl.textContent = input.value || 'Нет описания';
|
||||
textEl.classList.toggle('text-muted', !input.value);
|
||||
textEl.classList.toggle('t-desc-italic', !input.value);
|
||||
form.classList.remove('open');
|
||||
editBtn.hidden = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Скрыть / показать предмет в расписании ══════════════════════════════════
|
||||
function initToggleVisibility() {
|
||||
document.querySelectorAll('.toggle-visibility-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const res = await apiFetch(
|
||||
`/schedules/${btn.dataset.schedule}/subjects/${enc(btn.dataset.subject)}/visibility`,
|
||||
'POST'
|
||||
);
|
||||
if (!res.ok) { alert(res.error || 'Ошибка'); return; }
|
||||
const hidden = res.hidden;
|
||||
btn.dataset.hidden = hidden ? 'true' : 'false';
|
||||
btn.classList.toggle('subject-hidden', hidden);
|
||||
btn.title = hidden ? 'Показать в расписании' : 'Скрыть из расписания';
|
||||
btn.innerHTML = hidden
|
||||
? `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`
|
||||
: `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`;
|
||||
// Visual indicator on the card
|
||||
btn.closest('.t-subj-card')?.classList.toggle('subject-hidden', hidden);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Переименование трекера ═══════════════════════════════════════════════════
|
||||
function initRenameMetric() {
|
||||
const backdrop = document.getElementById('renameMetricModalBackdrop');
|
||||
const input = document.getElementById('rename-metric-input');
|
||||
const confirmBtn = document.getElementById('rename-metric-confirm-btn');
|
||||
if (!backdrop || !input || !confirmBtn) return;
|
||||
|
||||
let _metricId = null;
|
||||
let _titleEl = null;
|
||||
|
||||
document.querySelectorAll('.t-metric-rename-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const widget = btn.closest('.t-metric');
|
||||
_metricId = widget?.dataset.metricId;
|
||||
_titleEl = widget?.querySelector('.t-metric-title');
|
||||
input.value = _titleEl?.textContent.trim() || '';
|
||||
openModal('renameMetricModalBackdrop');
|
||||
setTimeout(() => { input.select(); }, 50);
|
||||
});
|
||||
});
|
||||
|
||||
let _saving = false;
|
||||
const doSave = async () => {
|
||||
const label = input.value.trim();
|
||||
if (!label || !_metricId || _saving) return;
|
||||
_saving = true;
|
||||
confirmBtn.disabled = true;
|
||||
const res = await apiFetch(`/api/metrics/${_metricId}/rename`, 'POST', { label });
|
||||
_saving = false;
|
||||
confirmBtn.disabled = false;
|
||||
if (res.ok) {
|
||||
if (_titleEl) _titleEl.textContent = label;
|
||||
closeModal('renameMetricModalBackdrop');
|
||||
} else {
|
||||
alert(res.error || 'Ошибка');
|
||||
}
|
||||
};
|
||||
|
||||
confirmBtn.addEventListener('click', doSave);
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); doSave(); } });
|
||||
}
|
||||
|
||||
// ═══ Тип сдачи предмета ═══════════════════════════════════════════════════════
|
||||
function initCompletionType() {
|
||||
document.querySelectorAll('.completion-type-select').forEach(sel => {
|
||||
sel.addEventListener('change', async () => {
|
||||
const res = await apiFetch(
|
||||
`/schedules/${sel.dataset.schedule}/subjects/${enc(sel.dataset.subject)}/completion_type`,
|
||||
'POST', { completion_type: sel.value || null }
|
||||
);
|
||||
if (!res.ok) alert(res.error || 'Ошибка');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Завершить / возобновить предмет ═════════════════════════════════════════
|
||||
function initToggleCompleted() {
|
||||
document.querySelectorAll('.toggle-completed-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const res = await apiFetch(
|
||||
`/schedules/${btn.dataset.schedule}/subjects/${enc(btn.dataset.subject)}/completed`,
|
||||
'POST'
|
||||
);
|
||||
if (!res.ok) { alert(res.error || 'Ошибка'); return; }
|
||||
const done = res.completed;
|
||||
btn.classList.toggle('is-completed', done);
|
||||
btn.title = done ? 'Возобновить предмет' : 'Отметить как завершённый';
|
||||
btn.innerHTML = done
|
||||
? `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`
|
||||
: `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><polyline points="20 6 9 17 4 12" opacity=".3"/></svg>`;
|
||||
const card = btn.closest('.t-subj-card');
|
||||
card?.classList.toggle('subject-completed', done);
|
||||
// Обновляем бейдж
|
||||
const head = card?.querySelector('.t-subj-head-left');
|
||||
let badge = head?.querySelector('.t-completed-badge');
|
||||
if (done && !badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 't-completed-badge';
|
||||
badge.textContent = 'Завершён';
|
||||
head?.appendChild(badge);
|
||||
} else if (!done && badge) {
|
||||
badge.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Ссылка на СДО ════════════════════════════════════════════════════════════
|
||||
function initSdoUrl() {
|
||||
document.querySelectorAll('.t-subj-meta-sdo').forEach(block => {
|
||||
const section = block.closest('.t-subj-meta-section');
|
||||
if (!section) return;
|
||||
const { schedule, subject } = section.dataset;
|
||||
const saveBtn = block.querySelector('.save-sdo-btn');
|
||||
const input = block.querySelector('.sdo-url-input');
|
||||
if (!saveBtn || !input) return;
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const res = await apiFetch(
|
||||
`/schedules/${schedule}/subjects/${enc(subject)}/sdo_url`,
|
||||
'POST', { sdo_url: input.value.trim() }
|
||||
);
|
||||
if (!res.ok) { alert(res.error || 'Ошибка'); return; }
|
||||
// Перезагружаем страницу, чтобы обновить кнопку открытия СДО
|
||||
location.reload();
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') saveBtn.click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Промт AI-трекера (клик по названию) ══════════════════════════════════════
|
||||
function initMetricAiPromptView() {
|
||||
document.querySelectorAll('.t-metric-title-clickable').forEach(title => {
|
||||
title.addEventListener('click', async () => {
|
||||
const widget = title.closest('.t-metric');
|
||||
const id = widget?.dataset.metricId;
|
||||
if (!id) return;
|
||||
const res = await apiFetch(`/api/metrics/${id}/ai_prompt`, 'GET');
|
||||
if (res.ok) {
|
||||
document.getElementById('ai-prompt-text').textContent = res.prompt || '—';
|
||||
openModal('aiPromptModalBackdrop');
|
||||
} else {
|
||||
alert(res.error || 'Ошибка');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Инициализация страницы предметов ────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initRenameModal();
|
||||
initEditMode();
|
||||
initSubjectRename();
|
||||
initDeleteSubject();
|
||||
initSlotInlineEdit();
|
||||
initDeleteSlot();
|
||||
initAddSlot();
|
||||
initEditPeriod();
|
||||
initDescriptions();
|
||||
initToggleVisibility();
|
||||
initRenameMetric();
|
||||
initCompletionType();
|
||||
initToggleCompleted();
|
||||
initSdoUrl();
|
||||
initMetricAiPromptView();
|
||||
});
|
||||
33
app/static/js/upload.js
Normal file
33
app/static/js/upload.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// ═══ Предпросмотр изображения на странице загрузки ═══════════════════════════
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const drop = document.getElementById('file-drop-area');
|
||||
const input = document.getElementById('file-input');
|
||||
const preview = document.getElementById('file-preview');
|
||||
const changeBtn = document.getElementById('change-file-btn');
|
||||
if (!drop || !input) return;
|
||||
|
||||
drop.addEventListener('click', () => input.click());
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
if (preview) {
|
||||
preview.src = e.target.result;
|
||||
preview.hidden = false;
|
||||
}
|
||||
if (changeBtn) changeBtn.hidden = false;
|
||||
drop.querySelector('.t-upload-drop-text')?.style.setProperty('display', 'none');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
changeBtn?.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
input.value = '';
|
||||
if (preview) preview.hidden = true;
|
||||
changeBtn.hidden = true;
|
||||
drop.querySelector('.t-upload-drop-text')?.style.removeProperty('display');
|
||||
});
|
||||
});
|
||||
33
app/templates/auth/login.html
Normal file
33
app/templates/auth/login.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Вход{% endblock %}
|
||||
{% block content %}
|
||||
<div class="t-auth-wrap">
|
||||
<div class="t-auth-card">
|
||||
<div class="t-auth-title">Вход в tablo</div>
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="login">Логин</label>
|
||||
<input type="text" name="login" id="login" class="t-input" required autofocus
|
||||
autocomplete="username" placeholder="Ваш логин">
|
||||
</div>
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="password">Пароль</label>
|
||||
<input type="password" name="password" id="password" class="t-input" required
|
||||
autocomplete="current-password" placeholder="••••••••">
|
||||
</div>
|
||||
<div class="t-field">
|
||||
<label class="t-form-check" for="remember">
|
||||
<input type="checkbox" name="remember" id="remember">
|
||||
Запомнить меня
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="t-btn t-btn-primary t-btn-full">Войти</button>
|
||||
</form>
|
||||
<hr class="t-divider">
|
||||
<div class="t-auth-footer">
|
||||
Нет аккаунта? <a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
33
app/templates/auth/register.html
Normal file
33
app/templates/auth/register.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Регистрация{% endblock %}
|
||||
{% block content %}
|
||||
<div class="t-auth-wrap">
|
||||
<div class="t-auth-card">
|
||||
<div class="t-auth-title">Регистрация</div>
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="login">Логин</label>
|
||||
<input type="text" name="login" id="login" class="t-input" required autofocus
|
||||
minlength="3" autocomplete="username" placeholder="Минимум 3 символа">
|
||||
<div class="t-hint">Минимум 3 символа, уникальный</div>
|
||||
</div>
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="password">Пароль</label>
|
||||
<input type="password" name="password" id="password" class="t-input" required
|
||||
minlength="6" autocomplete="new-password" placeholder="Минимум 6 символов">
|
||||
</div>
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="confirm">Повторите пароль</label>
|
||||
<input type="password" name="confirm" id="confirm" class="t-input" required
|
||||
autocomplete="new-password" placeholder="••••••••">
|
||||
</div>
|
||||
<button type="submit" class="t-btn t-btn-primary t-btn-full">Создать аккаунт</button>
|
||||
</form>
|
||||
<hr class="t-divider">
|
||||
<div class="t-auth-footer">
|
||||
Уже есть аккаунт? <a href="{{ url_for('auth.login') }}">Войти</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
103
app/templates/base.html
Normal file
103
app/templates/base.html
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
|
||||
<title>{% block title %}Расписание{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/nav.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/schedule.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/subjects.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_base.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_checkpoints.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_progress_bar.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_counter.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_stages.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_checklist.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_attendance.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_grades.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_deadlines.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_streak.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_effort_hours.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/metrics/_rating_history.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/forms.css') }}">
|
||||
<script>document.documentElement.dataset.theme=localStorage.getItem('tablo-theme')||'dark';</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="t-nav">
|
||||
<a class="t-brand" href="{{ url_for('schedules.profile') }}">
|
||||
<span class="t-brand-tab">tab</span><span class="t-brand-lo">lo</span>
|
||||
</a>
|
||||
{% if current_user.is_authenticated %}
|
||||
<span class="t-nav-user">{{ current_user.login }}</span>
|
||||
{% endif %}
|
||||
<div class="t-nav-r">
|
||||
{% block nav_extra %}{% endblock %}
|
||||
<button type="button" class="t-btn-icon t-theme-toggle" id="theme-toggle" title="Переключить тему" aria-label="Переключить тему">
|
||||
<svg class="t-icon-sun" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
<svg class="t-icon-moon" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a class="t-btn t-btn-outline" href="{{ url_for('schedules.upload') }}">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Новое
|
||||
</a>
|
||||
<a class="t-btn t-btn-ghost" href="{{ url_for('auth.logout') }}">Выйти</a>
|
||||
{% else %}
|
||||
<a class="t-btn t-btn-outline" href="{{ url_for('auth.login') }}">Войти</a>
|
||||
<a class="t-btn t-btn-primary" href="{{ url_for('auth.register') }}">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="t-page">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="t-alert t-alert-{{ category }}">
|
||||
{{ message }}
|
||||
<button type="button" class="t-alert-close" onclick="this.parentElement.remove()">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="t-footer">
|
||||
<a href="https://tablo.deev.su" class="t-footer-brand">
|
||||
<span class="t-brand-tab">tab</span><span class="t-brand-lo">lo</span>
|
||||
</a>
|
||||
<span class="t-footer-sep">·</span>
|
||||
<a href="https://tablo.deev.su" class="t-footer-link">tablo.deev.su</a>
|
||||
<span class="t-footer-sep">·</span>
|
||||
<span>Project by Deev Egor © 2026</span>
|
||||
</footer>
|
||||
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<script src="{{ url_for('static', filename='js/core.js') }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const saved = localStorage.getItem('tablo-theme') || 'dark';
|
||||
document.documentElement.dataset.theme = saved;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (!btn) return;
|
||||
function applyTheme(t) {
|
||||
document.documentElement.dataset.theme = t;
|
||||
localStorage.setItem('tablo-theme', t);
|
||||
}
|
||||
btn.addEventListener('click', () => {
|
||||
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
386
app/templates/macros/metrics.html
Normal file
386
app/templates/macros/metrics.html
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
{% macro render_metric(metric, can_edit=False, show_rename=False) %}
|
||||
{% set prog = metric.progress or {} %}
|
||||
{% set cfg = metric.config or {} %}
|
||||
<div class="t-metric t-metric--{{ metric.type }}"
|
||||
id="metric-{{ metric.id }}"
|
||||
data-metric-id="{{ metric.id }}"
|
||||
data-metric-type="{{ metric.type }}"
|
||||
data-progress='{{ prog | tojson }}'
|
||||
data-config='{{ cfg | tojson }}'
|
||||
data-grid-col="{{ metric.grid_col }}"
|
||||
data-grid-row="{{ metric.grid_row }}"
|
||||
data-grid-w="{{ metric.grid_w }}"
|
||||
data-grid-h="{{ metric.grid_h }}">
|
||||
|
||||
{# ── Заголовок ── #}
|
||||
<div class="t-metric-header">
|
||||
{% if metric.ai_prompt %}
|
||||
<span class="t-metric-title t-metric-title-clickable" title="Нажмите, чтобы увидеть промт">{{ metric.label }}</span>
|
||||
{% else %}
|
||||
<span class="t-metric-title">{{ metric.label }}</span>
|
||||
{% endif %}
|
||||
{% if can_edit and show_rename %}
|
||||
<button type="button" class="t-metric-rename-btn" title="Переименовать трекер" aria-label="Переименовать трекер">
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Кнопка удаления (абсолютная, правый верхний угол) ── #}
|
||||
{% if can_edit %}
|
||||
<button type="button" class="delete-metric-btn" title="Удалить" aria-label="Удалить трекер">
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<div class="t-metric-body">
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
CHECKPOINTS — контрольные точки
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% if metric.type == 'checkpoints' %}
|
||||
{% set total = cfg.get('total', 0) | int %}
|
||||
{% set checked = prog.get('checked', []) %}
|
||||
{% set done = checked | length %}
|
||||
{% set pct = (done / total * 100) | int if total else 0 %}
|
||||
<div class="t-cp-bar-row">
|
||||
<span class="t-cp-fraction">{{ done }}<span class="t-cp-fraction-max">/{{ total }}</span></span>
|
||||
<div class="t-cp-track"><div class="t-cp-fill" style="width:{{ pct }}%"></div></div>
|
||||
</div>
|
||||
<div class="t-cps">
|
||||
{% for i in range(total) %}
|
||||
{% set lbl = cfg.get('labels', [])[i] if cfg.get('labels') and i < (cfg.get('labels') | length) else (i + 1) | string %}
|
||||
{% if can_edit %}
|
||||
<button type="button" class="t-cp {{ 'done' if i in checked else '' }}"
|
||||
data-index="{{ i }}" title="{{ lbl }}" aria-label="Точка {{ lbl }}">{{ lbl }}</button>
|
||||
{% else %}
|
||||
<span class="t-cp {{ 'done' if i in checked else '' }}" title="{{ lbl }}">{{ lbl }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not total %}<span class="t-metric-empty">Не настроено</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
PROGRESS BAR — накопление баллов
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% elif metric.type == 'progress_bar' %}
|
||||
{% set cur = prog.get('current', 0) %}
|
||||
{% set mx = cfg.get('max', 100) | float %}
|
||||
{% set pct = [[cur / mx * 100, 0] | max, 100] | min | int if mx else 0 %}
|
||||
<div class="t-pb-wrap">
|
||||
<div class="t-pb-ring-wrap">
|
||||
<svg class="t-pb-ring" viewBox="0 0 80 80">
|
||||
<circle class="t-pb-ring-bg" cx="40" cy="40" r="34"/>
|
||||
<circle class="t-pb-ring-fill" cx="40" cy="40" r="34"
|
||||
stroke-dasharray="213.6"
|
||||
stroke-dashoffset="{{ ((100 - pct) / 100 * 213.6) | round(1) }}"/>
|
||||
</svg>
|
||||
<span class="t-pb-pct">{{ pct }}%</span>
|
||||
</div>
|
||||
<div class="t-pb-right">
|
||||
<div class="t-pb-val">{{ cur }}<span class="t-pb-val-max"> / {{ mx | int }}{% if cfg.get('unit') %} {{ cfg.get('unit') }}{% endif %}</span></div>
|
||||
{% if can_edit %}
|
||||
<div class="t-pb-input-row">
|
||||
<input type="number" class="t-input t-input-sm progress-input"
|
||||
value="{{ cur }}" min="0" max="{{ mx }}" step="{{ cfg.get('step', 1) }}"
|
||||
aria-label="Текущее значение">
|
||||
<button type="button" class="t-btn t-btn-primary t-btn-sm update-progress-btn" aria-label="Сохранить">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
COUNTER — счётчик
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% elif metric.type == 'counter' %}
|
||||
{% set cur = prog.get('current', 0) %}
|
||||
{% set mx = cfg.get('max') | int if cfg.get('max') else 0 %}
|
||||
<div class="t-counter-body">
|
||||
<div class="t-counter-main">
|
||||
{% if can_edit %}<button type="button" class="t-counter-btn counter-dec" aria-label="Уменьшить">−</button>{% endif %}
|
||||
<div class="t-counter-display">
|
||||
<span class="t-counter-val">{{ cur }}</span>
|
||||
<span class="t-counter-sub">
|
||||
{%- if mx %}/ {{ mx }}{% endif %}
|
||||
{%- if cfg.get('unit') %} {{ cfg.get('unit') }}{% endif -%}
|
||||
</span>
|
||||
</div>
|
||||
{% if can_edit %}<button type="button" class="t-counter-btn counter-inc" aria-label="Увеличить">+</button>{% endif %}
|
||||
</div>
|
||||
{% if mx %}
|
||||
<div class="t-counter-bar-row">
|
||||
<div class="t-counter-bar-fill" style="width:{{ [[cur / mx * 100, 0] | max, 100] | min | int }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
STAGES — этапы
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% elif metric.type == 'stages' %}
|
||||
{% set cur_stage = prog.get('current_stage', 0) %}
|
||||
{% set stages = cfg.get('stages', []) %}
|
||||
<div class="t-stages">
|
||||
{% for stage in stages %}
|
||||
{% set idx = loop.index0 %}
|
||||
{% set state = 'done' if idx < cur_stage else ('active' if idx == cur_stage else '') %}
|
||||
<div class="t-stage-item">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="t-stage-btn {{ state }} stage-btn" data-index="{{ idx }}" title="{{ stage }}">
|
||||
{% else %}
|
||||
<div class="t-stage-btn {{ state }}">
|
||||
{% endif %}
|
||||
<span class="t-stage-dot">
|
||||
{% if idx < cur_stage %}
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{% else %}{{ loop.index }}{% endif %}
|
||||
</span>
|
||||
<span class="t-stage-lbl">{{ stage }}</span>
|
||||
{% if can_edit %}</button>{% else %}</div>{% endif %}
|
||||
{% if not loop.last %}
|
||||
<div class="t-stage-conn {{ 'done' if idx < cur_stage else '' }}"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="t-metric-empty">Не настроено</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
CHECKLIST — чеклист
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% elif metric.type == 'checklist' %}
|
||||
{% set checked = prog.get('checked', []) %}
|
||||
{% set items = cfg.get('items', []) %}
|
||||
{% set pct = (checked | length / items | length * 100) | int if items else 0 %}
|
||||
<div class="t-cl-bar-row">
|
||||
<div class="t-cl-track"><div class="t-cl-fill" style="width:{{ pct }}%"></div></div>
|
||||
<span class="t-cl-frac">{{ checked | length }}/{{ items | length }}</span>
|
||||
</div>
|
||||
<div class="t-cl-list">
|
||||
{% for item in items %}
|
||||
{% set idx = loop.index0 %}
|
||||
<label class="t-cl-item {{ 'checked' if idx in checked else '' }}" for="cl-{{ metric.id }}-{{ idx }}">
|
||||
<input type="checkbox" class="checklist-item" data-index="{{ idx }}"
|
||||
{{ 'checked' if idx in checked else '' }}
|
||||
{{ 'disabled' if not can_edit else '' }}
|
||||
id="cl-{{ metric.id }}-{{ idx }}" aria-label="{{ item }}">
|
||||
<span class="t-cl-box">
|
||||
<svg width="8" height="8" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span class="t-cl-text">{{ item }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<span class="t-metric-empty">Нет пунктов</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
ATTENDANCE — посещаемость
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% elif metric.type == 'attendance' %}
|
||||
{% set records = prog.get('records', []) %}
|
||||
{% set present = records | selectattr('status', 'equalto', 'present') | list | length %}
|
||||
{% set absent = records | selectattr('status', 'equalto', 'absent') | list | length %}
|
||||
{% set excused = records | selectattr('status', 'equalto', 'excused') | list | length %}
|
||||
<div class="t-att-stats">
|
||||
<div class="t-att-stat t-att-stat--present">
|
||||
<span class="t-att-num">{{ present }}</span>
|
||||
<span class="t-att-lbl">Присут.</span>
|
||||
</div>
|
||||
<div class="t-att-stat t-att-stat--absent">
|
||||
<span class="t-att-num">{{ absent }}</span>
|
||||
<span class="t-att-lbl">Пропуск</span>
|
||||
</div>
|
||||
<div class="t-att-stat t-att-stat--excused">
|
||||
<span class="t-att-num">{{ excused }}</span>
|
||||
<span class="t-att-lbl">Уваж.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-att-dots">
|
||||
{% for rec in records[-40:] %}
|
||||
<span class="t-att-dot t-att-dot--{{ rec.status }}" title="{{ rec.date }}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<div class="t-att-form">
|
||||
<input type="date" class="t-input t-input-sm att-date" aria-label="Дата">
|
||||
<select class="t-select t-select-sm att-status" aria-label="Статус">
|
||||
<option value="present">Присут.</option>
|
||||
<option value="absent">Пропуск</option>
|
||||
<option value="excused">Уваж.</option>
|
||||
</select>
|
||||
<button type="button" class="t-btn t-btn-primary t-btn-sm add-att-btn" aria-label="Добавить">+</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
GRADES — оценки
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% elif metric.type == 'grades' %}
|
||||
{% set grades = prog.get('grades', {}) %}
|
||||
{% set works = cfg.get('works', []) %}
|
||||
{% set max_gr = cfg.get('max_grade', 5) | float %}
|
||||
<div class="t-gr-list">
|
||||
{% for work in works %}
|
||||
{% set val = grades.get(work.name, '') %}
|
||||
{% set pct = (val | float / max_gr * 100) | int if val != '' else 0 %}
|
||||
<div class="t-gr-row">
|
||||
<span class="t-gr-name" title="{{ work.name }}">{{ work.name }}</span>
|
||||
<div class="t-gr-track"><div class="t-gr-fill" style="width:{{ pct }}%"></div></div>
|
||||
<input type="number" class="t-input t-input-sm t-gr-input grade-input"
|
||||
data-work="{{ work.name }}" value="{{ val }}"
|
||||
min="0" max="{{ max_gr }}" aria-label="Оценка за {{ work.name }}"
|
||||
{{ 'disabled' if not can_edit else '' }}>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="t-metric-empty">Нет работ</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if works and can_edit %}
|
||||
<button type="button" class="t-btn t-btn-primary t-btn-sm t-gr-save save-grades-btn">Сохранить</button>
|
||||
{% endif %}
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
DEADLINES — дедлайны
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% elif metric.type == 'deadlines' %}
|
||||
{% set completed = prog.get('completed', []) %}
|
||||
{% set items = cfg.get('items', []) %}
|
||||
{% set pct = (completed | length / items | length * 100) | int if items else 0 %}
|
||||
<div class="t-dl-bar-row">
|
||||
<div class="t-dl-track"><div class="t-dl-fill" style="width:{{ pct }}%"></div></div>
|
||||
<span class="t-dl-frac">{{ completed | length }}/{{ items | length }}</span>
|
||||
</div>
|
||||
<div class="t-dl-list">
|
||||
{% for item in items %}
|
||||
{% set idx = loop.index0 %}
|
||||
<label class="t-dl-item {{ 'done' if idx in completed else '' }}" for="dl-{{ metric.id }}-{{ idx }}">
|
||||
<input type="checkbox" class="deadline-item" data-index="{{ idx }}"
|
||||
{{ 'checked' if idx in completed else '' }}
|
||||
{{ 'disabled' if not can_edit else '' }}
|
||||
id="dl-{{ metric.id }}-{{ idx }}" aria-label="{{ item.name }}">
|
||||
<span class="t-dl-box">
|
||||
<svg width="8" height="8" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span class="t-dl-name">{{ item.name }}</span>
|
||||
{% if item.get('date') %}<span class="t-dl-date">{{ item.date }}</span>{% endif %}
|
||||
</label>
|
||||
{% else %}
|
||||
<span class="t-metric-empty">Нет дедлайнов</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
STREAK — серия
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% elif metric.type == 'streak' %}
|
||||
{% set count = prog.get('records', []) | length %}
|
||||
<div class="t-streak-body">
|
||||
<div class="t-streak-flame">🔥</div>
|
||||
<div class="t-streak-num">{{ count }}</div>
|
||||
<div class="t-streak-lbl">{{ 'день' if count % 10 == 1 and count % 100 != 11 else ('дня' if count % 10 in [2,3,4] and count % 100 not in [12,13,14] else 'дней') }} подряд</div>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="t-btn t-btn-outline t-btn-sm t-streak-btn mark-today-btn">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Отметить сегодня
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
EFFORT HOURS — трудозатраты
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% elif metric.type == 'effort_hours' %}
|
||||
{% set total = prog.get('hours', 0) %}
|
||||
{% set goal = cfg.get('goal_hours', 0) | float %}
|
||||
{% set pct = [[total / goal * 100, 0] | max, 100] | min | int if goal else 0 %}
|
||||
<div class="t-ef-wrap">
|
||||
<div class="t-ef-left">
|
||||
<div class="t-ef-num">{{ total }}<span class="t-ef-unit">ч</span></div>
|
||||
{% if goal %}<div class="t-ef-goal">цель: {{ goal | int }}ч</div>{% endif %}
|
||||
</div>
|
||||
<div class="t-ef-right">
|
||||
{% if goal %}
|
||||
<div class="t-ef-bar-wrap">
|
||||
<div class="t-ef-bar-fill" style="width:{{ pct }}%"></div>
|
||||
<span class="t-ef-bar-pct">{{ pct }}%</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<div class="t-ef-input-row">
|
||||
<input type="number" class="t-input t-input-sm effort-input"
|
||||
placeholder="+ч" min="0.5" step="0.5" aria-label="Добавить часы">
|
||||
<button type="button" class="t-btn t-btn-primary t-btn-sm add-effort-btn">+</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ════════════════════════════════════════════════════════════════════════
|
||||
RATING HISTORY — история оценок
|
||||
════════════════════════════════════════════════════════════════════════ #}
|
||||
{% elif metric.type == 'rating_history' %}
|
||||
{% set entries = prog.get('entries', []) %}
|
||||
{% set scale = cfg.get('scale', 5) | float %}
|
||||
{% if entries %}
|
||||
{% set last = entries[-1] %}
|
||||
{% set avg = (entries | sum(attribute='grade') / entries | length) | round(1) %}
|
||||
{% endif %}
|
||||
|
||||
{# gh-1: только большая оценка — центральный вид #}
|
||||
<div class="t-rh-center">
|
||||
{% if entries %}
|
||||
<div class="t-rh-big-val">{{ last.grade }}</div>
|
||||
<div class="t-rh-big-scale">/ {{ scale | int }}</div>
|
||||
<div class="t-rh-big-avg">ср. {{ avg }}</div>
|
||||
{% else %}
|
||||
<span class="t-metric-empty">Нет записей</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# gh-2+: разделённый вид #}
|
||||
<div class="t-rh-wrap">
|
||||
<div class="t-rh-left">
|
||||
{% if entries %}
|
||||
<div class="t-rh-last-val">{{ last.grade }}</div>
|
||||
<div class="t-rh-last-scale">/ {{ scale | int }}</div>
|
||||
<div class="t-rh-avg-lbl">ср.</div>
|
||||
<div class="t-rh-avg">{{ avg }}</div>
|
||||
{% else %}
|
||||
<span class="t-metric-empty" style="font-size:9px">—</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="t-rh-right">
|
||||
<div class="t-rh-list">
|
||||
{% for entry in entries | reverse %}
|
||||
{% set pct = (entry.grade / scale * 100) | int %}
|
||||
<div class="t-rh-row">
|
||||
<span class="t-rh-date">{{ entry.date }}</span>
|
||||
<div class="t-rh-track"><div class="t-rh-fill" style="width:{{ pct }}%"></div></div>
|
||||
<span class="t-rh-val">{{ entry.grade }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="t-metric-empty">Нет записей</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<div class="t-rh-form">
|
||||
<input type="date" class="t-input t-input-sm rating-date" aria-label="Дата">
|
||||
<input type="number" class="t-input t-input-sm rating-grade"
|
||||
placeholder="Оц." min="1" max="{{ scale | int }}" aria-label="Оценка">
|
||||
<button type="button" class="t-btn t-btn-primary t-btn-sm add-rating-btn" aria-label="+">+</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>{# /t-metric-body #}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
185
app/templates/profile/index.html
Normal file
185
app/templates/profile/index.html
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Мои расписания{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="t-ph">
|
||||
<h1 class="t-ph-title">Мои расписания</h1>
|
||||
<div class="t-ph-right">
|
||||
<a class="t-btn t-btn-primary" href="{{ url_for('schedules.upload') }}">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Добавить
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if schedules %}
|
||||
<div class="t-sched-grid" id="sched-grid">
|
||||
{% for s in schedules %}
|
||||
<div class="t-sched-card">
|
||||
<div class="t-sched-card-title">{{ s.name }}</div>
|
||||
<div class="t-sched-card-meta">
|
||||
<span>
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{{ s.created_at.strftime('%d.%m.%Y') }}
|
||||
</span>
|
||||
<span>
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
{{ s.data | length }} предметов
|
||||
</span>
|
||||
</div>
|
||||
<div class="t-sched-card-actions">
|
||||
<a class="t-btn t-btn-primary t-btn-sm" href="{{ url_for('schedules.view', schedule_id=s.id) }}">Открыть</a>
|
||||
<button type="button" class="t-btn t-btn-outline t-btn-sm share-btn" data-schedule-id="{{ s.id }}" title="Поделиться расписанием">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
</button>
|
||||
<form action="{{ url_for('schedules.delete', schedule_id=s.id) }}" method="POST"
|
||||
onsubmit="return confirm('Удалить расписание «{{ s.name }}»?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="t-btn t-btn-danger t-btn-sm" title="Удалить расписание">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="t-sched-card-footer">
|
||||
<label class="t-form-check" for="merge-{{ s.id }}">
|
||||
<input type="checkbox" class="merge-check" value="{{ s.id }}" id="merge-{{ s.id }}">
|
||||
Выбрать для объединения
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Панель объединения #}
|
||||
<div class="t-merge-panel" id="merge-panel" hidden>
|
||||
<div class="t-label t-merge-title">Объединить выбранные расписания</div>
|
||||
<form action="{{ url_for('schedules.merge') }}" method="POST" id="merge-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div id="merge-ids"></div>
|
||||
<div class="t-input-group">
|
||||
<input type="text" name="name" class="t-input" placeholder="Название нового расписания" required
|
||||
aria-label="Название нового расписания">
|
||||
<button type="submit" class="t-btn t-btn-primary t-btn-group-end">Объединить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="t-empty">
|
||||
<div class="t-empty-icon">
|
||||
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="9" y1="15" x2="9.01" y2="15" stroke-width="2"/><line x1="12" y1="15" x2="12.01" y2="15" stroke-width="2"/><line x1="15" y1="15" x2="15.01" y2="15" stroke-width="2"/></svg>
|
||||
</div>
|
||||
<p>Расписаний пока нет</p>
|
||||
<a class="t-btn t-btn-primary" href="{{ url_for('schedules.upload') }}">Добавить первое</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Модальное окно шаринга #}
|
||||
<div class="t-modal-backdrop" id="shareModalBackdrop" role="dialog" aria-modal="true" aria-label="Поделиться расписанием">
|
||||
<div class="t-modal">
|
||||
<div class="t-modal-header">
|
||||
<span class="t-modal-title">Поделиться расписанием</span>
|
||||
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('shareModalBackdrop')" title="Закрыть">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="t-modal-body">
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="link-view">Ссылка для просмотра</label>
|
||||
<div class="t-input-group">
|
||||
<input type="text" class="t-input" id="link-view" readonly aria-label="Ссылка для просмотра">
|
||||
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-view" title="Скопировать">
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="t-hint">Другие могут смотреть, но не редактировать</div>
|
||||
</div>
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="link-template">Ссылка-шаблон</label>
|
||||
<div class="t-input-group">
|
||||
<input type="text" class="t-input" id="link-template" readonly aria-label="Ссылка-шаблон">
|
||||
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-template" title="Скопировать">
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="t-hint">Пользователь получит копию расписания</div>
|
||||
</div>
|
||||
<hr class="t-divider">
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="editor-login">Добавить редактора</label>
|
||||
<div class="t-input-group">
|
||||
<input type="text" class="t-input" id="editor-login" placeholder="Логин пользователя" aria-label="Логин редактора">
|
||||
<button type="button" class="t-btn t-btn-primary t-btn-group-end" id="add-editor-btn" title="Добавить редактора">Добавить</button>
|
||||
</div>
|
||||
<div class="t-hint">Редактор сможет изменять описания и трекеры</div>
|
||||
</div>
|
||||
<div id="editor-link-wrap" hidden class="t-field">
|
||||
<label class="t-label" for="link-edit">Ссылка для редактора</label>
|
||||
<div class="t-input-group">
|
||||
<input type="text" class="t-input" id="link-edit" readonly aria-label="Ссылка для редактора">
|
||||
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-edit" title="Скопировать">
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.querySelectorAll('.t-modal-backdrop').forEach(bd => {
|
||||
bd.addEventListener('click', e => { if (e.target === bd) bd.classList.remove('open'); });
|
||||
});
|
||||
|
||||
let currentScheduleId = null;
|
||||
|
||||
document.querySelectorAll('.share-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
currentScheduleId = btn.dataset.scheduleId;
|
||||
for (const type of ['view', 'template']) {
|
||||
const res = await apiFetch(`/schedules/${currentScheduleId}/share`, 'POST', { type });
|
||||
if (res.ok)
|
||||
document.getElementById(`link-${type}`).value = `${location.origin}/shared/${res.token}`;
|
||||
}
|
||||
document.getElementById('shareModalBackdrop').classList.add('open');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('add-editor-btn').addEventListener('click', async () => {
|
||||
const login = document.getElementById('editor-login').value.trim();
|
||||
if (!login) return;
|
||||
const res = await apiFetch(`/schedules/${currentScheduleId}/share/editors`, 'POST', { login });
|
||||
if (res.ok) {
|
||||
document.getElementById('link-edit').value = `${location.origin}/shared/${res.token}`;
|
||||
document.getElementById('editor-link-wrap').hidden = false;
|
||||
} else {
|
||||
alert(res.error || 'Ошибка');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(document.getElementById(btn.dataset.target).value);
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
setTimeout(() => btn.innerHTML = orig, 1500);
|
||||
});
|
||||
});
|
||||
|
||||
/* Объединение расписаний */
|
||||
const mergePanel = document.getElementById('merge-panel');
|
||||
const mergeIds = document.getElementById('merge-ids');
|
||||
document.querySelectorAll('.merge-check').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
const checked = document.querySelectorAll('.merge-check:checked');
|
||||
mergePanel.hidden = checked.length < 2;
|
||||
mergeIds.innerHTML = [...checked]
|
||||
.map(c => `<input type="hidden" name="schedule_ids" value="${c.value}">`)
|
||||
.join('');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
429
app/templates/schedule/subjects.html
Normal file
429
app/templates/schedule/subjects.html
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Предметы — {{ schedule.name }}{% endblock %}
|
||||
|
||||
{% block nav_extra %}
|
||||
<a class="t-btn t-btn-ghost" href="{{ url_for('schedules.view', schedule_id=schedule.id) }}">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Расписание
|
||||
</a>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="t-btn t-btn-outline t-edit-btn" id="edit-mode-btn" title="Режим редактирования">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
Редактировать
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% from "macros/metrics.html" import render_metric %}
|
||||
|
||||
<div class="t-ph">
|
||||
<div class="t-ph-name-row">
|
||||
<h1 class="t-ph-title">Предметы</h1>
|
||||
<span class="t-muted">{{ schedule.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not schedule.data %}
|
||||
<div class="t-empty">
|
||||
<div class="t-empty-icon">📋</div>
|
||||
<p>В расписании нет предметов</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="t-subj-grid">
|
||||
{% for item in schedule.data %}
|
||||
{% set subj = item.subject %}
|
||||
{% set cfg = configs.get(subj) %}
|
||||
{% set pidx = loop.index0 %}
|
||||
|
||||
{% set is_completed = cfg and cfg.completed %}
|
||||
<div class="t-subj-card {{ 'subject-hidden' if cfg and cfg.hidden else '' }} {{ 'subject-completed' if is_completed else '' }}" data-subject="{{ subj }}">
|
||||
|
||||
{# ── Шапка карточки ── #}
|
||||
<div class="t-subj-head">
|
||||
<div class="t-subj-head-left">
|
||||
<div class="t-subj-type-dots">
|
||||
{% for type_name, type_data in item.get('types', {}).items() %}
|
||||
{% set color = type_data.get('color', 'gray') if type_data is mapping and 'color' in type_data else 'gray' %}
|
||||
<span class="t-type t-type-{{ color }}">{{ type_name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="t-subj-name subject-name-display">{{ subj }}</span>
|
||||
{% if cfg and cfg.completion_type %}
|
||||
<span class="t-completion-badge t-completion-{{ cfg.completion_type | replace(' ', '_') | replace('.', '') | lower }}">{{ cfg.completion_type }}</span>
|
||||
{% endif %}
|
||||
{% if is_completed %}
|
||||
<span class="t-completed-badge">Завершён</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="t-subj-head-actions {% if can_edit %}edit-ctrl{% endif %}">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="t-btn-icon toggle-completed-btn {{ 'is-completed' if is_completed else '' }}"
|
||||
title="{{ 'Возобновить предмет' if is_completed else 'Отметить как завершённый' }}"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}">
|
||||
{% if is_completed %}
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{% else %}
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><polyline points="20 6 9 17 4 12" opacity=".3"/></svg>
|
||||
{% endif %}
|
||||
</button>
|
||||
<button type="button" class="t-btn-icon toggle-visibility-btn {{ 'subject-hidden' if cfg and cfg.hidden else '' }}"
|
||||
title="{{ 'Показать в расписании' if cfg and cfg.hidden else 'Скрыть из расписания' }}"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}"
|
||||
data-hidden="{{ 'true' if cfg and cfg.hidden else 'false' }}">
|
||||
{% if cfg and cfg.hidden %}
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
{% else %}
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
{% endif %}
|
||||
</button>
|
||||
<button type="button" class="t-btn-icon rename-subject-btn" title="Переименовать предмет"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="t-btn-icon danger delete-subject-btn" title="Удалить предмет"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Описание ── #}
|
||||
<div class="t-subj-section subject-description" data-schedule="{{ schedule.id }}" data-subject="{{ subj }}">
|
||||
<p class="description-text t-subj-desc {% if not (cfg and cfg.description) %}text-muted t-desc-italic{% endif %}">
|
||||
{{ cfg.description if cfg and cfg.description else 'Нет описания' }}
|
||||
</p>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="t-btn t-btn-ghost t-btn-xs edit-ctrl edit-desc-btn">Изменить описание</button>
|
||||
<div class="edit-desc-form t-subject-desc-form">
|
||||
<textarea class="t-input t-textarea desc-input" rows="2"
|
||||
aria-label="Описание предмета">{{ cfg.description if cfg and cfg.description else '' }}</textarea>
|
||||
<div class="t-subj-desc-actions">
|
||||
<button type="button" class="t-btn t-btn-primary t-btn-sm save-desc-btn">Сохранить</button>
|
||||
<button type="button" class="t-btn t-btn-outline t-btn-sm cancel-desc-btn">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Мета: тип сдачи + ссылка СДО ── #}
|
||||
<div class="t-subj-section t-subj-meta-section" data-schedule="{{ schedule.id }}" data-subject="{{ subj }}">
|
||||
<div class="t-subj-meta-row">
|
||||
{# Тип сдачи #}
|
||||
<div class="t-subj-meta-item">
|
||||
<span class="t-subj-meta-label">Тип сдачи</span>
|
||||
{% if can_edit %}
|
||||
<select class="t-select t-select-sm completion-type-select"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}"
|
||||
aria-label="Тип сдачи">
|
||||
<option value="">— не указан —</option>
|
||||
{% for ct in completion_types %}
|
||||
<option value="{{ ct }}" {{ 'selected' if cfg and cfg.completion_type == ct else '' }}>{{ ct }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<span class="t-subj-meta-value">{{ (cfg.completion_type if cfg and cfg.completion_type else '—') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Ссылка СДО #}
|
||||
<div class="t-subj-meta-item t-subj-meta-sdo">
|
||||
<span class="t-subj-meta-label">СДО</span>
|
||||
{% if can_edit %}
|
||||
<div class="t-subj-sdo-edit">
|
||||
<input type="url" class="t-input t-input-sm sdo-url-input"
|
||||
value="{{ cfg.sdo_url if cfg and cfg.sdo_url else '' }}"
|
||||
placeholder="https://..." aria-label="Ссылка на СДО">
|
||||
<button type="button" class="t-btn t-btn-primary t-btn-sm save-sdo-btn" title="Сохранить ссылку">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
{% if cfg and cfg.sdo_url %}
|
||||
<a href="{{ cfg.sdo_url }}" target="_blank" rel="noopener noreferrer"
|
||||
class="t-btn t-btn-outline t-btn-sm" title="Открыть СДО">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if cfg and cfg.sdo_url %}
|
||||
<a href="{{ cfg.sdo_url }}" target="_blank" rel="noopener noreferrer" class="t-subj-sdo-link">
|
||||
<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Открыть
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="t-subj-meta-value">—</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Расписание: по типам и периодам ── #}
|
||||
{% for type_name, type_data in item.get('types', {}).items() %}
|
||||
{% set color = type_data.get('color', 'gray') if type_data is mapping and 'color' in type_data else 'gray' %}
|
||||
{% set date_ranges = type_data.get('dates', type_data) if type_data is mapping and 'dates' in type_data else type_data %}
|
||||
|
||||
{% for dr, slots in date_ranges.items() %}
|
||||
<div class="t-subj-section t-subj-slots-section"
|
||||
data-subject="{{ subj }}"
|
||||
data-type="{{ type_name }}"
|
||||
data-period="{{ dr }}"
|
||||
data-schedule="{{ schedule.id }}">
|
||||
|
||||
{# Заголовок секции: тип + период #}
|
||||
<div class="t-subj-slots-head">
|
||||
<span class="t-type t-type-{{ color }}">{{ type_name }}</span>
|
||||
<span class="t-subj-period-display fs-small mono">{{ dr }}</span>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="t-btn-icon edit-ctrl edit-period-btn" title="Изменить период">
|
||||
<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</button>
|
||||
<div class="t-subj-period-form hidden">
|
||||
<div class="t-input-group">
|
||||
<input type="text" class="t-input t-input-sm period-input" value="{{ dr }}"
|
||||
placeholder="01.09-31.01" aria-label="Период">
|
||||
<button type="button" class="t-btn t-btn-primary t-btn-group-end save-period-btn">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<button type="button" class="t-btn t-btn-outline t-btn-xs edit-ctrl add-slot-btn ml-auto"
|
||||
title="Добавить строку"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}"
|
||||
data-type="{{ type_name }}" data-period="{{ dr }}">
|
||||
+ Строка
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Таблица слотов #}
|
||||
<table class="t-table t-subj-slot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">День</th>
|
||||
<th scope="col">Время</th>
|
||||
<th scope="col">Кабинет</th>
|
||||
<th scope="col">Преподаватель</th>
|
||||
{% if can_edit %}<th scope="col" class="t-table-actions"><span class="hidden">Действия</span></th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="slots-tbody">
|
||||
{% for slot in slots %}
|
||||
{% set slot_day = slot[0] if slot|length > 0 else '' %}
|
||||
{% set slot_time = slot[1] if slot|length > 1 else '' %}
|
||||
{% set slot_room = slot[2] if slot|length > 2 and slot[2] else '' %}
|
||||
{% set slot_teach = slot[3] if slot|length > 3 and slot[3] else '' %}
|
||||
<tr class="t-slot-row"
|
||||
data-day="{{ slot_day }}"
|
||||
data-time="{{ slot_time }}"
|
||||
data-type="{{ type_name }}"
|
||||
data-period="{{ dr }}"
|
||||
data-subject="{{ subj }}"
|
||||
data-schedule="{{ schedule.id }}">
|
||||
{# Режим просмотра #}
|
||||
<td class="slot-view-day">{{ slot_day | capitalize }}</td>
|
||||
<td class="slot-view-time mono fs-small">{{ slot_time or '—' }}</td>
|
||||
<td class="slot-view-room">{{ slot_room or '—' }}</td>
|
||||
<td class="slot-view-teach">{{ slot_teach or '—' }}</td>
|
||||
{% if can_edit %}
|
||||
<td>
|
||||
<div class="t-table-actions-cell edit-ctrl">
|
||||
<button type="button" class="t-btn-icon slot-inline-edit-btn" title="Редактировать строку">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="t-btn-icon danger slot-delete-btn" title="Удалить строку"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}"
|
||||
data-day="{{ slot_day }}" data-time="{{ slot_time }}"
|
||||
data-type="{{ type_name }}" data-daterange="{{ dr }}">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{# Строка редактирования — скрыта по умолчанию #}
|
||||
{% if can_edit %}
|
||||
<tr class="t-slot-edit-row hidden"
|
||||
data-day="{{ slot_day }}"
|
||||
data-time="{{ slot_time }}"
|
||||
data-type="{{ type_name }}"
|
||||
data-period="{{ dr }}"
|
||||
data-subject="{{ subj }}"
|
||||
data-schedule="{{ schedule.id }}">
|
||||
<td><input type="text" class="t-input t-input-sm edit-day" value="{{ slot_day }}" placeholder="пн" aria-label="День"></td>
|
||||
<td><input type="text" class="t-input t-input-sm edit-time" value="{{ slot_time }}" placeholder="09:00-10:30" aria-label="Время"></td>
|
||||
<td><input type="text" class="t-input t-input-sm edit-room" value="{{ slot_room }}" placeholder="Кабинет" aria-label="Кабинет"></td>
|
||||
<td><input type="text" class="t-input t-input-sm edit-teach" value="{{ slot_teach }}" placeholder="Преподаватель" aria-label="Преподаватель"></td>
|
||||
<td>
|
||||
<div class="t-slot-edit-save-row">
|
||||
<button type="button" class="t-btn-icon slot-save-btn" title="Сохранить">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
<button type="button" class="t-btn-icon slot-cancel-btn" title="Отмена">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{# ── Кнопка добавить новый тип/период (для можно расширить позже) ── #}
|
||||
|
||||
{# ── Трекеры ── #}
|
||||
<div class="t-subj-section">
|
||||
<div class="t-subj-section-head">
|
||||
<div class="t-subj-section-title">Трекеры</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="t-grid-edit-btn subj-grid-edit-btn"
|
||||
data-grid="metrics-subj-{{ pidx }}" title="Редактировать расположение">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
Расположение
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="t-metrics-grid" id="metrics-subj-{{ pidx }}"
|
||||
data-context="subject" data-schedule="{{ schedule.id }}">
|
||||
{% if cfg %}
|
||||
{% for metric in cfg.metrics %}
|
||||
{{ render_metric(metric, can_edit, show_rename=True) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="add-metric-section t-subj-add-metric"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ subj }}"
|
||||
data-slot="subj-{{ pidx }}">
|
||||
<div class="t-add-metric-btns">
|
||||
<button type="button" class="t-btn t-btn-outline t-btn-sm ai-metric-btn">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
AI
|
||||
</button>
|
||||
<button type="button" class="t-btn t-btn-outline t-btn-sm manual-metric-btn">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Добавить трекер
|
||||
</button>
|
||||
</div>
|
||||
<div class="ai-metric-form t-metric-form">
|
||||
<div class="t-input-group">
|
||||
<input type="text" class="t-input t-input-sm ai-prompt-input"
|
||||
placeholder="Напр: 12 лабораторных работ"
|
||||
aria-label="Описание для AI-генерации трекера">
|
||||
<button type="button" class="t-btn t-btn-primary t-ai-submit ai-prompt-submit" title="Сгенерировать трекер">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="manual-metric-form t-metric-form">
|
||||
<div class="t-two-col">
|
||||
<select class="t-select t-select-sm metric-type-select" aria-label="Тип трекера">
|
||||
<option value="">Тип трекера</option>
|
||||
<option value="checkpoints">Контрольные точки</option>
|
||||
<option value="progress_bar">Накопление баллов</option>
|
||||
<option value="counter">Счётчик</option>
|
||||
<option value="stages">Этапы</option>
|
||||
<option value="checklist">Чеклист</option>
|
||||
<option value="attendance">Посещаемость</option>
|
||||
<option value="grades">Оценки</option>
|
||||
<option value="deadlines">Дедлайны</option>
|
||||
<option value="streak">Серия</option>
|
||||
<option value="effort_hours">Трудозатраты</option>
|
||||
<option value="rating_history">История оценок</option>
|
||||
</select>
|
||||
<input type="text" class="t-input t-input-sm metric-label-input"
|
||||
placeholder="Название трекера"
|
||||
aria-label="Название трекера">
|
||||
</div>
|
||||
<div class="metric-config-area"></div>
|
||||
<button type="button" class="t-btn t-btn-primary t-btn-sm manual-metric-submit">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{# ══ Модальное окно промта AI-трекера ══ #}
|
||||
<div class="t-modal-backdrop" id="aiPromptModalBackdrop" role="dialog" aria-modal="true" aria-labelledby="ai-prompt-modal-title">
|
||||
<div class="t-modal t-modal-sm">
|
||||
<div class="t-modal-header">
|
||||
<span class="t-modal-title" id="ai-prompt-modal-title">Промт трекера</span>
|
||||
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('aiPromptModalBackdrop')" title="Закрыть">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="t-modal-body">
|
||||
<p class="t-label">Запрос, который использовался при создании трекера через AI:</p>
|
||||
<div class="t-ai-prompt-display" id="ai-prompt-text"></div>
|
||||
</div>
|
||||
<div class="t-modal-footer">
|
||||
<button type="button" class="t-btn t-btn-outline" onclick="closeModal('aiPromptModalBackdrop')">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ══ Модальное окно переименования трекера ══ #}
|
||||
<div class="t-modal-backdrop" id="renameMetricModalBackdrop" role="dialog" aria-modal="true" aria-labelledby="rename-metric-modal-title">
|
||||
<div class="t-modal t-modal-sm">
|
||||
<div class="t-modal-header">
|
||||
<span class="t-modal-title" id="rename-metric-modal-title">Переименовать трекер</span>
|
||||
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('renameMetricModalBackdrop')" title="Закрыть">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="t-modal-body">
|
||||
<label class="t-label" for="rename-metric-input">Новое название</label>
|
||||
<input type="text" class="t-input" id="rename-metric-input" aria-label="Новое название трекера">
|
||||
</div>
|
||||
<div class="t-modal-footer">
|
||||
<button type="button" class="t-btn t-btn-outline" onclick="closeModal('renameMetricModalBackdrop')">Отмена</button>
|
||||
<button type="button" class="t-btn t-btn-primary" id="rename-metric-confirm-btn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ══ Модальное окно переименования ══ #}
|
||||
<div class="t-modal-backdrop" id="renameModalBackdrop" role="dialog" aria-modal="true" aria-labelledby="rename-modal-title">
|
||||
<div class="t-modal t-modal-sm">
|
||||
<div class="t-modal-header">
|
||||
<span class="t-modal-title" id="rename-modal-title">Переименовать</span>
|
||||
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('renameModalBackdrop')" title="Закрыть">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="t-modal-body">
|
||||
<label class="t-label" for="rename-input">Новое название</label>
|
||||
<input type="text" class="t-input" id="rename-input" aria-label="Новое название">
|
||||
</div>
|
||||
<div class="t-modal-footer">
|
||||
<button type="button" class="t-btn t-btn-outline" onclick="closeModal('renameModalBackdrop')">Отмена</button>
|
||||
<button type="button" class="t-btn t-btn-primary" id="rename-confirm-btn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/subjects.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/metrics.js') }}"></script>
|
||||
<script>
|
||||
const SCHEDULE_ID = {{ schedule.id }};
|
||||
const CAN_EDIT = {{ 'true' if can_edit else 'false' }};
|
||||
|
||||
document.querySelectorAll('.t-modal-backdrop').forEach(bd => {
|
||||
bd.addEventListener('click', e => { if (e.target === bd) bd.classList.remove('open'); });
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
87
app/templates/schedule/upload.html
Normal file
87
app/templates/schedule/upload.html
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Новое расписание{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="t-ph">
|
||||
<h1 class="t-ph-title">Добавить расписание</h1>
|
||||
</div>
|
||||
|
||||
<div class="t-upload-card">
|
||||
<form method="POST" enctype="multipart/form-data" id="upload-form" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="sched-name">Название расписания</label>
|
||||
<input type="text" name="name" id="sched-name" class="t-input"
|
||||
placeholder="Например: 2 курс, осенний семестр" required
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="image-input">Изображение расписания</label>
|
||||
<label class="t-file-drop" for="image-input" id="file-drop-zone" aria-label="Загрузить изображение">
|
||||
<input type="file" name="image" id="image-input"
|
||||
accept=".jpg,.jpeg,.png,.webp,.bmp" required>
|
||||
<svg width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<div class="t-upload-drop-text">Выберите или перетащите файл</div>
|
||||
<div class="t-hint">JPG, PNG, WebP, BMP</div>
|
||||
</label>
|
||||
<div id="image-preview" class="t-image-preview">
|
||||
<img id="preview-img" class="t-preview-img" alt="Предпросмотр расписания">
|
||||
<button type="button" class="t-btn t-btn-ghost t-btn-sm t-change-file-btn" id="change-file-btn">
|
||||
Выбрать другой файл
|
||||
</button>
|
||||
<input type="file" name="image_replace" id="image-input-replace"
|
||||
accept=".jpg,.jpeg,.png,.webp,.bmp" hidden>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="extra-prompt">
|
||||
Дополнительные указания для нейросети
|
||||
<span class="text-muted fs-small">(необязательно)</span>
|
||||
</label>
|
||||
<textarea name="extra_prompt" id="extra-prompt" class="t-input t-textarea" rows="3"
|
||||
placeholder="Например: таблица читается слева направо, первый столбец — день недели"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="t-btn t-btn-primary t-btn-full" id="scan-btn">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
Сканировать расписание
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function showPreview(file) {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => {
|
||||
document.getElementById('preview-img').src = ev.target.result;
|
||||
document.getElementById('image-preview').classList.add('visible');
|
||||
document.getElementById('file-drop-zone').classList.add('hidden');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
document.getElementById('image-input').addEventListener('change', e => {
|
||||
showPreview(e.target.files[0]);
|
||||
});
|
||||
|
||||
document.getElementById('change-file-btn').addEventListener('click', () => {
|
||||
document.getElementById('file-drop-zone').classList.remove('hidden');
|
||||
document.getElementById('image-preview').classList.remove('visible');
|
||||
document.getElementById('image-input').value = '';
|
||||
document.getElementById('preview-img').src = '';
|
||||
});
|
||||
|
||||
document.getElementById('upload-form').addEventListener('submit', () => {
|
||||
const btn = document.getElementById('scan-btn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="t-spinner"></span> Сканирую...';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
453
app/templates/schedule/view.html
Normal file
453
app/templates/schedule/view.html
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ schedule.name }}{% endblock %}
|
||||
|
||||
{% block nav_extra %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% from "macros/metrics.html" import render_metric %}
|
||||
|
||||
{% set TYPE_SHORT = {
|
||||
'Лекция': 'Лек',
|
||||
'Практика': 'Пр',
|
||||
'Лаб. работа': 'Лаб',
|
||||
'Лабораторная': 'Лаб',
|
||||
'Семинар': 'Сем',
|
||||
'Не указано': ''
|
||||
} %}
|
||||
|
||||
{# ── Шапка ── #}
|
||||
<div class="t-ph">
|
||||
<div class="t-ph-name-row">
|
||||
<h1 class="t-ph-title schedule-name-display">{{ schedule.name }}</h1>
|
||||
{% if can_edit and not is_shared_view %}
|
||||
<button type="button" class="t-btn-icon edit-ctrl rename-schedule-btn" title="Переименовать расписание">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="t-ph-right">
|
||||
{% if can_edit and not is_shared_view %}
|
||||
<a class="t-btn t-btn-outline" href="{{ url_for('subjects.subjects_page', schedule_id=schedule.id) }}">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
Предметы
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_edit and not is_shared_view %}
|
||||
<button type="button" class="t-btn t-btn-outline" id="share-schedule-btn" data-schedule-id="{{ schedule.id }}">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
Поделиться
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ══════════════════════════════ РАСПИСАНИЕ ══════════════════════════════ #}
|
||||
<div class="t-grid" id="schedule-grid">
|
||||
{% for i in range(7) %}
|
||||
{% set day_slots = week_view[i] %}
|
||||
{% if day_slots %}
|
||||
<div class="t-day-card {{ 'today' if i == today_idx else '' }}">
|
||||
<div class="t-day-card-head {{ 'today' if i == today_idx else '' }}">
|
||||
<span class="t-day-name">{{ days_display[i] }}</span>
|
||||
{% if i == today_idx %}<span class="t-badge-today">сегодня</span>{% endif %}
|
||||
<span class="t-day-date">{{ week_dates[i].strftime('%d.%m') }}</span>
|
||||
</div>
|
||||
|
||||
{% for slot in day_slots %}
|
||||
{% set sid = 'se-' ~ i ~ '-' ~ loop.index0 %}
|
||||
|
||||
{% if slot.get('_group') %}
|
||||
{# Несколько предметов в одно время — показываем все сразу (фильтруем скрытые) #}
|
||||
{% set visible_slots = [] %}
|
||||
{% for s in slot.slots %}
|
||||
{% set _cfg = configs.get(s.subject) %}
|
||||
{% if not (_cfg and _cfg.hidden) %}{% set _ = visible_slots.append(s) %}{% endif %}
|
||||
{% endfor %}
|
||||
{% if visible_slots %}
|
||||
<div class="t-slot-group">
|
||||
<div class="t-slot-group-time">
|
||||
<span class="t-slot-time">{{ slot.time or '—' }}</span>
|
||||
{% if visible_slots | length > 1 %}<span class="t-slot-group-badge">{{ visible_slots | length }} предмета</span>{% endif %}
|
||||
</div>
|
||||
<div class="t-slot-group-items">
|
||||
{% for s in visible_slots %}
|
||||
<div class="t-slot-group-item">
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="t-slot-name">{{ s.subject }}</span>
|
||||
{% if s.classroom %}
|
||||
<div class="t-slot-room">
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ s.classroom }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% set t_short = TYPE_SHORT.get(s.type, s.type[:3] if s.type and s.type|length > 4 else s.type) %}
|
||||
{% if t_short %}
|
||||
<span class="t-type t-type-{{ s.color or 'gray' }}">{{ t_short }}</span>
|
||||
{% endif %}
|
||||
<div class="edit-ctrl t-slot-edit-controls">
|
||||
<button type="button" class="t-btn-icon danger slot-delete-btn" title="Удалить"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ s.subject }}"
|
||||
data-day="{{ s.day }}" data-time="{{ s.time }}"
|
||||
data-type="{{ s.type_name }}" data-daterange="{{ s.date_range }}">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}{# visible_slots #}
|
||||
|
||||
{% else %}
|
||||
{# Обычный слот — пропустить если предмет скрыт #}
|
||||
{% set _slot_cfg = configs.get(slot.subject) %}
|
||||
{% if not (_slot_cfg and _slot_cfg.hidden) %}
|
||||
{% set merged_type = '/' in (slot.type or '') %}
|
||||
<div class="t-slot" id="slot-{{ sid }}" data-slot-id="{{ sid }}">
|
||||
<div class="t-slot-time">{{ slot.time or '—' }}</div>
|
||||
<div class="t-slot-body">
|
||||
<div class="t-slot-name">{{ slot.subject }}</div>
|
||||
{% if slot.classroom %}
|
||||
<div class="t-slot-room">
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ slot.classroom }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="t-slot-right">
|
||||
{% set t_short = TYPE_SHORT.get(slot.type, slot.type[:3] if slot.type and slot.type|length > 4 else slot.type) %}
|
||||
{% if t_short and slot.type != 'Не указано' %}
|
||||
<span class="t-type t-type-{{ slot.color or 'gray' }}">{{ t_short }}</span>
|
||||
{% endif %}
|
||||
{% if not merged_type %}
|
||||
<div class="edit-ctrl t-slot-edit-controls">
|
||||
<button type="button" class="t-btn-icon slot-edit-time-btn" title="Изменить время"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ slot.subject }}"
|
||||
data-day="{{ slot.day }}" data-time="{{ slot.time }}"
|
||||
data-type="{{ slot.type_name }}" data-daterange="{{ slot.date_range }}">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</button>
|
||||
<button type="button" class="t-btn-icon danger slot-delete-btn" title="Удалить слот"
|
||||
data-schedule="{{ schedule.id }}" data-subject="{{ slot.subject }}"
|
||||
data-day="{{ slot.day }}" data-time="{{ slot.time }}"
|
||||
data-type="{{ slot.type_name }}" data-daterange="{{ slot.date_range }}">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="t-chevron">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-slot-detail" id="detail-{{ sid }}">
|
||||
{% if slot.teacher %}
|
||||
<div class="t-slot-teacher">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
{{ slot.teacher }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if slot.date_range and slot.date_range != 'Весь период' %}
|
||||
<div class="t-slot-teacher">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{{ slot.date_range }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% set cfg = configs.get(slot.subject) %}
|
||||
{% if cfg and cfg.completion_type %}
|
||||
<div class="t-slot-teacher">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>
|
||||
{{ cfg.completion_type }}
|
||||
{% if cfg.completed %}<span class="t-slot-completed-badge">Завершён</span>{% endif %}
|
||||
</div>
|
||||
{% elif cfg and cfg.completed %}
|
||||
<div class="t-slot-teacher">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span class="t-slot-completed-badge">Завершён</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if cfg and cfg.sdo_url %}
|
||||
<div class="t-slot-teacher">
|
||||
<a href="{{ cfg.sdo_url }}" target="_blank" rel="noopener noreferrer" class="t-slot-sdo-btn">
|
||||
<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Открыть в СДО
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if cfg and cfg.description %}
|
||||
<div class="t-slot-desc">{{ cfg.description }}</div>
|
||||
{% endif %}
|
||||
{% set metrics = cfg.metrics | list if cfg else [] %}
|
||||
{% if metrics %}
|
||||
<div class="t-slot-metrics">
|
||||
{% for m in metrics %}
|
||||
<a href="#metric-{{ m.id }}" class="t-slot-metric-link" data-metric-id="{{ m.id }}">{{ m.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted fs-small t-desc-italic">Трекеры не добавлены</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}{# not hidden #}
|
||||
{% endif %}{# _group / regular slot #}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# ══════════════════════════════ ДЭШБОРД ══════════════════════════════ #}
|
||||
{% if all_metrics %}
|
||||
{# Вычисляем сколько трекеров заполнены #}
|
||||
{% set completed_count = namespace(n=0) %}
|
||||
{% set filled_ids = [] %}
|
||||
{% set total_count = all_metrics | length %}
|
||||
{% for entry in all_metrics %}
|
||||
{% set m = entry.metric %}
|
||||
{% set prog = m.progress or {} %}
|
||||
{% set cfg_m = m.config or {} %}
|
||||
{% set is_full = false %}
|
||||
{% if m.type == 'checkpoints' %}
|
||||
{% set is_full = prog.get('checked', []) | length >= cfg_m.get('total', 1) | int %}
|
||||
{% elif m.type == 'progress_bar' %}
|
||||
{% set is_full = prog.get('current', 0) | float >= cfg_m.get('max', 1) | float %}
|
||||
{% elif m.type == 'counter' %}
|
||||
{% set is_full = cfg_m.get('max') and prog.get('current', 0) | int >= cfg_m.get('max') | int %}
|
||||
{% elif m.type == 'checklist' %}
|
||||
{% set is_full = cfg_m.get('items', []) | length > 0 and prog.get('checked', []) | length >= cfg_m.get('items', []) | length %}
|
||||
{% elif m.type == 'deadlines' %}
|
||||
{% set is_full = cfg_m.get('items', []) | length > 0 and prog.get('completed', []) | length >= cfg_m.get('items', []) | length %}
|
||||
{% elif m.type == 'stages' %}
|
||||
{% set is_full = cfg_m.get('stages', []) | length > 0 and prog.get('current_stage', 0) | int >= cfg_m.get('stages', []) | length %}
|
||||
{% elif m.type == 'effort_hours' %}
|
||||
{% set is_full = cfg_m.get('goal_hours') and prog.get('hours', 0) | float >= cfg_m.get('goal_hours') | float %}
|
||||
{% endif %}
|
||||
{% if is_full %}{% set completed_count.n = completed_count.n + 1 %}{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="t-dash-title-row" id="dashboard-section">
|
||||
<span class="t-section-title t-section-title-inline">Трекеры</span>
|
||||
<div class="t-dash-title-actions">
|
||||
{% if completed_count.n > 0 %}
|
||||
<button type="button" class="t-btn t-btn-ghost t-btn-xs" id="toggle-filled-btn"
|
||||
data-showing="false" title="Показать/скрыть заполненные трекеры">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
Показать заполненные ({{ completed_count.n }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if can_edit and not is_shared_view %}
|
||||
<button type="button" class="t-grid-edit-btn" id="dash-edit-btn"
|
||||
data-grid="dashboard-grid" title="Редактировать расположение">
|
||||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
Расположение
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-metrics-grid" id="dashboard-grid"
|
||||
data-context="dashboard" data-schedule="{{ schedule.id }}">
|
||||
{% for entry in all_metrics %}
|
||||
{% set m = entry.metric %}
|
||||
{% set prog = m.progress or {} %}
|
||||
{% set cfg_m = m.config or {} %}
|
||||
{% set is_full = false %}
|
||||
{% if m.type == 'checkpoints' %}
|
||||
{% set is_full = prog.get('checked', []) | length >= cfg_m.get('total', 1) | int and cfg_m.get('total', 0) | int > 0 %}
|
||||
{% elif m.type == 'progress_bar' %}
|
||||
{% set is_full = prog.get('current', 0) | float >= cfg_m.get('max', 1) | float and cfg_m.get('max', 0) | float > 0 %}
|
||||
{% elif m.type == 'counter' %}
|
||||
{% set is_full = cfg_m.get('max') and prog.get('current', 0) | int >= cfg_m.get('max') | int %}
|
||||
{% elif m.type == 'checklist' %}
|
||||
{% set is_full = cfg_m.get('items', []) | length > 0 and prog.get('checked', []) | length >= cfg_m.get('items', []) | length %}
|
||||
{% elif m.type == 'deadlines' %}
|
||||
{% set is_full = cfg_m.get('items', []) | length > 0 and prog.get('completed', []) | length >= cfg_m.get('items', []) | length %}
|
||||
{% elif m.type == 'stages' %}
|
||||
{% set is_full = cfg_m.get('stages', []) | length > 0 and prog.get('current_stage', 0) | int >= cfg_m.get('stages', []) | length %}
|
||||
{% elif m.type == 'effort_hours' %}
|
||||
{% set is_full = cfg_m.get('goal_hours') and prog.get('hours', 0) | float >= cfg_m.get('goal_hours') | float %}
|
||||
{% endif %}
|
||||
{% if is_full %}{% set _ = filled_ids.append(m.id) %}{% endif %}
|
||||
{{ render_metric(m, can_edit) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if filled_ids %}
|
||||
<script>
|
||||
[{{ filled_ids | join(', ') }}].forEach(id => {
|
||||
const el = document.getElementById('metric-' + id);
|
||||
if (el) el.classList.add('metric-filled-hidden');
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
{# ══ Модальное окно шаринга ══ #}
|
||||
{% if can_edit and not is_shared_view %}
|
||||
<div class="t-modal-backdrop" id="shareModalBackdrop" role="dialog" aria-modal="true" aria-label="Поделиться расписанием">
|
||||
<div class="t-modal">
|
||||
<div class="t-modal-header">
|
||||
<span class="t-modal-title">Поделиться расписанием</span>
|
||||
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('shareModalBackdrop')" title="Закрыть">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="t-modal-body">
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="link-view">Ссылка для просмотра</label>
|
||||
<div class="t-input-group">
|
||||
<input type="text" class="t-input" id="link-view" readonly aria-label="Ссылка для просмотра">
|
||||
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-view" title="Скопировать ссылку">
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="link-template">Ссылка-шаблон</label>
|
||||
<div class="t-input-group">
|
||||
<input type="text" class="t-input" id="link-template" readonly aria-label="Ссылка-шаблон">
|
||||
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-template" title="Скопировать ссылку">
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="t-divider">
|
||||
<div class="t-field">
|
||||
<label class="t-label" for="editor-login">Добавить редактора</label>
|
||||
<div class="t-input-group">
|
||||
<input type="text" class="t-input" id="editor-login" placeholder="Логин пользователя" aria-label="Логин редактора">
|
||||
<button type="button" class="t-btn t-btn-primary" id="add-editor-btn" title="Добавить редактора" class="t-btn-group-end">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="editor-link-wrap" hidden class="t-field">
|
||||
<label class="t-label" for="link-edit">Ссылка для редактора</label>
|
||||
<div class="t-input-group">
|
||||
<input type="text" class="t-input" id="link-edit" readonly aria-label="Ссылка для редактора">
|
||||
<button type="button" class="t-btn t-btn-outline t-share-copy-btn copy-btn" data-target="link-edit" title="Скопировать ссылку">
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ══ Модальное окно переименования ══ #}
|
||||
<div class="t-modal-backdrop" id="renameModalBackdrop" role="dialog" aria-modal="true" aria-labelledby="rename-modal-title">
|
||||
<div class="t-modal t-modal-sm">
|
||||
<div class="t-modal-header">
|
||||
<span class="t-modal-title" id="rename-modal-title">Переименовать</span>
|
||||
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('renameModalBackdrop')" title="Закрыть">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="t-modal-body">
|
||||
<label class="t-label" for="rename-input">Новое название</label>
|
||||
<input type="text" class="t-input" id="rename-input" aria-label="Новое название">
|
||||
</div>
|
||||
<div class="t-modal-footer">
|
||||
<button type="button" class="t-btn t-btn-outline" onclick="closeModal('renameModalBackdrop')">Отмена</button>
|
||||
<button type="button" class="t-btn t-btn-primary" id="rename-confirm-btn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ══ Модальное окно изменения времени ══ #}
|
||||
<div class="t-modal-backdrop" id="slotTimeModalBackdrop" role="dialog" aria-modal="true" aria-label="Изменить время слота">
|
||||
<div class="t-modal t-modal-sm">
|
||||
<div class="t-modal-header">
|
||||
<span class="t-modal-title">Изменить время слота</span>
|
||||
<button type="button" class="t-btn-icon t-modal-close-btn" onclick="closeModal('slotTimeModalBackdrop')" title="Закрыть">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="t-modal-body">
|
||||
<label class="t-label" for="slot-time-input">Время (формат 09:00-10:30)</label>
|
||||
<input type="text" class="t-input" id="slot-time-input" placeholder="09:00-10:30" aria-label="Время слота">
|
||||
</div>
|
||||
<div class="t-modal-footer">
|
||||
<button type="button" class="t-btn t-btn-outline" onclick="closeModal('slotTimeModalBackdrop')">Отмена</button>
|
||||
<button type="button" class="t-btn t-btn-primary" id="slot-time-save-btn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/schedule.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/subjects.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/metrics.js') }}"></script>
|
||||
<script>
|
||||
const SCHEDULE_ID = {{ schedule.id }};
|
||||
const CAN_EDIT = {{ 'true' if can_edit else 'false' }};
|
||||
|
||||
/* ── Заполненные трекеры: показать/скрыть ── */
|
||||
(function() {
|
||||
const btn = document.getElementById('toggle-filled-btn');
|
||||
if (!btn) return;
|
||||
const filled = document.querySelectorAll('.t-metric.metric-filled-hidden');
|
||||
const count = filled.length;
|
||||
let showing = false;
|
||||
btn.addEventListener('click', () => {
|
||||
showing = !showing;
|
||||
filled.forEach(el => el.classList.toggle('metric-filled-hidden', !showing));
|
||||
btn.innerHTML = showing
|
||||
? `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Скрыть заполненные (${count})`
|
||||
: `<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg> Показать заполненные (${count})`;
|
||||
});
|
||||
})();
|
||||
|
||||
/* ── Слоты: разворот ── */
|
||||
document.querySelectorAll('.t-slot').forEach(slot => {
|
||||
slot.addEventListener('click', e => {
|
||||
if (e.target.closest('button')) return;
|
||||
const sid = slot.dataset.slotId;
|
||||
const detail = document.getElementById('detail-' + sid);
|
||||
if (!detail) return;
|
||||
const isOpen = slot.classList.contains('open');
|
||||
slot.classList.toggle('open', !isOpen);
|
||||
detail.style.display = isOpen ? 'none' : 'block';
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Модальные окна: закрытие по клику на backdrop ── */
|
||||
document.querySelectorAll('.t-modal-backdrop').forEach(bd => {
|
||||
bd.addEventListener('click', e => { if (e.target === bd) bd.classList.remove('open'); });
|
||||
});
|
||||
|
||||
/* ── Кнопки копирования ── */
|
||||
document.querySelectorAll('.copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(document.getElementById(btn.dataset.target).value);
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
setTimeout(() => btn.innerHTML = orig, 1500);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if can_edit and not is_shared_view %}
|
||||
<script>
|
||||
document.getElementById('share-schedule-btn')?.addEventListener('click', async () => {
|
||||
for (const type of ['view', 'template']) {
|
||||
const res = await apiFetch(`/schedules/${SCHEDULE_ID}/share`, 'POST', { type });
|
||||
if (res.ok)
|
||||
document.getElementById(`link-${type}`).value = `${location.origin}/shared/${res.token}`;
|
||||
}
|
||||
openModal('shareModalBackdrop');
|
||||
});
|
||||
|
||||
document.getElementById('add-editor-btn')?.addEventListener('click', async () => {
|
||||
const login = document.getElementById('editor-login').value.trim();
|
||||
if (!login) return;
|
||||
const res = await apiFetch(`/schedules/${SCHEDULE_ID}/share/editors`, 'POST', { login });
|
||||
if (res.ok) {
|
||||
document.getElementById('link-edit').value = `${location.origin}/shared/${res.token}`;
|
||||
document.getElementById('editor-link-wrap').hidden = false;
|
||||
} else alert(res.error || 'Ошибка');
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
1
migrations/README
Normal file
1
migrations/README
Normal file
|
|
@ -0,0 +1 @@
|
|||
Single-database configuration for Flask.
|
||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
102
migrations/versions/a9875eef468a_init.py
Normal file
102
migrations/versions/a9875eef468a_init.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""init
|
||||
|
||||
Revision ID: a9875eef468a
|
||||
Revises:
|
||||
Create Date: 2026-04-01 09:39:29.714693
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a9875eef468a'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('login', sa.String(length=64), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=256), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_users_login'), ['login'], unique=True)
|
||||
|
||||
op.create_table('schedules',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('shares',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('schedule_id', sa.Integer(), nullable=False),
|
||||
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||
sa.Column('share_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('token', sa.String(length=36), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['schedule_id'], ['schedules.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('token')
|
||||
)
|
||||
op.create_table('subject_configs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('schedule_id', sa.Integer(), nullable=False),
|
||||
sa.Column('subject_name', sa.String(length=256), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['schedule_id'], ['schedules.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('schedule_id', 'subject_name', name='uq_schedule_subject')
|
||||
)
|
||||
op.create_table('metrics',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('subject_config_id', sa.Integer(), nullable=False),
|
||||
sa.Column('type', sa.String(length=32), nullable=False),
|
||||
sa.Column('label', sa.String(length=256), nullable=False),
|
||||
sa.Column('config', sa.JSON(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['subject_config_id'], ['subject_configs.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('share_editors',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('share_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['share_id'], ['shares.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('share_id', 'user_id', name='uq_share_editor')
|
||||
)
|
||||
op.create_table('metric_progress',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('metric_id', sa.Integer(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['metric_id'], ['metrics.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('metric_progress')
|
||||
op.drop_table('share_editors')
|
||||
op.drop_table('metrics')
|
||||
op.drop_table('subject_configs')
|
||||
op.drop_table('shares')
|
||||
op.drop_table('schedules')
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_users_login'))
|
||||
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"""merge progress into metric
|
||||
|
||||
Revision ID: aa6f28d23573
|
||||
Revises: a9875eef468a
|
||||
Create Date: 2026-05-03 11:13:14.874531
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'aa6f28d23573'
|
||||
down_revision = 'a9875eef468a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.drop_table('metric_progress')
|
||||
with op.batch_alter_table('metrics', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('progress', sa.JSON(), nullable=False, server_default='{}'))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('metrics', schema=None) as batch_op:
|
||||
batch_op.drop_column('progress')
|
||||
|
||||
op.create_table('metric_progress',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('metric_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('data', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['metric_id'], ['metrics.id'], name='metric_progress_metric_id_fkey'),
|
||||
sa.PrimaryKeyConstraint('id', name='metric_progress_pkey')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"""add grid layout to metrics
|
||||
|
||||
Revision ID: b3c9d1e2f4a5
|
||||
Revises: aa6f28d23573
|
||||
Create Date: 2026-05-03
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'b3c9d1e2f4a5'
|
||||
down_revision = 'aa6f28d23573'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table('metrics') as batch_op:
|
||||
batch_op.add_column(sa.Column('grid_col', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch_op.add_column(sa.Column('grid_row', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch_op.add_column(sa.Column('grid_w', sa.Integer(), nullable=False, server_default='1'))
|
||||
batch_op.add_column(sa.Column('grid_h', sa.Integer(), nullable=False, server_default='1'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('metrics') as batch_op:
|
||||
batch_op.drop_column('grid_h')
|
||||
batch_op.drop_column('grid_w')
|
||||
batch_op.drop_column('grid_row')
|
||||
batch_op.drop_column('grid_col')
|
||||
22
migrations/versions/c4d2e3f5a6b7_grid_h_half_rows.py
Normal file
22
migrations/versions/c4d2e3f5a6b7_grid_h_half_rows.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""grid_h in half-row units (1=0.5row, 2=1row, 4=2rows)
|
||||
|
||||
Revision ID: c4d2e3f5a6b7
|
||||
Revises: b3c9d1e2f4a5
|
||||
Create Date: 2026-05-03
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = 'c4d2e3f5a6b7'
|
||||
down_revision = 'b3c9d1e2f4a5'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Старые записи: grid_h=1 означало «1 строка» → теперь «2 полустроки»
|
||||
op.execute("UPDATE metrics SET grid_h = grid_h * 2")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("UPDATE metrics SET grid_h = GREATEST(grid_h / 2, 1)")
|
||||
26
migrations/versions/d5e3f4a6b7c8_subject_hidden.py
Normal file
26
migrations/versions/d5e3f4a6b7c8_subject_hidden.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Add hidden flag to subject_configs
|
||||
|
||||
Revision ID: d5e3f4a6b7c8
|
||||
Revises: c4d2e3f5a6b7
|
||||
Create Date: 2026-05-04
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'd5e3f4a6b7c8'
|
||||
down_revision = 'c4d2e3f5a6b7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table('subject_configs') as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('hidden', sa.Boolean(), nullable=False, server_default='0')
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('subject_configs') as batch_op:
|
||||
batch_op.drop_column('hidden')
|
||||
40
migrations/versions/e6f4a5b8c9d0_subject_meta_fields.py
Normal file
40
migrations/versions/e6f4a5b8c9d0_subject_meta_fields.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Add completion_type, completed, sdo_url to subject_configs; ai_prompt to metrics
|
||||
|
||||
Revision ID: e6f4a5b8c9d0
|
||||
Revises: d5e3f4a6b7c8
|
||||
Create Date: 2026-05-11
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'e6f4a5b8c9d0'
|
||||
down_revision = 'd5e3f4a6b7c8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table('subject_configs') as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('completion_type', sa.String(32), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column('completed', sa.Boolean(), nullable=False, server_default='0')
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column('sdo_url', sa.Text(), nullable=True)
|
||||
)
|
||||
with op.batch_alter_table('metrics') as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('ai_prompt', sa.Text(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('subject_configs') as batch_op:
|
||||
batch_op.drop_column('sdo_url')
|
||||
batch_op.drop_column('completed')
|
||||
batch_op.drop_column('completion_type')
|
||||
with op.batch_alter_table('metrics') as batch_op:
|
||||
batch_op.drop_column('ai_prompt')
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
Flask==3.1.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Migrate==4.0.7
|
||||
Flask-Login==0.6.3
|
||||
Flask-WTF==1.2.2
|
||||
psycopg2-binary==2.9.9
|
||||
openai==1.58.1
|
||||
python-dotenv==1.0.1
|
||||
Werkzeug==3.1.3
|
||||
Pillow==11.0.0
|
||||
9
run.py
Normal file
9
run.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import sys
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
Loading…
Add table
Reference in a new issue