From 3946aeb81e9b29cf66b0d51e5a8640f2333e0d96 Mon Sep 17 00:00:00 2001 From: DeevEV Date: Tue, 19 May 2026 13:49:09 +0300 Subject: [PATCH] Full Project --- .env.example | 17 + README.md | 84 +++ app/__init__.py | 31 + app/config.py | 14 + app/models/__init__.py | 4 + app/models/metric.py | 37 + app/models/schedule.py | 40 ++ app/models/share.py | 30 + app/models/user.py | 28 + app/routes/__init__.py | 0 app/routes/auth.py | 55 ++ app/routes/schedules.py | 471 +++++++++++++ app/routes/shares.py | 111 +++ app/routes/subjects.py | 301 +++++++++ app/services/__init__.py | 0 app/services/ai_metrics.py | 80 +++ app/services/ai_scan.py | 134 ++++ app/services/merge.py | 23 + app/services/schedule_helpers.py | 119 ++++ app/static/css/base.css | 128 ++++ app/static/css/components.css | 258 +++++++ app/static/css/forms.css | 47 ++ app/static/css/metrics.css | 1 + app/static/css/metrics/_attendance.css | 114 ++++ app/static/css/metrics/_base.css | 227 +++++++ app/static/css/metrics/_checklist.css | 115 ++++ app/static/css/metrics/_checkpoints.css | 104 +++ app/static/css/metrics/_counter.css | 118 ++++ app/static/css/metrics/_deadlines.css | 128 ++++ app/static/css/metrics/_effort_hours.css | 107 +++ app/static/css/metrics/_grades.css | 88 +++ app/static/css/metrics/_progress_bar.css | 110 +++ app/static/css/metrics/_rating_history.css | 107 +++ app/static/css/metrics/_stages.css | 130 ++++ app/static/css/metrics/_streak.css | 72 ++ app/static/css/nav.css | 63 ++ app/static/css/schedule.css | 279 ++++++++ app/static/css/subjects.css | 235 +++++++ app/static/favicon.ico | Bin 0 -> 15406 bytes app/static/favicon.svg | 4 + app/static/js/core.js | 86 +++ app/static/js/metrics.js | 631 ++++++++++++++++++ app/static/js/schedule.js | 20 + app/static/js/subjects.js | 439 ++++++++++++ app/static/js/upload.js | 33 + app/templates/auth/login.html | 33 + app/templates/auth/register.html | 33 + app/templates/base.html | 103 +++ app/templates/macros/metrics.html | 386 +++++++++++ app/templates/profile/index.html | 185 +++++ app/templates/schedule/subjects.html | 429 ++++++++++++ app/templates/schedule/upload.html | 87 +++ app/templates/schedule/view.html | 453 +++++++++++++ migrations/README | 1 + migrations/alembic.ini | 50 ++ migrations/env.py | 113 ++++ migrations/script.py.mako | 24 + migrations/versions/a9875eef468a_init.py | 102 +++ ...aa6f28d23573_merge_progress_into_metric.py | 40 ++ ...b3c9d1e2f4a5_add_grid_layout_to_metrics.py | 30 + .../versions/c4d2e3f5a6b7_grid_h_half_rows.py | 22 + .../versions/d5e3f4a6b7c8_subject_hidden.py | 26 + .../e6f4a5b8c9d0_subject_meta_fields.py | 40 ++ requirements.txt | 10 + run.py | 9 + 65 files changed, 7399 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/models/__init__.py create mode 100644 app/models/metric.py create mode 100644 app/models/schedule.py create mode 100644 app/models/share.py create mode 100644 app/models/user.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/schedules.py create mode 100644 app/routes/shares.py create mode 100644 app/routes/subjects.py create mode 100644 app/services/__init__.py create mode 100644 app/services/ai_metrics.py create mode 100644 app/services/ai_scan.py create mode 100644 app/services/merge.py create mode 100644 app/services/schedule_helpers.py create mode 100644 app/static/css/base.css create mode 100644 app/static/css/components.css create mode 100644 app/static/css/forms.css create mode 100644 app/static/css/metrics.css create mode 100644 app/static/css/metrics/_attendance.css create mode 100644 app/static/css/metrics/_base.css create mode 100644 app/static/css/metrics/_checklist.css create mode 100644 app/static/css/metrics/_checkpoints.css create mode 100644 app/static/css/metrics/_counter.css create mode 100644 app/static/css/metrics/_deadlines.css create mode 100644 app/static/css/metrics/_effort_hours.css create mode 100644 app/static/css/metrics/_grades.css create mode 100644 app/static/css/metrics/_progress_bar.css create mode 100644 app/static/css/metrics/_rating_history.css create mode 100644 app/static/css/metrics/_stages.css create mode 100644 app/static/css/metrics/_streak.css create mode 100644 app/static/css/nav.css create mode 100644 app/static/css/schedule.css create mode 100644 app/static/css/subjects.css create mode 100644 app/static/favicon.ico create mode 100644 app/static/favicon.svg create mode 100644 app/static/js/core.js create mode 100644 app/static/js/metrics.js create mode 100644 app/static/js/schedule.js create mode 100644 app/static/js/subjects.js create mode 100644 app/static/js/upload.js create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/register.html create mode 100644 app/templates/base.html create mode 100644 app/templates/macros/metrics.html create mode 100644 app/templates/profile/index.html create mode 100644 app/templates/schedule/subjects.html create mode 100644 app/templates/schedule/upload.html create mode 100644 app/templates/schedule/view.html create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/a9875eef468a_init.py create mode 100644 migrations/versions/aa6f28d23573_merge_progress_into_metric.py create mode 100644 migrations/versions/b3c9d1e2f4a5_add_grid_layout_to_metrics.py create mode 100644 migrations/versions/c4d2e3f5a6b7_grid_h_half_rows.py create mode 100644 migrations/versions/d5e3f4a6b7c8_subject_hidden.py create mode 100644 migrations/versions/e6f4a5b8c9d0_subject_meta_fields.py create mode 100644 requirements.txt create mode 100644 run.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b6be73b --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4fda4ab --- /dev/null +++ b/README.md @@ -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) + +--- + +
+ ⭐ Если проект оказался полезным, поставьте звездочку на GitHub! +

Создано с ❤️ от вашего дорогого - deev.space ©

+
diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..94c126a --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..cb05107 --- /dev/null +++ b/app/config.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..550828b --- /dev/null +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/metric.py b/app/models/metric.py new file mode 100644 index 0000000..d38be2a --- /dev/null +++ b/app/models/metric.py @@ -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) diff --git a/app/models/schedule.py b/app/models/schedule.py new file mode 100644 index 0000000..5339d4a --- /dev/null +++ b/app/models/schedule.py @@ -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'), + ) diff --git a/app/models/share.py b/app/models/share.py new file mode 100644 index 0000000..0aadf7c --- /dev/null +++ b/app/models/share.py @@ -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'), + ) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..7418fff --- /dev/null +++ b/app/models/user.py @@ -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)) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..a7aa52e --- /dev/null +++ b/app/routes/auth.py @@ -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')) diff --git a/app/routes/schedules.py b/app/routes/schedules.py new file mode 100644 index 0000000..1c87a5a --- /dev/null +++ b/app/routes/schedules.py @@ -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/') +@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//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//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//subjects//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//subjects//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//subjects//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//subjects//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//subjects//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//subjects/', + 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//subjects//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 diff --git a/app/routes/shares.py b/app/routes/shares.py new file mode 100644 index 0000000..b6aee92 --- /dev/null +++ b/app/routes/shares.py @@ -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//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//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/') +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) diff --git a/app/routes/subjects.py b/app/routes/subjects.py new file mode 100644 index 0000000..c092608 --- /dev/null +++ b/app/routes/subjects.py @@ -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//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//subjects//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//subjects//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//subjects//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//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//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//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//subjects//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//subjects//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//subjects//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//subjects//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//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/', 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 + ) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/ai_metrics.py b/app/services/ai_metrics.py new file mode 100644 index 0000000..0d0e2c6 --- /dev/null +++ b/app/services/ai_metrics.py @@ -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) diff --git a/app/services/ai_scan.py b/app/services/ai_scan.py new file mode 100644 index 0000000..409f80d --- /dev/null +++ b/app/services/ai_scan.py @@ -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)) diff --git a/app/services/merge.py b/app/services/merge.py new file mode 100644 index 0000000..1d108c1 --- /dev/null +++ b/app/services/merge.py @@ -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()) diff --git a/app/services/schedule_helpers.py b/app/services/schedule_helpers.py new file mode 100644 index 0000000..ed5c804 --- /dev/null +++ b/app/services/schedule_helpers.py @@ -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')] diff --git a/app/static/css/base.css b/app/static/css/base.css new file mode 100644 index 0000000..0af0a3b --- /dev/null +++ b/app/static/css/base.css @@ -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; +} diff --git a/app/static/css/components.css b/app/static/css/components.css new file mode 100644 index 0000000..f6a96d9 --- /dev/null +++ b/app/static/css/components.css @@ -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; } diff --git a/app/static/css/forms.css b/app/static/css/forms.css new file mode 100644 index 0000000..e001d8e --- /dev/null +++ b/app/static/css/forms.css @@ -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; } diff --git a/app/static/css/metrics.css b/app/static/css/metrics.css new file mode 100644 index 0000000..febf1e0 --- /dev/null +++ b/app/static/css/metrics.css @@ -0,0 +1 @@ +/* Этот файл заменён на app/static/css/metrics/_*.css */ diff --git a/app/static/css/metrics/_attendance.css b/app/static/css/metrics/_attendance.css new file mode 100644 index 0000000..4927c6a --- /dev/null +++ b/app/static/css/metrics/_attendance.css @@ -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; } +} diff --git a/app/static/css/metrics/_base.css b/app/static/css/metrics/_base.css new file mode 100644 index 0000000..25262d2 --- /dev/null +++ b/app/static/css/metrics/_base.css @@ -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); +} diff --git a/app/static/css/metrics/_checklist.css b/app/static/css/metrics/_checklist.css new file mode 100644 index 0000000..ffa13cd --- /dev/null +++ b/app/static/css/metrics/_checklist.css @@ -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; } +} diff --git a/app/static/css/metrics/_checkpoints.css b/app/static/css/metrics/_checkpoints.css new file mode 100644 index 0000000..ad9a9a9 --- /dev/null +++ b/app/static/css/metrics/_checkpoints.css @@ -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; } +} diff --git a/app/static/css/metrics/_counter.css b/app/static/css/metrics/_counter.css new file mode 100644 index 0000000..c355dc3 --- /dev/null +++ b/app/static/css/metrics/_counter.css @@ -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; } +} diff --git a/app/static/css/metrics/_deadlines.css b/app/static/css/metrics/_deadlines.css new file mode 100644 index 0000000..3d30082 --- /dev/null +++ b/app/static/css/metrics/_deadlines.css @@ -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; } +} diff --git a/app/static/css/metrics/_effort_hours.css b/app/static/css/metrics/_effort_hours.css new file mode 100644 index 0000000..3bff3df --- /dev/null +++ b/app/static/css/metrics/_effort_hours.css @@ -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; } +} diff --git a/app/static/css/metrics/_grades.css b/app/static/css/metrics/_grades.css new file mode 100644 index 0000000..0508af9 --- /dev/null +++ b/app/static/css/metrics/_grades.css @@ -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; } +} diff --git a/app/static/css/metrics/_progress_bar.css b/app/static/css/metrics/_progress_bar.css new file mode 100644 index 0000000..4317b48 --- /dev/null +++ b/app/static/css/metrics/_progress_bar.css @@ -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; } +} diff --git a/app/static/css/metrics/_rating_history.css b/app/static/css/metrics/_rating_history.css new file mode 100644 index 0000000..3587c6b --- /dev/null +++ b/app/static/css/metrics/_rating_history.css @@ -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; } +} diff --git a/app/static/css/metrics/_stages.css b/app/static/css/metrics/_stages.css new file mode 100644 index 0000000..ba49264 --- /dev/null +++ b/app/static/css/metrics/_stages.css @@ -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; } +} diff --git a/app/static/css/metrics/_streak.css b/app/static/css/metrics/_streak.css new file mode 100644 index 0000000..a5313cc --- /dev/null +++ b/app/static/css/metrics/_streak.css @@ -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; } +} diff --git a/app/static/css/nav.css b/app/static/css/nav.css new file mode 100644 index 0000000..30308e4 --- /dev/null +++ b/app/static/css/nav.css @@ -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; } diff --git a/app/static/css/schedule.css b/app/static/css/schedule.css new file mode 100644 index 0000000..0a52ec8 --- /dev/null +++ b/app/static/css/schedule.css @@ -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; } diff --git a/app/static/css/subjects.css b/app/static/css/subjects.css new file mode 100644 index 0000000..1771f37 --- /dev/null +++ b/app/static/css/subjects.css @@ -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; } diff --git a/app/static/favicon.ico b/app/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a26c9a3a8d7ad1f601499f30fdece4e0c5405d20 GIT binary patch literal 15406 zcmeHNX>3$g6n;einhpwXQD{VkKSZKJG-!~-e~O9=#>7O8Q86Y)i-`9Ei>AneAndGoyA9~Xr3>4Go^X^;guJmr`vrWN@~ zvr^5b($v3AW0-@X$3t5x@*3rn)9LC>w@iceCuhSd%XB$51}fFQ>PFhBB7J*bHB?{A5&tXMn)BQ2sb2nxHlc6l+V&daXZT?pj$ByI=zl)*BD2Gv z^`9ByCv5qbzGZeBcK4z!MxnNSKcrjkG0aagGOYK*meTpKu5gwnAGZasKmQ+S-q>Y@ zlI9-_=?YY~BE3Q%RHXCkXN<->jttYwWlW60KyY~V()d-J6apxuc_ta37x1C^NiU^Z zOiOXDDNZw+hC-;fX8SVPsDpG#@uZlO)C4|4I4d;TMt{H&tKfN)W|HP9sc zu2*O2cP0G}X9tr_`-n7^b+`UTE~BBJO(jnTjlo|(?YjW?50qMZPpis3b!9E1p`YKX zma5W8>#Fqbub=8WZb1LZrZuAeebsM;O+R7mA8u!|2V6JV9#9Flw2$m4UldmTwBO?C zpu)a$O~DKpa`!M2=ZHfb8%j9!%KYt5^xJW+qw!8@(A9-|;8(=@QQvRuvaxUU!}r{J zdepuv=_lI!hHu1vKzba`>(D)57jaQp=k1HIrr==_o0<--ay9+Lv#Lg4I z8)77^erZaY#k2_f`-xO@k~3!GPOmPAjW7~c#6N}Cf&L@r#`wi8n@1bHbzptQV|N)% zZ026wHt^si%)GzHcb`$SYm!qkR^!^%YW-TAXAHKPnw%Jx$HyEQto;J<;RmY&`6z}% zKE4ArZ^L2qAO3#sUFO%!u-+H-eoNzs&xMxs6R@NdT0Bl*U7BA;UKPzzZ8 zS%ERhM@0K(qfeiFsf1gaPGAf)&3v~=7w$S};irDn&~a1>hhfjDcp3e{a!lk8I*qY! zY3dnl#(2W~nBdPwe<=Cur0K7QuN1L!yVBSv%sf$hPz;`b4|lF*e~LG_B(V;PgRe3$ zuSk8;RF9grJ&<90AR=|p!cTV)q%k9Vm&FhU^5aWWQ+ENZm5h~ewt*Uc8gs>0)=ObP zRqJj;__viki=%uZR@T;61?!7u8{!SLZrb>>{P6EGgkdY;m&E$o`)lwH%p60!;lfXH zY?oKEJ1@Kvmv5dhgdwM531?wB#V#pc5GXby^lJ7e-rVyav->L?46UoQkI<8Az5E?# zVR=Kxk+AyOrTVq-Q>@wHjX`lhxpfqWR9{nR-*cnCj+3)~RD2~%_uh^aQy}kJ_-XyR z)Ok$ALN7OOU&8&_bBb+1^fr5n4?V@V_E#@cqz?qA8m$(7;@MjEj8D9q#^v??T8wqR z=7WjW-V&UzoFn}{_?x>=!fM-tKD26Bf+^R-Pcms8^T$OXdxu(~xw{DH&YaR7j^dw@ z+&d69BlxLLAHVno^bKG0v8~!4YNIy18$kCag0X2JJVx-7ymY+3xT)kRsA%1Z_dMi# zXYc>=$hKP@`t3HwLK&T3;VVgK0Lf9ILSK<7gK(Ee+6gwCpJeIQXD+dh!6e;nCA zN-=LhmL|=7T1n~o`}?i$K)q<{1opKOy>AC-65FF_XoUpASU(w4StV< c-zN~`+VL>Adgn+!RtY1=o5&Lu2-O1r0Vu13?EnA( literal 0 HcmV?d00001 diff --git a/app/static/favicon.svg b/app/static/favicon.svg new file mode 100644 index 0000000..d7b9abe --- /dev/null +++ b/app/static/favicon.svg @@ -0,0 +1,4 @@ + + + lo + \ No newline at end of file diff --git a/app/static/js/core.js b/app/static/js/core.js new file mode 100644 index 0000000..e0ba5e4 --- /dev/null +++ b/app/static/js/core.js @@ -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(); + }); +} diff --git a/app/static/js/metrics.js b/app/static/js/metrics.js new file mode 100644 index 0000000..bc7aaaf --- /dev/null +++ b/app/static/js/metrics.js @@ -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 = `${dateVal}` + + `
` + + `${grade}`; + // Удалить «Нет записей» если было + 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 = ''; + submitBtn.disabled = true; + const res = await apiFetch( + `/schedules/${sid}/subjects/${enc(subject)}/metrics/ai`, 'POST', { prompt } + ); + submitBtn.innerHTML = ''; + 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 = ''; + 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(); +}); diff --git a/app/static/js/schedule.js b/app/static/js/schedule.js new file mode 100644 index 0000000..f675f82 --- /dev/null +++ b/app/static/js/schedule.js @@ -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(); +}); diff --git a/app/static/js/subjects.js b/app/static/js/subjects.js new file mode 100644 index 0000000..19e6c5d --- /dev/null +++ b/app/static/js/subjects.js @@ -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 = ` + + + + + +
+ + +
+ `; + 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 + ? `` + : ``; + // 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 + ? `` + : ``; + 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(); +}); diff --git a/app/static/js/upload.js b/app/static/js/upload.js new file mode 100644 index 0000000..85d7e83 --- /dev/null +++ b/app/static/js/upload.js @@ -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'); + }); +}); diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..2f9d64d --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Вход{% endblock %} +{% block content %} +
+
+
Вход в tablo
+
+ +
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..4dfe9e6 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Регистрация{% endblock %} +{% block content %} +
+
+
Регистрация
+
+ +
+ + +
Минимум 3 символа, уникальный
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..6b85e0f --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,103 @@ + + + + + + + + {% block title %}Расписание{% endblock %} + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + + + + +{% block scripts %}{% endblock %} + + diff --git a/app/templates/macros/metrics.html b/app/templates/macros/metrics.html new file mode 100644 index 0000000..bff4554 --- /dev/null +++ b/app/templates/macros/metrics.html @@ -0,0 +1,386 @@ +{% macro render_metric(metric, can_edit=False, show_rename=False) %} +{% set prog = metric.progress or {} %} +{% set cfg = metric.config or {} %} +
+ + {# ── Заголовок ── #} +
+ {% if metric.ai_prompt %} + {{ metric.label }} + {% else %} + {{ metric.label }} + {% endif %} + {% if can_edit and show_rename %} + + {% endif %} +
+ + {# ── Кнопка удаления (абсолютная, правый верхний угол) ── #} + {% if can_edit %} + + {% endif %} + +
+ + {# ════════════════════════════════════════════════════════════════════════ + 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 %} +
+ {{ done }}/{{ total }} +
+
+
+ {% 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 %} + + {% else %} + {{ lbl }} + {% endif %} + {% endfor %} + {% if not total %}Не настроено{% endif %} +
+ + {# ════════════════════════════════════════════════════════════════════════ + 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 %} +
+
+ + + + + {{ pct }}% +
+
+
{{ cur }} / {{ mx | int }}{% if cfg.get('unit') %} {{ cfg.get('unit') }}{% endif %}
+ {% if can_edit %} +
+ + +
+ {% endif %} +
+
+ + {# ════════════════════════════════════════════════════════════════════════ + COUNTER — счётчик + ════════════════════════════════════════════════════════════════════════ #} + {% elif metric.type == 'counter' %} + {% set cur = prog.get('current', 0) %} + {% set mx = cfg.get('max') | int if cfg.get('max') else 0 %} +
+
+ {% if can_edit %}{% endif %} +
+ {{ cur }} + + {%- if mx %}/ {{ mx }}{% endif %} + {%- if cfg.get('unit') %} {{ cfg.get('unit') }}{% endif -%} + +
+ {% if can_edit %}{% endif %} +
+ {% if mx %} +
+
+
+ {% endif %} +
+ + {# ════════════════════════════════════════════════════════════════════════ + STAGES — этапы + ════════════════════════════════════════════════════════════════════════ #} + {% elif metric.type == 'stages' %} + {% set cur_stage = prog.get('current_stage', 0) %} + {% set stages = cfg.get('stages', []) %} +
+ {% for stage in stages %} + {% set idx = loop.index0 %} + {% set state = 'done' if idx < cur_stage else ('active' if idx == cur_stage else '') %} +
+ {% if can_edit %} + {% else %}
{% endif %} + {% if not loop.last %} +
+ {% endif %} +
+ {% else %} + Не настроено + {% endfor %} +
+ + {# ════════════════════════════════════════════════════════════════════════ + 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 %} +
+
+ {{ checked | length }}/{{ items | length }} +
+
+ {% for item in items %} + {% set idx = loop.index0 %} + + {% else %} + Нет пунктов + {% endfor %} +
+ + {# ════════════════════════════════════════════════════════════════════════ + 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 %} +
+
+ {{ present }} + Присут. +
+
+ {{ absent }} + Пропуск +
+
+ {{ excused }} + Уваж. +
+
+
+ {% for rec in records[-40:] %} + + {% endfor %} +
+ {% if can_edit %} +
+ + + +
+ {% 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 %} +
+ {% for work in works %} + {% set val = grades.get(work.name, '') %} + {% set pct = (val | float / max_gr * 100) | int if val != '' else 0 %} +
+ {{ work.name }} +
+ +
+ {% else %} + Нет работ + {% endfor %} +
+ {% if works and can_edit %} + + {% 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 %} +
+
+ {{ completed | length }}/{{ items | length }} +
+
+ {% for item in items %} + {% set idx = loop.index0 %} + + {% else %} + Нет дедлайнов + {% endfor %} +
+ + {# ════════════════════════════════════════════════════════════════════════ + STREAK — серия + ════════════════════════════════════════════════════════════════════════ #} + {% elif metric.type == 'streak' %} + {% set count = prog.get('records', []) | length %} +
+
🔥
+
{{ count }}
+
{{ 'день' 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 'дней') }} подряд
+
+ {% if can_edit %} + + {% 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 %} +
+
+
{{ total }}ч
+ {% if goal %}
цель: {{ goal | int }}ч
{% endif %} +
+
+ {% if goal %} +
+
+ {{ pct }}% +
+ {% endif %} + {% if can_edit %} +
+ + +
+ {% endif %} +
+
+ + {# ════════════════════════════════════════════════════════════════════════ + 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: только большая оценка — центральный вид #} +
+ {% if entries %} +
{{ last.grade }}
+
/ {{ scale | int }}
+
ср. {{ avg }}
+ {% else %} + Нет записей + {% endif %} +
+ + {# gh-2+: разделённый вид #} +
+
+ {% if entries %} +
{{ last.grade }}
+
/ {{ scale | int }}
+
ср.
+
{{ avg }}
+ {% else %} + — + {% endif %} +
+
+
+ {% for entry in entries | reverse %} + {% set pct = (entry.grade / scale * 100) | int %} +
+ {{ entry.date }} +
+ {{ entry.grade }} +
+ {% else %} + Нет записей + {% endfor %} +
+ {% if can_edit %} +
+ + + +
+ {% endif %} +
+
+ + {% endif %} +
{# /t-metric-body #} + +{% endmacro %} diff --git a/app/templates/profile/index.html b/app/templates/profile/index.html new file mode 100644 index 0000000..425b23b --- /dev/null +++ b/app/templates/profile/index.html @@ -0,0 +1,185 @@ +{% extends "base.html" %} +{% block title %}Мои расписания{% endblock %} +{% block content %} + +
+

Мои расписания

+ +
+ +{% if schedules %} +
+ {% for s in schedules %} +
+
{{ s.name }}
+
+ + + {{ s.created_at.strftime('%d.%m.%Y') }} + + + + {{ s.data | length }} предметов + +
+
+ Открыть + +
+ + +
+
+ +
+ {% endfor %} +
+ +{# Панель объединения #} + + +{% else %} +
+
+ +
+

Расписаний пока нет

+ Добавить первое +
+{% endif %} + +{# Модальное окно шаринга #} + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/schedule/subjects.html b/app/templates/schedule/subjects.html new file mode 100644 index 0000000..752ab11 --- /dev/null +++ b/app/templates/schedule/subjects.html @@ -0,0 +1,429 @@ +{% extends "base.html" %} +{% block title %}Предметы — {{ schedule.name }}{% endblock %} + +{% block nav_extra %} + + + Расписание + +{% if can_edit %} + +{% endif %} +{% endblock %} + +{% block content %} +{% from "macros/metrics.html" import render_metric %} + +
+
+

Предметы

+ {{ schedule.name }} +
+
+ +{% if not schedule.data %} +
+
📋
+

В расписании нет предметов

+
+{% else %} + +
+ {% 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 %} +
+ + {# ── Шапка карточки ── #} +
+
+
+ {% 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' %} + {{ type_name }} + {% endfor %} +
+ {{ subj }} + {% if cfg and cfg.completion_type %} + {{ cfg.completion_type }} + {% endif %} + {% if is_completed %} + Завершён + {% endif %} +
+
+ {% if can_edit %} + + + + + {% endif %} +
+
+ + {# ── Описание ── #} +
+

+ {{ cfg.description if cfg and cfg.description else 'Нет описания' }} +

+ {% if can_edit %} + +
+ +
+ + +
+
+ {% endif %} +
+ + {# ── Мета: тип сдачи + ссылка СДО ── #} +
+
+ {# Тип сдачи #} +
+ Тип сдачи + {% if can_edit %} + + {% else %} + {{ (cfg.completion_type if cfg and cfg.completion_type else '—') }} + {% endif %} +
+ {# Ссылка СДО #} +
+ СДО + {% if can_edit %} +
+ + + {% if cfg and cfg.sdo_url %} + + + + {% endif %} +
+ {% else %} + {% if cfg and cfg.sdo_url %} + + + Открыть + + {% else %} + — + {% endif %} + {% endif %} +
+
+
+ + {# ── Расписание: по типам и периодам ── #} + {% 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() %} +
+ + {# Заголовок секции: тип + период #} +
+ {{ type_name }} + {{ dr }} + {% if can_edit %} + + + {% endif %} + {% if can_edit %} + + {% endif %} +
+ + {# Таблица слотов #} + + + + + + + + {% if can_edit %}{% endif %} + + + + {% 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 '' %} + + {# Режим просмотра #} + + + + + {% if can_edit %} + + {% endif %} + + {# Строка редактирования — скрыта по умолчанию #} + {% if can_edit %} + + + + + + + + {% endif %} + {% endfor %} + +
ДеньВремяКабинетПреподаватель
{{ slot_day | capitalize }}{{ slot_time or '—' }}{{ slot_room or '—' }}{{ slot_teach or '—' }} +
+ + +
+
+
+ {% endfor %} + {% endfor %} + + {# ── Кнопка добавить новый тип/период (для можно расширить позже) ── #} + + {# ── Трекеры ── #} +
+
+
Трекеры
+ {% if can_edit %} + + {% endif %} +
+
+ {% if cfg %} + {% for metric in cfg.metrics %} + {{ render_metric(metric, can_edit, show_rename=True) }} + {% endfor %} + {% endif %} +
+ + {% if can_edit %} +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ +
+
+ {% endif %} +
+ +
+ {% endfor %} +
+ +{% endif %} + +{# ══ Модальное окно промта AI-трекера ══ #} + + +{# ══ Модальное окно переименования трекера ══ #} + + +{# ══ Модальное окно переименования ══ #} + + +{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/app/templates/schedule/upload.html b/app/templates/schedule/upload.html new file mode 100644 index 0000000..8fb7acb --- /dev/null +++ b/app/templates/schedule/upload.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Новое расписание{% endblock %} +{% block content %} + +
+

Добавить расписание

+
+ +
+
+ + +
+ + +
+ +
+ + +
+ Предпросмотр расписания + + +
+
+ +
+ + +
+ + +
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/schedule/view.html b/app/templates/schedule/view.html new file mode 100644 index 0000000..aed6192 --- /dev/null +++ b/app/templates/schedule/view.html @@ -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 = { + 'Лекция': 'Лек', + 'Практика': 'Пр', + 'Лаб. работа': 'Лаб', + 'Лабораторная': 'Лаб', + 'Семинар': 'Сем', + 'Не указано': '' +} %} + +{# ── Шапка ── #} +
+
+

{{ schedule.name }}

+ {% if can_edit and not is_shared_view %} + + {% endif %} +
+
+ {% if can_edit and not is_shared_view %} + + + Предметы + + {% endif %} + {% if can_edit and not is_shared_view %} + + {% endif %} +
+
+ +{# ══════════════════════════════ РАСПИСАНИЕ ══════════════════════════════ #} +
+ {% for i in range(7) %} + {% set day_slots = week_view[i] %} + {% if day_slots %} +
+
+ {{ days_display[i] }} + {% if i == today_idx %}сегодня{% endif %} + {{ week_dates[i].strftime('%d.%m') }} +
+ + {% 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 %} +
+
+ {{ slot.time or '—' }} + {% if visible_slots | length > 1 %}{{ visible_slots | length }} предмета{% endif %} +
+
+ {% for s in visible_slots %} +
+
+ {{ s.subject }} + {% if s.classroom %} +
+ + {{ s.classroom }} +
+ {% endif %} +
+ {% 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 %} + {{ t_short }} + {% endif %} +
+ +
+
+ {% endfor %} +
+
+ {% 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 '') %} +
+
{{ slot.time or '—' }}
+
+
{{ slot.subject }}
+ {% if slot.classroom %} +
+ + {{ slot.classroom }} +
+ {% endif %} +
+
+ {% 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 != 'Не указано' %} + {{ t_short }} + {% endif %} + {% if not merged_type %} +
+ + +
+ {% endif %} + ▾ +
+
+
+ {% if slot.teacher %} +
+ + {{ slot.teacher }} +
+ {% endif %} + {% if slot.date_range and slot.date_range != 'Весь период' %} +
+ + {{ slot.date_range }} +
+ {% endif %} + {% set cfg = configs.get(slot.subject) %} + {% if cfg and cfg.completion_type %} +
+ + {{ cfg.completion_type }} + {% if cfg.completed %}Завершён{% endif %} +
+ {% elif cfg and cfg.completed %} +
+ + Завершён +
+ {% endif %} + {% if cfg and cfg.sdo_url %} + + {% endif %} + {% if cfg and cfg.description %} +
{{ cfg.description }}
+ {% endif %} + {% set metrics = cfg.metrics | list if cfg else [] %} + {% if metrics %} +
+ {% for m in metrics %} + {{ m.label }} + {% endfor %} +
+ {% else %} +
Трекеры не добавлены
+ {% endif %} +
+ {% endif %}{# not hidden #} + {% endif %}{# _group / regular slot #} + + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ +{# ══════════════════════════════ ДЭШБОРД ══════════════════════════════ #} +{% 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 %} + +
+ Трекеры +
+ {% if completed_count.n > 0 %} + + {% endif %} + {% if can_edit and not is_shared_view %} + + {% endif %} +
+
+
+ {% 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 %} +
+{% if filled_ids %} + +{% endif %} +{% endif %} + + + +{# ══ Модальное окно шаринга ══ #} +{% if can_edit and not is_shared_view %} + +{% endif %} + +{# ══ Модальное окно переименования ══ #} + + +{# ══ Модальное окно изменения времени ══ #} + + +{% endblock %} + +{% block scripts %} + + + + + +{% if can_edit and not is_shared_view %} + +{% endif %} +{% endblock %} diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -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 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/a9875eef468a_init.py b/migrations/versions/a9875eef468a_init.py new file mode 100644 index 0000000..f10c1b8 --- /dev/null +++ b/migrations/versions/a9875eef468a_init.py @@ -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 ### diff --git a/migrations/versions/aa6f28d23573_merge_progress_into_metric.py b/migrations/versions/aa6f28d23573_merge_progress_into_metric.py new file mode 100644 index 0000000..d9acad7 --- /dev/null +++ b/migrations/versions/aa6f28d23573_merge_progress_into_metric.py @@ -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 ### diff --git a/migrations/versions/b3c9d1e2f4a5_add_grid_layout_to_metrics.py b/migrations/versions/b3c9d1e2f4a5_add_grid_layout_to_metrics.py new file mode 100644 index 0000000..8f8c303 --- /dev/null +++ b/migrations/versions/b3c9d1e2f4a5_add_grid_layout_to_metrics.py @@ -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') diff --git a/migrations/versions/c4d2e3f5a6b7_grid_h_half_rows.py b/migrations/versions/c4d2e3f5a6b7_grid_h_half_rows.py new file mode 100644 index 0000000..15fc433 --- /dev/null +++ b/migrations/versions/c4d2e3f5a6b7_grid_h_half_rows.py @@ -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)") diff --git a/migrations/versions/d5e3f4a6b7c8_subject_hidden.py b/migrations/versions/d5e3f4a6b7c8_subject_hidden.py new file mode 100644 index 0000000..cd7b62e --- /dev/null +++ b/migrations/versions/d5e3f4a6b7c8_subject_hidden.py @@ -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') diff --git a/migrations/versions/e6f4a5b8c9d0_subject_meta_fields.py b/migrations/versions/e6f4a5b8c9d0_subject_meta_fields.py new file mode 100644 index 0000000..704e235 --- /dev/null +++ b/migrations/versions/e6f4a5b8c9d0_subject_meta_fields.py @@ -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') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bef465f --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..8beb920 --- /dev/null +++ b/run.py @@ -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)