diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..317b9aa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.venv +.env +backend/db.sqlite3 +backend/staticfiles +**/__pycache__ +**/*.pyc +.pytest_cache +.coverage +htmlcov +docs/old_scripts/*.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..33807b2 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DJANGO_SETTINGS_MODULE=config.settings +DJANGO_SECRET_KEY=replace-with-a-long-random-value +DJANGO_DEBUG=true +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DATABASE_URL=mysql://hulumath:hulumath@127.0.0.1:3307/hulumath +REDIS_URL=redis://localhost:6379/0 +CORS_ALLOWED_ORIGINS=http://localhost:3000 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..d1549e1 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,22 @@ +# 复制为服务器项目根目录下的 .env.production,并替换全部示例值。 +# 包含 #、空格等特殊字符的值请用单引号包裹。 + +DJANGO_SETTINGS_MODULE=config.settings +DJANGO_SECRET_KEY='replace-with-at-least-50-random-characters' +DJANGO_DEBUG=false +DJANGO_ALLOWED_HOSTS=math.example.com + +DATABASE_URL='mysql://hulumath:replace-password@127.0.0.1:3307/hulumath' +REDIS_URL='redis://127.0.0.1:6379/0' + +CORS_ALLOWED_ORIGINS=https://math.example.com +CSRF_TRUSTED_ORIGINS=https://math.example.com +API_ANON_RATE=120/minute +API_USER_RATE=600/minute +SECURE_HSTS_SECONDS=31536000 + +# 仅供部署脚本直接访问 127.0.0.1:8000 时设置 Host 头。 +DEPLOY_HEALTH_HOST=math.example.com + +# 宝塔/Docker MySQL 的 mysqldump 不在 PATH 时取消注释并填写实际路径。 +# MYSQLDUMP_BIN=/www/server/mysql/bin/mysqldump diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4b554f2 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install MySQL build dependencies + run: | + sudo apt-get update + sudo apt-get install -y default-libmysqlclient-dev pkg-config + - name: Install dependencies + run: pip install -r requirements-dev.txt + - name: Check migrations + working-directory: backend + run: python manage.py makemigrations --check --dry-run + - name: Django checks + working-directory: backend + run: python manage.py check + - name: ASGI import check + working-directory: backend + run: python -c "from config.asgi import application; print(type(application).__name__)" + - name: Unit tests + run: pytest -q + - name: Start MySQL 8.0.35 + run: | + docker rm -f hulumath-ci-mysql 2>/dev/null || true + docker run -d --name hulumath-ci-mysql \ + -p 3307:3306 \ + -e MYSQL_ROOT_PASSWORD=ci-root-password \ + -e MYSQL_DATABASE=hulumath \ + mysql:8.0.35 \ + --character-set-server=utf8mb4 \ + --collation-server=utf8mb4_0900_ai_ci + for i in $(seq 1 60); do + if docker exec hulumath-ci-mysql \ + mysqladmin ping -h 127.0.0.1 -uroot -pci-root-password --silent; then + exit 0 + fi + sleep 2 + done + docker logs hulumath-ci-mysql + exit 1 + - name: MySQL migrations and tests + env: + DATABASE_URL: mysql://root:ci-root-password@127.0.0.1:3307/hulumath + run: | + python backend/manage.py check --database default + python backend/manage.py check_mysql + python backend/manage.py migrate --noinput + pytest -q + - name: Build image + run: docker build -t hulumath:${{ gitea.sha }} . diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..834779f --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,150 @@ +name: PR合并自动部署 + +on: + pull_request: + branches: + - main + types: + - closed + +concurrency: + group: production-deploy + cancel-in-progress: false + +jobs: + test: + if: ${{ github.event.pull_request.merged == true }} + runs-on: ubuntu-latest + steps: + - name: 检出 main + uses: actions/checkout@v4 + with: + ref: main + + - name: 配置 Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: 安装 MySQL 编译依赖 + run: | + sudo apt-get update + sudo apt-get install -y default-libmysqlclient-dev pkg-config + + - name: 安装测试依赖 + run: pip install -r requirements-dev.txt + + - name: 检查迁移文件 + working-directory: backend + run: python manage.py makemigrations --check --dry-run + + - name: Django 系统检查 + working-directory: backend + run: python manage.py check + + - name: ASGI 启动导入检查 + working-directory: backend + run: python -c "from config.asgi import application; print(type(application).__name__)" + + - name: 运行测试 + run: pytest -q + + - name: 启动 MySQL 8.0.35 + run: | + docker rm -f hulumath-deploy-mysql 2>/dev/null || true + docker run -d --name hulumath-deploy-mysql \ + -p 3307:3306 \ + -e MYSQL_ROOT_PASSWORD=ci-root-password \ + -e MYSQL_DATABASE=hulumath \ + mysql:8.0.35 \ + --character-set-server=utf8mb4 \ + --collation-server=utf8mb4_0900_ai_ci + for i in $(seq 1 60); do + if docker exec hulumath-deploy-mysql \ + mysqladmin ping -h 127.0.0.1 -uroot -pci-root-password --silent; then + exit 0 + fi + sleep 2 + done + docker logs hulumath-deploy-mysql + exit 1 + + - name: MySQL 迁移与全量测试 + env: + DATABASE_URL: mysql://root:ci-root-password@127.0.0.1:3307/hulumath + run: | + python backend/manage.py check --database default + python backend/manage.py check_mysql + python backend/manage.py migrate --noinput + pytest -q + + deploy: + if: ${{ github.event.pull_request.merged == true }} + needs: + - test + runs-on: ubuntu-latest + steps: + - name: 配置 SSH 环境 + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + set -eu + mkdir -p ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + + - name: 远程执行 Django 部署 + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + run: | + ssh -T -i ~/.ssh/deploy_key \ + "$DEPLOY_USER@$DEPLOY_HOST" <<'EOF' + + set -Eeuo pipefail + + PROJECT="/www/wwwroot/Hulumath-Web" + REPOSITORY_URL="http://117.72.28.96:8765/Jacky/Hulumath-Web.git" + PYTHON_BIN="/www/server/pyporject_evn/versions/3.12.13/bin/python3" + + exec 9>/tmp/hulumath-production-deploy.lock + if ! flock -n 9; then + echo "已有部署正在执行,本次退出" + exit 1 + fi + + echo "========================================" + echo "开始部署 Django 生产版" + echo "项目目录: $PROJECT" + echo "========================================" + + cd "$PROJECT" + git config --global --add safe.directory "$PROJECT" || true + + if [ ! -d .git ]; then + echo "当前目录不是 Git 仓库" + exit 1 + fi + + PREVIOUS_REVISION="$(git rev-parse HEAD)" + + echo "获取远程 main..." + git remote set-url origin "$REPOSITORY_URL" + git fetch origin main + git reset --hard origin/main + + chmod +x scripts/deploy_production.sh + + PROJECT_DIR="$PROJECT" \ + PYTHON_BIN="$PYTHON_BIN" \ + PREVIOUS_REVISION="$PREVIOUS_REVISION" \ + bash scripts/deploy_production.sh + + echo "========================================" + echo "部署完成" + echo "========================================" + + EOF diff --git a/.gitignore b/.gitignore index 36b13f1..d9f1b7b 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ celerybeat.pid # Environments .env .venv +.venv-production/ env/ venv/ ENV/ @@ -173,4 +174,3 @@ cython_debug/ # PyPI configuration file .pypirc - diff --git a/Bible.md b/Bible.md index 07de916..dd5ed18 100644 --- a/Bible.md +++ b/Bible.md @@ -25,7 +25,7 @@ - 首版 Contest 以实时 1v1 为主推体验,每日异步赛与其同期交付或紧随其后。 - 实时范围严格限制为单一匹配模式,不在首版扩展房间赛、淘汰赛和观战。 - 全年龄用户必须通过水平分层、兴趣选择和个性化首页解决体验冲突。 -- 后端确定改为 Django 模块化单体,数据库确定改为 PostgreSQL。 +- 后端确定改为 Django 模块化单体,数据库确定改为 MySQL 8.0.35。 - 在当前仓库中实施分阶段重构;每个新模块通过验收后替换并删除对应旧代码。 --- @@ -38,7 +38,7 @@ 5. 六个一级模块为:首页、数学人生、比赛、工具箱、探索发现、我的。 6. LaTeX Lab 放入工具箱,并作为工具箱重点能力。 7. 后端采用 Django 模块化单体。 -8. 数据库采用 PostgreSQL。 +8. 数据库采用 MySQL 8.0.35。 9. 当前仓库进行分阶段重构,不长期并行维护两套产品系统。 本次后续评审需要形成以下结论: 1. 实时 1v1 与每日异步赛是否必须在同一次公开发布中交付。 @@ -500,7 +500,7 @@ Django + Django REST Framework ├── Engagement └── AI Tutor │ -PostgreSQL + Redis + 对象存储 +MySQL 8.0.35 + Redis + 对象存储 首期采用模块化单体,不拆微服务。 11.2 采用 Django 的原因 本项目生产版的核心需求集中在: @@ -645,7 +645,7 @@ user_id + story_id + story_version 禁止记录密码、Token、完整 AI Key 和敏感个人信息。 16.2 监控 - API 请求量、错误率和 P95 延迟。 -- PostgreSQL 连接与慢查询。 +- MySQL 8.0.35 连接与慢查询。 - Redis 状态。 - WebSocket 在线连接和断线率。 - Contest 提交成功率。 @@ -829,7 +829,7 @@ P2 5. 一级模块为:首页、数学人生、比赛、工具箱、探索发现、我的。 6. LaTeX Lab 属于工具箱的重点子模块。 7. 后端采用 Django 模块化单体。 -8. 正式数据库采用 PostgreSQL。 +8. 正式数据库采用 MySQL 8.0.35。 23.2 首发前必须决策 1. 实时 1v1 与每日异步赛必须同一次公开发布。 2. 邀请码注册使用用户名加密码。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3ce073e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends default-libmysqlclient-dev pkg-config gcc \ + && addgroup --system hulumath \ + && adduser --system --ingroup hulumath hulumath \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend ./backend +COPY docs ./docs + +WORKDIR /app/backend +RUN python manage.py collectstatic --noinput + +USER hulumath +EXPOSE 8000 + +CMD ["uvicorn", "config.asgi:application", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..10d126d --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: install migrate seed run test check + +install: + python3 -m venv .venv + .venv/bin/pip install -r requirements-dev.txt + +migrate: + cd backend && ../.venv/bin/python manage.py migrate + +seed: + cd backend && ../.venv/bin/python manage.py seed_initial_content + cd backend && ../.venv/bin/python manage.py seed_contests + +run: + cd backend && ../.venv/bin/python manage.py runserver + +test: + .venv/bin/python -m pytest -q + +check: + cd backend && ../.venv/bin/python manage.py check + cd backend && ../.venv/bin/python manage.py makemigrations --check --dry-run diff --git a/README.md b/README.md index e5f6917..2eb9519 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,93 @@ -# Hulumath-Web +# 葫芦数学 Hulumath -Hulumath Online Version \ No newline at end of file +面向全年龄数学兴趣用户的“数学人生宇宙”。当前仓库包含可运行的 Django 模块化单体、响应式 Web 客户端、运营后台、内容种子、实时比赛基础设施和生产部署配置。 + +## 已实现 + +- 邀请码注册、登录、个人资料、会话记录和管理员审计模型 +- 12 题 MathBTI、16 种数学人格、人物卡与数学精灵初始化 +- 统一版本化剧情引擎、85 节点信仰者主线、2 个人物 Skill 样板 +- 剧情服务端存档、嵌套资源效果、结局与幂等选择 +- 入门、标准、进阶三赛道的实时 1v1、今日挑战和单人闯关 +- 题目版本、服务端计时判分、Elo Rating、排行榜和基础反作弊 +- Channels WebSocket 比赛进度通道,Redis Channel Layer +- LaTeX 文档与版本、六级零基础课程、练习判定 +- 54 条旧版志愿者视频、五维能力地图、专业筛选与融合视频流 +- 视频观看进度、幂等奖励、收藏、五维能力、数学精灵和人物卡册 +- 多工具工具箱:口算入口、科学计算器、符号查询、函数绘图和 LaTeX Lab +- Django Admin、健康检查、请求 ID、限流与统一 API 错误结构 +- MySQL 8.0/Redis Docker Compose、Gitea CI 和自动化测试 + +## 本地启动 + +要求 Python 3.9 或更高版本。 + +```bash +make install +make migrate +make seed +make run +``` + +访问 `http://127.0.0.1:8000/`。本地种子邀请码为 `HULU2026`,仅用于开发体验。 + +管理后台位于 `http://127.0.0.1:8000/admin/`。创建管理员: + +```bash +cd backend +../.venv/bin/python manage.py createsuperuser +``` + +## 测试 + +```bash +make check +make test +``` + +测试覆盖邀请码消费、密码哈希、MathBTI 计分、剧情校验与幂等存档、Contest 判分与超时、实时匹配、Elo 结算、LaTeX 判定和成长初始化。 + +## 生产运行 + +复制 `.env.production.example` 为服务器上的 `.env.production`,并注入真实密钥。生产模式要求: + +- `DJANGO_DEBUG=false` +- 强随机 `DJANGO_SECRET_KEY` +- MySQL 8.0 `DATABASE_URL` +- Redis `REDIS_URL` +- 正确的 `DJANGO_ALLOWED_HOSTS` 和 `CORS_ALLOWED_ORIGINS` + +本地验证生产拓扑: + +```bash +docker compose up --build +``` + +迁移由独立 `migrate` 服务执行,Web 进程只在迁移成功、MySQL 和 Redis 健康后启动。 + +Gitea 会在 PR 合并到 `main` 后自动测试和部署: + +- [Ubuntu + 宝塔面板从零部署](docs/BAOTA_UBUNTU_FROM_ZERO.md) +- [自动发布机制与运维说明](docs/DEPLOYMENT.md) +- [MySQL 8 数据迁移说明](docs/MYSQL8_MIGRATION.md) + +## 目录 + +```text +backend/ +├── accounts/ # 用户、邀请码、会话、审计 +├── math_life/ # MathBTI、统一剧情、人物 Skill +├── contest/ # 题库、比赛、匹配、Rating +├── latex_lab/ # 公式、课程、练习 +├── progression/ # 五维能力、精灵、卡牌、奖励 +├── content/ # 视频、知识卡片、人物内容 +├── engagement/ # 签到与通知 +├── common/ # 健康检查、错误、日志 +└── config/ # Django/ASGI 配置 +``` + +API 统一使用 `/api/v1/` 前缀,WebSocket 使用 `/ws/v1/` 前缀。 + +## 当前环境限制 + +Taro 小程序和 Next.js 独立客户端尚未生成。当前机器没有 Node.js,规定的小程序模板初始化工具无法运行;现有响应式 Web 客户端与 `/api/v1/` 契约可作为后续两端共用后端。 diff --git a/backend/accounts/__init__.py b/backend/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/accounts/admin.py b/backend/accounts/admin.py new file mode 100644 index 0000000..1b38bf4 --- /dev/null +++ b/backend/accounts/admin.py @@ -0,0 +1,35 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from .models import ( + AuditLog, + InviteCode, + InviteCodeUsage, + User, + UserSession, + VisitorMigration, +) + + +@admin.register(User) +class HulumathUserAdmin(UserAdmin): + fieldsets = UserAdmin.fieldsets + ( + ("葫芦数学", {"fields": ("nickname", "avatar_url", "bio", "track", "rating")}), + ) + add_fieldsets = UserAdmin.add_fieldsets + ( + ("葫芦数学", {"fields": ("nickname", "track")}), + ) + list_display = ("username", "nickname", "track", "rating", "is_active", "date_joined") + + +@admin.register(InviteCode) +class InviteCodeAdmin(admin.ModelAdmin): + list_display = ("code", "group", "used_count", "max_uses", "expires_at", "is_active") + list_filter = ("is_active", "group") + search_fields = ("code", "group") + + +admin.site.register(InviteCodeUsage) +admin.site.register(UserSession) +admin.site.register(VisitorMigration) +admin.site.register(AuditLog) diff --git a/backend/accounts/apps.py b/backend/accounts/apps.py new file mode 100644 index 0000000..3e3c765 --- /dev/null +++ b/backend/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' diff --git a/backend/accounts/migration_service.py b/backend/accounts/migration_service.py new file mode 100644 index 0000000..67c1fba --- /dev/null +++ b/backend/accounts/migration_service.py @@ -0,0 +1,104 @@ +from django.db import transaction +from django.utils import timezone + +from content.models import ContentInteraction, ContentItem +from math_life.models import ( + MathBTIAssessment, + MathBTIResult, + MathIdentity, + Story, + StoryRun, + StoryVersion, +) +from math_life.services import score_mathbti +from progression.models import Card, UserCard +from progression.services import initialize_math_identity + +from .models import VisitorMigration + + +def migration_summary(payload): + mathbti = 1 if isinstance(payload.get("mathbti"), dict) else 0 + return { + "mathbti_results": mathbti, + "story_saves": min(len(payload.get("story_saves", [])), 20), + "favorites": min(len(payload.get("favorites", [])), 200), + "cards": min(len(payload.get("cards", [])), 100), + } + + +@transaction.atomic +def apply_visitor_migration(user): + migration = VisitorMigration.objects.select_for_update().get(user=user) + if migration.status == VisitorMigration.Status.APPLIED: + return migration + + payload = migration.payload + mathbti = payload.get("mathbti") + if isinstance(mathbti, dict): + assessment = MathBTIAssessment.objects.filter( + version=mathbti.get("version"), + is_published=True, + ).first() + if assessment: + code, scores = score_mathbti(assessment.definition, mathbti.get("answers", [])) + identity = MathIdentity.objects.get(code=code) + MathBTIResult.objects.create( + user=user, + assessment=assessment, + identity=identity, + answers=mathbti["answers"], + axis_scores=scores, + ) + initialize_math_identity(user, identity) + + for item in payload.get("story_saves", [])[:20]: + if not isinstance(item, dict): + continue + story = Story.objects.filter(slug=item.get("story_slug")).first() + if story is None: + continue + version = StoryVersion.objects.filter( + story=story, + version=item.get("version"), + is_published=True, + ).first() + node_id = item.get("current_node") + if version is None or node_id not in version.content.get("nodes", {}): + continue + StoryRun.objects.get_or_create( + user=user, + story_version=version, + status=StoryRun.Status.ACTIVE, + defaults={ + "current_node": node_id, + "state": item.get("state", {}) if isinstance(item.get("state"), dict) else {}, + }, + ) + + favorites = ContentItem.objects.filter( + slug__in=payload.get("favorites", [])[:200], + is_published=True, + ) + ContentInteraction.objects.bulk_create( + [ + ContentInteraction( + user=user, + content=item, + action=ContentInteraction.Action.FAVORITE, + ) + for item in favorites + ], + ignore_conflicts=True, + ) + + cards = Card.objects.filter(slug__in=payload.get("cards", [])[:100]) + UserCard.objects.bulk_create( + [UserCard(user=user, card=card, source="visitor_migration") for card in cards], + ignore_conflicts=True, + ) + + migration.status = VisitorMigration.Status.APPLIED + migration.applied_at = timezone.now() + migration.save(update_fields=["status", "applied_at"]) + return migration diff --git a/backend/accounts/migrations/0001_initial.py b/backend/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..bd631f9 --- /dev/null +++ b/backend/accounts/migrations/0001_initial.py @@ -0,0 +1,104 @@ +# Generated by Django 4.2.23 on 2026-08-08 11:15 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('nickname', models.CharField(max_length=40)), + ('avatar_url', models.URLField(blank=True)), + ('bio', models.CharField(blank=True, max_length=200)), + ('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], default='standard', max_length=16)), + ('rating', models.PositiveIntegerField(default=1000)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='InviteCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=32, unique=True)), + ('group', models.CharField(blank=True, max_length=80)), + ('max_uses', models.PositiveIntegerField(default=1)), + ('used_count', models.PositiveIntegerField(default=0)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invite_codes', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('session_key', models.CharField(max_length=40, unique=True)), + ('user_agent', models.CharField(blank=True, max_length=300)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('last_seen_at', models.DateTimeField(auto_now=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_sessions', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='InviteCodeUsage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('used_at', models.DateTimeField(auto_now_add=True)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('invite_code', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='usages', to='accounts.invitecode')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='invite_usage', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='AuditLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(max_length=80)), + ('target_type', models.CharField(max_length=80)), + ('target_id', models.CharField(blank=True, max_length=80)), + ('reason', models.CharField(blank=True, max_length=300)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/accounts/migrations/0002_visitormigration.py b/backend/accounts/migrations/0002_visitormigration.py new file mode 100644 index 0000000..f5122ce --- /dev/null +++ b/backend/accounts/migrations/0002_visitormigration.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.23 on 2026-08-08 11:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='VisitorMigration', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('payload', models.JSONField(default=dict)), + ('summary', models.JSONField(default=dict)), + ('status', models.CharField(choices=[('pending', '待确认'), ('applied', '已迁移')], default='pending', max_length=16)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('applied_at', models.DateTimeField(blank=True, null=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='visitor_migration', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/accounts/migrations/__init__.py b/backend/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/accounts/models.py b/backend/accounts/models.py new file mode 100644 index 0000000..098f1c7 --- /dev/null +++ b/backend/accounts/models.py @@ -0,0 +1,90 @@ +import uuid + +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils import timezone + + +class User(AbstractUser): + class Track(models.TextChoices): + BEGINNER = "beginner", "入门" + STANDARD = "standard", "标准" + ADVANCED = "advanced", "进阶" + OPEN = "open", "Open" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + nickname = models.CharField(max_length=40) + avatar_url = models.URLField(blank=True) + bio = models.CharField(max_length=200, blank=True) + track = models.CharField(max_length=16, choices=Track.choices, default=Track.STANDARD) + rating = models.PositiveIntegerField(default=1000) + + def __str__(self): + return self.nickname or self.username + + +class InviteCode(models.Model): + code = models.CharField(max_length=32, unique=True) + group = models.CharField(max_length=80, blank=True) + max_uses = models.PositiveIntegerField(default=1) + used_count = models.PositiveIntegerField(default=0) + expires_at = models.DateTimeField(null=True, blank=True) + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey( + User, null=True, blank=True, on_delete=models.SET_NULL, related_name="invite_codes" + ) + created_at = models.DateTimeField(auto_now_add=True) + + @property + def is_available(self): + return ( + self.is_active + and self.used_count < self.max_uses + and (self.expires_at is None or self.expires_at > timezone.now()) + ) + + def __str__(self): + return self.code + + +class InviteCodeUsage(models.Model): + invite_code = models.ForeignKey(InviteCode, on_delete=models.PROTECT, related_name="usages") + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="invite_usage") + used_at = models.DateTimeField(auto_now_add=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + + +class UserSession(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="login_sessions") + session_key = models.CharField(max_length=40, unique=True) + user_agent = models.CharField(max_length=300, blank=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + last_seen_at = models.DateTimeField(auto_now=True) + revoked_at = models.DateTimeField(null=True, blank=True) + + +class VisitorMigration(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "待确认" + APPLIED = "applied", "已迁移" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="visitor_migration") + payload = models.JSONField(default=dict) + summary = models.JSONField(default=dict) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING) + created_at = models.DateTimeField(auto_now_add=True) + applied_at = models.DateTimeField(null=True, blank=True) + + +class AuditLog(models.Model): + actor = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) + action = models.CharField(max_length=80) + target_type = models.CharField(max_length=80) + target_id = models.CharField(max_length=80, blank=True) + reason = models.CharField(max_length=300, blank=True) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] diff --git a/backend/accounts/serializers.py b/backend/accounts/serializers.py new file mode 100644 index 0000000..6d50700 --- /dev/null +++ b/backend/accounts/serializers.py @@ -0,0 +1,72 @@ +import json + +from django.contrib.auth.password_validation import validate_password +from django.db import transaction +from rest_framework import serializers + +from .migration_service import migration_summary +from .models import InviteCode, InviteCodeUsage, User, VisitorMigration + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("id", "username", "nickname", "avatar_url", "bio", "track", "rating") + read_only_fields = ("id", "rating") + + +class RegisterSerializer(serializers.Serializer): + invite_code = serializers.CharField(max_length=32) + username = serializers.RegexField(r"^[A-Za-z0-9_]{3,30}$") + password = serializers.CharField(write_only=True, min_length=8) + nickname = serializers.CharField(max_length=40) + track = serializers.ChoiceField(choices=User.Track.choices, default=User.Track.STANDARD) + visitor_data = serializers.JSONField(required=False, default=dict, write_only=True) + + def validate_username(self, value): + if User.objects.filter(username__iexact=value).exists(): + raise serializers.ValidationError("用户名已存在") + return value + + def validate_password(self, value): + validate_password(value) + return value + + def validate_visitor_data(self, value): + if not isinstance(value, dict): + raise serializers.ValidationError("游客数据必须是对象") + for field in ("story_saves", "favorites", "cards"): + if field in value and not isinstance(value[field], list): + raise serializers.ValidationError(f"{field} 必须是数组") + if "mathbti" in value and not isinstance(value["mathbti"], dict): + raise serializers.ValidationError("mathbti 必须是对象") + if len(json.dumps(value, ensure_ascii=False).encode("utf-8")) > 256 * 1024: + raise serializers.ValidationError("游客数据不能超过 256KB") + return value + + @transaction.atomic + def create(self, validated_data): + code_text = validated_data.pop("invite_code") + visitor_data = validated_data.pop("visitor_data", {}) + try: + invite = InviteCode.objects.select_for_update().get(code__iexact=code_text) + except InviteCode.DoesNotExist as exc: + raise serializers.ValidationError({"invite_code": "邀请码无效"}) from exc + if not invite.is_available: + raise serializers.ValidationError({"invite_code": "邀请码已过期或已用完"}) + + user = User.objects.create_user(**validated_data) + InviteCodeUsage.objects.create( + invite_code=invite, + user=user, + ip_address=self.context.get("ip_address"), + ) + invite.used_count += 1 + invite.save(update_fields=["used_count"]) + if visitor_data: + VisitorMigration.objects.create( + user=user, + payload=visitor_data, + summary=migration_summary(visitor_data), + ) + return user diff --git a/backend/accounts/test_migration_service.py b/backend/accounts/test_migration_service.py new file mode 100644 index 0000000..cc36902 --- /dev/null +++ b/backend/accounts/test_migration_service.py @@ -0,0 +1,99 @@ +import pytest + +from accounts.migration_service import apply_visitor_migration, migration_summary +from accounts.models import User, VisitorMigration +from content.models import ContentInteraction, ContentItem +from math_life.models import ( + MathBTIAssessment, + MathBTIResult, + MathIdentity, + Story, + StoryRun, + StoryVersion, +) +from progression.models import Card, UserCard + + +@pytest.mark.django_db +def test_apply_visitor_migration_确认后事务化导入且可重复调用(): + user = User.objects.create_user( + username="visitor_user", + password="StrongPass_2026", + nickname="游客用户", + ) + definition = { + "axes": [{"id": "style"}], + "scoring": {"axes": ["style"], "cutoff": 0}, + "questions": [ + {"id": 1, "axis": "style", "options": [{"score": 0}, {"score": 1}]} + ], + } + MathBTIAssessment.objects.create( + version="test-1", + title="测试 MathBTI", + definition=definition, + is_published=True, + ) + MathIdentity.objects.create( + code="1", + name="测试身份", + clan="信仰者", + mathematician="高斯", + ) + story = Story.objects.create(slug="visitor-story", title="游客人生") + StoryVersion.objects.create( + story=story, + version=1, + is_published=True, + content={ + "start_node": "start", + "nodes": { + "start": {"scene": "开始", "choices": []}, + "saved": {"scene": "存档", "choices": []}, + }, + }, + ) + content = ContentItem.objects.create( + slug="favorite-content", + title="收藏内容", + kind=ContentItem.Kind.KNOWLEDGE, + is_published=True, + ) + legacy_card = Card.objects.create( + slug="legacy-card", + name="旧人物卡", + mathematician="欧拉", + description="游客阶段获得", + ) + payload = { + "mathbti": {"version": "test-1", "answers": [1]}, + "story_saves": [ + { + "story_slug": "visitor-story", + "version": 1, + "current_node": "saved", + "state": {"energy": 3}, + } + ], + "favorites": ["favorite-content"], + "cards": ["legacy-card"], + } + VisitorMigration.objects.create( + user=user, + payload=payload, + summary=migration_summary(payload), + ) + + first = apply_visitor_migration(user) + second = apply_visitor_migration(user) + + assert first.status == VisitorMigration.Status.APPLIED + assert second.status == VisitorMigration.Status.APPLIED + assert MathBTIResult.objects.filter(user=user).count() == 1 + assert StoryRun.objects.get(user=user).current_node == "saved" + assert ContentInteraction.objects.filter( + user=user, + content=content, + action=ContentInteraction.Action.FAVORITE, + ).count() == 1 + assert UserCard.objects.filter(user=user, card=legacy_card).count() == 1 diff --git a/backend/accounts/test_serializers.py b/backend/accounts/test_serializers.py new file mode 100644 index 0000000..6e40a7a --- /dev/null +++ b/backend/accounts/test_serializers.py @@ -0,0 +1,76 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone +from rest_framework import serializers + +from accounts.models import InviteCode, InviteCodeUsage, User +from accounts.serializers import RegisterSerializer + + +def registration_data(code="VALID-CODE", username="math_user"): + return { + "invite_code": code, + "username": username, + "password": "StrongPass_2026", + "nickname": "数学少年", + "track": User.Track.STANDARD, + } + + +@pytest.mark.django_db +def test_register_serializer_有效邀请码创建用户并原子消费(): + invite = InviteCode.objects.create(code="VALID-CODE", max_uses=1) + serializer = RegisterSerializer( + data=registration_data(), + context={"ip_address": "127.0.0.1"}, + ) + + assert serializer.is_valid(), serializer.errors + user = serializer.save() + + invite.refresh_from_db() + assert invite.used_count == 1 + assert user.check_password("StrongPass_2026") + usage = InviteCodeUsage.objects.get(user=user) + assert usage.invite_code == invite + assert usage.ip_address == "127.0.0.1" + + +@pytest.mark.django_db +def test_register_serializer_过期邀请码拒绝创建用户(): + InviteCode.objects.create( + code="EXPIRED", + max_uses=1, + expires_at=timezone.now() - timedelta(seconds=1), + ) + serializer = RegisterSerializer(data=registration_data(code="EXPIRED")) + + assert serializer.is_valid(), serializer.errors + with pytest.raises(serializers.ValidationError, match="已过期或已用完"): + serializer.save() + assert User.objects.count() == 0 + + +@pytest.mark.django_db +def test_register_serializer_用户名大小写重复时校验失败(): + User.objects.create_user( + username="Math_User", + password="StrongPass_2026", + nickname="已有用户", + ) + serializer = RegisterSerializer(data=registration_data(username="math_user")) + + assert not serializer.is_valid() + assert "用户名已存在" in str(serializer.errors["username"]) + + +@pytest.mark.django_db +def test_register_serializer_游客迁移字段类型错误时拒绝(): + InviteCode.objects.create(code="VALID-CODE", max_uses=1) + data = registration_data() + data["visitor_data"] = {"favorites": None} + serializer = RegisterSerializer(data=data) + + assert not serializer.is_valid() + assert "favorites 必须是数组" in str(serializer.errors["visitor_data"]) diff --git a/backend/accounts/tests.py b/backend/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/accounts/urls.py b/backend/accounts/urls.py new file mode 100644 index 0000000..152a432 --- /dev/null +++ b/backend/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from .views import LoginView, LogoutView, MeView, RegisterView, VisitorMigrationView + + +urlpatterns = [ + path("register/", RegisterView.as_view(), name="register"), + path("login/", LoginView.as_view(), name="login"), + path("logout/", LogoutView.as_view(), name="logout"), + path("me/", MeView.as_view(), name="me"), + path("visitor-migration/", VisitorMigrationView.as_view(), name="visitor-migration"), +] diff --git a/backend/accounts/views.py b/backend/accounts/views.py new file mode 100644 index 0000000..1d56c0c --- /dev/null +++ b/backend/accounts/views.py @@ -0,0 +1,111 @@ +from django.contrib.auth import authenticate, login, logout +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .migration_service import apply_visitor_migration +from .models import UserSession, VisitorMigration +from .serializers import RegisterSerializer, UserSerializer + + +def client_ip(request): + forwarded = request.META.get("HTTP_X_FORWARDED_FOR") + return forwarded.split(",")[0].strip() if forwarded else request.META.get("REMOTE_ADDR") + + +class RegisterView(APIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + serializer = RegisterSerializer( + data=request.data, + context={"ip_address": client_ip(request)}, + ) + serializer.is_valid(raise_exception=True) + user = serializer.save() + login(request, user) + self._record_session(request, user) + payload = UserSerializer(user).data + payload["visitor_migration_pending"] = hasattr(user, "visitor_migration") + return Response(payload, status=status.HTTP_201_CREATED) + + @staticmethod + def _record_session(request, user): + if not request.session.session_key: + request.session.save() + UserSession.objects.update_or_create( + session_key=request.session.session_key, + defaults={ + "user": user, + "user_agent": request.headers.get("User-Agent", "")[:300], + "ip_address": client_ip(request), + }, + ) + + +class LoginView(APIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + user = authenticate( + request, + username=request.data.get("username", ""), + password=request.data.get("password", ""), + ) + if user is None or not user.is_active: + return Response( + {"error": {"code": "invalid_credentials", "message": "用户名或密码错误"}}, + status=status.HTTP_400_BAD_REQUEST, + ) + login(request, user) + RegisterView._record_session(request, user) + return Response(UserSerializer(user).data) + + +class LogoutView(APIView): + def post(self, request): + if request.session.session_key: + UserSession.objects.filter(session_key=request.session.session_key).delete() + logout(request) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class MeView(APIView): + def get(self, request): + return Response(UserSerializer(request.user).data) + + def patch(self, request): + serializer = UserSerializer(request.user, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + +class VisitorMigrationView(APIView): + def get(self, request): + migration = VisitorMigration.objects.filter(user=request.user).first() + if migration is None: + return Response({"pending": False, "summary": {}}) + return Response( + { + "pending": migration.status == VisitorMigration.Status.PENDING, + "status": migration.status, + "summary": migration.summary, + } + ) + + def post(self, request): + migration = VisitorMigration.objects.filter(user=request.user).first() + if migration is None: + return Response( + {"error": {"code": "no_visitor_data", "message": "没有待迁移的游客数据"}}, + status=status.HTTP_404_NOT_FOUND, + ) + migration = apply_visitor_migration(request.user) + return Response( + { + "pending": False, + "status": migration.status, + "summary": migration.summary, + } + ) diff --git a/backend/common/__init__.py b/backend/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/common/admin.py b/backend/common/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/common/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/common/api.py b/backend/common/api.py new file mode 100644 index 0000000..05328aa --- /dev/null +++ b/backend/common/api.py @@ -0,0 +1,18 @@ +from rest_framework.views import exception_handler as drf_exception_handler + + +def exception_handler(exc, context): + response = drf_exception_handler(exc, context) + if response is None: + return response + + request = context.get("request") + response.data = { + "error": { + "code": getattr(exc, "default_code", "request_error"), + "message": "请求未能完成", + "details": response.data, + "request_id": getattr(request, "request_id", None), + } + } + return response diff --git a/backend/common/apps.py b/backend/common/apps.py new file mode 100644 index 0000000..01cca2f --- /dev/null +++ b/backend/common/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommonConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'common' diff --git a/backend/common/consumers.py b/backend/common/consumers.py new file mode 100644 index 0000000..01f1e9e --- /dev/null +++ b/backend/common/consumers.py @@ -0,0 +1,17 @@ +from channels.generic.websocket import AsyncJsonWebsocketConsumer + + +class HealthConsumer(AsyncJsonWebsocketConsumer): + group_name = "healthcheck" + + async def connect(self): + try: + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.channel_layer.group_discard(self.group_name, self.channel_name) + except Exception: + await self.close(code=1011) + return + + await self.accept() + await self.send_json({"status": "ok", "channel_layer": "ok"}) + await self.close() diff --git a/backend/common/logging.py b/backend/common/logging.py new file mode 100644 index 0000000..fb53a2c --- /dev/null +++ b/backend/common/logging.py @@ -0,0 +1,11 @@ +import contextvars +import logging + + +request_id_context = contextvars.ContextVar("request_id", default="-") + + +class RequestIDFilter(logging.Filter): + def filter(self, record): + record.request_id = request_id_context.get() + return True diff --git a/backend/common/management/__init__.py b/backend/common/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/common/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/common/management/commands/__init__.py b/backend/common/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/common/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/common/management/commands/check_mysql.py b/backend/common/management/commands/check_mysql.py new file mode 100644 index 0000000..5db39cb --- /dev/null +++ b/backend/common/management/commands/check_mysql.py @@ -0,0 +1,63 @@ +import re + +from django.core.management.base import BaseCommand, CommandError +from django.db import connection + + +MINIMUM_VERSION = (8, 0, 35) + + +class Command(BaseCommand): + help = "检查生产 MySQL 的版本、字符集、引擎、严格模式和事务隔离级别" + + def handle(self, *args, **options): + if connection.vendor != "mysql": + raise CommandError(f"数据库必须是 MySQL,当前为 {connection.vendor}") + + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT VERSION(), + @@character_set_server, + @@collation_server, + @@sql_mode, + @@transaction_isolation, + @@default_storage_engine + """ + ) + ( + version_text, + charset, + collation, + sql_mode, + isolation, + storage_engine, + ) = cursor.fetchone() + + match = re.match(r"^(\d+)\.(\d+)\.(\d+)", version_text) + if match is None: + raise CommandError(f"无法解析 MySQL 版本: {version_text}") + version = tuple(int(part) for part in match.groups()) + if version < MINIMUM_VERSION: + minimum = ".".join(str(part) for part in MINIMUM_VERSION) + raise CommandError(f"MySQL 版本过低: {version_text},最低要求 {minimum}") + if charset.lower() != "utf8mb4": + raise CommandError(f"character_set_server 必须是 utf8mb4,当前为 {charset}") + if not collation.lower().startswith("utf8mb4_"): + raise CommandError(f"collation_server 必须属于 utf8mb4,当前为 {collation}") + + modes = {mode.strip().upper() for mode in sql_mode.split(",")} + if not {"STRICT_TRANS_TABLES", "STRICT_ALL_TABLES"} & modes: + raise CommandError(f"MySQL 未启用严格模式: {sql_mode}") + if isolation.upper().replace("_", "-") != "READ-COMMITTED": + raise CommandError(f"事务隔离级别必须是 READ-COMMITTED,当前为 {isolation}") + if storage_engine.lower() != "innodb": + raise CommandError(f"默认存储引擎必须是 InnoDB,当前为 {storage_engine}") + + self.stdout.write( + self.style.SUCCESS( + "MySQL 检查通过: " + f"version={version_text}, charset={charset}, " + f"collation={collation}, isolation={isolation}, engine={storage_engine}" + ) + ) diff --git a/backend/common/middleware.py b/backend/common/middleware.py new file mode 100644 index 0000000..8eff3f2 --- /dev/null +++ b/backend/common/middleware.py @@ -0,0 +1,19 @@ +import uuid + +from .logging import request_id_context + + +class RequestIDMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) + request.request_id = request_id + token = request_id_context.set(request_id) + try: + response = self.get_response(request) + response["X-Request-ID"] = request_id + return response + finally: + request_id_context.reset(token) diff --git a/backend/common/migrations/__init__.py b/backend/common/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/common/models.py b/backend/common/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/backend/common/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/common/tests.py b/backend/common/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/common/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/common/views.py b/backend/common/views.py new file mode 100644 index 0000000..bc787c7 --- /dev/null +++ b/backend/common/views.py @@ -0,0 +1,14 @@ +from django.db import connection +from django.http import JsonResponse +from django.shortcuts import render + + +def home(request): + return render(request, "index.html") + + +def health(request): + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + cursor.fetchone() + return JsonResponse({"status": "ok", "database": "ok"}) diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..b04a7aa --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,29 @@ +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application +from django.urls import path + + +django_asgi_application = get_asgi_application() + +from common.consumers import HealthConsumer +from contest.routing import websocket_urlpatterns + + +application = ProtocolTypeRouter( + { + "http": django_asgi_application, + "websocket": AuthMiddlewareStack( + URLRouter( + [ + path("ws/health/", HealthConsumer.as_asgi()), + *websocket_urlpatterns, + ] + ) + ), + } +) diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..6967f44 --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,196 @@ +import os +from pathlib import Path + +import dj_database_url +from django.core.exceptions import ImproperlyConfigured + + +BASE_DIR = Path(__file__).resolve().parent.parent +PROJECT_ROOT = BASE_DIR.parent + +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-before-production") +DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" +if not DEBUG and SECRET_KEY == "dev-only-change-before-production": + raise ImproperlyConfigured("生产环境必须设置 DJANGO_SECRET_KEY") +ALLOWED_HOSTS = [ + host.strip() + for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver").split(",") + if host.strip() +] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "corsheaders", + "rest_framework", + "channels", + "accounts", + "math_life", + "contest", + "progression", + "latex_lab", + "content", + "engagement", + "common", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "common.middleware.RequestIDMiddleware", +] + +ROOT_URLCONF = "config.urls" +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +DATABASE_URL = os.getenv("DATABASE_URL") +if not DEBUG and not DATABASE_URL: + raise ImproperlyConfigured("生产环境必须设置 MySQL DATABASE_URL") +if not DEBUG and not DATABASE_URL.startswith("mysql://"): + raise ImproperlyConfigured("生产环境 DATABASE_URL 必须使用 MySQL") +DATABASES = { + "default": dj_database_url.parse( + DATABASE_URL or f"sqlite:///{BASE_DIR / 'db.sqlite3'}", + conn_max_age=60, + conn_health_checks=True, + ) +} +if DATABASES["default"]["ENGINE"] == "django.db.backends.mysql": + DATABASES["default"]["OPTIONS"] = { + "charset": "utf8mb4", + "init_command": ( + "SET sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO," + "NO_ENGINE_SUBSTITUTION'" + ), + "isolation_level": "read committed", + } + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] +AUTH_USER_MODEL = "accounts.User" + +LANGUAGE_CODE = "zh-hans" +TIME_ZONE = "Asia/Shanghai" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "static"] +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, + "EXCEPTION_HANDLER": "common.api.exception_handler", + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": os.getenv("API_ANON_RATE", "120/minute"), + "user": os.getenv("API_USER_RATE", "600/minute"), + }, +} + +CORS_ALLOWED_ORIGINS = [ + origin.strip() + for origin in os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") + if origin.strip() +] +CORS_ALLOW_CREDENTIALS = True +CSRF_TRUSTED_ORIGINS = [ + origin.strip() + for origin in os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",") + if origin.strip() +] + +REDIS_URL = os.getenv("REDIS_URL") +if not DEBUG and not REDIS_URL: + raise ImproperlyConfigured("生产环境必须设置 Redis REDIS_URL") +if REDIS_URL: + CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": {"hosts": [REDIS_URL]}, + } + } +else: + CHANNEL_LAYERS = { + "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"} + } + +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SAMESITE = "Lax" +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = "DENY" +SECURE_SSL_REDIRECT = not DEBUG +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG +SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "31536000" if not DEBUG else "0")) +SECURE_HSTS_INCLUDE_SUBDOMAINS = not DEBUG +SECURE_HSTS_PRELOAD = not DEBUG + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": { + "format": "%(asctime)s %(levelname)s %(name)s request_id=%(request_id)s %(message)s" + } + }, + "filters": {"request_id": {"()": "common.logging.RequestIDFilter"}}, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "standard", + "filters": ["request_id"], + } + }, + "root": {"handlers": ["console"], "level": "INFO"}, +} diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..15fd2c0 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,21 @@ +from django.contrib import admin +from django.urls import include, path + +from common.views import health, home + + +admin.site.site_header = "葫芦数学运营后台" +admin.site.site_title = "葫芦数学" +admin.site.index_title = "内容与运营" + +urlpatterns = [ + path("", home, name="home"), + path("health/", health, name="health"), + path("admin/", admin.site.urls), + path("api/v1/accounts/", include("accounts.urls")), + path("api/v1/math-life/", include("math_life.urls")), + path("api/v1/contests/", include("contest.urls")), + path("api/v1/latex/", include("latex_lab.urls")), + path("api/v1/content/", include("content.urls")), + path("api/v1/progression/", include("progression.urls")), +] diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..8ae71e3 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/backend/content/__init__.py b/backend/content/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/content/admin.py b/backend/content/admin.py new file mode 100644 index 0000000..091f003 --- /dev/null +++ b/backend/content/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin + +from .models import ContentInteraction, ContentItem, VideoProgress + + +@admin.register(ContentItem) +class ContentItemAdmin(admin.ModelAdmin): + list_display = ( + "title", + "kind", + "ability_dimension", + "discipline", + "duration_seconds", + "is_published", + ) + list_filter = ("kind", "ability_dimension", "discipline", "is_published") + search_fields = ("title", "summary", "body") + + +admin.site.register(ContentInteraction) +admin.site.register(VideoProgress) diff --git a/backend/content/apps.py b/backend/content/apps.py new file mode 100644 index 0000000..273d169 --- /dev/null +++ b/backend/content/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ContentConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'content' diff --git a/backend/content/migrations/0001_initial.py b/backend/content/migrations/0001_initial.py new file mode 100644 index 0000000..2f00c14 --- /dev/null +++ b/backend/content/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.23 on 2026-08-08 11:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ContentItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(unique=True)), + ('title', models.CharField(max_length=160)), + ('kind', models.CharField(choices=[('video', '视频'), ('knowledge', '知识卡片'), ('person', '数学人物')], max_length=16)), + ('summary', models.TextField(blank=True)), + ('body', models.TextField(blank=True)), + ('cover_url', models.URLField(blank=True)), + ('media_url', models.URLField(blank=True)), + ('topics', models.JSONField(blank=True, default=list)), + ('source', models.CharField(blank=True, max_length=300)), + ('is_published', models.BooleanField(default=False)), + ('published_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'ordering': ['-published_at'], + }, + ), + migrations.CreateModel( + name='ContentInteraction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('view', '浏览'), ('favorite', '收藏'), ('complete', '完成')], max_length=16)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interactions', to='content.contentitem')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='contentinteraction', + constraint=models.UniqueConstraint(fields=('user', 'content', 'action'), name='unique_content_interaction'), + ), + ] diff --git a/backend/content/migrations/0002_contentitem_ability_dimension_contentitem_author_and_more.py b/backend/content/migrations/0002_contentitem_ability_dimension_contentitem_author_and_more.py new file mode 100644 index 0000000..76b8b9d --- /dev/null +++ b/backend/content/migrations/0002_contentitem_ability_dimension_contentitem_author_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 4.2.23 on 2026-08-08 11:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('content', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='contentitem', + name='ability_dimension', + field=models.CharField(blank=True, choices=[('vision', '数学眼光'), ('humanities', '数学人文'), ('detection', '数学侦探'), ('modeling', '数学建模'), ('connection', '数学联结')], max_length=20), + ), + migrations.AddField( + model_name='contentitem', + name='author', + field=models.CharField(blank=True, max_length=120), + ), + migrations.AddField( + model_name='contentitem', + name='comment_count', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='contentitem', + name='discipline', + field=models.CharField(blank=True, max_length=80), + ), + migrations.AddField( + model_name='contentitem', + name='discipline_icon', + field=models.CharField(blank=True, max_length=10), + ), + migrations.AddField( + model_name='contentitem', + name='duration_seconds', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='contentitem', + name='module', + field=models.CharField(blank=True, max_length=40), + ), + migrations.AddField( + model_name='contentitem', + name='sub_category', + field=models.CharField(blank=True, max_length=80), + ), + migrations.AddField( + model_name='contentitem', + name='view_count', + field=models.PositiveIntegerField(default=0), + ), + migrations.CreateModel( + name='VideoProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('position_seconds', models.PositiveIntegerField(default=0)), + ('completed', models.BooleanField(default=False)), + ('reward_granted', models.BooleanField(default=False)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_progress', to='content.contentitem')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_progress', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='videoprogress', + constraint=models.UniqueConstraint(fields=('user', 'content'), name='unique_user_video_progress'), + ), + ] diff --git a/backend/content/migrations/__init__.py b/backend/content/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/content/models.py b/backend/content/models.py new file mode 100644 index 0000000..2a36fb3 --- /dev/null +++ b/backend/content/models.py @@ -0,0 +1,85 @@ +from django.conf import settings +from django.db import models + + +class ContentItem(models.Model): + class Ability(models.TextChoices): + VISION = "vision", "数学眼光" + HUMANITIES = "humanities", "数学人文" + DETECTION = "detection", "数学侦探" + MODELING = "modeling", "数学建模" + CONNECTION = "connection", "数学联结" + + class Kind(models.TextChoices): + VIDEO = "video", "视频" + KNOWLEDGE = "knowledge", "知识卡片" + PERSON = "person", "数学人物" + + slug = models.SlugField(unique=True) + title = models.CharField(max_length=160) + kind = models.CharField(max_length=16, choices=Kind.choices) + summary = models.TextField(blank=True) + body = models.TextField(blank=True) + cover_url = models.URLField(blank=True) + media_url = models.URLField(blank=True) + topics = models.JSONField(default=list, blank=True) + source = models.CharField(max_length=300, blank=True) + ability_dimension = models.CharField( + max_length=20, choices=Ability.choices, blank=True + ) + module = models.CharField(max_length=40, blank=True) + sub_category = models.CharField(max_length=80, blank=True) + discipline = models.CharField(max_length=80, blank=True) + discipline_icon = models.CharField(max_length=10, blank=True) + author = models.CharField(max_length=120, blank=True) + duration_seconds = models.PositiveIntegerField(default=0) + view_count = models.PositiveIntegerField(default=0) + comment_count = models.PositiveIntegerField(default=0) + is_published = models.BooleanField(default=False) + published_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-published_at"] + + def __str__(self): + return self.title + + +class ContentInteraction(models.Model): + class Action(models.TextChoices): + VIEW = "view", "浏览" + FAVORITE = "favorite", "收藏" + COMPLETE = "complete", "完成" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + content = models.ForeignKey(ContentItem, on_delete=models.CASCADE, related_name="interactions") + action = models.CharField(max_length=16, choices=Action.choices) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("user", "content", "action"), name="unique_content_interaction" + ) + ] + + +class VideoProgress(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="video_progress" + ) + content = models.ForeignKey( + ContentItem, on_delete=models.CASCADE, related_name="video_progress" + ) + position_seconds = models.PositiveIntegerField(default=0) + completed = models.BooleanField(default=False) + reward_granted = models.BooleanField(default=False) + updated_at = models.DateTimeField(auto_now=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("user", "content"), name="unique_user_video_progress" + ) + ] diff --git a/backend/content/services.py b/backend/content/services.py new file mode 100644 index 0000000..7c090f0 --- /dev/null +++ b/backend/content/services.py @@ -0,0 +1,68 @@ +from django.db import transaction +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from progression.models import RewardTransaction, UserAbility + +from .models import ContentInteraction, ContentItem, VideoProgress + + +ABILITY_TO_DIMENSION = { + ContentItem.Ability.VISION: UserAbility.Dimension.VISION, + ContentItem.Ability.HUMANITIES: UserAbility.Dimension.HUMANITIES, + ContentItem.Ability.DETECTION: UserAbility.Dimension.DETECTION, + ContentItem.Ability.MODELING: UserAbility.Dimension.MODELING, + ContentItem.Ability.CONNECTION: UserAbility.Dimension.CONNECTION, +} + + +@transaction.atomic +def complete_video(user, content): + if content.kind != ContentItem.Kind.VIDEO: + raise ValidationError("只有视频内容可以提交观看完成") + + progress, _ = VideoProgress.objects.select_for_update().get_or_create( + user=user, + content=content, + ) + progress.position_seconds = max(progress.position_seconds, content.duration_seconds) + progress.completed = True + progress.completed_at = progress.completed_at or timezone.now() + + reward = None + dimension = ABILITY_TO_DIMENSION.get(content.ability_dimension) + if dimension and not progress.reward_granted: + transaction_key = f"video-complete:{content.id}" + reward_tx, created = RewardTransaction.objects.get_or_create( + user=user, + idempotency_key=transaction_key, + defaults={ + "source": "video_complete", + "rewards": {"ability": dimension, "fragments": 1}, + }, + ) + if created: + ability, _ = UserAbility.objects.select_for_update().get_or_create( + user=user, + dimension=dimension, + ) + ability.fragments += 1 + ability.save(update_fields=["fragments"]) + progress.reward_granted = True + reward = reward_tx.rewards + + progress.save( + update_fields=[ + "position_seconds", + "completed", + "completed_at", + "reward_granted", + "updated_at", + ] + ) + ContentInteraction.objects.get_or_create( + user=user, + content=content, + action=ContentInteraction.Action.COMPLETE, + ) + return progress, reward diff --git a/backend/content/test_services.py b/backend/content/test_services.py new file mode 100644 index 0000000..a34e970 --- /dev/null +++ b/backend/content/test_services.py @@ -0,0 +1,64 @@ +import pytest +from rest_framework.exceptions import ValidationError + +from accounts.models import User +from content.models import ContentInteraction, ContentItem, VideoProgress +from content.services import complete_video +from progression.models import RewardTransaction, UserAbility + + +@pytest.mark.django_db +def test_complete_video_重复提交只发放一次能力碎片(): + user = User.objects.create_user( + username="video_user", + password="StrongPass_2026", + nickname="视频用户", + ) + video = ContentItem.objects.create( + slug="video-modeling", + title="建模视频", + kind=ContentItem.Kind.VIDEO, + ability_dimension=ContentItem.Ability.MODELING, + duration_seconds=360, + is_published=True, + ) + + first, first_reward = complete_video(user, video) + second, second_reward = complete_video(user, video) + + ability = UserAbility.objects.get( + user=user, + dimension=UserAbility.Dimension.MODELING, + ) + assert first.completed is True + assert second.reward_granted is True + assert first_reward == {"ability": "modeling", "fragments": 1} + assert second_reward is None + assert ability.fragments == 1 + assert RewardTransaction.objects.filter(user=user).count() == 1 + assert VideoProgress.objects.filter(user=user, content=video).count() == 1 + assert ContentInteraction.objects.filter( + user=user, + content=video, + action=ContentInteraction.Action.COMPLETE, + ).count() == 1 + + +@pytest.mark.django_db +def test_complete_video_非视频内容拒绝完成(): + user = User.objects.create_user( + username="knowledge_user", + password="StrongPass_2026", + nickname="知识用户", + ) + content = ContentItem.objects.create( + slug="knowledge-item", + title="知识卡片", + kind=ContentItem.Kind.KNOWLEDGE, + is_published=True, + ) + + with pytest.raises(ValidationError, match="只有视频"): + complete_video(user, content) + + assert RewardTransaction.objects.count() == 0 diff --git a/backend/content/tests.py b/backend/content/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/content/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/content/urls.py b/backend/content/urls.py new file mode 100644 index 0000000..8c41995 --- /dev/null +++ b/backend/content/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from .views import ( + ContentDetailView, + ContentListView, + FavoriteView, + VideoCatalogView, + VideoCompleteView, +) + + +urlpatterns = [ + path("", ContentListView.as_view(), name="content-list"), + path("videos/catalog/", VideoCatalogView.as_view(), name="video-catalog"), + path("/", ContentDetailView.as_view(), name="content-detail"), + path("/favorite/", FavoriteView.as_view(), name="content-favorite"), + path("/complete/", VideoCompleteView.as_view(), name="video-complete"), +] diff --git a/backend/content/views.py b/backend/content/views.py new file mode 100644 index 0000000..af2cf62 --- /dev/null +++ b/backend/content/views.py @@ -0,0 +1,181 @@ +from django.db.models import Count +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import ContentInteraction, ContentItem, VideoProgress +from .services import complete_video + + +ABILITY_META = { + ContentItem.Ability.VISION: {"label": "数学眼光", "icon": "◉", "color": "#5d73e8"}, + ContentItem.Ability.HUMANITIES: {"label": "数学人文", "icon": "▤", "color": "#b76d38"}, + ContentItem.Ability.DETECTION: {"label": "数学侦探", "icon": "⌕", "color": "#287f78"}, + ContentItem.Ability.MODELING: {"label": "数学建模", "icon": "⌁", "color": "#d98628"}, + ContentItem.Ability.CONNECTION: {"label": "数学联结", "icon": "⛓", "color": "#8667b5"}, +} + + +def serialize_item(item, progress=None): + return { + "slug": item.slug, + "title": item.title, + "kind": item.kind, + "summary": item.summary, + "body": item.body, + "cover_url": item.cover_url, + "media_url": item.media_url, + "topics": item.topics, + "source": item.source, + "ability_dimension": item.ability_dimension, + "ability": ABILITY_META.get(item.ability_dimension), + "module": item.module, + "sub_category": item.sub_category, + "discipline": item.discipline, + "discipline_icon": item.discipline_icon, + "author": item.author, + "duration_seconds": item.duration_seconds, + "view_count": item.view_count, + "comment_count": item.comment_count, + "published_at": item.published_at, + "progress": ( + { + "position_seconds": progress.position_seconds, + "completed": progress.completed, + "reward_granted": progress.reward_granted, + } + if progress + else None + ), + } + + +class ContentListView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + queryset = ContentItem.objects.filter(is_published=True) + kind = request.query_params.get("kind") + ability = request.query_params.get("ability") + discipline = request.query_params.get("discipline") + if kind: + queryset = queryset.filter(kind=kind) + if ability: + queryset = queryset.filter(ability_dimension=ability) + if discipline: + queryset = queryset.filter(discipline=discipline) + + progress_by_content = {} + if request.user.is_authenticated: + progress_by_content = { + item.content_id: item + for item in VideoProgress.objects.filter( + user=request.user, + content__in=queryset, + ) + } + return Response( + [ + serialize_item(item, progress_by_content.get(item.id)) + for item in queryset[:100] + ] + ) + + +class VideoCatalogView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + videos = ContentItem.objects.filter( + kind=ContentItem.Kind.VIDEO, + is_published=True, + ) + counts = { + row["ability_dimension"]: row["count"] + for row in videos.values("ability_dimension").annotate(count=Count("id")) + } + abilities = [ + { + "id": ability, + **meta, + "count": counts.get(ability, 0), + } + for ability, meta in ABILITY_META.items() + ] + disciplines = [ + { + "name": row["discipline"], + "icon": row["discipline_icon"], + "count": row["count"], + } + for row in videos.values("discipline", "discipline_icon") + .annotate(count=Count("id")) + .order_by("discipline") + ] + return Response( + { + "total": videos.count(), + "abilities": abilities, + "disciplines": disciplines, + } + ) + + +class ContentDetailView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request, slug): + item = get_object_or_404(ContentItem, slug=slug, is_published=True) + progress = None + if request.user.is_authenticated: + ContentInteraction.objects.get_or_create( + user=request.user, + content=item, + action=ContentInteraction.Action.VIEW, + ) + progress, _ = VideoProgress.objects.get_or_create( + user=request.user, + content=item, + ) + return Response(serialize_item(item, progress)) + + +class VideoCompleteView(APIView): + def post(self, request, slug): + item = get_object_or_404( + ContentItem, + slug=slug, + kind=ContentItem.Kind.VIDEO, + is_published=True, + ) + progress, reward = complete_video(request.user, item) + return Response( + { + "completed": progress.completed, + "reward_granted": progress.reward_granted, + "reward": reward, + } + ) + + +class FavoriteView(APIView): + def post(self, request, slug): + item = get_object_or_404(ContentItem, slug=slug, is_published=True) + _, created = ContentInteraction.objects.get_or_create( + user=request.user, + content=item, + action=ContentInteraction.Action.FAVORITE, + ) + return Response( + {"favorite": True}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + def delete(self, request, slug): + ContentInteraction.objects.filter( + user=request.user, + content__slug=slug, + action=ContentInteraction.Action.FAVORITE, + ).delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/contest/__init__.py b/backend/contest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/contest/admin.py b/backend/contest/admin.py new file mode 100644 index 0000000..e146533 --- /dev/null +++ b/backend/contest/admin.py @@ -0,0 +1,47 @@ +from django.contrib import admin + +from .models import ( + CheatFlag, + Contest, + ContestAnswer, + ContestAttempt, + ContestQuestion, + LeaderboardSnapshot, + Question, + QuestionVersion, + RatingHistory, + RealtimeMatch, +) + + +class ContestQuestionInline(admin.TabularInline): + model = ContestQuestion + extra = 1 + + +@admin.register(Contest) +class ContestAdmin(admin.ModelAdmin): + list_display = ("title", "kind", "track", "status", "starts_at", "ends_at") + list_filter = ("kind", "track", "status") + inlines = [ContestQuestionInline] + + +@admin.register(ContestAttempt) +class ContestAttemptAdmin(admin.ModelAdmin): + list_display = ("id", "user", "contest", "status", "score", "duration_ms", "started_at") + list_filter = ("status", "contest__kind", "contest__track") + readonly_fields = ("started_at", "submitted_at") + + +@admin.register(CheatFlag) +class CheatFlagAdmin(admin.ModelAdmin): + list_display = ("attempt", "reason", "status", "created_at") + list_filter = ("status", "reason") + + +admin.site.register(Question) +admin.site.register(QuestionVersion) +admin.site.register(ContestAnswer) +admin.site.register(RealtimeMatch) +admin.site.register(RatingHistory) +admin.site.register(LeaderboardSnapshot) diff --git a/backend/contest/apps.py b/backend/contest/apps.py new file mode 100644 index 0000000..6e9d520 --- /dev/null +++ b/backend/contest/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ContestConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'contest' diff --git a/backend/contest/consumers.py b/backend/contest/consumers.py new file mode 100644 index 0000000..9f0f26a --- /dev/null +++ b/backend/contest/consumers.py @@ -0,0 +1,54 @@ +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer + +from .models import RealtimeMatch + + +class MatchConsumer(AsyncJsonWebsocketConsumer): + async def connect(self): + self.match_id = self.scope["url_route"]["kwargs"]["match_id"] + self.group_name = f"match_{self.match_id}" + user = self.scope["user"] + if not user.is_authenticated or not await self._is_participant(user.id): + await self.close(code=4403) + return + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + await self.send_json({"type": "connected", "match_id": str(self.match_id)}) + + async def disconnect(self, close_code): + if hasattr(self, "group_name"): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive_json(self, content, **kwargs): + event_type = content.get("type") + if event_type == "ping": + await self.send_json({"type": "pong"}) + return + if event_type == "progress": + answered_count = max(0, min(int(content.get("answered_count", 0)), 100)) + await self.channel_layer.group_send( + self.group_name, + { + "type": "match.progress", + "user_id": str(self.scope["user"].id), + "answered_count": answered_count, + }, + ) + + async def match_progress(self, event): + await self.send_json( + { + "type": "progress", + "user_id": event["user_id"], + "answered_count": event["answered_count"], + } + ) + + @database_sync_to_async + def _is_participant(self, user_id): + return RealtimeMatch.objects.filter(id=self.match_id).filter( + player_one_id=user_id + ).exists() or RealtimeMatch.objects.filter( + id=self.match_id, player_two_id=user_id + ).exists() diff --git a/backend/contest/management/__init__.py b/backend/contest/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/contest/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/contest/management/commands/__init__.py b/backend/contest/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/contest/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/contest/management/commands/seed_contests.py b/backend/contest/management/commands/seed_contests.py new file mode 100644 index 0000000..a9c08a1 --- /dev/null +++ b/backend/contest/management/commands/seed_contests.py @@ -0,0 +1,112 @@ +from django.core.management.base import BaseCommand + +from contest.models import Contest, ContestQuestion, Question, QuestionVersion + + +QUESTIONS = { + Question.Track.BEGINNER: [ + ("b-12-plus-19", "12 + 19", "31"), + ("b-8-times-7", "8 × 7", "56"), + ("b-90-minus-37", "90 - 37", "53"), + ("b-144-div-12", "144 ÷ 12", "12"), + ("b-25-times-4", "25 × 4", "100"), + ], + Question.Track.STANDARD: [ + ("s-17-times-23", "17 × 23", "391"), + ("s-625-div-25", "625 ÷ 25", "25"), + ("s-48-times-15", "48 × 15", "720"), + ("s-1000-minus-387", "1000 - 387", "613"), + ("s-35-squared", "35²", "1225"), + ], + Question.Track.ADVANCED: [ + ("a-mod-2pow10", "2¹⁰ 除以 7 的余数", "2"), + ("a-sum-1-50", "1 到 50 的整数和", "1275"), + ("a-15-choose-2", "C(15,2)", "105"), + ("a-sqrt-2025", "√2025", "45"), + ("a-3pow6", "3⁶", "729"), + ], +} + + +class Command(BaseCommand): + help = "创建首批分层题目、实时 1v1、每日赛和单人练习" + + def handle(self, *args, **options): + versions = {} + for track, questions in QUESTIONS.items(): + versions[track] = [] + for slug, prompt, answer in questions: + question, _ = Question.objects.update_or_create( + slug=slug, + defaults={"track": track, "tags": ["口算"], "is_active": True}, + ) + version, _ = QuestionVersion.objects.update_or_create( + question=question, + version=1, + defaults={ + "prompt": prompt, + "answer": answer, + "explanation": f"答案为 {answer}", + }, + ) + versions[track].append(version) + + contest_specs = [] + for track, label in ( + (Question.Track.BEGINNER, "入门"), + (Question.Track.STANDARD, "标准"), + (Question.Track.ADVANCED, "进阶"), + ): + contest_specs.extend( + [ + ( + f"realtime-{track}", + f"{label}实时 1v1", + Contest.Kind.REALTIME, + track, + 60, + ), + ( + f"daily-{track}", + f"{label}今日挑战", + Contest.Kind.DAILY, + track, + 180, + ), + ( + f"practice-{track}", + f"{label}单人闯关", + Contest.Kind.PRACTICE, + track, + 300, + ), + ] + ) + for slug, title, kind, track, duration in contest_specs: + contest, _ = Contest.objects.update_or_create( + slug=slug, + defaults={ + "title": title, + "kind": kind, + "track": track, + "duration_seconds": duration, + "status": Contest.Status.PUBLISHED, + }, + ) + ContestQuestion.objects.filter(contest=contest).delete() + ContestQuestion.objects.bulk_create( + [ + ContestQuestion( + contest=contest, + question_version=version, + order=index, + points=100, + ) + for index, version in enumerate(versions[track], start=1) + ] + ) + self.stdout.write( + self.style.SUCCESS( + f"已创建 {sum(map(len, QUESTIONS.values()))} 道题和 {len(contest_specs)} 场比赛" + ) + ) diff --git a/backend/contest/migrations/0001_initial.py b/backend/contest/migrations/0001_initial.py new file mode 100644 index 0000000..ff34b3b --- /dev/null +++ b/backend/contest/migrations/0001_initial.py @@ -0,0 +1,166 @@ +# Generated by Django 4.2.23 on 2026-08-08 11:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Contest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(unique=True)), + ('title', models.CharField(max_length=120)), + ('kind', models.CharField(choices=[('realtime', '实时 1v1'), ('daily', '今日挑战'), ('practice', '单人闯关'), ('weekly', '主题周赛')], max_length=16)), + ('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], max_length=16)), + ('status', models.CharField(choices=[('draft', '草稿'), ('published', '已发布'), ('closed', '已结束')], default='draft', max_length=16)), + ('duration_seconds', models.PositiveIntegerField(default=60)), + ('starts_at', models.DateTimeField(blank=True, null=True)), + ('ends_at', models.DateTimeField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(unique=True)), + ('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], max_length=16)), + ('tags', models.JSONField(blank=True, default=list)), + ('is_active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='RealtimeMatch', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('waiting', '等待对手'), ('active', '进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='waiting', max_length=16)), + ('player_one_rating', models.PositiveIntegerField()), + ('player_two_rating', models.PositiveIntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('contest', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contest.contest')), + ('player_one', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='matches_as_player_one', to=settings.AUTH_USER_MODEL)), + ('player_two', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='matches_as_player_two', to=settings.AUTH_USER_MODEL)), + ('winner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='won_matches', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='RatingHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rating_before', models.PositiveIntegerField()), + ('rating_after', models.PositiveIntegerField()), + ('delta', models.SmallIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('match', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rating_changes', to='contest.realtimematch')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rating_history', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='QuestionVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField()), + ('prompt', models.TextField()), + ('answer', models.CharField(max_length=200)), + ('explanation', models.TextField(blank=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='contest.question')), + ], + ), + migrations.CreateModel( + name='LeaderboardSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], max_length=16)), + ('period', models.CharField(max_length=40)), + ('rankings', models.JSONField(default=list)), + ('generated_at', models.DateTimeField(auto_now_add=True)), + ('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contest.contest')), + ], + ), + migrations.CreateModel( + name='ContestQuestion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.PositiveIntegerField()), + ('points', models.PositiveIntegerField(default=100)), + ('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_questions', to='contest.contest')), + ('question_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contest.questionversion')), + ], + options={ + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='ContestAttempt', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('active', '进行中'), ('submitted', '已提交'), ('expired', '已超时')], default='active', max_length=16)), + ('score', models.PositiveIntegerField(default=0)), + ('correct_count', models.PositiveIntegerField(default=0)), + ('answer_count', models.PositiveIntegerField(default=0)), + ('duration_ms', models.PositiveIntegerField(default=0)), + ('submission_key', models.CharField(blank=True, max_length=80, null=True)), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('submitted_at', models.DateTimeField(blank=True, null=True)), + ('contest', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attempts', to='contest.contest')), + ('match', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='attempts', to='contest.realtimematch')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_attempts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-started_at'], + }, + ), + migrations.CreateModel( + name='ContestAnswer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('submitted_answer', models.CharField(max_length=200)), + ('is_correct', models.BooleanField()), + ('elapsed_ms', models.PositiveIntegerField()), + ('answered_at', models.DateTimeField(auto_now_add=True)), + ('attempt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='contest.contestattempt')), + ('contest_question', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contest.contestquestion')), + ], + ), + migrations.CreateModel( + name='CheatFlag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reason', models.CharField(max_length=120)), + ('evidence', models.JSONField(default=dict)), + ('status', models.CharField(choices=[('open', '待处理'), ('dismissed', '已忽略'), ('confirmed', '已确认')], default='open', max_length=16)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('attempt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cheat_flags', to='contest.contestattempt')), + ], + ), + migrations.AddConstraint( + model_name='questionversion', + constraint=models.UniqueConstraint(fields=('question', 'version'), name='unique_question_version'), + ), + migrations.AddConstraint( + model_name='contestquestion', + constraint=models.UniqueConstraint(fields=('contest', 'order'), name='unique_contest_question_order'), + ), + migrations.AddConstraint( + model_name='contestattempt', + constraint=models.UniqueConstraint(fields=('user', 'submission_key'), name='unique_user_contest_submission'), + ), + migrations.AddConstraint( + model_name='contestanswer', + constraint=models.UniqueConstraint(fields=('attempt', 'contest_question'), name='unique_attempt_question_answer'), + ), + ] diff --git a/backend/contest/migrations/0002_realtimematch_matchmaking_lookup_idx.py b/backend/contest/migrations/0002_realtimematch_matchmaking_lookup_idx.py new file mode 100644 index 0000000..e827608 --- /dev/null +++ b/backend/contest/migrations/0002_realtimematch_matchmaking_lookup_idx.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.23 on 2026-08-08 13:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contest', '0001_initial'), + ] + + operations = [ + migrations.AddIndex( + model_name='realtimematch', + index=models.Index(fields=['contest', 'status', 'player_one_rating', 'created_at'], name='matchmaking_lookup_idx'), + ), + ] diff --git a/backend/contest/migrations/__init__.py b/backend/contest/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/contest/models.py b/backend/contest/models.py new file mode 100644 index 0000000..670e967 --- /dev/null +++ b/backend/contest/models.py @@ -0,0 +1,194 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class Question(models.Model): + class Track(models.TextChoices): + BEGINNER = "beginner", "入门" + STANDARD = "standard", "标准" + ADVANCED = "advanced", "进阶" + OPEN = "open", "Open" + + slug = models.SlugField(unique=True) + track = models.CharField(max_length=16, choices=Track.choices) + tags = models.JSONField(default=list, blank=True) + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.slug + + +class QuestionVersion(models.Model): + question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="versions") + version = models.PositiveIntegerField() + prompt = models.TextField() + answer = models.CharField(max_length=200) + explanation = models.TextField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("question", "version"), name="unique_question_version") + ] + + def __str__(self): + return f"{self.question.slug} v{self.version}" + + +class Contest(models.Model): + class Kind(models.TextChoices): + REALTIME = "realtime", "实时 1v1" + DAILY = "daily", "今日挑战" + PRACTICE = "practice", "单人闯关" + WEEKLY = "weekly", "主题周赛" + + class Status(models.TextChoices): + DRAFT = "draft", "草稿" + PUBLISHED = "published", "已发布" + CLOSED = "closed", "已结束" + + slug = models.SlugField(unique=True) + title = models.CharField(max_length=120) + kind = models.CharField(max_length=16, choices=Kind.choices) + track = models.CharField(max_length=16, choices=Question.Track.choices) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT) + duration_seconds = models.PositiveIntegerField(default=60) + starts_at = models.DateTimeField(null=True, blank=True) + ends_at = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return self.title + + +class ContestQuestion(models.Model): + contest = models.ForeignKey(Contest, on_delete=models.CASCADE, related_name="contest_questions") + question_version = models.ForeignKey(QuestionVersion, on_delete=models.PROTECT) + order = models.PositiveIntegerField() + points = models.PositiveIntegerField(default=100) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("contest", "order"), name="unique_contest_question_order") + ] + ordering = ["order"] + + +class RealtimeMatch(models.Model): + class Status(models.TextChoices): + WAITING = "waiting", "等待对手" + ACTIVE = "active", "进行中" + COMPLETED = "completed", "已完成" + CANCELLED = "cancelled", "已取消" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + contest = models.ForeignKey(Contest, on_delete=models.PROTECT) + player_one = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="matches_as_player_one" + ) + player_two = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="matches_as_player_two", + ) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.WAITING) + player_one_rating = models.PositiveIntegerField() + player_two_rating = models.PositiveIntegerField(null=True, blank=True) + winner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="won_matches", + ) + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + indexes = [ + models.Index( + fields=("contest", "status", "player_one_rating", "created_at"), + name="matchmaking_lookup_idx", + ) + ] + + +class ContestAttempt(models.Model): + class Status(models.TextChoices): + ACTIVE = "active", "进行中" + SUBMITTED = "submitted", "已提交" + EXPIRED = "expired", "已超时" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + contest = models.ForeignKey(Contest, on_delete=models.PROTECT, related_name="attempts") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="contest_attempts") + match = models.ForeignKey( + RealtimeMatch, null=True, blank=True, on_delete=models.PROTECT, related_name="attempts" + ) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE) + score = models.PositiveIntegerField(default=0) + correct_count = models.PositiveIntegerField(default=0) + answer_count = models.PositiveIntegerField(default=0) + duration_ms = models.PositiveIntegerField(default=0) + submission_key = models.CharField(max_length=80, null=True, blank=True) + started_at = models.DateTimeField(auto_now_add=True) + submitted_at = models.DateTimeField(null=True, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("user", "submission_key"), name="unique_user_contest_submission" + ) + ] + ordering = ["-started_at"] + + +class ContestAnswer(models.Model): + attempt = models.ForeignKey(ContestAttempt, on_delete=models.CASCADE, related_name="answers") + contest_question = models.ForeignKey(ContestQuestion, on_delete=models.PROTECT) + submitted_answer = models.CharField(max_length=200) + is_correct = models.BooleanField() + elapsed_ms = models.PositiveIntegerField() + answered_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("attempt", "contest_question"), name="unique_attempt_question_answer" + ) + ] + + +class RatingHistory(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="rating_history") + match = models.ForeignKey(RealtimeMatch, on_delete=models.PROTECT, related_name="rating_changes") + rating_before = models.PositiveIntegerField() + rating_after = models.PositiveIntegerField() + delta = models.SmallIntegerField() + created_at = models.DateTimeField(auto_now_add=True) + + +class LeaderboardSnapshot(models.Model): + contest = models.ForeignKey(Contest, on_delete=models.CASCADE) + track = models.CharField(max_length=16, choices=Question.Track.choices) + period = models.CharField(max_length=40) + rankings = models.JSONField(default=list) + generated_at = models.DateTimeField(auto_now_add=True) + + +class CheatFlag(models.Model): + class Status(models.TextChoices): + OPEN = "open", "待处理" + DISMISSED = "dismissed", "已忽略" + CONFIRMED = "confirmed", "已确认" + + attempt = models.ForeignKey(ContestAttempt, on_delete=models.CASCADE, related_name="cheat_flags") + reason = models.CharField(max_length=120) + evidence = models.JSONField(default=dict) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/contest/routing.py b/backend/contest/routing.py new file mode 100644 index 0000000..9b8677a --- /dev/null +++ b/backend/contest/routing.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .consumers import MatchConsumer + + +websocket_urlpatterns = [ + path("ws/v1/contest/matches//", MatchConsumer.as_asgi()), +] diff --git a/backend/contest/services.py b/backend/contest/services.py new file mode 100644 index 0000000..211cecc --- /dev/null +++ b/backend/contest/services.py @@ -0,0 +1,279 @@ +from decimal import Decimal + +from django.db import transaction +from django.db.models import Q +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from accounts.models import User +from .models import ( + CheatFlag, + Contest, + ContestAnswer, + ContestAttempt, + RatingHistory, + RealtimeMatch, +) + + +def normalize_answer(value): + text = str(value).strip().lower().replace(" ", "") + try: + return str(Decimal(text).normalize()) + except Exception: + return text + + +def attempt_payload(attempt, include_results=False): + questions = [] + answers = {answer.contest_question_id: answer for answer in attempt.answers.all()} + for item in attempt.contest.contest_questions.select_related("question_version").all(): + question = { + "order": item.order, + "prompt": item.question_version.prompt, + "metadata": item.question_version.metadata, + "points": item.points, + } + if include_results and item.id in answers: + answer = answers[item.id] + question.update( + { + "submitted_answer": answer.submitted_answer, + "is_correct": answer.is_correct, + "correct_answer": item.question_version.answer, + "explanation": item.question_version.explanation, + } + ) + questions.append(question) + return { + "attempt_id": attempt.id, + "contest": attempt.contest.title, + "kind": attempt.contest.kind, + "status": attempt.status, + "duration_seconds": attempt.contest.duration_seconds, + "server_started_at": attempt.started_at, + "score": attempt.score, + "correct_count": attempt.correct_count, + "answer_count": attempt.answer_count, + "duration_ms": attempt.duration_ms, + "questions": questions, + } + + +@transaction.atomic +def start_attempt(user, contest): + now = timezone.now() + if contest.status != Contest.Status.PUBLISHED: + raise ValidationError("比赛尚未发布") + if contest.starts_at and contest.starts_at > now: + raise ValidationError("比赛尚未开始") + if contest.ends_at and contest.ends_at <= now: + raise ValidationError("比赛已经结束") + if contest.kind == Contest.Kind.DAILY: + existing = ContestAttempt.objects.filter(user=user, contest=contest).first() + if existing: + return attempt_payload( + existing, + include_results=existing.status != ContestAttempt.Status.ACTIVE, + ) + attempt = ContestAttempt.objects.create(contest=contest, user=user) + return attempt_payload(attempt) + + +@transaction.atomic +def submit_attempt(user, attempt_id, raw_answers, submission_key): + attempt = ( + ContestAttempt.objects.select_for_update() + .select_related("contest") + .get(id=attempt_id, user=user) + ) + if attempt.status != ContestAttempt.Status.ACTIVE: + if submission_key and attempt.submission_key == submission_key: + return attempt_payload(attempt, include_results=True) + raise ValidationError("该答题记录已经结算") + if not submission_key: + raise ValidationError({"Idempotency-Key": "正式提交必须提供幂等键"}) + + now = timezone.now() + duration_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000)) + limit_ms = attempt.contest.duration_seconds * 1000 + items = list( + attempt.contest.contest_questions.select_related("question_version").all() + ) + if not isinstance(raw_answers, list): + raise ValidationError({"answers": "答案必须是数组"}) + by_order = {} + try: + for item in raw_answers: + order = int(item["order"]) + if order <= 0 or order in by_order: + raise ValueError + by_order[order] = item.get("answer", "") + except (KeyError, TypeError, ValueError) as exc: + raise ValidationError({"answers": "答案题号无效或重复"}) from exc + score = 0 + correct_count = 0 + for contest_question in items: + submitted = str(by_order.get(contest_question.order, ""))[:200] + correct = normalize_answer(submitted) == normalize_answer( + contest_question.question_version.answer + ) + if correct and duration_ms <= limit_ms: + score += contest_question.points + correct_count += 1 + ContestAnswer.objects.create( + attempt=attempt, + contest_question=contest_question, + submitted_answer=submitted, + is_correct=correct and duration_ms <= limit_ms, + elapsed_ms=min(duration_ms, limit_ms + 60_000), + ) + + attempt.status = ( + ContestAttempt.Status.SUBMITTED + if duration_ms <= limit_ms + else ContestAttempt.Status.EXPIRED + ) + attempt.score = score + attempt.correct_count = correct_count + attempt.answer_count = len(raw_answers) + attempt.duration_ms = duration_ms + attempt.submission_key = submission_key + attempt.submitted_at = now + attempt.save() + + if raw_answers and duration_ms / len(raw_answers) < 150: + CheatFlag.objects.create( + attempt=attempt, + reason="extreme_answer_speed", + evidence={ + "duration_ms": duration_ms, + "answer_count": len(raw_answers), + }, + ) + if attempt.match_id: + finalize_match(attempt.match_id) + return attempt_payload(attempt, include_results=True) + + +@transaction.atomic +def find_match(user, contest): + if contest.kind != Contest.Kind.REALTIME or contest.status != Contest.Status.PUBLISHED: + raise ValidationError("实时比赛不可用") + existing = ( + RealtimeMatch.objects.filter( + Q(player_one=user) | Q(player_two=user), + contest=contest, + status__in=[RealtimeMatch.Status.WAITING, RealtimeMatch.Status.ACTIVE], + ) + .order_by("-created_at") + .first() + ) + if existing: + return existing + + waiting = ( + RealtimeMatch.objects.select_for_update(skip_locked=True) + .filter( + contest=contest, + status=RealtimeMatch.Status.WAITING, + player_one_rating__gte=max(0, user.rating - 300), + player_one_rating__lte=user.rating + 300, + ) + .exclude(player_one=user) + .order_by("created_at") + .first() + ) + if waiting is None: + return RealtimeMatch.objects.create( + contest=contest, + player_one=user, + player_one_rating=user.rating, + ) + + waiting.player_two = user + waiting.player_two_rating = user.rating + waiting.status = RealtimeMatch.Status.ACTIVE + waiting.started_at = timezone.now() + waiting.save( + update_fields=["player_two", "player_two_rating", "status", "started_at"] + ) + ContestAttempt.objects.bulk_create( + [ + ContestAttempt(contest=contest, user=waiting.player_one, match=waiting), + ContestAttempt(contest=contest, user=user, match=waiting), + ] + ) + return waiting + + +def match_payload(match, user): + attempt = match.attempts.filter(user=user).first() + opponent = match.player_two if match.player_one_id == user.id else match.player_one + return { + "match_id": match.id, + "status": match.status, + "opponent": ( + {"nickname": opponent.nickname, "rating": opponent.rating} + if opponent + else None + ), + "attempt": attempt_payload(attempt) if attempt else None, + "websocket_path": f"/ws/v1/contest/matches/{match.id}/", + } + + +def _elo_delta(rating, opponent_rating, score, k=32): + expected = 1 / (1 + 10 ** ((opponent_rating - rating) / 400)) + return round(k * (score - expected)) + + +@transaction.atomic +def finalize_match(match_id): + match = RealtimeMatch.objects.select_for_update().get(id=match_id) + if match.status != RealtimeMatch.Status.ACTIVE: + return match + attempts = list(match.attempts.select_related("user").order_by("user_id")) + if len(attempts) != 2 or any( + attempt.status == ContestAttempt.Status.ACTIVE for attempt in attempts + ): + return match + + first = next(item for item in attempts if item.user_id == match.player_one_id) + second = next(item for item in attempts if item.user_id == match.player_two_id) + if first.score > second.score: + first_result, second_result = 1.0, 0.0 + match.winner_id = first.user_id + elif second.score > first.score: + first_result, second_result = 0.0, 1.0 + match.winner_id = second.user_id + else: + first_result = second_result = 0.5 + + users = { + user.id: user + for user in User.objects.select_for_update().filter( + id__in=[match.player_one_id, match.player_two_id] + ) + } + player_one = users[match.player_one_id] + player_two = users[match.player_two_id] + deltas = ( + _elo_delta(player_one.rating, player_two.rating, first_result), + _elo_delta(player_two.rating, player_one.rating, second_result), + ) + for user, delta in zip((player_one, player_two), deltas): + before = user.rating + user.rating = max(0, before + delta) + user.save(update_fields=["rating"]) + RatingHistory.objects.create( + user=user, + match=match, + rating_before=before, + rating_after=user.rating, + delta=delta, + ) + match.status = RealtimeMatch.Status.COMPLETED + match.completed_at = timezone.now() + match.save(update_fields=["winner", "status", "completed_at"]) + return match diff --git a/backend/contest/test_services.py b/backend/contest/test_services.py new file mode 100644 index 0000000..e3faea9 --- /dev/null +++ b/backend/contest/test_services.py @@ -0,0 +1,203 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from accounts.models import User +from contest.models import ( + Contest, + ContestAttempt, + ContestQuestion, + Question, + QuestionVersion, + RatingHistory, + RealtimeMatch, +) +from contest.services import ( + finalize_match, + find_match, + normalize_answer, + start_attempt, + submit_attempt, +) + + +@pytest.fixture +def user(db): + return User.objects.create_user( + username="contest_user", + password="StrongPass_2026", + nickname="比赛用户", + ) + + +@pytest.fixture +def daily_contest(db): + question = Question.objects.create( + slug="sum-question", + track=Question.Track.STANDARD, + ) + version = QuestionVersion.objects.create( + question=question, + version=1, + prompt="17 + 25", + answer="42", + explanation="相加得 42", + ) + contest = Contest.objects.create( + slug="daily-test", + title="测试今日赛", + kind=Contest.Kind.DAILY, + track=Question.Track.STANDARD, + status=Contest.Status.PUBLISHED, + duration_seconds=60, + ) + ContestQuestion.objects.create( + contest=contest, + question_version=version, + order=1, + points=100, + ) + return contest + + +@pytest.mark.parametrize( + "raw, expected", + [ + pytest.param(" 1.0 ", "1", id="小数标准化"), + pytest.param("ABC ", "abc", id="文本去空格并转小写"), + pytest.param("-0", "-0", id="保留十进制负零表示"), + ], +) +def test_normalize_answer_标准化输入(raw, expected): + assert normalize_answer(raw) == expected + + +@pytest.mark.django_db +def test_start_attempt_每日赛重复进入复用同一记录(user, daily_contest): + first = start_attempt(user, daily_contest) + second = start_attempt(user, daily_contest) + + assert first["attempt_id"] == second["attempt_id"] + assert ContestAttempt.objects.count() == 1 + + +@pytest.mark.django_db +def test_submit_attempt_服务端判分且幂等重放(user, daily_contest): + started = start_attempt(user, daily_contest) + + result = submit_attempt( + user, + started["attempt_id"], + [{"order": 1, "answer": "42.0"}], + "submission-1", + ) + replay = submit_attempt( + user, + started["attempt_id"], + [{"order": 1, "answer": "0"}], + "submission-1", + ) + + assert result["status"] == ContestAttempt.Status.SUBMITTED + assert result["score"] == 100 + assert result["correct_count"] == 1 + assert result["questions"][0]["correct_answer"] == "42" + assert replay["score"] == 100 + + +@pytest.mark.django_db +def test_submit_attempt_缺少幂等键时拒绝(user, daily_contest): + started = start_attempt(user, daily_contest) + + with pytest.raises(ValidationError, match="幂等键"): + submit_attempt( + user, + started["attempt_id"], + [{"order": 1, "answer": "42"}], + None, + ) + + +@pytest.mark.django_db +def test_submit_attempt_畸形题号返回校验错误且记录保持进行中(user, daily_contest): + started = start_attempt(user, daily_contest) + + with pytest.raises(ValidationError): + submit_attempt( + user, + started["attempt_id"], + [{"order": "first", "answer": "42"}], + "malformed-order", + ) + + attempt = ContestAttempt.objects.get(id=started["attempt_id"]) + assert attempt.status == ContestAttempt.Status.ACTIVE + assert attempt.answers.count() == 0 + + +@pytest.mark.django_db +def test_submit_attempt_超过服务端时限不计分(user, daily_contest): + started = start_attempt(user, daily_contest) + ContestAttempt.objects.filter(id=started["attempt_id"]).update( + started_at=timezone.now() - timedelta(seconds=61) + ) + + result = submit_attempt( + user, + started["attempt_id"], + [{"order": 1, "answer": "42"}], + "late-submit", + ) + + assert result["status"] == ContestAttempt.Status.EXPIRED + assert result["score"] == 0 + assert result["correct_count"] == 0 + + +@pytest.mark.django_db +def test_find_match_同分玩家配对并由_finalize_match_唯一结算_rating(): + first = User.objects.create_user( + username="player_one", + password="StrongPass_2026", + nickname="玩家一", + ) + second = User.objects.create_user( + username="player_two", + password="StrongPass_2026", + nickname="玩家二", + ) + contest = Contest.objects.create( + slug="realtime-test", + title="测试实时赛", + kind=Contest.Kind.REALTIME, + track=Question.Track.STANDARD, + status=Contest.Status.PUBLISHED, + ) + + waiting = find_match(first, contest) + active = find_match(second, contest) + active.refresh_from_db() + + assert waiting.id == active.id + assert active.status == RealtimeMatch.Status.ACTIVE + assert active.attempts.count() == 2 + + active.attempts.filter(user=first).update( + status=ContestAttempt.Status.SUBMITTED, + score=200, + ) + active.attempts.filter(user=second).update( + status=ContestAttempt.Status.SUBMITTED, + score=100, + ) + finalized = finalize_match(active.id) + first.refresh_from_db() + second.refresh_from_db() + + assert finalized.status == RealtimeMatch.Status.COMPLETED + assert finalized.winner == first + assert first.rating == 1016 + assert second.rating == 984 + assert RatingHistory.objects.filter(match=active).count() == 2 diff --git a/backend/contest/tests.py b/backend/contest/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/contest/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/contest/urls.py b/backend/contest/urls.py new file mode 100644 index 0000000..a0fcadb --- /dev/null +++ b/backend/contest/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from .views import ( + AttemptStartView, + AttemptSubmitView, + ContestListView, + LeaderboardView, + MatchmakingView, + MatchStateView, +) + + +urlpatterns = [ + path("", ContestListView.as_view(), name="contest-list"), + path("/start/", AttemptStartView.as_view(), name="attempt-start"), + path("/matchmaking/", MatchmakingView.as_view(), name="matchmaking"), + path("/leaderboard/", LeaderboardView.as_view(), name="leaderboard"), + path("attempts//submit/", AttemptSubmitView.as_view(), name="attempt-submit"), + path("matches//", MatchStateView.as_view(), name="match-state"), +] diff --git a/backend/contest/views.py b/backend/contest/views.py new file mode 100644 index 0000000..04ef6de --- /dev/null +++ b/backend/contest/views.py @@ -0,0 +1,100 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Contest, ContestAttempt, RealtimeMatch +from .services import ( + find_match, + match_payload, + start_attempt, + submit_attempt, +) + + +class ContestListView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + contests = Contest.objects.filter(status=Contest.Status.PUBLISHED).order_by( + "kind", "title" + ) + return Response( + [ + { + "slug": contest.slug, + "title": contest.title, + "kind": contest.kind, + "track": contest.track, + "duration_seconds": contest.duration_seconds, + "starts_at": contest.starts_at, + "ends_at": contest.ends_at, + } + for contest in contests + ] + ) + + +class AttemptStartView(APIView): + def post(self, request, slug): + contest = get_object_or_404(Contest, slug=slug) + return Response(start_attempt(request.user, contest), status=status.HTTP_201_CREATED) + + +class AttemptSubmitView(APIView): + def post(self, request, attempt_id): + get_object_or_404(ContestAttempt, id=attempt_id, user=request.user) + payload = submit_attempt( + user=request.user, + attempt_id=attempt_id, + raw_answers=request.data.get("answers", []), + submission_key=request.headers.get("Idempotency-Key"), + ) + return Response(payload) + + +class MatchmakingView(APIView): + def post(self, request, slug): + contest = get_object_or_404(Contest, slug=slug) + match = find_match(request.user, contest) + return Response(match_payload(match, request.user), status=status.HTTP_202_ACCEPTED) + + +class MatchStateView(APIView): + def get(self, request, match_id): + match = get_object_or_404( + RealtimeMatch.objects.select_related("player_one", "player_two"), + id=match_id, + ) + if request.user.id not in (match.player_one_id, match.player_two_id): + return Response(status=status.HTTP_403_FORBIDDEN) + return Response(match_payload(match, request.user)) + + +class LeaderboardView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request, slug): + contest = get_object_or_404(Contest, slug=slug) + attempts = ( + ContestAttempt.objects.filter( + contest=contest, + status=ContestAttempt.Status.SUBMITTED, + cheat_flags__isnull=True, + ) + .select_related("user") + .order_by("-score", "duration_ms", "submitted_at")[:100] + ) + return Response( + [ + { + "rank": index, + "nickname": attempt.user.nickname, + "track": attempt.user.track, + "score": attempt.score, + "correct_count": attempt.correct_count, + "duration_ms": attempt.duration_ms, + } + for index, attempt in enumerate(attempts, start=1) + ] + ) diff --git a/backend/engagement/__init__.py b/backend/engagement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/engagement/admin.py b/backend/engagement/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/engagement/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/engagement/apps.py b/backend/engagement/apps.py new file mode 100644 index 0000000..b0f3b66 --- /dev/null +++ b/backend/engagement/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EngagementConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'engagement' diff --git a/backend/engagement/migrations/0001_initial.py b/backend/engagement/migrations/0001_initial.py new file mode 100644 index 0000000..4c0629f --- /dev/null +++ b/backend/engagement/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.23 on 2026-08-08 11:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(max_length=40)), + ('title', models.CharField(max_length=120)), + ('body', models.TextField()), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='DailyCheckIn', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('streak', models.PositiveIntegerField(default=1)), + ('reward', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkins', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='dailycheckin', + constraint=models.UniqueConstraint(fields=('user', 'date'), name='unique_user_daily_checkin'), + ), + ] diff --git a/backend/engagement/migrations/__init__.py b/backend/engagement/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/engagement/models.py b/backend/engagement/models.py new file mode 100644 index 0000000..90634c4 --- /dev/null +++ b/backend/engagement/models.py @@ -0,0 +1,24 @@ +from django.conf import settings +from django.db import models + + +class DailyCheckIn(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="checkins") + date = models.DateField() + streak = models.PositiveIntegerField(default=1) + reward = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("user", "date"), name="unique_user_daily_checkin") + ] + + +class Notification(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notifications") + category = models.CharField(max_length=40) + title = models.CharField(max_length=120) + body = models.TextField() + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/engagement/tests.py b/backend/engagement/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/engagement/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/engagement/views.py b/backend/engagement/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/backend/engagement/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/latex_lab/__init__.py b/backend/latex_lab/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/latex_lab/admin.py b/backend/latex_lab/admin.py new file mode 100644 index 0000000..e7de94f --- /dev/null +++ b/backend/latex_lab/admin.py @@ -0,0 +1,29 @@ +from django.contrib import admin + +from .models import ( + FormulaDocument, + FormulaRevision, + HandwritingRecognitionJob, + LatexAttempt, + LatexCourse, + LatexExercise, + LatexLesson, +) + + +class LatexLessonInline(admin.StackedInline): + model = LatexLesson + extra = 1 + + +@admin.register(LatexCourse) +class LatexCourseAdmin(admin.ModelAdmin): + list_display = ("title", "order", "is_published") + inlines = [LatexLessonInline] + + +admin.site.register(FormulaDocument) +admin.site.register(FormulaRevision) +admin.site.register(LatexExercise) +admin.site.register(LatexAttempt) +admin.site.register(HandwritingRecognitionJob) diff --git a/backend/latex_lab/apps.py b/backend/latex_lab/apps.py new file mode 100644 index 0000000..5f75e55 --- /dev/null +++ b/backend/latex_lab/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LatexLabConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'latex_lab' diff --git a/backend/latex_lab/migrations/0001_initial.py b/backend/latex_lab/migrations/0001_initial.py new file mode 100644 index 0000000..ab4cb63 --- /dev/null +++ b/backend/latex_lab/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.23 on 2026-08-08 11:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FormulaDocument', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=120)), + ('source', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='formulas', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='LatexCourse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(unique=True)), + ('title', models.CharField(max_length=120)), + ('description', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0)), + ('is_published', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='LatexLesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField()), + ('title', models.CharField(max_length=120)), + ('content', models.TextField()), + ('example_source', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='latex_lab.latexcourse')), + ], + options={ + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='LatexExercise', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('prompt', models.TextField()), + ('expected_source', models.TextField()), + ('explanation', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0)), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exercises', to='latex_lab.latexlesson')), + ], + ), + migrations.CreateModel( + name='LatexAttempt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('submitted_source', models.TextField()), + ('is_correct', models.BooleanField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('exercise', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='latex_lab.latexexercise')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='latex_attempts', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='HandwritingRecognitionJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('pending', '等待'), ('complete', '完成'), ('failed', '失败')], default='pending', max_length=16)), + ('result', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='FormulaRevision', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('source', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='latex_lab.formuladocument')), + ], + ), + migrations.AddConstraint( + model_name='latexlesson', + constraint=models.UniqueConstraint(fields=('course', 'slug'), name='unique_course_lesson'), + ), + ] diff --git a/backend/latex_lab/migrations/__init__.py b/backend/latex_lab/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/latex_lab/models.py b/backend/latex_lab/models.py new file mode 100644 index 0000000..6c4e23d --- /dev/null +++ b/backend/latex_lab/models.py @@ -0,0 +1,77 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class FormulaDocument(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="formulas") + title = models.CharField(max_length=120) + source = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + + +class FormulaRevision(models.Model): + document = models.ForeignKey(FormulaDocument, on_delete=models.CASCADE, related_name="revisions") + source = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + +class LatexCourse(models.Model): + slug = models.SlugField(unique=True) + title = models.CharField(max_length=120) + description = models.TextField(blank=True) + order = models.PositiveIntegerField(default=0) + is_published = models.BooleanField(default=False) + + def __str__(self): + return self.title + + +class LatexLesson(models.Model): + course = models.ForeignKey(LatexCourse, on_delete=models.CASCADE, related_name="lessons") + slug = models.SlugField() + title = models.CharField(max_length=120) + content = models.TextField() + example_source = models.TextField(blank=True) + order = models.PositiveIntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("course", "slug"), name="unique_course_lesson") + ] + ordering = ["order"] + + +class LatexExercise(models.Model): + lesson = models.ForeignKey(LatexLesson, on_delete=models.CASCADE, related_name="exercises") + prompt = models.TextField() + expected_source = models.TextField() + explanation = models.TextField(blank=True) + order = models.PositiveIntegerField(default=0) + + +class LatexAttempt(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="latex_attempts") + exercise = models.ForeignKey(LatexExercise, on_delete=models.PROTECT) + submitted_source = models.TextField() + is_correct = models.BooleanField() + created_at = models.DateTimeField(auto_now_add=True) + + +class HandwritingRecognitionJob(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "等待" + COMPLETE = "complete", "完成" + FAILED = "failed", "失败" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING) + result = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/latex_lab/serializers.py b/backend/latex_lab/serializers.py new file mode 100644 index 0000000..3c000cf --- /dev/null +++ b/backend/latex_lab/serializers.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from .models import FormulaDocument, FormulaRevision + + +class FormulaDocumentSerializer(serializers.ModelSerializer): + class Meta: + model = FormulaDocument + fields = ("id", "title", "source", "created_at", "updated_at") + read_only_fields = ("id", "created_at", "updated_at") + + def validate_source(self, value): + if len(value) > 50_000: + raise serializers.ValidationError("公式源码不能超过 50000 个字符") + return value + + def create(self, validated_data): + document = FormulaDocument.objects.create( + user=self.context["request"].user, + **validated_data, + ) + FormulaRevision.objects.create(document=document, source=document.source) + return document + + def update(self, instance, validated_data): + instance = super().update(instance, validated_data) + FormulaRevision.objects.create(document=instance, source=instance.source) + return instance diff --git a/backend/latex_lab/test_views.py b/backend/latex_lab/test_views.py new file mode 100644 index 0000000..1ec6197 --- /dev/null +++ b/backend/latex_lab/test_views.py @@ -0,0 +1,15 @@ +from latex_lab.views import ExerciseSubmitView + + +def test_normalize_忽略公式结构外的空白(): + left = ExerciseSubmitView.normalize(r"\frac { a } { b }") + right = ExerciseSubmitView.normalize(r"\frac{a}{b}") + + assert left == right + + +def test_normalize_保留_text_命令内部的语义空格(): + with_space = ExerciseSubmitView.normalize(r"\text{a b}") + without_space = ExerciseSubmitView.normalize(r"\text{ab}") + + assert with_space != without_space diff --git a/backend/latex_lab/tests.py b/backend/latex_lab/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/latex_lab/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/latex_lab/urls.py b/backend/latex_lab/urls.py new file mode 100644 index 0000000..4703920 --- /dev/null +++ b/backend/latex_lab/urls.py @@ -0,0 +1,18 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import CourseListView, ExerciseSubmitView, FormulaDocumentViewSet + + +router = DefaultRouter() +router.register("documents", FormulaDocumentViewSet, basename="formula-document") + +urlpatterns = [ + path("", include(router.urls)), + path("courses/", CourseListView.as_view(), name="latex-course-list"), + path( + "exercises//submit/", + ExerciseSubmitView.as_view(), + name="latex-exercise-submit", + ), +] diff --git a/backend/latex_lab/views.py b/backend/latex_lab/views.py new file mode 100644 index 0000000..439f2d2 --- /dev/null +++ b/backend/latex_lab/views.py @@ -0,0 +1,106 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, status, viewsets +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import LatexAttempt, LatexCourse, LatexExercise +from .serializers import FormulaDocumentSerializer + + +class FormulaDocumentViewSet(viewsets.ModelViewSet): + serializer_class = FormulaDocumentSerializer + + def get_queryset(self): + return self.request.user.formulas.all() + + +class CourseListView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + courses = LatexCourse.objects.filter(is_published=True).prefetch_related( + "lessons__exercises" + ) + return Response( + [ + { + "slug": course.slug, + "title": course.title, + "description": course.description, + "lessons": [ + { + "slug": lesson.slug, + "title": lesson.title, + "content": lesson.content, + "example_source": lesson.example_source, + "exercise_count": lesson.exercises.count(), + } + for lesson in course.lessons.all() + ], + } + for course in courses + ] + ) + + +class ExerciseSubmitView(APIView): + @staticmethod + def normalize(source): + source = source or "" + text_commands = (r"\text", r"\mbox", r"\textrm", r"\textsf", r"\texttt") + normalized = [] + index = 0 + while index < len(source): + command = next( + (item for item in text_commands if source.startswith(item, index)), + None, + ) + if command is None: + if not source[index].isspace(): + normalized.append(source[index]) + index += 1 + continue + + command_end = index + len(command) + group_start = command_end + while group_start < len(source) and source[group_start].isspace(): + group_start += 1 + if group_start >= len(source) or source[group_start] != "{": + normalized.append(command) + index = command_end + continue + + depth = 0 + group_end = group_start + while group_end < len(source): + if source[group_end] == "{": + depth += 1 + elif source[group_end] == "}": + depth -= 1 + if depth == 0: + group_end += 1 + break + group_end += 1 + normalized.append(command) + normalized.append(source[group_start:group_end]) + index = group_end + return "".join(normalized) + + def post(self, request, exercise_id): + exercise = get_object_or_404(LatexExercise, id=exercise_id) + submitted = str(request.data.get("source", ""))[:50_000] + is_correct = self.normalize(submitted) == self.normalize(exercise.expected_source) + LatexAttempt.objects.create( + user=request.user, + exercise=exercise, + submitted_source=submitted, + is_correct=is_correct, + ) + return Response( + { + "is_correct": is_correct, + "expected_source": exercise.expected_source if not is_correct else None, + "explanation": exercise.explanation, + }, + status=status.HTTP_201_CREATED, + ) diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/math_life/__init__.py b/backend/math_life/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/math_life/admin.py b/backend/math_life/admin.py new file mode 100644 index 0000000..b949a91 --- /dev/null +++ b/backend/math_life/admin.py @@ -0,0 +1,45 @@ +from django.contrib import admin +from django.core.exceptions import ValidationError + +from .models import ( + Character, + MathBTIAssessment, + MathBTIResult, + MathIdentity, + SkillPackage, + Story, + StoryChoice, + StoryRun, + StoryVersion, + UserRelationship, +) +from .services import validate_story_content + + +@admin.register(StoryVersion) +class StoryVersionAdmin(admin.ModelAdmin): + list_display = ("story", "version", "is_published", "published_at", "created_at") + list_filter = ("is_published", "story__kind") + + def save_model(self, request, obj, form, change): + errors = validate_story_content(obj.content) + if errors: + raise ValidationError(errors) + super().save_model(request, obj, form, change) + + +@admin.register(StoryRun) +class StoryRunAdmin(admin.ModelAdmin): + list_display = ("id", "user", "story_version", "status", "current_node", "updated_at") + list_filter = ("status", "story_version__story") + readonly_fields = ("started_at", "updated_at", "completed_at") + + +admin.site.register(MathIdentity) +admin.site.register(MathBTIAssessment) +admin.site.register(MathBTIResult) +admin.site.register(Character) +admin.site.register(Story) +admin.site.register(StoryChoice) +admin.site.register(UserRelationship) +admin.site.register(SkillPackage) diff --git a/backend/math_life/apps.py b/backend/math_life/apps.py new file mode 100644 index 0000000..28e87ec --- /dev/null +++ b/backend/math_life/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MathLifeConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'math_life' diff --git a/backend/math_life/management/__init__.py b/backend/math_life/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/math_life/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/math_life/management/commands/__init__.py b/backend/math_life/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/math_life/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/math_life/management/commands/seed_initial_content.py b/backend/math_life/management/commands/seed_initial_content.py new file mode 100644 index 0000000..73daf0f --- /dev/null +++ b/backend/math_life/management/commands/seed_initial_content.py @@ -0,0 +1,357 @@ +import json +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from accounts.models import InviteCode +from content.models import ContentItem +from latex_lab.models import LatexCourse, LatexExercise, LatexLesson +from math_life.models import ( + MathBTIAssessment, + MathIdentity, + SkillPackage, + Story, + StoryVersion, +) +from math_life.services import validate_story_content + + +DISCIPLINE_ICONS = { + "人工智能": "🤖", + "计算机": "💻", + "电子信息": "📡", + "微电子": "🔌", + "机械工程": "⚙", + "自动化": "◉", + "能源动力": "🔥", + "化工材料": "⚗", + "生物医学": "✦", + "经济管理": "▥", + "环境工程": "♧", + "数学建模": "📐", +} + + +def load_json(path): + with path.open(encoding="utf-8") as source: + return json.load(source) + + +def video_ability(video): + title = video["title"] + module = video["module"] + discipline = video["discipline"] + if module == "数思" or title == "你被平均数骗过吗?": + return ContentItem.Ability.VISION + if module == "数说" or title == "消费者行为预测的回归分析": + return ContentItem.Ability.HUMANITIES + if discipline in {"生物医学", "自动化"}: + return ContentItem.Ability.DETECTION + if discipline in {"机械工程", "化工材料", "环境工程", "能源动力", "数学建模"}: + return ContentItem.Ability.MODELING + if title in {"金融里的数学:为什么风险可以算出来", "博弈论与市场竞争"}: + return ContentItem.Ability.MODELING + return ContentItem.Ability.CONNECTION + + +def skill_content(title, person, dilemma): + return { + "title": title, + "start_node": "opening", + "nodes": { + "opening": { + "character": "旁白", + "scene": f"你成为青年时期的{person}。{dilemma}", + "choices": [ + { + "text": "接受风险,争取更大的可能", + "next": "risk", + "effects": {"time": -2, "reputation": 1}, + }, + { + "text": "先保住当下,再等待机会", + "next": "steady", + "effects": {"money": 2, "reputation": -1}, + }, + ], + }, + "risk": { + "character": person, + "scene": "选择带来了压力,也让你接触到原本看不见的问题。", + "choices": [ + { + "text": "把有限时间投入研究", + "next": "crossroads", + "effects": {"energy": -2, "research": 3}, + } + ], + }, + "steady": { + "character": person, + "scene": "稳定让你积累了资源,但窗口正在逐渐关闭。", + "choices": [ + { + "text": "用积累换一次尝试", + "next": "crossroads", + "effects": {"money": -1, "research": 2}, + } + ], + }, + "crossroads": { + "character": "同伴", + "scene": "同伴提供了不完整的消息。你必须决定是独自推进,还是公开方法。", + "choices": [ + { + "text": "先完成证明,再公开", + "next": "ending_scholar", + "effects": {"research": 2, "cooperation": -1}, + }, + { + "text": "邀请同伴共同验证", + "next": "ending_bridge", + "effects": {"cooperation": 3, "reputation": 1}, + }, + ], + }, + "ending_scholar": { + "character": "旁白", + "scene": "你守住了方法的完整性,也承担了独行的代价。", + "choices": [], + }, + "ending_bridge": { + "character": "旁白", + "scene": "成果不再只属于一个人,你让更多人能够继续向前。", + "choices": [], + }, + }, + } + + +class Command(BaseCommand): + help = "导入 MathBTI、信仰者主线和两个人物 Skill 样板" + + def handle(self, *args, **options): + docs = Path(settings.PROJECT_ROOT) / "docs" + mathbti_path = docs / "old_scripts" / "seed_mathbti.json" + story_path = docs / "数学少年线_story.json" + videos_path = docs / "old_scripts" / "seed_videos.json" + if not mathbti_path.exists() or not story_path.exists() or not videos_path.exists(): + raise CommandError("缺少 docs 中的初始内容文件") + + definition = load_json(mathbti_path) + assessment, _ = MathBTIAssessment.objects.update_or_create( + version=definition["version"], + defaults={ + "title": definition["title"], + "definition": definition, + "is_published": True, + "published_at": timezone.now(), + }, + ) + for code, result in definition["results"].items(): + MathIdentity.objects.update_or_create( + code=code, + defaults={ + "name": result["name"], + "clan": result["clan_name"], + "mathematician": result["mathematician"], + "description": result["description"], + "initial_abilities": result.get("stats5", {}), + "portrait": result.get("portrait", ""), + }, + ) + + story_document = load_json(story_path) + errors = validate_story_content(story_document) + if errors: + raise CommandError("; ".join(errors)) + flagship, _ = Story.objects.update_or_create( + slug="believer-math-teen", + defaults={ + "title": story_document["title"], + "summary": story_document.get("description", ""), + "kind": Story.Kind.FLAGSHIP, + "estimated_minutes": 120, + "is_visible": True, + }, + ) + StoryVersion.objects.update_or_create( + story=flagship, + version=1, + defaults={ + "content": story_document, + "is_published": True, + "published_at": timezone.now(), + }, + ) + + samples = [ + ( + "hua-luogeng-skill", + "华罗庚:从自学到远行", + "华罗庚", + "你没有完整的学院路径,却收到一次改变研究方向的机会。", + ["《华罗庚传》", "中国科学院公开人物资料"], + ), + ( + "su-buqing-skill", + "苏步青:选择回国", + "苏步青", + "海外研究条件优越,故乡却需要从零建设数学教育。", + ["复旦大学校史资料", "中国科学院公开人物资料"], + ), + ] + for slug, title, person, dilemma, sources in samples: + story, _ = Story.objects.update_or_create( + slug=slug, + defaults={ + "title": title, + "summary": dilemma, + "kind": Story.Kind.SKILL, + "estimated_minutes": 20, + "is_visible": True, + }, + ) + document = skill_content(title, person, dilemma) + StoryVersion.objects.update_or_create( + story=story, + version=1, + defaults={ + "content": document, + "is_published": True, + "published_at": timezone.now(), + }, + ) + SkillPackage.objects.update_or_create( + story=story, + defaults={ + "real_person": person, + "historical_context": dilemma, + "fact_sources": sources, + "fictional_scope": "人生节点基于公开资料,具体对话与选择为艺术加工。", + "creator": "葫芦数学内容组", + }, + ) + + InviteCode.objects.get_or_create( + code="HULU2026", + defaults={"group": "本地首发体验", "max_uses": 100, "is_active": True}, + ) + + course, _ = LatexCourse.objects.update_or_create( + slug="latex-from-zero", + defaults={ + "title": "LaTeX 零基础表达", + "description": "从上下标到完整数学证明的六步课程。", + "order": 1, + "is_published": True, + }, + ) + lesson_specs = [ + ("basics", "上标、下标和基础运算", r"x^2 + y_1", r"x^2+y_1"), + ("fractions", "分式、根式和括号", r"\frac{a}{b}+\sqrt{x}", r"\frac{a}{b}+\sqrt{x}"), + ("calculus", "求和、积分和极限", r"\sum_{i=1}^{n}i", r"\sum_{i=1}^{n}i"), + ("matrix", "矩阵和方程组", r"\begin{matrix}a&b\\c&d\end{matrix}", r"\begin{matrix}a&b\\c&d\end{matrix}"), + ("alignment", "多行公式与对齐", r"\begin{aligned}a&=b\\&=c\end{aligned}", r"\begin{aligned}a&=b\\&=c\end{aligned}"), + ("proof", "完整解答与证明排版", r"\because a=b,\ \therefore a+c=b+c", r"\because a=b,\therefore a+c=b+c"), + ] + for order, (slug, title, example, expected) in enumerate(lesson_specs, start=1): + lesson, _ = LatexLesson.objects.update_or_create( + course=course, + slug=slug, + defaults={ + "title": title, + "content": f"本节通过可运行示例学习{title}。", + "example_source": example, + "order": order, + }, + ) + LatexExercise.objects.update_or_create( + lesson=lesson, + order=1, + defaults={ + "prompt": f"输入与示例等价的 {title} 公式。", + "expected_source": expected, + "explanation": "注意命令、花括号和环境闭合。", + }, + ) + + content_specs = [ + ( + "why-proof-matters", + "为什么数学家坚持证明", + ContentItem.Kind.KNOWLEDGE, + "答案正确并不等于我们知道它为什么正确。", + ["证明", "数学精神"], + ), + ( + "gauss-17-gon", + "高斯与正十七边形", + ContentItem.Kind.PERSON, + "一个十九岁少年的发现,如何连接古典几何与代数。", + ["高斯", "几何"], + ), + ( + "model-is-not-world", + "模型不是现实本身", + ContentItem.Kind.KNOWLEDGE, + "建模从选择变量开始,也从那一刻开始承担遗漏的代价。", + ["建模", "应用"], + ), + ] + for slug, title, kind, summary, topics in content_specs: + ContentItem.objects.update_or_create( + slug=slug, + defaults={ + "title": title, + "kind": kind, + "summary": summary, + "body": summary, + "topics": topics, + "source": "葫芦数学首发内容", + "is_published": True, + "published_at": timezone.now(), + }, + ) + + videos = load_json(videos_path) + for index, video in enumerate(videos, start=1): + ContentItem.objects.update_or_create( + slug=f"legacy-video-{index:03d}", + defaults={ + "title": video["title"], + "kind": ContentItem.Kind.VIDEO, + "summary": video.get("description", ""), + "body": video.get("description", ""), + "cover_url": video.get("cover_url", ""), + "media_url": video.get("video_url", ""), + "topics": [ + video.get("module", ""), + video.get("sub_category", ""), + video.get("discipline", ""), + ], + "source": "老版志愿者视频流", + "ability_dimension": video_ability(video), + "module": video.get("module", ""), + "sub_category": video.get("sub_category", ""), + "discipline": video.get("discipline", ""), + "discipline_icon": DISCIPLINE_ICONS.get( + video.get("discipline"), + video.get("discipline_icon") or "∑", + ), + "author": video.get("author", ""), + "duration_seconds": int(video.get("duration_min", 0)) * 60, + "view_count": 800 + index * 137, + "comment_count": 20 + (index * 29) % 760, + "is_published": True, + "published_at": timezone.now(), + }, + ) + + self.stdout.write( + self.style.SUCCESS( + f"已导入 MathBTI {assessment.version}、人生内容、LaTeX 课程和 {len(videos)} 条视频" + ) + ) diff --git a/backend/math_life/migrations/0001_initial.py b/backend/math_life/migrations/0001_initial.py new file mode 100644 index 0000000..159316c --- /dev/null +++ b/backend/math_life/migrations/0001_initial.py @@ -0,0 +1,170 @@ +# Generated by Django 4.2.23 on 2026-08-08 11:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Character', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(unique=True)), + ('name', models.CharField(max_length=80)), + ('profile', models.TextField(blank=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ], + ), + migrations.CreateModel( + name='MathBTIAssessment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.CharField(max_length=30, unique=True)), + ('title', models.CharField(max_length=120)), + ('definition', models.JSONField()), + ('is_published', models.BooleanField(default=False)), + ('published_at', models.DateTimeField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='MathIdentity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=8, unique=True)), + ('name', models.CharField(max_length=80)), + ('clan', models.CharField(max_length=40)), + ('mathematician', models.CharField(max_length=80)), + ('description', models.TextField(blank=True)), + ('initial_abilities', models.JSONField(blank=True, default=dict)), + ('portrait', models.CharField(blank=True, max_length=200)), + ], + ), + migrations.CreateModel( + name='Story', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(unique=True)), + ('title', models.CharField(max_length=120)), + ('summary', models.TextField(blank=True)), + ('kind', models.CharField(choices=[('flagship', '旗舰人生'), ('skill', '人物 Skill'), ('special', '特别篇')], default='flagship', max_length=16)), + ('estimated_minutes', models.PositiveIntegerField(default=20)), + ('is_visible', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='UserRelationship', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('trust', models.SmallIntegerField(default=0)), + ('rivalry', models.SmallIntegerField(default=0)), + ('debt', models.SmallIntegerField(default=0)), + ('cooperation', models.SmallIntegerField(default=0)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('character', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='math_life.character')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='StoryVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField()), + ('content', models.JSONField()), + ('is_published', models.BooleanField(default=False)), + ('published_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='math_life.story')), + ], + options={ + 'ordering': ['story', '-version'], + }, + ), + migrations.CreateModel( + name='StoryRun', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('current_node', models.CharField(max_length=100)), + ('state', models.JSONField(default=dict)), + ('status', models.CharField(choices=[('active', '进行中'), ('completed', '已完成'), ('abandoned', '已放弃')], default='active', max_length=16)), + ('ending_code', models.CharField(blank=True, max_length=80)), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('story_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='runs', to='math_life.storyversion')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='story_runs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='StoryChoice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sequence', models.PositiveIntegerField()), + ('idempotency_key', models.CharField(blank=True, max_length=80, null=True)), + ('node_id', models.CharField(max_length=100)), + ('choice_index', models.PositiveIntegerField()), + ('choice_text', models.CharField(max_length=300)), + ('effects', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='math_life.storyrun')), + ], + options={ + 'ordering': ['sequence'], + }, + ), + migrations.CreateModel( + name='SkillPackage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('real_person', models.CharField(max_length=120)), + ('historical_context', models.TextField()), + ('fact_sources', models.JSONField(default=list)), + ('fictional_scope', models.TextField()), + ('creator', models.CharField(max_length=120)), + ('story', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='skill_package', to='math_life.story')), + ], + ), + migrations.CreateModel( + name='MathBTIResult', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('answers', models.JSONField(default=list)), + ('axis_scores', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('assessment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='math_life.mathbtiassessment')), + ('identity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='math_life.mathidentity')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mathbti_results', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.AddConstraint( + model_name='userrelationship', + constraint=models.UniqueConstraint(fields=('user', 'character'), name='unique_user_character'), + ), + migrations.AddConstraint( + model_name='storyversion', + constraint=models.UniqueConstraint(fields=('story', 'version'), name='unique_story_version'), + ), + migrations.AddConstraint( + model_name='storychoice', + constraint=models.UniqueConstraint(fields=('run', 'sequence'), name='unique_run_choice_sequence'), + ), + migrations.AddConstraint( + model_name='storychoice', + constraint=models.UniqueConstraint(fields=('run', 'idempotency_key'), name='unique_run_choice_idempotency'), + ), + ] diff --git a/backend/math_life/migrations/__init__.py b/backend/math_life/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/math_life/models.py b/backend/math_life/models.py new file mode 100644 index 0000000..e237267 --- /dev/null +++ b/backend/math_life/models.py @@ -0,0 +1,151 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class MathIdentity(models.Model): + code = models.CharField(max_length=8, unique=True) + name = models.CharField(max_length=80) + clan = models.CharField(max_length=40) + mathematician = models.CharField(max_length=80) + description = models.TextField(blank=True) + initial_abilities = models.JSONField(default=dict, blank=True) + portrait = models.CharField(max_length=200, blank=True) + + def __str__(self): + return f"{self.code} {self.name}" + + +class MathBTIAssessment(models.Model): + version = models.CharField(max_length=30, unique=True) + title = models.CharField(max_length=120) + definition = models.JSONField() + is_published = models.BooleanField(default=False) + published_at = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return self.title + + +class MathBTIResult(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="mathbti_results") + assessment = models.ForeignKey(MathBTIAssessment, on_delete=models.PROTECT) + identity = models.ForeignKey(MathIdentity, on_delete=models.PROTECT) + answers = models.JSONField(default=list) + axis_scores = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + +class Character(models.Model): + slug = models.SlugField(unique=True) + name = models.CharField(max_length=80) + profile = models.TextField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + + def __str__(self): + return self.name + + +class Story(models.Model): + class Kind(models.TextChoices): + FLAGSHIP = "flagship", "旗舰人生" + SKILL = "skill", "人物 Skill" + SPECIAL = "special", "特别篇" + + slug = models.SlugField(unique=True) + title = models.CharField(max_length=120) + summary = models.TextField(blank=True) + kind = models.CharField(max_length=16, choices=Kind.choices, default=Kind.FLAGSHIP) + estimated_minutes = models.PositiveIntegerField(default=20) + is_visible = models.BooleanField(default=True) + + def __str__(self): + return self.title + + +class StoryVersion(models.Model): + story = models.ForeignKey(Story, on_delete=models.CASCADE, related_name="versions") + version = models.PositiveIntegerField() + content = models.JSONField() + is_published = models.BooleanField(default=False) + published_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("story", "version"), name="unique_story_version") + ] + ordering = ["story", "-version"] + + def __str__(self): + return f"{self.story} v{self.version}" + + +class StoryRun(models.Model): + class Status(models.TextChoices): + ACTIVE = "active", "进行中" + COMPLETED = "completed", "已完成" + ABANDONED = "abandoned", "已放弃" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="story_runs") + story_version = models.ForeignKey(StoryVersion, on_delete=models.PROTECT, related_name="runs") + current_node = models.CharField(max_length=100) + state = models.JSONField(default=dict) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE) + ending_code = models.CharField(max_length=80, blank=True) + started_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-updated_at"] + + +class StoryChoice(models.Model): + run = models.ForeignKey(StoryRun, on_delete=models.CASCADE, related_name="choices") + sequence = models.PositiveIntegerField() + idempotency_key = models.CharField(max_length=80, null=True, blank=True) + node_id = models.CharField(max_length=100) + choice_index = models.PositiveIntegerField() + choice_text = models.CharField(max_length=300) + effects = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("run", "sequence"), name="unique_run_choice_sequence"), + models.UniqueConstraint( + fields=("run", "idempotency_key"), name="unique_run_choice_idempotency" + ), + ] + ordering = ["sequence"] + + +class UserRelationship(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + character = models.ForeignKey(Character, on_delete=models.CASCADE) + trust = models.SmallIntegerField(default=0) + rivalry = models.SmallIntegerField(default=0) + debt = models.SmallIntegerField(default=0) + cooperation = models.SmallIntegerField(default=0) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("user", "character"), name="unique_user_character") + ] + + +class SkillPackage(models.Model): + story = models.OneToOneField(Story, on_delete=models.CASCADE, related_name="skill_package") + real_person = models.CharField(max_length=120) + historical_context = models.TextField() + fact_sources = models.JSONField(default=list) + fictional_scope = models.TextField() + creator = models.CharField(max_length=120) diff --git a/backend/math_life/services.py b/backend/math_life/services.py new file mode 100644 index 0000000..a595205 --- /dev/null +++ b/backend/math_life/services.py @@ -0,0 +1,192 @@ +from copy import deepcopy + +from django.db import transaction +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from .models import StoryChoice, StoryRun, StoryVersion + + +def score_mathbti(definition, answer_indexes): + questions = definition.get("questions", []) + if len(answer_indexes) != len(questions): + raise ValidationError({"answers": "答案数量与题目数量不一致"}) + + axis_scores = {axis["id"]: 0 for axis in definition.get("axes", [])} + for question, raw_index in zip(questions, answer_indexes): + try: + option_index = int(raw_index) + options = question["options"] + if option_index < 0 or option_index >= len(options): + raise IndexError + option = options[option_index] + except (IndexError, KeyError, TypeError, ValueError) as exc: + raise ValidationError({"answers": f"题目 {question.get('id')} 的答案无效"}) from exc + axis_scores[question["axis"]] += int(option["score"]) + + cutoff = int(definition.get("scoring", {}).get("cutoff", 4)) + axis_order = definition.get("scoring", {}).get("axes", list(axis_scores)) + identity_code = "".join("1" if axis_scores[axis] > cutoff else "0" for axis in axis_order) + return identity_code, axis_scores + + +def validate_story_content(content): + nodes = content.get("nodes") + start = content.get("start_node") + errors = [] + if not isinstance(nodes, dict) or not nodes: + return ["nodes 必须是非空对象"] + if start not in nodes: + errors.append("start_node 不存在") + + referenced = set() + for node_id, node in nodes.items(): + if not isinstance(node.get("choices", []), list): + errors.append(f"{node_id}.choices 必须是数组") + continue + for choice in node.get("choices", []): + target = choice.get("next") + if not target: + errors.append(f"{node_id} 存在缺少 next 的选项") + elif target not in nodes: + errors.append(f"{node_id} 引用了不存在的节点 {target}") + else: + referenced.add(target) + + if start in nodes: + reachable = set() + pending = [start] + while pending: + node_id = pending.pop() + if node_id in reachable: + continue + reachable.add(node_id) + pending.extend( + choice.get("next") + for choice in nodes[node_id].get("choices", []) + if choice.get("next") in nodes + ) + unreachable = sorted(set(nodes) - reachable) + if unreachable: + errors.append(f"存在不可达节点: {', '.join(unreachable[:10])}") + return errors + + +def _merge_effects(state, effects): + result = deepcopy(state) + for key, value in effects.items(): + if isinstance(value, dict): + result[key] = _merge_effects(result.get(key, {}), value) + elif isinstance(value, (int, float)): + result[key] = result.get(key, 0) + value + else: + result[key] = value + return result + + +def _node_payload(run): + node = run.story_version.content["nodes"][run.current_node] + choices = [ + {"index": index, "text": choice.get("text", "")} + for index, choice in enumerate(node.get("choices", [])) + ] + return { + "run_id": run.id, + "story": run.story_version.story.title, + "version": run.story_version.version, + "status": run.status, + "current_node": run.current_node, + "state": run.state, + "node": { + "scene": node.get("scene", ""), + "character": node.get("character", ""), + "sprite": node.get("sprite", ""), + "choices": choices, + }, + } + + +@transaction.atomic +def start_story(user, story): + version = ( + StoryVersion.objects.select_related("story") + .filter(story=story, is_published=True) + .order_by("-version") + .first() + ) + if version is None: + raise ValidationError("该人生尚未发布") + active = StoryRun.objects.filter( + user=user, story_version=version, status=StoryRun.Status.ACTIVE + ).first() + if active: + return _node_payload(active) + run = StoryRun.objects.create( + user=user, + story_version=version, + current_node=version.content["start_node"], + state={}, + ) + return _node_payload(run) + + +def get_run_payload(run): + return _node_payload(run) + + +@transaction.atomic +def make_choice(*, run_id, user, choice_index, idempotency_key=None): + run = ( + StoryRun.objects.select_for_update() + .select_related("story_version__story") + .get(id=run_id, user=user) + ) + if idempotency_key: + existing = StoryChoice.objects.filter( + run=run, idempotency_key=idempotency_key + ).first() + if existing: + return _node_payload(run) + if run.status != StoryRun.Status.ACTIVE: + raise ValidationError("该人生已经结束") + + node_id = run.current_node + node = run.story_version.content["nodes"][node_id] + choices = node.get("choices", []) + try: + normalized_index = int(choice_index) + if normalized_index < 0 or normalized_index >= len(choices): + raise IndexError + choice = choices[normalized_index] + except (IndexError, TypeError, ValueError) as exc: + raise ValidationError({"choice_index": "选项不存在"}) from exc + + sequence = run.choices.count() + 1 + effects = choice.get("effects", {}) + StoryChoice.objects.create( + run=run, + sequence=sequence, + idempotency_key=idempotency_key, + node_id=node_id, + choice_index=normalized_index, + choice_text=choice.get("text", ""), + effects=effects, + ) + run.state = _merge_effects(run.state, effects) + run.current_node = choice["next"] + next_node = run.story_version.content["nodes"][run.current_node] + if not next_node.get("choices"): + run.status = StoryRun.Status.COMPLETED + run.ending_code = run.current_node + run.completed_at = timezone.now() + run.save( + update_fields=[ + "state", + "current_node", + "status", + "ending_code", + "completed_at", + "updated_at", + ] + ) + return _node_payload(run) diff --git a/backend/math_life/test_services.py b/backend/math_life/test_services.py new file mode 100644 index 0000000..62e583e --- /dev/null +++ b/backend/math_life/test_services.py @@ -0,0 +1,144 @@ +import pytest +from rest_framework.exceptions import ValidationError + +from accounts.models import User +from math_life.models import Story, StoryChoice, StoryRun, StoryVersion +from math_life.services import ( + make_choice, + score_mathbti, + start_story, + validate_story_content, +) + + +@pytest.fixture +def mathbti_definition(): + return { + "axes": [{"id": "style"}, {"id": "purpose"}], + "scoring": {"axes": ["style", "purpose"], "cutoff": 1}, + "questions": [ + { + "id": 1, + "axis": "style", + "options": [{"score": 0}, {"score": 2}], + }, + { + "id": 2, + "axis": "purpose", + "options": [{"score": 0}, {"score": 2}], + }, + ], + } + + +def test_score_mathbti_按轴汇总并生成身份编码(mathbti_definition): + code, scores = score_mathbti(mathbti_definition, [1, 0]) + + assert code == "10" + assert scores == {"style": 2, "purpose": 0} + + +def test_score_mathbti_答案数量不一致时拒绝(mathbti_definition): + with pytest.raises(ValidationError, match="答案数量"): + score_mathbti(mathbti_definition, [1]) + + +def test_score_mathbti_负数选项索引必须拒绝(mathbti_definition): + with pytest.raises(ValidationError, match="答案无效"): + score_mathbti(mathbti_definition, [-1, 0]) + + +def test_validate_story_content_报告缺失引用与不可达节点(): + document = { + "start_node": "start", + "nodes": { + "start": {"choices": [{"text": "错误入口", "next": "missing"}]}, + "orphan": {"choices": []}, + }, + } + + errors = validate_story_content(document) + + assert any("不存在的节点 missing" in error for error in errors) + assert any("不可达节点: orphan" in error for error in errors) + + +@pytest.fixture +def story_setup(db): + user = User.objects.create_user( + username="story_user", + password="StrongPass_2026", + nickname="剧情用户", + ) + story = Story.objects.create(slug="test-story", title="测试人生") + version = StoryVersion.objects.create( + story=story, + version=1, + is_published=True, + content={ + "start_node": "start", + "nodes": { + "start": { + "scene": "起点", + "choices": [ + { + "text": "向左", + "next": "left_end", + "effects": {"energy": -1, "favorability": {"高斯": 2}}, + }, + { + "text": "向右", + "next": "right_end", + "effects": {"energy": 2}, + }, + ], + }, + "left_end": {"scene": "左结局", "choices": []}, + "right_end": {"scene": "右结局", "choices": []}, + }, + }, + ) + return user, story, version + + +@pytest.mark.django_db +def test_make_choice_应用嵌套效果完成结局且幂等(story_setup): + user, story, _ = story_setup + started = start_story(user, story) + + result = make_choice( + run_id=started["run_id"], + user=user, + choice_index=0, + idempotency_key="choice-1", + ) + replay = make_choice( + run_id=started["run_id"], + user=user, + choice_index=0, + idempotency_key="choice-1", + ) + + assert result["status"] == StoryRun.Status.COMPLETED + assert result["current_node"] == "left_end" + assert result["state"] == {"energy": -1, "favorability": {"高斯": 2}} + assert replay["current_node"] == "left_end" + assert StoryChoice.objects.count() == 1 + + +@pytest.mark.django_db +def test_make_choice_负数索引必须拒绝且存档不变(story_setup): + user, story, _ = story_setup + started = start_story(user, story) + + with pytest.raises(ValidationError, match="选项不存在"): + make_choice( + run_id=started["run_id"], + user=user, + choice_index=-1, + idempotency_key="invalid-choice", + ) + + run = StoryRun.objects.get(id=started["run_id"]) + assert run.current_node == "start" + assert StoryChoice.objects.count() == 0 diff --git a/backend/math_life/tests.py b/backend/math_life/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/math_life/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/math_life/urls.py b/backend/math_life/urls.py new file mode 100644 index 0000000..98fe39f --- /dev/null +++ b/backend/math_life/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from .views import ( + MathBTIAssessmentView, + MathBTISubmitView, + StoryChoiceView, + StoryListView, + StoryRunView, + StoryStartView, +) + + +urlpatterns = [ + path("mathbti/", MathBTIAssessmentView.as_view(), name="mathbti-assessment"), + path("mathbti/submit/", MathBTISubmitView.as_view(), name="mathbti-submit"), + path("stories/", StoryListView.as_view(), name="story-list"), + path("stories//start/", StoryStartView.as_view(), name="story-start"), + path("runs//", StoryRunView.as_view(), name="story-run"), + path("runs//choice/", StoryChoiceView.as_view(), name="story-choice"), +] diff --git a/backend/math_life/views.py b/backend/math_life/views.py new file mode 100644 index 0000000..f5e35dd --- /dev/null +++ b/backend/math_life/views.py @@ -0,0 +1,129 @@ +from copy import deepcopy + +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from progression.services import initialize_math_identity + +from .models import ( + MathBTIAssessment, + MathBTIResult, + MathIdentity, + Story, + StoryRun, +) +from .services import get_run_payload, make_choice, score_mathbti, start_story + + +class MathBTIAssessmentView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + assessment = ( + MathBTIAssessment.objects.filter(is_published=True).order_by("-published_at").first() + ) + if assessment is None: + return Response( + {"error": {"code": "not_published", "message": "MathBTI 尚未发布"}}, + status=status.HTTP_404_NOT_FOUND, + ) + definition = deepcopy(assessment.definition) + for question in definition.get("questions", []): + for option in question.get("options", []): + option.pop("score", None) + definition.pop("results", None) + return Response({"version": assessment.version, "definition": definition}) + + +class MathBTISubmitView(APIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + assessment = get_object_or_404( + MathBTIAssessment, + version=request.data.get("version"), + is_published=True, + ) + identity_code, axis_scores = score_mathbti( + assessment.definition, + request.data.get("answers", []), + ) + identity = get_object_or_404(MathIdentity, code=identity_code) + if request.user.is_authenticated: + result = MathBTIResult.objects.create( + user=request.user, + assessment=assessment, + identity=identity, + answers=request.data.get("answers", []), + axis_scores=axis_scores, + ) + initialize_math_identity(request.user, identity) + result_id = result.id + else: + result_id = None + return Response( + { + "id": result_id, + "identity": { + "code": identity.code, + "name": identity.name, + "clan": identity.clan, + "mathematician": identity.mathematician, + "description": identity.description, + "portrait": identity.portrait, + "initial_abilities": identity.initial_abilities, + }, + "axis_scores": axis_scores, + }, + status=status.HTTP_201_CREATED, + ) + + +class StoryListView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + stories = Story.objects.filter(is_visible=True).order_by("kind", "title") + return Response( + [ + { + "slug": story.slug, + "title": story.title, + "summary": story.summary, + "kind": story.kind, + "estimated_minutes": story.estimated_minutes, + "available": story.versions.filter(is_published=True).exists(), + } + for story in stories + ] + ) + + +class StoryStartView(APIView): + def post(self, request, slug): + story = get_object_or_404(Story, slug=slug, is_visible=True) + return Response(start_story(request.user, story), status=status.HTTP_201_CREATED) + + +class StoryRunView(APIView): + def get(self, request, run_id): + run = get_object_or_404( + StoryRun.objects.select_related("story_version__story"), + id=run_id, + user=request.user, + ) + return Response(get_run_payload(run)) + + +class StoryChoiceView(APIView): + def post(self, request, run_id): + get_object_or_404(StoryRun, id=run_id, user=request.user) + payload = make_choice( + run_id=run_id, + user=request.user, + choice_index=request.data.get("choice_index"), + idempotency_key=request.headers.get("Idempotency-Key"), + ) + return Response(payload) diff --git a/backend/progression/__init__.py b/backend/progression/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/progression/admin.py b/backend/progression/admin.py new file mode 100644 index 0000000..90a8d2b --- /dev/null +++ b/backend/progression/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import Card, RewardTransaction, UserAbility, UserCard, UserPet + + +admin.site.register(UserPet) +admin.site.register(UserAbility) +admin.site.register(Card) +admin.site.register(UserCard) +admin.site.register(RewardTransaction) diff --git a/backend/progression/apps.py b/backend/progression/apps.py new file mode 100644 index 0000000..62395e6 --- /dev/null +++ b/backend/progression/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProgressionConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'progression' diff --git a/backend/progression/migrations/0001_initial.py b/backend/progression/migrations/0001_initial.py new file mode 100644 index 0000000..527854d --- /dev/null +++ b/backend/progression/migrations/0001_initial.py @@ -0,0 +1,83 @@ +# Generated by Django 4.2.23 on 2026-08-08 11:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Card', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(unique=True)), + ('name', models.CharField(max_length=80)), + ('mathematician', models.CharField(max_length=80)), + ('description', models.TextField()), + ('image', models.CharField(blank=True, max_length=200)), + ], + ), + migrations.CreateModel( + name='UserPet', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='小葫芦', max_length=40)), + ('level', models.PositiveIntegerField(default=1)), + ('experience', models.PositiveIntegerField(default=0)), + ('appearance', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='pet', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserCard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('source', models.CharField(max_length=80)), + ('acquired_at', models.DateTimeField(auto_now_add=True)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='progression.card')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cards', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserAbility', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dimension', models.CharField(choices=[('vision', '眼光'), ('humanities', '人文'), ('detection', '侦探'), ('modeling', '建模'), ('connection', '联结')], max_length=20)), + ('level', models.PositiveIntegerField(default=0)), + ('fragments', models.PositiveIntegerField(default=0)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='abilities', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='RewardTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('idempotency_key', models.CharField(max_length=100)), + ('source', models.CharField(max_length=80)), + ('rewards', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rewards', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='usercard', + constraint=models.UniqueConstraint(fields=('user', 'card'), name='unique_user_card'), + ), + migrations.AddConstraint( + model_name='userability', + constraint=models.UniqueConstraint(fields=('user', 'dimension'), name='unique_user_ability'), + ), + migrations.AddConstraint( + model_name='rewardtransaction', + constraint=models.UniqueConstraint(fields=('user', 'idempotency_key'), name='unique_user_reward_transaction'), + ), + ] diff --git a/backend/progression/migrations/__init__.py b/backend/progression/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/progression/models.py b/backend/progression/models.py new file mode 100644 index 0000000..b918e10 --- /dev/null +++ b/backend/progression/models.py @@ -0,0 +1,68 @@ +from django.conf import settings +from django.db import models + + +class UserPet(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pet") + name = models.CharField(max_length=40, default="小葫芦") + level = models.PositiveIntegerField(default=1) + experience = models.PositiveIntegerField(default=0) + appearance = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + +class UserAbility(models.Model): + class Dimension(models.TextChoices): + VISION = "vision", "眼光" + HUMANITIES = "humanities", "人文" + DETECTION = "detection", "侦探" + MODELING = "modeling", "建模" + CONNECTION = "connection", "联结" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="abilities") + dimension = models.CharField(max_length=20, choices=Dimension.choices) + level = models.PositiveIntegerField(default=0) + fragments = models.PositiveIntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("user", "dimension"), name="unique_user_ability") + ] + + +class Card(models.Model): + slug = models.SlugField(unique=True) + name = models.CharField(max_length=80) + mathematician = models.CharField(max_length=80) + description = models.TextField() + image = models.CharField(max_length=200, blank=True) + + def __str__(self): + return self.name + + +class UserCard(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="cards") + card = models.ForeignKey(Card, on_delete=models.PROTECT) + source = models.CharField(max_length=80) + acquired_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=("user", "card"), name="unique_user_card") + ] + + +class RewardTransaction(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="rewards") + idempotency_key = models.CharField(max_length=100) + source = models.CharField(max_length=80) + rewards = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("user", "idempotency_key"), name="unique_user_reward_transaction" + ) + ] diff --git a/backend/progression/services.py b/backend/progression/services.py new file mode 100644 index 0000000..8644d29 --- /dev/null +++ b/backend/progression/services.py @@ -0,0 +1,24 @@ +from django.db import transaction + +from .models import Card, UserAbility, UserCard, UserPet + + +@transaction.atomic +def initialize_math_identity(user, identity): + UserPet.objects.get_or_create(user=user) + for dimension, _ in UserAbility.Dimension.choices: + UserAbility.objects.get_or_create(user=user, dimension=dimension) + card, _ = Card.objects.get_or_create( + slug=f"mathbti-{identity.code}", + defaults={ + "name": f"{identity.mathematician}人物卡", + "mathematician": identity.mathematician, + "description": identity.description, + "image": identity.portrait, + }, + ) + UserCard.objects.get_or_create( + user=user, + card=card, + defaults={"source": "mathbti"}, + ) diff --git a/backend/progression/test_services.py b/backend/progression/test_services.py new file mode 100644 index 0000000..a1dc490 --- /dev/null +++ b/backend/progression/test_services.py @@ -0,0 +1,32 @@ +import pytest + +from accounts.models import User +from math_life.models import MathIdentity +from progression.models import Card, UserAbility, UserCard, UserPet +from progression.services import initialize_math_identity + + +@pytest.mark.django_db +def test_initialize_math_identity_重复调用保持精灵能力和人物卡唯一(): + user = User.objects.create_user( + username="identity_user", + password="StrongPass_2026", + nickname="身份用户", + ) + identity = MathIdentity.objects.create( + code="1000", + name="定理工匠", + clan="信仰者", + mathematician="高斯", + description="严谨推导与纯粹热爱。", + portrait="/static/img/mathematicians/1000.png", + ) + + initialize_math_identity(user, identity) + initialize_math_identity(user, identity) + + assert UserPet.objects.filter(user=user).count() == 1 + assert UserAbility.objects.filter(user=user).count() == len(UserAbility.Dimension.choices) + assert Card.objects.filter(slug="mathbti-1000").count() == 1 + assert UserCard.objects.filter(user=user, card__slug="mathbti-1000").count() == 1 + assert set(UserAbility.objects.filter(user=user).values_list("level", flat=True)) == {0} diff --git a/backend/progression/tests.py b/backend/progression/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/progression/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/progression/urls.py b/backend/progression/urls.py new file mode 100644 index 0000000..96055a3 --- /dev/null +++ b/backend/progression/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import ProgressionProfileView + + +urlpatterns = [ + path("me/", ProgressionProfileView.as_view(), name="progression-profile"), +] diff --git a/backend/progression/views.py b/backend/progression/views.py new file mode 100644 index 0000000..3d20205 --- /dev/null +++ b/backend/progression/views.py @@ -0,0 +1,44 @@ +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import UserAbility, UserPet + + +class ProgressionProfileView(APIView): + def get(self, request): + pet, _ = UserPet.objects.get_or_create(user=request.user) + existing = {ability.dimension: ability for ability in request.user.abilities.all()} + abilities = [] + for dimension, label in UserAbility.Dimension.choices: + ability = existing.get(dimension) + if ability is None: + ability = UserAbility.objects.create(user=request.user, dimension=dimension) + abilities.append( + { + "dimension": dimension, + "label": label, + "level": ability.level, + "fragments": ability.fragments, + } + ) + return Response( + { + "pet": { + "name": pet.name, + "level": pet.level, + "experience": pet.experience, + "appearance": pet.appearance, + }, + "abilities": abilities, + "cards": [ + { + "slug": item.card.slug, + "name": item.card.name, + "mathematician": item.card.mathematician, + "image": item.card.image, + "acquired_at": item.acquired_at, + } + for item in request.user.cards.select_related("card") + ], + } + ) diff --git a/backend/static/css/app.css b/backend/static/css/app.css new file mode 100644 index 0000000..4e78682 --- /dev/null +++ b/backend/static/css/app.css @@ -0,0 +1,229 @@ +:root { + --ink: #17211b; + --muted: #657068; + --paper: #f3f1e9; + --panel: rgba(255, 255, 252, .78); + --line: rgba(23, 33, 27, .13); + --green: #196548; + --lime: #cce85b; + --orange: #ef704f; + --blue: #6689c7; + --radius: 22px; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + color: var(--ink); + background: var(--paper); + font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + min-height: 100vh; +} +button, input, select, textarea { font: inherit; } +button { color: inherit; } +.ambient { position: fixed; border-radius: 50%; filter: blur(1px); pointer-events: none; z-index: -1; opacity: .55; } +.ambient-one { width: 38vw; height: 38vw; right: -12vw; top: -12vw; background: radial-gradient(circle, #d9ee8c 0, transparent 68%); } +.ambient-two { width: 42vw; height: 42vw; left: 12vw; bottom: -26vw; background: radial-gradient(circle, #d7e2f4 0, transparent 68%); } +.app-shell { min-height: 100vh; display: grid; grid-template-columns: 230px 1fr; } +.sidebar { + position: sticky; top: 0; height: 100vh; padding: 30px 22px; + border-right: 1px solid var(--line); display: flex; flex-direction: column; + background: rgba(243, 241, 233, .74); backdrop-filter: blur(18px); +} +.brand { display: flex; gap: 12px; align-items: center; color: inherit; text-decoration: none; } +.brand-mark { + display: grid; place-items: center; width: 42px; height: 42px; border-radius: 13px; + background: var(--ink); color: var(--lime); font-family: Georgia, serif; font-size: 24px; +} +.brand strong, .brand small { display: block; } +.brand strong { font-size: 17px; letter-spacing: .08em; } +.brand small { margin-top: 3px; color: var(--muted); font-size: 9px; letter-spacing: .22em; } +.nav { margin-top: 58px; display: grid; gap: 7px; } +.nav-item { + border: 0; background: transparent; border-radius: 12px; padding: 13px 12px; + text-align: left; cursor: pointer; color: var(--muted); transition: .2s ease; +} +.nav-item span { display: inline-block; width: 30px; font: 10px/1 Georgia, serif; color: #99a097; } +.nav-item:hover, .nav-item.active { color: var(--ink); background: rgba(255,255,255,.7); transform: translateX(3px); } +.nav-item.active { box-shadow: inset 3px 0 var(--green); } +.sidebar-foot { margin-top: auto; display: grid; gap: 15px; } +.system-status { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; } +.system-status i { width: 7px; height: 7px; margin-right: 7px; display: inline-block; border-radius: 50%; background: #5bb67c; box-shadow: 0 0 0 4px rgba(91,182,124,.13); } +.ghost-button, .text-button { + background: transparent; border: 1px solid var(--line); border-radius: 10px; padding: 11px 14px; cursor: pointer; +} +.main { padding: 0 46px 70px; min-width: 0; } +.topbar { height: 82px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); } +.topbar p { color: var(--muted); font-size: 11px; letter-spacing: .08em; } +.user-chip { display: flex; align-items: center; gap: 9px; font-size: 13px; } +.avatar { width: 31px; height: 31px; border-radius: 50%; display: grid; place-items: center; background: var(--ink); color: white; font-size: 11px; } +.view { display: none; animation: rise .35s ease; } +.view.active { display: block; } +@keyframes rise { from { opacity: 0; transform: translateY(8px); } } +.hero-grid { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(280px, .75fr); gap: 18px; margin-top: 28px; } +.hero, .daily-card, .spirit, .content-card, .editor-shell, .profile-panel { + border: 1px solid rgba(23,33,27,.1); border-radius: var(--radius); background: var(--panel); + box-shadow: 0 24px 70px rgba(38, 48, 40, .06); backdrop-filter: blur(14px); +} +.hero { min-height: 450px; padding: 55px; display: flex; flex-direction: column; position: relative; overflow: hidden; } +.hero::after { + content: ""; position: absolute; width: 330px; height: 330px; right: -100px; bottom: -150px; + border: 55px solid rgba(25,101,72,.09); border-radius: 50%; +} +.kicker { color: var(--green); font-size: 10px; font-weight: 700; letter-spacing: .24em; } +.hero h1, .page-title h1 { margin: 20px 0 18px; font: 500 clamp(42px, 5vw, 76px)/1.03 Georgia, "Songti SC", serif; letter-spacing: -.04em; } +.hero h1 em { color: var(--green); font-weight: inherit; } +.hero p { max-width: 600px; color: var(--muted); font-size: 15px; line-height: 1.9; } +.hero-actions { margin-top: auto; display: flex; align-items: center; gap: 13px; position: relative; z-index: 1; } +.primary-button, .dark-button { + border: 0; border-radius: 12px; padding: 14px 19px; cursor: pointer; transition: .2s ease; font-weight: 650; +} +.primary-button { background: var(--green); color: white; } +.primary-button b { margin-left: 22px; } +.primary-button:hover, .dark-button:hover { transform: translateY(-2px); box-shadow: 0 12px 25px rgba(23,33,27,.16); } +.text-button { border-color: transparent; } +.hero-index { position: absolute; top: 35px; right: 45px; color: rgba(25,101,72,.12); font: 110px/1 Georgia, serif; } +.daily-card { background: var(--ink); color: white; padding: 32px; display: flex; flex-direction: column; align-items: flex-start; } +.card-label { color: #aeb9b1; font-size: 9px; letter-spacing: .22em; } +.pulse-orbit { + width: 130px; height: 130px; border: 1px solid rgba(204,232,91,.35); border-radius: 50%; + margin: 35px auto 25px; align-self: center; display: grid; place-items: center; position: relative; +} +.pulse-orbit::before { content: ""; position: absolute; inset: 12px; border: 1px dashed rgba(204,232,91,.35); border-radius: 50%; } +.pulse-orbit span { color: var(--lime); font: 48px/1 Georgia, serif; } +.pulse-orbit small { position: absolute; bottom: 27px; color: #aeb9b1; } +.daily-card h2 { margin: 10px 0 7px; font: 27px/1.2 Georgia, serif; } +.daily-card p { color: #aeb9b1; line-height: 1.75; font-size: 13px; } +.dark-button { margin-top: auto; width: 100%; background: var(--lime); color: var(--ink); } +.section { margin-top: 70px; } +.section-heading { display: flex; align-items: end; justify-content: space-between; margin-bottom: 22px; } +.section-heading h2 { margin: 8px 0 0; font: 32px Georgia, serif; } +.section-heading p { margin: 0; color: var(--muted); font-size: 13px; } +.spirit-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; } +.spirit { min-height: 218px; padding: 24px; position: relative; overflow: hidden; transition: .2s ease; } +.spirit:hover { transform: translateY(-5px); } +.spirit b { color: #9da49f; font: 12px Georgia, serif; } +.spirit h3 { margin: 31px 0 11px; font: 27px Georgia, serif; } +.spirit p { color: var(--muted); font-size: 13px; line-height: 1.65; } +.spirit span { position: absolute; bottom: 20px; color: var(--green); font-size: 10px; letter-spacing: .08em; } +.spirit::after { content: ""; position: absolute; width: 75px; height: 75px; border-radius: 50%; right: -28px; top: -28px; opacity: .55; } +.believer::after { background: var(--lime); }.communicator::after { background: var(--blue); }.practitioner::after { background: var(--orange); }.intuitive::after { background: #bb8ac4; } +.page-title { padding: 65px 0 35px; } +.page-title h1 { font-size: clamp(44px, 6vw, 70px); margin-bottom: 12px; } +.page-title p { color: var(--muted); } +.content-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +.content-grid.loading { color: var(--muted); display: block; padding: 40px 0; } +.content-card { min-height: 235px; padding: 26px; display: flex; flex-direction: column; } +.content-card .meta { color: var(--green); font-size: 9px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; } +.content-card h3 { margin: 25px 0 11px; font: 25px/1.25 Georgia, serif; } +.content-card p { color: var(--muted); font-size: 13px; line-height: 1.75; } +.content-card footer { margin-top: auto; display: flex; align-items: center; justify-content: space-between; } +.content-card footer span { color: var(--muted); font-size: 11px; } +.content-card footer button { border: 0; border-bottom: 1px solid var(--ink); padding: 4px 0; background: transparent; cursor: pointer; } +.track-switch { display: flex; gap: 5px; margin-bottom: 22px; } +.track-switch button { border: 1px solid var(--line); padding: 9px 18px; border-radius: 99px; background: transparent; cursor: pointer; } +.track-switch button.active { background: var(--ink); color: white; } +.editor-shell { display: grid; grid-template-columns: 1fr 1fr; overflow: hidden; min-height: 430px; } +.editor-pane, .preview-pane { padding: 28px; } +.editor-pane { background: #1d2721; color: white; } +.editor-pane label, .preview-pane > span { display: block; color: #9eaaa2; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; } +.editor-pane textarea { width: 100%; height: 330px; margin-top: 22px; resize: none; border: 0; outline: 0; background: transparent; color: #dcefa1; font: 16px/1.8 "SFMono-Regular", Consolas, monospace; } +.preview-pane { display: flex; flex-direction: column; } +#latex-preview { margin: auto; max-width: 100%; overflow-wrap: anywhere; font: 28px/1.6 Georgia, serif; } +.preview-pane button { align-self: flex-end; } +.profile-panel { padding: 35px; } +.metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; } +.metric { background: rgba(25,101,72,.06); border-radius: 14px; padding: 17px; } +.metric b, .metric span { display: block; }.metric b { font: 28px Georgia, serif; }.metric span { margin-top: 5px; color: var(--muted); font-size: 11px; } +.tool-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-bottom: 22px; } +.tool-card { + min-height: 165px; border: 1px solid var(--line); border-radius: 18px; padding: 22px; + background: rgba(255,255,252,.7); text-align: left; cursor: pointer; transition: .2s ease; +} +.tool-card:hover, .tool-card.active { transform: translateY(-3px); border-color: rgba(25,101,72,.48); box-shadow: 0 16px 35px rgba(38,48,40,.08); } +.tool-card.active { background: var(--ink); color: white; } +.tool-card > span { display: grid; place-items: center; width: 52px; height: 52px; border-radius: 15px; background: rgba(25,101,72,.08); color: var(--green); font: 700 17px Georgia, serif; } +.tool-card.active > span { color: var(--lime); background: rgba(204,232,91,.1); } +.tool-card b, .tool-card small { display: block; }.tool-card b { margin-top: 18px; font: 19px Georgia, serif; }.tool-card small { margin-top: 7px; color: var(--muted); line-height: 1.5; } +.tool-card.active small { color: #aeb9b1; } +.tool-workspace { display: none; border: 1px solid var(--line); border-radius: var(--radius); padding: 30px; background: var(--panel); } +.tool-workspace.active { display: block; animation: rise .3s ease; } +.workspace-heading { display: flex; justify-content: space-between; gap: 30px; align-items: end; margin-bottom: 25px; } +.workspace-heading h2 { margin: 7px 0 0; font: 31px Georgia, serif; }.workspace-heading p { max-width: 480px; color: var(--muted); font-size: 13px; } +.calculator-shell { max-width: 800px; margin: auto; } +.calculator-display { min-height: 145px; border-radius: 18px; padding: 25px; background: var(--ink); color: white; display: flex; flex-direction: column; justify-content: space-between; text-align: right; } +.calculator-display small { color: #9eaaa2; }.calculator-display output { color: var(--lime); font: 48px/1 Georgia, serif; overflow-wrap: anywhere; } +.formula-input, .search-input { width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: white; outline-color: var(--green); } +.calculator-shell > .formula-input { margin: 14px 0; font: 18px "SFMono-Regular", Consolas, monospace; } +.calculator-actions { display: flex; flex-wrap: wrap; gap: 8px; }.calculator-actions button { border: 1px solid var(--line); border-radius: 10px; padding: 11px 15px; background: white; cursor: pointer; }.calculator-actions .primary-button { margin-left: auto; color: white; background: var(--green); border: 0; } +.search-input { width: min(370px, 100%); } +.symbol-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } +.symbol-card { border: 1px solid var(--line); border-radius: 15px; padding: 18px; background: white; cursor: pointer; text-align: left; } +.symbol-card strong { display: block; color: var(--green); font: 29px Georgia, serif; }.symbol-card b { display: block; margin-top: 10px; }.symbol-card small { display: block; margin-top: 5px; color: var(--muted); }.symbol-card code { display: inline-block; margin-top: 12px; padding: 4px 7px; border-radius: 6px; background: #f0f1eb; color: #405048; } +.graph-shell { display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 20px; } +.graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; } +#graph-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; } +.discover-title { padding-bottom: 24px; } +.ability-map { border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; background: rgba(255,255,252,.64); overflow: hidden; } +.map-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; }.map-heading h2 { margin: 0 0 6px; font: 30px Georgia, serif; }.map-heading p { margin: 0; color: var(--muted); font-size: 13px; } +.ability-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: end; }.ability-legend span { color: var(--muted); font-size: 10px; }.ability-legend i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 4px; } +.map-stage { position: relative; max-width: 1050px; height: 390px; margin: 8px auto 0; } +.map-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }.map-lines path { fill: none; stroke: #c8c9c2; stroke-width: 2; stroke-dasharray: 12 14; vector-effect: non-scaling-stroke; } +.ability-node { position: absolute; width: 128px; height: 105px; border: 2px solid currentColor; border-radius: 15px; background: #fbfbf8; color: var(--green); cursor: pointer; box-shadow: 0 12px 24px rgba(38,48,40,.05); transition: .2s ease; } +.ability-node:hover, .ability-node.active { transform: translateY(-5px); box-shadow: 0 17px 32px rgba(38,48,40,.13); background: white; } +.ability-node span, .ability-node b, .ability-node small { display: block; }.ability-node span { font-size: 25px; }.ability-node b { margin-top: 7px; color: var(--ink); }.ability-node small { margin: 7px auto 0; width: 34px; border-radius: 99px; padding: 3px; background: currentColor; color: white; } +.node-vision { left: calc(50% - 64px); top: 5px; color: #5d73e8; }.node-humanities { left: 8%; top: 65px; color: #b76d38; }.node-detection { left: 18%; bottom: 12px; color: #287f78; }.node-modeling { right: 18%; bottom: 12px; color: #d98628; }.node-connection { right: 8%; top: 65px; color: #8667b5; } +.pet-node { position: absolute; left: calc(50% - 49px); top: 145px; width: 98px; height: 98px; border: 2px solid #b49d8b; border-radius: 50%; background: white; display: grid; place-items: center; box-shadow: 0 13px 25px rgba(38,48,40,.12); } +.pet-node b { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; background: #778990; color: white; font: 22px Georgia, serif; }.pet-node span { position: absolute; bottom: 8px; font-size: 10px; color: var(--muted); } +.map-hint { margin: 0; text-align: center; color: #a0a49e; font-size: 11px; } +.video-browser { margin-top: 28px; }.filter-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 11px; } +.filter-chip { border: 1px solid var(--line); border-radius: 99px; padding: 9px 14px; background: rgba(255,255,252,.7); color: var(--muted); cursor: pointer; } +.filter-chip.active { background: var(--ink); border-color: var(--ink); color: white; } +.discipline-filters .filter-chip { border-radius: 9px; } +.video-section-heading { margin: 35px 0 16px; display: flex; justify-content: space-between; align-items: end; }.video-section-heading h2 { margin: 8px 0 0; font: 30px Georgia, serif; }.video-section-heading > span { color: var(--muted); font-size: 12px; } +.video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }.video-grid.loading { display: block; color: var(--muted); padding: 35px 0; } +.video-card { border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,252,.82); overflow: hidden; cursor: pointer; transition: .2s ease; } +.video-card:hover { transform: translateY(-4px); box-shadow: 0 18px 38px rgba(38,48,40,.1); } +.video-cover { height: 175px; padding: 15px; display: flex; align-items: center; justify-content: center; position: relative; background: linear-gradient(135deg, var(--cover-a), var(--cover-b)); } +.video-cover > span { opacity: .34; font-size: 52px; }.video-duration { position: absolute; left: 13px; top: 12px; border-radius: 7px; padding: 5px 8px; color: white; background: rgba(23,33,27,.62); font-size: 11px; } +.video-complete { position: absolute; right: 12px; top: 12px; background: var(--green); color: white; border-radius: 99px; padding: 5px 8px; font-size: 9px; } +.video-body { padding: 19px; }.ability-badge { display: inline-block; border: 1px solid var(--badge-color); color: var(--badge-color); border-radius: 8px; padding: 5px 8px; font-size: 10px; }.video-body h3 { min-height: 48px; margin: 14px 0 16px; font: 20px/1.3 Georgia, serif; }.video-metrics, .video-author { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; }.video-author { justify-content: flex-start; gap: 8px; align-items: center; margin-top: 14px; }.video-author i { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--badge-color); color: white; font-style: normal; } +.video-dialog { width: min(760px, calc(100% - 30px)); }.video-player { min-height: 300px; border-radius: 17px; background: var(--ink); display: grid; place-items: center; color: white; text-align: center; padding: 30px; }.video-player span { display: block; color: var(--lime); font-size: 55px; }.video-player p { color: #aeb9b1; }.video-detail-meta { display: flex; gap: 12px; flex-wrap: wrap; color: var(--muted); font-size: 11px; margin: 18px 0; }.video-dialog h2 { font: 31px/1.2 Georgia, serif; }.video-dialog .video-description { color: var(--muted); line-height: 1.8; }.video-dialog-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 22px; } +dialog { width: min(460px, calc(100% - 30px)); border: 1px solid var(--line); border-radius: 20px; padding: 30px; color: var(--ink); background: #faf9f3; box-shadow: 0 30px 100px rgba(0,0,0,.22); } +dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } +.dialog-close { position: absolute; right: 16px; top: 13px; border: 0; background: transparent; font-size: 25px; cursor: pointer; } +.dialog-tabs { display: flex; border-bottom: 1px solid var(--line); margin-bottom: 25px; } +.dialog-tabs button { border: 0; background: transparent; padding: 10px 15px; color: var(--muted); cursor: pointer; } +.dialog-tabs button.active { color: var(--ink); border-bottom: 2px solid var(--green); } +.auth-form { display: grid; gap: 15px; }.auth-form h2 { font: 28px Georgia, serif; margin: 0 0 5px; } +.auth-form label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; } +.auth-form input, .auth-form select { width: 100%; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 12px; outline-color: var(--green); } +.hidden { display: none !important; }.form-error { min-height: 18px; color: #b84136; font-size: 12px; } +.experience-dialog { width: min(760px, calc(100% - 30px)); } +.experience-dialog h2 { font: 34px Georgia, serif; }.experience-dialog .scene { white-space: pre-line; line-height: 1.9; color: #3e4942; } +.choice-list { display: grid; gap: 9px; margin-top: 25px; } +.choice-list button { text-align: left; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: white; cursor: pointer; } +.choice-list button:hover { border-color: var(--green); } +.quiz-progress { height: 4px; background: #e4e3da; margin: 15px 0 30px; }.quiz-progress i { display: block; height: 100%; background: var(--green); transition: width .2s; } +.toast { position: fixed; right: 25px; bottom: 25px; background: var(--ink); color: white; border-radius: 12px; padding: 13px 17px; opacity: 0; transform: translateY(10px); pointer-events: none; transition: .2s; z-index: 20; } +.toast.show { opacity: 1; transform: none; } +.csrf-token { display: none; } + +@media (max-width: 1050px) { + .app-shell { grid-template-columns: 80px 1fr; }.sidebar { padding: 25px 13px; }.brand span:last-child, .nav-item:not(.active) span, .nav-item { font-size: 0; } + .nav-item { text-align: center; }.nav-item span { width: auto; font-size: 11px !important; }.sidebar-foot { display: none; } + .hero-grid { grid-template-columns: 1fr; }.daily-card { min-height: 350px; }.spirit-grid { grid-template-columns: 1fr 1fr; }.content-grid { grid-template-columns: 1fr 1fr; } + .tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 700px) { + .app-shell { display: block; }.sidebar { position: fixed; top: auto; bottom: 0; width: 100%; height: 68px; z-index: 10; border: 0; border-top: 1px solid var(--line); padding: 8px; } + .brand, .sidebar-foot { display: none; }.nav { margin: 0; grid-template-columns: repeat(6, 1fr); gap: 2px; }.nav-item { padding: 11px 2px; font-size: 0; }.nav-item span { display: block; margin: auto; } + .main { padding: 0 18px 90px; }.topbar { height: 66px; }.topbar p { max-width: 55%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .hero { min-height: 500px; padding: 35px 25px; }.hero h1 { font-size: 44px; }.hero-actions { align-items: stretch; flex-direction: column; } + .section-heading { display: block; }.section-heading p { margin-top: 12px; }.spirit-grid, .content-grid, .editor-shell, .metric-grid, .tool-grid, .symbol-grid, .video-grid, .graph-shell { grid-template-columns: 1fr; } + .content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; } + .tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 20px; } + .map-stage { height: 480px; transform: scale(.92); }.ability-node { width: 108px; height: 94px; }.node-vision { left: calc(50% - 54px); }.node-humanities { left: 0; top: 100px; }.node-connection { right: 0; top: 100px; }.node-detection { left: 2%; bottom: 30px; }.node-modeling { right: 2%; bottom: 30px; }.pet-node { top: 190px; } + .map-lines { display: none; }.ability-legend { margin-top: 14px; justify-content: start; }.video-cover { height: 150px; } +} diff --git a/backend/static/js/app.js b/backend/static/js/app.js new file mode 100644 index 0000000..8359daf --- /dev/null +++ b/backend/static/js/app.js @@ -0,0 +1,961 @@ +const state = { + user: null, + stories: [], + contests: [], + track: "standard", + videoCatalog: null, + videos: [], + videoAbility: "", + videoDiscipline: "", +}; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + +function csrfToken() { + const match = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/); + return match ? decodeURIComponent(match[1]) : ""; +} + +async function api(path, options = {}) { + const headers = { Accept: "application/json", ...(options.headers || {}) }; + if (options.body && typeof options.body !== "string") { + headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(options.body); + } + if (!["GET", "HEAD"].includes(options.method || "GET")) headers["X-CSRFToken"] = csrfToken(); + const response = await fetch(`/api/v1/${path}`, { credentials: "same-origin", ...options, headers }); + if (response.status === 204) return null; + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const details = payload.error?.details; + const message = payload.error?.message || payload.detail || + (details ? Object.values(details).flat().join(" ") : "请求失败"); + const error = new Error(message); + error.status = response.status; + throw error; + } + return payload; +} + +function showToast(message) { + const toast = $("#toast"); + toast.textContent = message; + toast.classList.add("show"); + window.setTimeout(() => toast.classList.remove("show"), 2600); +} + +function navigate(view) { + $$(".nav-item").forEach((button) => button.classList.toggle("active", button.dataset.view === view)); + $$(".view").forEach((section) => section.classList.toggle("active", section.id === `view-${view}`)); + window.scrollTo({ top: 0, behavior: "smooth" }); + if (view === "profile") loadProfile(); +} + +function openAuth() { + $("#auth-error").textContent = ""; + $("#auth-dialog").showModal(); +} + +function requireAuth() { + if (state.user) return true; + openAuth(); + showToast("请先登录或使用邀请码注册"); + return false; +} + +function updateUserUI() { + const chip = $("#user-chip"); + chip.replaceChildren(); + const avatar = document.createElement("span"); + avatar.className = "avatar"; + avatar.textContent = state.user ? state.user.nickname.slice(0, 1) : "游"; + const name = document.createElement("span"); + name.textContent = state.user ? `${state.user.nickname} · ${state.user.rating}` : "游客"; + chip.append(avatar, name); + $("#auth-button").textContent = state.user ? "退出登录" : "登录 / 注册"; +} + +async function loadUser() { + try { + state.user = await api("accounts/me/"); + } catch (error) { + if (error.status !== 401 && error.status !== 403) console.warn(error); + state.user = null; + } + updateUserUI(); +} + +function card({ meta, title, body, foot, action, onClick, disabled = false }) { + const article = document.createElement("article"); + article.className = "content-card"; + const metaEl = document.createElement("span"); + metaEl.className = "meta"; + metaEl.textContent = meta; + const titleEl = document.createElement("h3"); + titleEl.textContent = title; + const bodyEl = document.createElement("p"); + bodyEl.textContent = body; + const footer = document.createElement("footer"); + const footEl = document.createElement("span"); + footEl.textContent = foot; + const button = document.createElement("button"); + button.textContent = action; + button.disabled = disabled; + if (onClick) button.addEventListener("click", onClick); + footer.append(footEl, button); + article.append(metaEl, titleEl, bodyEl, footer); + return article; +} + +async function loadStories() { + const root = $("#story-list"); + try { + state.stories = await api("math-life/stories/"); + root.classList.remove("loading"); + root.replaceChildren(...state.stories.map((story) => card({ + meta: story.kind === "flagship" ? "旗舰人生" : story.kind === "skill" ? "人物 Skill" : "特别篇", + title: story.title, + body: story.summary || "在不完整信息中判断机会、关系与代价。", + foot: `约 ${story.estimated_minutes} 分钟`, + action: story.available ? "开始人生 →" : "查看预告", + disabled: !story.available, + onClick: () => beginStory(story.slug), + }))); + } catch (error) { + root.textContent = error.message; + } +} + +async function beginStory(slug) { + if (!requireAuth()) return; + try { + const run = await api(`math-life/stories/${slug}/start/`, { method: "POST", body: {} }); + renderStoryNode(run); + $("#experience-dialog").showModal(); + } catch (error) { + showToast(error.message); + } +} + +function renderStoryNode(run) { + const root = $("#experience-content"); + root.replaceChildren(); + const label = document.createElement("span"); + label.className = "kicker"; + label.textContent = `${run.story} · ${run.current_node}`; + const title = document.createElement("h2"); + title.textContent = run.node.character || "旁白"; + const scene = document.createElement("p"); + scene.className = "scene"; + scene.textContent = run.node.scene; + const choices = document.createElement("div"); + choices.className = "choice-list"; + if (run.status === "completed") { + const ending = document.createElement("p"); + ending.textContent = "这段人生已经抵达结局,路径已写入你的数学档案。"; + choices.append(ending); + } else { + run.node.choices.forEach((choice) => { + const button = document.createElement("button"); + button.textContent = choice.text; + button.addEventListener("click", async () => { + button.disabled = true; + try { + const next = await api(`math-life/runs/${run.run_id}/choice/`, { + method: "POST", + headers: { "Idempotency-Key": crypto.randomUUID() }, + body: { choice_index: choice.index }, + }); + renderStoryNode(next); + } catch (error) { + showToast(error.message); + button.disabled = false; + } + }); + choices.append(button); + }); + } + root.append(label, title, scene, choices); +} + +async function startMathBTI() { + try { + const assessment = await api("math-life/mathbti/"); + const questions = assessment.definition.questions; + const answers = []; + let index = 0; + const root = $("#experience-content"); + const render = () => { + const question = questions[index]; + root.replaceChildren(); + const label = document.createElement("span"); + label.className = "kicker"; + label.textContent = `MATHBTI · ${index + 1} / ${questions.length}`; + const progress = document.createElement("div"); + progress.className = "quiz-progress"; + const bar = document.createElement("i"); + bar.style.width = `${((index + 1) / questions.length) * 100}%`; + progress.append(bar); + const title = document.createElement("h2"); + title.textContent = question.question; + const choices = document.createElement("div"); + choices.className = "choice-list"; + question.options.forEach((option, optionIndex) => { + const button = document.createElement("button"); + button.textContent = option.text; + button.addEventListener("click", async () => { + answers.push(optionIndex); + index += 1; + if (index < questions.length) { + render(); + return; + } + try { + const result = await api("math-life/mathbti/submit/", { + method: "POST", + body: { version: assessment.version, answers }, + }); + renderMathBTIResult(result); + } catch (error) { + showToast(error.message); + } + }); + choices.append(button); + }); + root.append(label, progress, title, choices); + }; + render(); + $("#experience-dialog").showModal(); + } catch (error) { + showToast(error.message); + } +} + +function renderMathBTIResult(result) { + const root = $("#experience-content"); + root.replaceChildren(); + const label = document.createElement("span"); + label.className = "kicker"; + label.textContent = `${result.identity.clan} · ${result.identity.code}`; + const title = document.createElement("h2"); + title.textContent = `${result.identity.name} · ${result.identity.mathematician}`; + const description = document.createElement("p"); + description.className = "scene"; + description.textContent = result.identity.description; + const button = document.createElement("button"); + button.className = "primary-button"; + button.textContent = state.user ? "进入推荐人生" : "注册并保存身份"; + button.addEventListener("click", () => { + $("#experience-dialog").close(); + if (state.user) navigate("life"); else openAuth(); + }); + root.append(label, title, description, button); +} + +async function loadContests() { + const root = $("#contest-list"); + try { + state.contests = await api("contests/"); + renderContests(); + } catch (error) { + root.textContent = error.message; + } +} + +function renderContests() { + const root = $("#contest-list"); + const contests = state.contests.filter((contest) => contest.track === state.track); + root.classList.remove("loading"); + root.replaceChildren(...contests.map((contest) => card({ + meta: contest.kind === "realtime" ? "实时竞技" : contest.kind === "daily" ? "今日挑战" : "自由练习", + title: contest.title, + body: contest.kind === "realtime" ? "Rating 匹配,同题序列,服务端计时和唯一结算。" : "完成整组题目,正式答案只在提交后显示。", + foot: `${contest.duration_seconds} 秒`, + action: contest.kind === "realtime" ? "开始匹配 →" : "开始答题 →", + onClick: () => beginContest(contest), + }))); +} + +async function beginContest(contest) { + if (!requireAuth()) return; + try { + if (contest.kind === "realtime") { + const match = await api(`contests/${contest.slug}/matchmaking/`, { method: "POST", body: {} }); + if (match.status === "waiting") { + showToast("已进入匹配队列,等待同赛道对手"); + return; + } + renderAttempt(match.attempt); + } else { + renderAttempt(await api(`contests/${contest.slug}/start/`, { method: "POST", body: {} })); + } + $("#experience-dialog").showModal(); + } catch (error) { + showToast(error.message); + } +} + +function renderAttempt(attempt) { + const root = $("#experience-content"); + root.replaceChildren(); + const label = document.createElement("span"); + label.className = "kicker"; + label.textContent = `${attempt.kind} · ${attempt.duration_seconds} 秒`; + const title = document.createElement("h2"); + title.textContent = attempt.contest; + if (attempt.status !== "active") { + const summary = document.createElement("p"); + summary.className = "scene"; + summary.textContent = `得分 ${attempt.score},答对 ${attempt.correct_count} 题,用时 ${(attempt.duration_ms / 1000).toFixed(1)} 秒。`; + root.append(label, title, summary); + return; + } + const form = document.createElement("form"); + form.className = "choice-list"; + attempt.questions.forEach((question) => { + const field = document.createElement("label"); + field.textContent = `${question.order}. ${question.prompt}`; + const input = document.createElement("input"); + input.name = String(question.order); + input.inputMode = "decimal"; + input.autocomplete = "off"; + input.style.cssText = "margin-top:7px;width:100%;padding:12px;border:1px solid #d6d8d1;border-radius:9px"; + field.append(input); + form.append(field); + }); + const submit = document.createElement("button"); + submit.className = "primary-button"; + submit.type = "submit"; + submit.textContent = "提交并结算"; + form.append(submit); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + submit.disabled = true; + const data = new FormData(form); + const answers = attempt.questions.map((question) => ({ + order: question.order, + answer: data.get(String(question.order)) || "", + })); + try { + const result = await api(`contests/attempts/${attempt.attempt_id}/submit/`, { + method: "POST", + headers: { "Idempotency-Key": crypto.randomUUID() }, + body: { answers }, + }); + renderAttempt(result); + } catch (error) { + showToast(error.message); + submit.disabled = false; + } + }); + root.append(label, title, form); +} + +function filterChip(label, value, type, active) { + const button = document.createElement("button"); + button.className = `filter-chip${active ? " active" : ""}`; + button.textContent = label; + button.dataset[type] = value; + button.addEventListener("click", () => { + if (type === "ability") state.videoAbility = value; + if (type === "discipline") state.videoDiscipline = value; + renderVideoFilters(); + renderVideos(); + }); + return button; +} + +function renderVideoFilters() { + if (!state.videoCatalog) return; + const abilityRoot = $("#ability-filters"); + abilityRoot.replaceChildren( + filterChip(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility), + ...state.videoCatalog.abilities.map((ability) => + filterChip( + `${ability.icon} ${ability.label} ${ability.count}`, + ability.id, + "ability", + state.videoAbility === ability.id, + ) + ), + ); + const disciplineRoot = $("#discipline-filters"); + disciplineRoot.replaceChildren( + filterChip("全部专业", "", "discipline", !state.videoDiscipline), + ...state.videoCatalog.disciplines.map((discipline) => + filterChip( + `${discipline.icon || "∑"} ${discipline.name}`, + discipline.name, + "discipline", + state.videoDiscipline === discipline.name, + ) + ), + ); + + const selected = state.videoCatalog.abilities.find( + (item) => item.id === state.videoAbility + ); + $("#video-stream-title").textContent = selected ? `${selected.label}视频流` : "全部视频"; + $$(".ability-node").forEach((node) => { + node.classList.toggle("active", node.dataset.ability === state.videoAbility); + }); +} + +function formatCount(value) { + if (value >= 10000) return `${(value / 10000).toFixed(1)}万`; + return String(value); +} + +function formatDuration(seconds) { + const minutes = Math.floor(seconds / 60); + const remaining = seconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`; +} + +function videoCard(item, index) { + const article = document.createElement("article"); + article.className = "video-card"; + article.setAttribute("role", "button"); + article.setAttribute("aria-label", `打开视频:${item.title}`); + article.tabIndex = 0; + const ability = item.ability || { label: "数学探索", icon: "∑", color: "#657068" }; + article.style.setProperty("--badge-color", ability.color); + article.style.setProperty("--cover-a", `${ability.color}22`); + article.style.setProperty("--cover-b", `${ability.color}55`); + + const cover = document.createElement("div"); + cover.className = "video-cover"; + const duration = document.createElement("b"); + duration.className = "video-duration"; + duration.textContent = formatDuration(item.duration_seconds); + const icon = document.createElement("span"); + icon.textContent = item.discipline_icon || ability.icon; + cover.append(duration, icon); + if (item.progress?.completed) { + const complete = document.createElement("i"); + complete.className = "video-complete"; + complete.textContent = "已完成"; + cover.append(complete); + } + + const body = document.createElement("div"); + body.className = "video-body"; + const badge = document.createElement("span"); + badge.className = "ability-badge"; + badge.textContent = `${ability.icon} ${ability.label}`; + const title = document.createElement("h3"); + title.textContent = item.title; + const metrics = document.createElement("div"); + metrics.className = "video-metrics"; + const views = document.createElement("span"); + views.textContent = `◉ ${formatCount(item.view_count)}`; + const comments = document.createElement("span"); + comments.textContent = `◌ ${formatCount(item.comment_count)}`; + metrics.append(views, comments); + const author = document.createElement("div"); + author.className = "video-author"; + const avatar = document.createElement("i"); + avatar.textContent = item.discipline?.slice(0, 1) || "数"; + const authorName = document.createElement("span"); + authorName.textContent = item.author || "葫芦数学志愿者"; + author.append(avatar, authorName); + body.append(badge, title, metrics, author); + article.append(cover, body); + article.addEventListener("click", () => openVideo(item)); + article.addEventListener("keydown", (event) => { + if (event.key === "Enter") openVideo(item); + }); + return article; +} + +function renderVideos() { + const root = $("#video-list"); + const videos = state.videos.filter( + (item) => + (!state.videoAbility || item.ability_dimension === state.videoAbility) && + (!state.videoDiscipline || item.discipline === state.videoDiscipline) + ); + root.classList.remove("loading"); + root.replaceChildren(...videos.map(videoCard)); + $("#video-result-count").textContent = `${videos.length} 条内容`; +} + +function openVideo(item) { + const root = $("#video-dialog-content"); + root.replaceChildren(); + const player = document.createElement("div"); + player.className = "video-player"; + const icon = document.createElement("span"); + icon.textContent = item.discipline_icon || "▶"; + const status = document.createElement("h3"); + status.textContent = item.media_url ? "志愿者视频外部播放" : "视频素材待上传"; + const note = document.createElement("p"); + note.textContent = item.media_url + ? "点击下方按钮打开原始视频来源。观看后可回到这里记录完成。" + : "内容卡、分类和成长链路已经就绪,运营上传视频地址后即可播放。"; + player.append(icon, status, note); + + const title = document.createElement("h2"); + title.textContent = item.title; + const meta = document.createElement("div"); + meta.className = "video-detail-meta"; + [ + `${item.discipline_icon || "∑"} ${item.discipline}`, + formatDuration(item.duration_seconds), + item.author, + item.ability?.label || "数学探索", + ].forEach((value) => { + const span = document.createElement("span"); + span.textContent = value; + meta.append(span); + }); + const description = document.createElement("p"); + description.className = "video-description"; + description.textContent = item.summary; + const actions = document.createElement("div"); + actions.className = "video-dialog-actions"; + if (item.media_url) { + const link = document.createElement("a"); + link.className = "ghost-button"; + link.href = item.media_url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.textContent = "打开视频来源"; + actions.append(link); + } else { + const unavailable = document.createElement("span"); + unavailable.textContent = "暂时没有可播放地址"; + actions.append(unavailable); + } + const complete = document.createElement("button"); + complete.className = "primary-button"; + complete.textContent = item.progress?.completed ? "已完成观看" : "标记完成 · +1 能力碎片"; + complete.disabled = Boolean(item.progress?.completed); + complete.addEventListener("click", async () => { + if (!requireAuth()) return; + complete.disabled = true; + try { + const result = await api(`content/${item.slug}/complete/`, { + method: "POST", + body: {}, + }); + item.progress = { completed: true, reward_granted: result.reward_granted }; + complete.textContent = "已完成观看"; + renderVideos(); + showToast(result.reward ? "观看完成,获得 1 枚能力碎片" : "观看记录已保存"); + } catch (error) { + complete.disabled = false; + showToast(error.message); + } + }); + actions.append(complete); + root.append(player, title, meta, description, actions); + $("#video-dialog").showModal(); +} + +async function loadContent() { + const root = $("#video-list"); + try { + const [catalog, videos] = await Promise.all([ + api("content/videos/catalog/"), + api("content/?kind=video"), + ]); + state.videoCatalog = catalog; + state.videos = videos; + const abilityRoot = $("#ability-legend"); + abilityRoot.replaceChildren( + ...catalog.abilities.map((ability) => { + const item = document.createElement("span"); + const dot = document.createElement("i"); + dot.style.background = ability.color; + item.append(dot, document.createTextNode(`${ability.label} · ${ability.count}`)); + return item; + }) + ); + catalog.abilities.forEach((ability) => { + const node = $(`.ability-node[data-ability="${ability.id}"]`); + if (node) node.querySelector("small").textContent = ability.count; + }); + renderVideoFilters(); + renderVideos(); + } catch (error) { + root.textContent = error.message; + } +} + +async function loadProfile() { + const root = $("#profile-panel"); + if (!state.user) { + root.innerHTML = '

登录后查看完整档案。

'; + $("[data-open-auth]", root).addEventListener("click", openAuth); + return; + } + try { + const profile = await api("progression/me/"); + root.replaceChildren(); + const heading = document.createElement("h2"); + heading.textContent = `${state.user.nickname} · Rating ${state.user.rating}`; + const pet = document.createElement("p"); + pet.textContent = `数学精灵:${profile.pet.name},等级 ${profile.pet.level}`; + const metrics = document.createElement("div"); + metrics.className = "metric-grid"; + profile.abilities.forEach((ability) => { + const item = document.createElement("div"); + item.className = "metric"; + const value = document.createElement("b"); + value.textContent = ability.level; + const label = document.createElement("span"); + label.textContent = `${ability.label} · ${ability.fragments} 碎片`; + item.append(value, label); + metrics.append(item); + }); + root.append(heading, pet, metrics); + } catch (error) { + root.textContent = error.message; + } +} + +const MATH_SYMBOLS = [ + ["∑", "求和", "一组数或表达式的总和", "\\sum"], + ["∏", "连乘", "一组数或表达式的乘积", "\\prod"], + ["∫", "积分", "连续累积与面积", "\\int"], + ["∂", "偏导数", "多元函数对一个变量的变化率", "\\partial"], + ["∞", "无穷", "没有有限边界", "\\infty"], + ["√", "平方根", "平方后得到原数的值", "\\sqrt{x}"], + ["≈", "约等于", "数值近似相等", "\\approx"], + ["≠", "不等于", "两个量不相等", "\\ne"], + ["≤", "小于等于", "不大于右侧的量", "\\le"], + ["≥", "大于等于", "不小于右侧的量", "\\ge"], + ["∈", "属于", "元素属于某个集合", "\\in"], + ["∉", "不属于", "元素不在某个集合中", "\\notin"], + ["⊂", "真子集", "集合被另一个集合包含", "\\subset"], + ["∪", "并集", "属于至少一个集合的元素", "\\cup"], + ["∩", "交集", "同时属于多个集合的元素", "\\cap"], + ["∀", "任意", "对所有对象都成立", "\\forall"], + ["∃", "存在", "至少存在一个对象", "\\exists"], + ["⇒", "推出", "前件能够推出后件", "\\Rightarrow"], + ["π", "圆周率", "圆周长与直径之比", "\\pi"], + ["θ", "角变量", "常用于表示角度", "\\theta"], +]; + +function evaluateExpression(source, variables = {}) { + const input = String(source).replaceAll("π", "pi").replaceAll("×", "*").replaceAll("÷", "/"); + let position = 0; + const functions = { + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, + sqrt: Math.sqrt, + log: Math.log10, + ln: Math.log, + abs: Math.abs, + }; + + const skip = () => { + while (/\s/.test(input[position] || "")) position += 1; + }; + const parseExpression = () => { + let value = parseTerm(); + while (true) { + skip(); + const operator = input[position]; + if (operator !== "+" && operator !== "-") break; + position += 1; + const right = parseTerm(); + value = operator === "+" ? value + right : value - right; + } + return value; + }; + const parseTerm = () => { + let value = parsePower(); + while (true) { + skip(); + const operator = input[position]; + if (operator !== "*" && operator !== "/") break; + position += 1; + const right = parsePower(); + value = operator === "*" ? value * right : value / right; + } + return value; + }; + const parsePower = () => { + let value = parseUnary(); + skip(); + if (input[position] === "^") { + position += 1; + value = value ** parsePower(); + } + return value; + }; + const parseUnary = () => { + skip(); + if (input[position] === "+") { + position += 1; + return parseUnary(); + } + if (input[position] === "-") { + position += 1; + return -parseUnary(); + } + return parsePrimary(); + }; + const parsePrimary = () => { + skip(); + if (input[position] === "(") { + position += 1; + const value = parseExpression(); + skip(); + if (input[position] !== ")") throw new Error("缺少右括号"); + position += 1; + return value; + } + const numberMatch = input.slice(position).match(/^(?:\d+\.?\d*|\.\d+)/); + if (numberMatch) { + position += numberMatch[0].length; + return Number(numberMatch[0]); + } + const identifierMatch = input.slice(position).match(/^[A-Za-z]+/); + if (!identifierMatch) throw new Error(`无法识别位置 ${position + 1} 的内容`); + const identifier = identifierMatch[0].toLowerCase(); + position += identifierMatch[0].length; + if (identifier === "pi") return Math.PI; + if (identifier === "e") return Math.E; + if (Object.hasOwn(variables, identifier)) return Number(variables[identifier]); + if (!functions[identifier]) throw new Error(`不支持函数 ${identifier}`); + skip(); + if (input[position] !== "(") throw new Error(`${identifier} 后需要括号`); + position += 1; + const argument = parseExpression(); + skip(); + if (input[position] !== ")") throw new Error("函数缺少右括号"); + position += 1; + return functions[identifier](argument); + }; + + const value = parseExpression(); + skip(); + if (position !== input.length) throw new Error(`无法识别位置 ${position + 1} 的内容`); + if (!Number.isFinite(value)) throw new Error("结果不是有限数值"); + return value; +} + +function runCalculator() { + const input = $("#calc-input").value; + try { + const result = evaluateExpression(input); + $("#calc-history").textContent = input; + $("#calc-output").textContent = Number(result.toPrecision(12)).toString(); + } catch (error) { + $("#calc-history").textContent = error.message; + $("#calc-output").textContent = "错误"; + } +} + +function renderSymbols(query = "") { + const keyword = query.trim().toLowerCase(); + const matches = MATH_SYMBOLS.filter((symbol) => + symbol.join(" ").toLowerCase().includes(keyword) + ); + $("#symbol-list").replaceChildren( + ...matches.map(([glyph, name, description, latex]) => { + const button = document.createElement("button"); + button.className = "symbol-card"; + const symbol = document.createElement("strong"); + symbol.textContent = glyph; + const title = document.createElement("b"); + title.textContent = name; + const detail = document.createElement("small"); + detail.textContent = description; + const code = document.createElement("code"); + code.textContent = latex; + button.append(symbol, title, detail, code); + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(latex); + showToast(`已复制 ${latex}`); + } catch { + showToast(`LaTeX:${latex}`); + } + }); + return button; + }) + ); +} + +function drawGraph() { + const canvas = $("#graph-canvas"); + const context = canvas.getContext("2d"); + const expression = $("#graph-expression").value; + const range = Number($("#graph-range").value); + const width = canvas.width; + const height = canvas.height; + $("#graph-range-label").textContent = `−${range} 到 ${range}`; + $("#graph-error").textContent = ""; + context.clearRect(0, 0, width, height); + context.fillStyle = "#fbfbf7"; + context.fillRect(0, 0, width, height); + + const toX = (x) => ((x + range) / (range * 2)) * width; + const toY = (y) => height / 2 - (y / range) * (height / 2); + context.strokeStyle = "#e4e5df"; + context.lineWidth = 1; + for (let value = -range; value <= range; value += 1) { + context.beginPath(); + context.moveTo(toX(value), 0); + context.lineTo(toX(value), height); + context.stroke(); + context.beginPath(); + context.moveTo(0, toY(value)); + context.lineTo(width, toY(value)); + context.stroke(); + } + context.strokeStyle = "#718078"; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(0, height / 2); + context.lineTo(width, height / 2); + context.moveTo(width / 2, 0); + context.lineTo(width / 2, height); + context.stroke(); + + context.strokeStyle = "#196548"; + context.lineWidth = 3; + context.beginPath(); + let drawing = false; + try { + for (let pixel = 0; pixel <= width; pixel += 2) { + const x = (pixel / width) * range * 2 - range; + const y = evaluateExpression(expression, { x }); + const screenY = toY(y); + if (screenY < -height * 2 || screenY > height * 3) { + drawing = false; + continue; + } + if (!drawing) context.moveTo(pixel, screenY); + else context.lineTo(pixel, screenY); + drawing = true; + } + context.stroke(); + } catch (error) { + $("#graph-error").textContent = error.message; + } +} + +function switchTool(tool) { + if (tool === "mental") { + navigate("contest"); + return; + } + $$(".tool-card").forEach((card) => { + card.classList.toggle("active", card.dataset.tool === tool); + }); + $$(".tool-workspace").forEach((workspace) => { + workspace.classList.toggle("active", workspace.id === `tool-${tool}`); + }); + if (tool === "graph") window.setTimeout(drawGraph, 30); +} + +async function saveFormula() { + if (!requireAuth()) return; + try { + await api("latex/documents/", { + method: "POST", + body: { title: `公式 ${new Date().toLocaleString()}`, source: $("#latex-source").value }, + }); + showToast("公式及首个版本已保存"); + } catch (error) { + showToast(error.message); + } +} + +function bindUI() { + $$(".nav-item").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.view))); + $$("[data-jump]").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.jump))); + $$("[data-open-auth]").forEach((button) => button.addEventListener("click", openAuth)); + $("#start-mathbti").addEventListener("click", startMathBTI); + $("#save-formula").addEventListener("click", saveFormula); + $("#latex-source").addEventListener("input", (event) => { $("#latex-preview").textContent = event.target.value; }); + $$(".tool-card").forEach((button) => { + button.addEventListener("click", () => switchTool(button.dataset.tool)); + }); + $("#calc-run").addEventListener("click", runCalculator); + $("#calc-input").addEventListener("keydown", (event) => { + if (event.key === "Enter") runCalculator(); + }); + $$("[data-calc-example]").forEach((button) => { + button.addEventListener("click", () => { + $("#calc-input").value = button.dataset.calcExample; + runCalculator(); + }); + }); + $("#symbol-search").addEventListener("input", (event) => { + renderSymbols(event.target.value); + }); + $("#graph-run").addEventListener("click", drawGraph); + $("#graph-range").addEventListener("input", drawGraph); + $("#graph-expression").addEventListener("keydown", (event) => { + if (event.key === "Enter") drawGraph(); + }); + $$(".ability-node").forEach((button) => { + button.addEventListener("click", () => { + state.videoAbility = + state.videoAbility === button.dataset.ability ? "" : button.dataset.ability; + renderVideoFilters(); + renderVideos(); + $("#video-list").scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }); + $("#auth-button").addEventListener("click", async () => { + if (!state.user) return openAuth(); + await api("accounts/logout/", { method: "POST", body: {} }); + state.user = null; + updateUserUI(); + showToast("已退出登录"); + }); + $$("dialog .dialog-close").forEach((button) => button.addEventListener("click", () => button.closest("dialog").close())); + $$("[data-auth-tab]").forEach((button) => button.addEventListener("click", () => { + $$("[data-auth-tab]").forEach((item) => item.classList.toggle("active", item === button)); + $("#login-form").classList.toggle("hidden", button.dataset.authTab !== "login"); + $("#register-form").classList.toggle("hidden", button.dataset.authTab !== "register"); + $("#auth-error").textContent = ""; + })); + $$("#track-switch button").forEach((button) => button.addEventListener("click", () => { + state.track = button.dataset.track; + $$("#track-switch button").forEach((item) => item.classList.toggle("active", item === button)); + renderContests(); + })); + $("#login-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + state.user = await api("accounts/login/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) }); + $("#auth-dialog").close(); + updateUserUI(); + await loadContent(); + showToast("登录成功"); + } catch (error) { + $("#auth-error").textContent = error.message; + } + }); + $("#register-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + state.user = await api("accounts/register/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) }); + $("#auth-dialog").close(); + updateUserUI(); + await loadContent(); + showToast("账号和数学档案已建立"); + } catch (error) { + $("#auth-error").textContent = error.message; + } + }); +} + +async function boot() { + bindUI(); + renderSymbols(); + runCalculator(); + await Promise.all([loadUser(), loadStories(), loadContests(), loadContent()]); +} + +boot(); diff --git a/backend/staticfiles/admin/css/autocomplete.4a81fc4242d0.css b/backend/staticfiles/admin/css/autocomplete.4a81fc4242d0.css new file mode 100644 index 0000000..69c94e7 --- /dev/null +++ b/backend/staticfiles/admin/css/autocomplete.4a81fc4242d0.css @@ -0,0 +1,275 @@ +select.admin-autocomplete { + width: 20em; +} + +.select2-container--admin-autocomplete.select2-container { + min-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single, +.select2-container--admin-autocomplete .select2-selection--multiple { + min-height: 30px; + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection, +.select2-container--admin-autocomplete.select2-container--open .select2-selection { + border-color: var(--body-quiet-color); + min-height: 30px; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single { + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-selection--single { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { + color: var(--body-fg); + line-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: text; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 10px 5px 5px; + width: 100%; + display: flex; + flex-wrap: wrap; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li { + list-style: none; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder { + color: var(--body-quiet-color); + margin-top: 5px; + float: left; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin: 5px; + position: absolute; + right: 0; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { + background-color: var(--darkened-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove { + color: var(--body-quiet-color); + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { + color: var(--body-fg); +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple { + border: solid var(--body-quiet-color) 1px; + outline: 0; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.select2-container--admin-autocomplete .select2-search--dropdown { + background: var(--darkened-bg); +} + +.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { + background: transparent; + color: var(--body-fg); + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; +} + +.select2-container--admin-autocomplete .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; + color: var(--body-fg); + background: var(--body-bg); +} + +.select2-container--admin-autocomplete .select2-results__option[role=group] { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { + background-color: var(--selected-bg); + color: var(--body-fg); +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option { + padding-left: 1em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; +} + +.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary); + color: var(--primary-fg); +} + +.select2-container--admin-autocomplete .select2-results__group { + cursor: default; + display: block; + padding: 6px; +} diff --git a/backend/staticfiles/admin/css/autocomplete.4a81fc4242d0.css.gz b/backend/staticfiles/admin/css/autocomplete.4a81fc4242d0.css.gz new file mode 100644 index 0000000..7cd12df Binary files /dev/null and b/backend/staticfiles/admin/css/autocomplete.4a81fc4242d0.css.gz differ diff --git a/backend/staticfiles/admin/css/autocomplete.css b/backend/staticfiles/admin/css/autocomplete.css new file mode 100644 index 0000000..69c94e7 --- /dev/null +++ b/backend/staticfiles/admin/css/autocomplete.css @@ -0,0 +1,275 @@ +select.admin-autocomplete { + width: 20em; +} + +.select2-container--admin-autocomplete.select2-container { + min-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single, +.select2-container--admin-autocomplete .select2-selection--multiple { + min-height: 30px; + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection, +.select2-container--admin-autocomplete.select2-container--open .select2-selection { + border-color: var(--body-quiet-color); + min-height: 30px; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single { + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-selection--single { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { + color: var(--body-fg); + line-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: text; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 10px 5px 5px; + width: 100%; + display: flex; + flex-wrap: wrap; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li { + list-style: none; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder { + color: var(--body-quiet-color); + margin-top: 5px; + float: left; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin: 5px; + position: absolute; + right: 0; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { + background-color: var(--darkened-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove { + color: var(--body-quiet-color); + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { + color: var(--body-fg); +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple { + border: solid var(--body-quiet-color) 1px; + outline: 0; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.select2-container--admin-autocomplete .select2-search--dropdown { + background: var(--darkened-bg); +} + +.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { + background: transparent; + color: var(--body-fg); + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; +} + +.select2-container--admin-autocomplete .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; + color: var(--body-fg); + background: var(--body-bg); +} + +.select2-container--admin-autocomplete .select2-results__option[role=group] { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { + background-color: var(--selected-bg); + color: var(--body-fg); +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option { + padding-left: 1em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; +} + +.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary); + color: var(--primary-fg); +} + +.select2-container--admin-autocomplete .select2-results__group { + cursor: default; + display: block; + padding: 6px; +} diff --git a/backend/staticfiles/admin/css/autocomplete.css.gz b/backend/staticfiles/admin/css/autocomplete.css.gz new file mode 100644 index 0000000..7cd12df Binary files /dev/null and b/backend/staticfiles/admin/css/autocomplete.css.gz differ diff --git a/backend/staticfiles/admin/css/base.523eb49842a7.css b/backend/staticfiles/admin/css/base.523eb49842a7.css new file mode 100644 index 0000000..f584fa4 --- /dev/null +++ b/backend/staticfiles/admin/css/base.523eb49842a7.css @@ -0,0 +1,1145 @@ +/* + DJANGO Admin styles +*/ + +/* VARIABLE DEFINITIONS */ +html[data-theme="light"], +:root { + --primary: #79aec8; + --secondary: #417690; + --accent: #f5dd5d; + --primary-fg: #fff; + + --body-fg: #333; + --body-bg: #fff; + --body-quiet-color: #666; + --body-loud-color: #000; + + --header-color: #ffc; + --header-branding-color: var(--accent); + --header-bg: var(--secondary); + --header-link-color: var(--primary-fg); + + --breadcrumbs-fg: #c4dce8; + --breadcrumbs-link-fg: var(--body-bg); + --breadcrumbs-bg: var(--primary); + + --link-fg: #417893; + --link-hover-color: #036; + --link-selected-fg: #5b80b2; + + --hairline-color: #e8e8e8; + --border-color: #ccc; + + --error-fg: #ba2121; + + --message-success-bg: #dfd; + --message-warning-bg: #ffc; + --message-error-bg: #ffefef; + + --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ + --selected-bg: #e4e4e4; /* E.g. selected table cells */ + --selected-row: #ffc; + + --button-fg: #fff; + --button-bg: var(--primary); + --button-hover-bg: #609ab6; + --default-button-bg: var(--secondary); + --default-button-hover-bg: #205067; + --close-button-bg: #747474; + --close-button-hover-bg: #333; + --delete-button-bg: #ba2121; + --delete-button-hover-bg: #a41515; + + --object-tools-fg: var(--button-fg); + --object-tools-bg: var(--close-button-bg); + --object-tools-hover-bg: var(--close-button-hover-bg); + + --font-family-primary: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + Roboto, + "Helvetica Neue", + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; + --font-family-monospace: + ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Monospace", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Courier New", + monospace, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + padding: 0; + font-size: 0.875rem; + font-family: var(--font-family-primary); + color: var(--body-fg); + background: var(--body-bg); +} + +/* LINKS */ + +a:link, a:visited { + color: var(--link-fg); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} + +a:focus, a:hover { + color: var(--link-hover-color); +} + +a:focus { + text-decoration: underline; +} + +a img { + border: none; +} + +a.section:link, a.section:visited { + color: var(--header-link-color); + text-decoration: none; +} + +a.section:focus, a.section:hover { + text-decoration: underline; +} + +/* GLOBAL DEFAULTS */ + +p, ol, ul, dl { + margin: .2em 0 .8em 0; +} + +p { + padding: 0; + line-height: 140%; +} + +h1,h2,h3,h4,h5 { + font-weight: bold; +} + +h1 { + margin: 0 0 20px; + font-weight: 300; + font-size: 1.25rem; + color: var(--body-quiet-color); +} + +h2 { + font-size: 1rem; + margin: 1em 0 .5em 0; +} + +h2.subhead { + font-weight: normal; + margin-top: 0; +} + +h3 { + font-size: 0.875rem; + margin: .8em 0 .3em 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +h4 { + font-size: 0.75rem; + margin: 1em 0 .8em 0; + padding-bottom: 3px; +} + +h5 { + font-size: 0.625rem; + margin: 1.5em 0 .5em 0; + color: var(--body-quiet-color); + text-transform: uppercase; + letter-spacing: 1px; +} + +ul > li { + list-style-type: square; + padding: 1px 0; +} + +li ul { + margin-bottom: 0; +} + +li, dt, dd { + font-size: 0.8125rem; + line-height: 1.25rem; +} + +dt { + font-weight: bold; + margin-top: 4px; +} + +dd { + margin-left: 0; +} + +form { + margin: 0; + padding: 0; +} + +fieldset { + margin: 0; + min-width: 0; + padding: 0; + border: none; + border-top: 1px solid var(--hairline-color); +} + +blockquote { + font-size: 0.6875rem; + color: #777; + margin-left: 2px; + padding-left: 10px; + border-left: 5px solid #ddd; +} + +code, pre { + font-family: var(--font-family-monospace); + color: var(--body-quiet-color); + font-size: 0.75rem; + overflow-x: auto; +} + +pre.literal-block { + margin: 10px; + background: var(--darkened-bg); + padding: 6px 8px; +} + +code strong { + color: #930; +} + +hr { + clear: both; + color: var(--hairline-color); + background-color: var(--hairline-color); + height: 1px; + border: none; + margin: 0; + padding: 0; + line-height: 1px; +} + +/* TEXT STYLES & MODIFIERS */ + +.small { + font-size: 0.6875rem; +} + +.mini { + font-size: 0.625rem; +} + +.help, p.help, form p.help, div.help, form div.help, div.help li { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +div.help ul { + margin-bottom: 0; +} + +.help-tooltip { + cursor: help; +} + +p img, h1 img, h2 img, h3 img, h4 img, td img { + vertical-align: middle; +} + +.quiet, a.quiet:link, a.quiet:visited { + color: var(--body-quiet-color); + font-weight: normal; +} + +.clear { + clear: both; +} + +.nowrap { + white-space: nowrap; +} + +.hidden { + display: none !important; +} + +/* TABLES */ + +table { + border-collapse: collapse; + border-color: var(--border-color); +} + +td, th { + font-size: 0.8125rem; + line-height: 1rem; + border-bottom: 1px solid var(--hairline-color); + vertical-align: top; + padding: 8px; +} + +th { + font-weight: 600; + text-align: left; +} + +thead th, +tfoot td { + color: var(--body-quiet-color); + padding: 5px 10px; + font-size: 0.6875rem; + background: var(--body-bg); + border: none; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +tfoot td { + border-bottom: none; + border-top: 1px solid var(--hairline-color); +} + +thead th.required { + color: var(--body-loud-color); +} + +tr.alt { + background: var(--darkened-bg); +} + +tr:nth-child(odd), .row-form-errors { + background: var(--body-bg); +} + +tr:nth-child(even), +tr:nth-child(even) .errorlist, +tr:nth-child(odd) + .row-form-errors, +tr:nth-child(odd) + .row-form-errors .errorlist { + background: var(--darkened-bg); +} + +/* SORTABLE TABLES */ + +thead th { + padding: 5px 10px; + line-height: normal; + text-transform: uppercase; + background: var(--darkened-bg); +} + +thead th a:link, thead th a:visited { + color: var(--body-quiet-color); +} + +thead th.sorted { + background: var(--selected-bg); +} + +thead th.sorted .text { + padding-right: 42px; +} + +table thead th .text span { + padding: 8px 10px; + display: block; +} + +table thead th .text a { + display: block; + cursor: pointer; + padding: 8px 10px; +} + +table thead th .text a:focus, table thead th .text a:hover { + background: var(--selected-bg); +} + +thead th.sorted a.sortremove { + visibility: hidden; +} + +table thead th.sorted:hover a.sortremove { + visibility: visible; +} + +table thead th.sorted .sortoptions { + display: block; + padding: 9px 5px 0 5px; + float: right; + text-align: right; +} + +table thead th.sorted .sortpriority { + font-size: .8em; + min-width: 12px; + text-align: center; + vertical-align: 3px; + margin-left: 2px; + margin-right: 2px; +} + +table thead th.sorted .sortoptions a { + position: relative; + width: 14px; + height: 14px; + display: inline-block; + background: url("../img/sorting-icons.3a097b59f104.svg") 0 0 no-repeat; + background-size: 14px auto; +} + +table thead th.sorted .sortoptions a.sortremove { + background-position: 0 0; +} + +table thead th.sorted .sortoptions a.sortremove:after { + content: '\\'; + position: absolute; + top: -6px; + left: 3px; + font-weight: 200; + font-size: 1.125rem; + color: var(--body-quiet-color); +} + +table thead th.sorted .sortoptions a.sortremove:focus:after, +table thead th.sorted .sortoptions a.sortremove:hover:after { + color: var(--link-fg); +} + +table thead th.sorted .sortoptions a.sortremove:focus, +table thead th.sorted .sortoptions a.sortremove:hover { + background-position: 0 -14px; +} + +table thead th.sorted .sortoptions a.ascending { + background-position: 0 -28px; +} + +table thead th.sorted .sortoptions a.ascending:focus, +table thead th.sorted .sortoptions a.ascending:hover { + background-position: 0 -42px; +} + +table thead th.sorted .sortoptions a.descending { + top: 1px; + background-position: 0 -56px; +} + +table thead th.sorted .sortoptions a.descending:focus, +table thead th.sorted .sortoptions a.descending:hover { + background-position: 0 -70px; +} + +/* FORM DEFAULTS */ + +input, textarea, select, .form-row p, form .button { + margin: 2px 0; + padding: 2px 3px; + vertical-align: middle; + font-family: var(--font-family-primary); + font-weight: normal; + font-size: 0.8125rem; +} +.form-row div.help { + padding: 2px 3px; +} + +textarea { + vertical-align: top; +} + +input[type=text], input[type=password], input[type=email], input[type=url], +input[type=number], input[type=tel], textarea, select, .vTextField { + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 5px 6px; + margin-top: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} + +input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, +input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus, +textarea:focus, select:focus, .vTextField:focus { + border-color: var(--body-quiet-color); +} + +select { + height: 1.875rem; +} + +select[multiple] { + /* Allow HTML size attribute to override the height in the rule above. */ + height: auto; + min-height: 150px; +} + +/* FORM BUTTONS */ + +.button, input[type=submit], input[type=button], .submit-row input, a.button { + background: var(--button-bg); + padding: 10px 15px; + border: none; + border-radius: 4px; + color: var(--button-fg); + cursor: pointer; + transition: background 0.15s; +} + +a.button { + padding: 4px 5px; +} + +.button:active, input[type=submit]:active, input[type=button]:active, +.button:focus, input[type=submit]:focus, input[type=button]:focus, +.button:hover, input[type=submit]:hover, input[type=button]:hover { + background: var(--button-hover-bg); +} + +.button[disabled], input[type=submit][disabled], input[type=button][disabled] { + opacity: 0.4; +} + +.button.default, input[type=submit].default, .submit-row input.default { + border: none; + font-weight: 400; + background: var(--default-button-bg); +} + +.button.default:active, input[type=submit].default:active, +.button.default:focus, input[type=submit].default:focus, +.button.default:hover, input[type=submit].default:hover { + background: var(--default-button-hover-bg); +} + +.button[disabled].default, +input[type=submit][disabled].default, +input[type=button][disabled].default { + opacity: 0.4; +} + + +/* MODULES */ + +.module { + border: none; + margin-bottom: 30px; + background: var(--body-bg); +} + +.module p, .module ul, .module h3, .module h4, .module dl, .module pre { + padding-left: 10px; + padding-right: 10px; +} + +.module blockquote { + margin-left: 12px; +} + +.module ul, .module ol { + margin-left: 1.5em; +} + +.module h3 { + margin-top: .6em; +} + +.module h2, .module caption, .inline-group h2 { + margin: 0; + padding: 8px; + font-weight: 400; + font-size: 0.8125rem; + text-align: left; + background: var(--primary); + color: var(--header-link-color); +} + +.module caption, +.inline-group h2 { + font-size: 0.75rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.module table { + border-collapse: collapse; +} + +/* MESSAGES & ERRORS */ + +ul.messagelist { + padding: 0; + margin: 0; +} + +ul.messagelist li { + display: block; + font-weight: 400; + font-size: 0.8125rem; + padding: 10px 10px 10px 65px; + margin: 0 0 10px 0; + background: var(--message-success-bg) url("../img/icon-yes.d2f9f035226a.svg") 40px 12px no-repeat; + background-size: 16px auto; + color: var(--body-fg); + word-break: break-word; +} + +ul.messagelist li.warning { + background: var(--message-warning-bg) url("../img/icon-alert.034cc7d8a67f.svg") 40px 14px no-repeat; + background-size: 14px auto; +} + +ul.messagelist li.error { + background: var(--message-error-bg) url("../img/icon-no.439e821418cd.svg") 40px 12px no-repeat; + background-size: 16px auto; +} + +.errornote { + font-size: 0.875rem; + font-weight: 700; + display: block; + padding: 10px 12px; + margin: 0 0 10px 0; + color: var(--error-fg); + border: 1px solid var(--error-fg); + border-radius: 4px; + background-color: var(--body-bg); + background-position: 5px 12px; + overflow-wrap: break-word; +} + +ul.errorlist { + margin: 0 0 4px; + padding: 0; + color: var(--error-fg); + background: var(--body-bg); +} + +ul.errorlist li { + font-size: 0.8125rem; + display: block; + margin-bottom: 4px; + overflow-wrap: break-word; +} + +ul.errorlist li:first-child { + margin-top: 0; +} + +ul.errorlist li a { + color: inherit; + text-decoration: underline; +} + +td ul.errorlist { + margin: 0; + padding: 0; +} + +td ul.errorlist li { + margin: 0; +} + +.form-row.errors { + margin: 0; + border: none; + border-bottom: 1px solid var(--hairline-color); + background: none; +} + +.form-row.errors ul.errorlist li { + padding-left: 0; +} + +.errors input, .errors select, .errors textarea, +td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { + border: 1px solid var(--error-fg); +} + +.description { + font-size: 0.75rem; + padding: 5px 0 0 12px; +} + +/* BREADCRUMBS */ + +div.breadcrumbs { + background: var(--breadcrumbs-bg); + padding: 10px 40px; + border: none; + color: var(--breadcrumbs-fg); + text-align: left; +} + +div.breadcrumbs a { + color: var(--breadcrumbs-link-fg); +} + +div.breadcrumbs a:focus, div.breadcrumbs a:hover { + color: var(--breadcrumbs-fg); +} + +/* ACTION ICONS */ + +.viewlink, .inlineviewlink { + padding-left: 16px; + background: url("../img/icon-viewlink.41eb31f7826e.svg") 0 1px no-repeat; +} + +.addlink { + padding-left: 16px; + background: url("../img/icon-addlink.d519b3bab011.svg") 0 1px no-repeat; +} + +.changelink, .inlinechangelink { + padding-left: 16px; + background: url("../img/icon-changelink.18d2fd706348.svg") 0 1px no-repeat; +} + +.deletelink { + padding-left: 16px; + background: url("../img/icon-deletelink.564ef9dc3854.svg") 0 1px no-repeat; +} + +a.deletelink:link, a.deletelink:visited { + color: #CC3434; /* XXX Probably unused? */ +} + +a.deletelink:focus, a.deletelink:hover { + color: #993333; /* XXX Probably unused? */ + text-decoration: none; +} + +/* OBJECT TOOLS */ + +.object-tools { + font-size: 0.625rem; + font-weight: bold; + padding-left: 0; + float: right; + position: relative; + margin-top: -48px; +} + +.object-tools li { + display: block; + float: left; + margin-left: 5px; + height: 1rem; +} + +.object-tools a { + border-radius: 15px; +} + +.object-tools a:link, .object-tools a:visited { + display: block; + float: left; + padding: 3px 12px; + background: var(--object-tools-bg); + color: var(--object-tools-fg); + font-weight: 400; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.object-tools a:focus, .object-tools a:hover { + background-color: var(--object-tools-hover-bg); +} + +.object-tools a:focus{ + text-decoration: none; +} + +.object-tools a.viewsitelink, .object-tools a.addlink { + background-repeat: no-repeat; + background-position: right 7px center; + padding-right: 26px; +} + +.object-tools a.viewsitelink { + background-image: url("../img/tooltag-arrowright.bbfb788a849e.svg"); +} + +.object-tools a.addlink { + background-image: url("../img/tooltag-add.e59d620a9742.svg"); +} + +/* OBJECT HISTORY */ + +#change-history table { + width: 100%; +} + +#change-history table tbody th { + width: 16em; +} + +#change-history .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* PAGE STRUCTURE */ + +#container { + position: relative; + width: 100%; + min-width: 980px; + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +#container > div { + flex-shrink: 0; +} + +#container > .main { + display: flex; + flex: 1 0 auto; +} + +.main > .content { + flex: 1 0; + max-width: 100%; +} + +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + +#content { + padding: 20px 40px; +} + +.dashboard #content { + width: 600px; +} + +#content-main { + float: left; + width: 100%; +} + +#content-related { + float: right; + width: 260px; + position: relative; + margin-right: -300px; +} + +#footer { + clear: both; + padding: 10px; +} + +/* COLUMN TYPES */ + +.colMS { + margin-right: 300px; +} + +.colSM { + margin-left: 300px; +} + +.colSM #content-related { + float: left; + margin-right: 0; + margin-left: -300px; +} + +.colSM #content-main { + float: right; +} + +.popup .colM { + width: auto; +} + +/* HEADER */ + +#header { + width: auto; + height: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 40px; + background: var(--header-bg); + color: var(--header-color); + overflow: hidden; +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--header-link-color); +} + +#header a:focus , #header a:hover { + text-decoration: underline; +} + +#branding { + display: flex; +} + +#branding h1 { + padding: 0; + margin: 0; + margin-inline-end: 20px; + font-weight: 300; + font-size: 1.5rem; + color: var(--header-branding-color); +} + +#branding h1 a:link, #branding h1 a:visited { + color: var(--accent); +} + +#branding h2 { + padding: 0 10px; + font-size: 0.875rem; + margin: -8px 0 8px 0; + font-weight: normal; + color: var(--header-color); +} + +#branding a:hover { + text-decoration: none; +} + +#logout-form { + display: inline; +} + +#logout-form button { + background: none; + border: 0; + cursor: pointer; + font-family: var(--font-family-primary); +} + +#user-tools { + float: right; + margin: 0 0 0 20px; + text-align: right; +} + +#user-tools, #logout-form button{ + padding: 0; + font-weight: 300; + font-size: 0.6875rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +#user-tools a, #logout-form button { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); +} + +#user-tools a:focus, #user-tools a:hover, +#logout-form button:active, #logout-form button:hover { + text-decoration: none; + border-bottom: 0; +} + +#logout-form button:active, #logout-form button:hover { + margin-bottom: 1px; +} + +/* SIDEBAR */ + +#content-related { + background: var(--darkened-bg); +} + +#content-related .module { + background: none; +} + +#content-related h3 { + color: var(--body-quiet-color); + padding: 0 16px; + margin: 0 0 16px; +} + +#content-related h4 { + font-size: 0.8125rem; +} + +#content-related p { + padding-left: 16px; + padding-right: 16px; +} + +#content-related .actionlist { + padding: 0; + margin: 16px; +} + +#content-related .actionlist li { + line-height: 1.2; + margin-bottom: 10px; + padding-left: 18px; +} + +#content-related .module h2 { + background: none; + padding: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--hairline-color); + font-size: 1.125rem; + color: var(--body-fg); +} + +.delete-confirmation form input[type="submit"] { + background: var(--delete-button-bg); + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); +} + +.delete-confirmation form input[type="submit"]:active, +.delete-confirmation form input[type="submit"]:focus, +.delete-confirmation form input[type="submit"]:hover { + background: var(--delete-button-hover-bg); +} + +.delete-confirmation form .cancel-link { + display: inline-block; + vertical-align: middle; + height: 0.9375rem; + line-height: 0.9375rem; + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); + background: var(--close-button-bg); + margin: 0 0 0 10px; +} + +.delete-confirmation form .cancel-link:active, +.delete-confirmation form .cancel-link:focus, +.delete-confirmation form .cancel-link:hover { + background: var(--close-button-hover-bg); +} + +/* POPUP */ +.popup #content { + padding: 20px; +} + +.popup #container { + min-width: 0; +} + +.popup #header { + padding: 10px 20px; +} + +/* PAGINATOR */ + +.paginator { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8125rem; + padding-top: 10px; + padding-bottom: 10px; + line-height: 22px; + margin: 0; + border-top: 1px solid var(--hairline-color); + width: 100%; +} + +.paginator a:link, .paginator a:visited { + padding: 2px 6px; + background: var(--button-bg); + text-decoration: none; + color: var(--button-fg); +} + +.paginator a.showall { + border: none; + background: none; + color: var(--link-fg); +} + +.paginator a.showall:focus, .paginator a.showall:hover { + background: none; + color: var(--link-hover-color); +} + +.paginator .end { + margin-right: 6px; +} + +.paginator .this-page { + padding: 2px 6px; + font-weight: bold; + font-size: 0.8125rem; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +.paginator input { + margin-left: auto; +} + +.base-svgs { + display: none; +} diff --git a/backend/staticfiles/admin/css/base.523eb49842a7.css.gz b/backend/staticfiles/admin/css/base.523eb49842a7.css.gz new file mode 100644 index 0000000..ee59980 Binary files /dev/null and b/backend/staticfiles/admin/css/base.523eb49842a7.css.gz differ diff --git a/backend/staticfiles/admin/css/base.css b/backend/staticfiles/admin/css/base.css new file mode 100644 index 0000000..93db7d0 --- /dev/null +++ b/backend/staticfiles/admin/css/base.css @@ -0,0 +1,1145 @@ +/* + DJANGO Admin styles +*/ + +/* VARIABLE DEFINITIONS */ +html[data-theme="light"], +:root { + --primary: #79aec8; + --secondary: #417690; + --accent: #f5dd5d; + --primary-fg: #fff; + + --body-fg: #333; + --body-bg: #fff; + --body-quiet-color: #666; + --body-loud-color: #000; + + --header-color: #ffc; + --header-branding-color: var(--accent); + --header-bg: var(--secondary); + --header-link-color: var(--primary-fg); + + --breadcrumbs-fg: #c4dce8; + --breadcrumbs-link-fg: var(--body-bg); + --breadcrumbs-bg: var(--primary); + + --link-fg: #417893; + --link-hover-color: #036; + --link-selected-fg: #5b80b2; + + --hairline-color: #e8e8e8; + --border-color: #ccc; + + --error-fg: #ba2121; + + --message-success-bg: #dfd; + --message-warning-bg: #ffc; + --message-error-bg: #ffefef; + + --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ + --selected-bg: #e4e4e4; /* E.g. selected table cells */ + --selected-row: #ffc; + + --button-fg: #fff; + --button-bg: var(--primary); + --button-hover-bg: #609ab6; + --default-button-bg: var(--secondary); + --default-button-hover-bg: #205067; + --close-button-bg: #747474; + --close-button-hover-bg: #333; + --delete-button-bg: #ba2121; + --delete-button-hover-bg: #a41515; + + --object-tools-fg: var(--button-fg); + --object-tools-bg: var(--close-button-bg); + --object-tools-hover-bg: var(--close-button-hover-bg); + + --font-family-primary: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + Roboto, + "Helvetica Neue", + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; + --font-family-monospace: + ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Monospace", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Courier New", + monospace, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + padding: 0; + font-size: 0.875rem; + font-family: var(--font-family-primary); + color: var(--body-fg); + background: var(--body-bg); +} + +/* LINKS */ + +a:link, a:visited { + color: var(--link-fg); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} + +a:focus, a:hover { + color: var(--link-hover-color); +} + +a:focus { + text-decoration: underline; +} + +a img { + border: none; +} + +a.section:link, a.section:visited { + color: var(--header-link-color); + text-decoration: none; +} + +a.section:focus, a.section:hover { + text-decoration: underline; +} + +/* GLOBAL DEFAULTS */ + +p, ol, ul, dl { + margin: .2em 0 .8em 0; +} + +p { + padding: 0; + line-height: 140%; +} + +h1,h2,h3,h4,h5 { + font-weight: bold; +} + +h1 { + margin: 0 0 20px; + font-weight: 300; + font-size: 1.25rem; + color: var(--body-quiet-color); +} + +h2 { + font-size: 1rem; + margin: 1em 0 .5em 0; +} + +h2.subhead { + font-weight: normal; + margin-top: 0; +} + +h3 { + font-size: 0.875rem; + margin: .8em 0 .3em 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +h4 { + font-size: 0.75rem; + margin: 1em 0 .8em 0; + padding-bottom: 3px; +} + +h5 { + font-size: 0.625rem; + margin: 1.5em 0 .5em 0; + color: var(--body-quiet-color); + text-transform: uppercase; + letter-spacing: 1px; +} + +ul > li { + list-style-type: square; + padding: 1px 0; +} + +li ul { + margin-bottom: 0; +} + +li, dt, dd { + font-size: 0.8125rem; + line-height: 1.25rem; +} + +dt { + font-weight: bold; + margin-top: 4px; +} + +dd { + margin-left: 0; +} + +form { + margin: 0; + padding: 0; +} + +fieldset { + margin: 0; + min-width: 0; + padding: 0; + border: none; + border-top: 1px solid var(--hairline-color); +} + +blockquote { + font-size: 0.6875rem; + color: #777; + margin-left: 2px; + padding-left: 10px; + border-left: 5px solid #ddd; +} + +code, pre { + font-family: var(--font-family-monospace); + color: var(--body-quiet-color); + font-size: 0.75rem; + overflow-x: auto; +} + +pre.literal-block { + margin: 10px; + background: var(--darkened-bg); + padding: 6px 8px; +} + +code strong { + color: #930; +} + +hr { + clear: both; + color: var(--hairline-color); + background-color: var(--hairline-color); + height: 1px; + border: none; + margin: 0; + padding: 0; + line-height: 1px; +} + +/* TEXT STYLES & MODIFIERS */ + +.small { + font-size: 0.6875rem; +} + +.mini { + font-size: 0.625rem; +} + +.help, p.help, form p.help, div.help, form div.help, div.help li { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +div.help ul { + margin-bottom: 0; +} + +.help-tooltip { + cursor: help; +} + +p img, h1 img, h2 img, h3 img, h4 img, td img { + vertical-align: middle; +} + +.quiet, a.quiet:link, a.quiet:visited { + color: var(--body-quiet-color); + font-weight: normal; +} + +.clear { + clear: both; +} + +.nowrap { + white-space: nowrap; +} + +.hidden { + display: none !important; +} + +/* TABLES */ + +table { + border-collapse: collapse; + border-color: var(--border-color); +} + +td, th { + font-size: 0.8125rem; + line-height: 1rem; + border-bottom: 1px solid var(--hairline-color); + vertical-align: top; + padding: 8px; +} + +th { + font-weight: 600; + text-align: left; +} + +thead th, +tfoot td { + color: var(--body-quiet-color); + padding: 5px 10px; + font-size: 0.6875rem; + background: var(--body-bg); + border: none; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +tfoot td { + border-bottom: none; + border-top: 1px solid var(--hairline-color); +} + +thead th.required { + color: var(--body-loud-color); +} + +tr.alt { + background: var(--darkened-bg); +} + +tr:nth-child(odd), .row-form-errors { + background: var(--body-bg); +} + +tr:nth-child(even), +tr:nth-child(even) .errorlist, +tr:nth-child(odd) + .row-form-errors, +tr:nth-child(odd) + .row-form-errors .errorlist { + background: var(--darkened-bg); +} + +/* SORTABLE TABLES */ + +thead th { + padding: 5px 10px; + line-height: normal; + text-transform: uppercase; + background: var(--darkened-bg); +} + +thead th a:link, thead th a:visited { + color: var(--body-quiet-color); +} + +thead th.sorted { + background: var(--selected-bg); +} + +thead th.sorted .text { + padding-right: 42px; +} + +table thead th .text span { + padding: 8px 10px; + display: block; +} + +table thead th .text a { + display: block; + cursor: pointer; + padding: 8px 10px; +} + +table thead th .text a:focus, table thead th .text a:hover { + background: var(--selected-bg); +} + +thead th.sorted a.sortremove { + visibility: hidden; +} + +table thead th.sorted:hover a.sortremove { + visibility: visible; +} + +table thead th.sorted .sortoptions { + display: block; + padding: 9px 5px 0 5px; + float: right; + text-align: right; +} + +table thead th.sorted .sortpriority { + font-size: .8em; + min-width: 12px; + text-align: center; + vertical-align: 3px; + margin-left: 2px; + margin-right: 2px; +} + +table thead th.sorted .sortoptions a { + position: relative; + width: 14px; + height: 14px; + display: inline-block; + background: url(../img/sorting-icons.svg) 0 0 no-repeat; + background-size: 14px auto; +} + +table thead th.sorted .sortoptions a.sortremove { + background-position: 0 0; +} + +table thead th.sorted .sortoptions a.sortremove:after { + content: '\\'; + position: absolute; + top: -6px; + left: 3px; + font-weight: 200; + font-size: 1.125rem; + color: var(--body-quiet-color); +} + +table thead th.sorted .sortoptions a.sortremove:focus:after, +table thead th.sorted .sortoptions a.sortremove:hover:after { + color: var(--link-fg); +} + +table thead th.sorted .sortoptions a.sortremove:focus, +table thead th.sorted .sortoptions a.sortremove:hover { + background-position: 0 -14px; +} + +table thead th.sorted .sortoptions a.ascending { + background-position: 0 -28px; +} + +table thead th.sorted .sortoptions a.ascending:focus, +table thead th.sorted .sortoptions a.ascending:hover { + background-position: 0 -42px; +} + +table thead th.sorted .sortoptions a.descending { + top: 1px; + background-position: 0 -56px; +} + +table thead th.sorted .sortoptions a.descending:focus, +table thead th.sorted .sortoptions a.descending:hover { + background-position: 0 -70px; +} + +/* FORM DEFAULTS */ + +input, textarea, select, .form-row p, form .button { + margin: 2px 0; + padding: 2px 3px; + vertical-align: middle; + font-family: var(--font-family-primary); + font-weight: normal; + font-size: 0.8125rem; +} +.form-row div.help { + padding: 2px 3px; +} + +textarea { + vertical-align: top; +} + +input[type=text], input[type=password], input[type=email], input[type=url], +input[type=number], input[type=tel], textarea, select, .vTextField { + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 5px 6px; + margin-top: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} + +input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, +input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus, +textarea:focus, select:focus, .vTextField:focus { + border-color: var(--body-quiet-color); +} + +select { + height: 1.875rem; +} + +select[multiple] { + /* Allow HTML size attribute to override the height in the rule above. */ + height: auto; + min-height: 150px; +} + +/* FORM BUTTONS */ + +.button, input[type=submit], input[type=button], .submit-row input, a.button { + background: var(--button-bg); + padding: 10px 15px; + border: none; + border-radius: 4px; + color: var(--button-fg); + cursor: pointer; + transition: background 0.15s; +} + +a.button { + padding: 4px 5px; +} + +.button:active, input[type=submit]:active, input[type=button]:active, +.button:focus, input[type=submit]:focus, input[type=button]:focus, +.button:hover, input[type=submit]:hover, input[type=button]:hover { + background: var(--button-hover-bg); +} + +.button[disabled], input[type=submit][disabled], input[type=button][disabled] { + opacity: 0.4; +} + +.button.default, input[type=submit].default, .submit-row input.default { + border: none; + font-weight: 400; + background: var(--default-button-bg); +} + +.button.default:active, input[type=submit].default:active, +.button.default:focus, input[type=submit].default:focus, +.button.default:hover, input[type=submit].default:hover { + background: var(--default-button-hover-bg); +} + +.button[disabled].default, +input[type=submit][disabled].default, +input[type=button][disabled].default { + opacity: 0.4; +} + + +/* MODULES */ + +.module { + border: none; + margin-bottom: 30px; + background: var(--body-bg); +} + +.module p, .module ul, .module h3, .module h4, .module dl, .module pre { + padding-left: 10px; + padding-right: 10px; +} + +.module blockquote { + margin-left: 12px; +} + +.module ul, .module ol { + margin-left: 1.5em; +} + +.module h3 { + margin-top: .6em; +} + +.module h2, .module caption, .inline-group h2 { + margin: 0; + padding: 8px; + font-weight: 400; + font-size: 0.8125rem; + text-align: left; + background: var(--primary); + color: var(--header-link-color); +} + +.module caption, +.inline-group h2 { + font-size: 0.75rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.module table { + border-collapse: collapse; +} + +/* MESSAGES & ERRORS */ + +ul.messagelist { + padding: 0; + margin: 0; +} + +ul.messagelist li { + display: block; + font-weight: 400; + font-size: 0.8125rem; + padding: 10px 10px 10px 65px; + margin: 0 0 10px 0; + background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; + background-size: 16px auto; + color: var(--body-fg); + word-break: break-word; +} + +ul.messagelist li.warning { + background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; + background-size: 14px auto; +} + +ul.messagelist li.error { + background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; + background-size: 16px auto; +} + +.errornote { + font-size: 0.875rem; + font-weight: 700; + display: block; + padding: 10px 12px; + margin: 0 0 10px 0; + color: var(--error-fg); + border: 1px solid var(--error-fg); + border-radius: 4px; + background-color: var(--body-bg); + background-position: 5px 12px; + overflow-wrap: break-word; +} + +ul.errorlist { + margin: 0 0 4px; + padding: 0; + color: var(--error-fg); + background: var(--body-bg); +} + +ul.errorlist li { + font-size: 0.8125rem; + display: block; + margin-bottom: 4px; + overflow-wrap: break-word; +} + +ul.errorlist li:first-child { + margin-top: 0; +} + +ul.errorlist li a { + color: inherit; + text-decoration: underline; +} + +td ul.errorlist { + margin: 0; + padding: 0; +} + +td ul.errorlist li { + margin: 0; +} + +.form-row.errors { + margin: 0; + border: none; + border-bottom: 1px solid var(--hairline-color); + background: none; +} + +.form-row.errors ul.errorlist li { + padding-left: 0; +} + +.errors input, .errors select, .errors textarea, +td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { + border: 1px solid var(--error-fg); +} + +.description { + font-size: 0.75rem; + padding: 5px 0 0 12px; +} + +/* BREADCRUMBS */ + +div.breadcrumbs { + background: var(--breadcrumbs-bg); + padding: 10px 40px; + border: none; + color: var(--breadcrumbs-fg); + text-align: left; +} + +div.breadcrumbs a { + color: var(--breadcrumbs-link-fg); +} + +div.breadcrumbs a:focus, div.breadcrumbs a:hover { + color: var(--breadcrumbs-fg); +} + +/* ACTION ICONS */ + +.viewlink, .inlineviewlink { + padding-left: 16px; + background: url(../img/icon-viewlink.svg) 0 1px no-repeat; +} + +.addlink { + padding-left: 16px; + background: url(../img/icon-addlink.svg) 0 1px no-repeat; +} + +.changelink, .inlinechangelink { + padding-left: 16px; + background: url(../img/icon-changelink.svg) 0 1px no-repeat; +} + +.deletelink { + padding-left: 16px; + background: url(../img/icon-deletelink.svg) 0 1px no-repeat; +} + +a.deletelink:link, a.deletelink:visited { + color: #CC3434; /* XXX Probably unused? */ +} + +a.deletelink:focus, a.deletelink:hover { + color: #993333; /* XXX Probably unused? */ + text-decoration: none; +} + +/* OBJECT TOOLS */ + +.object-tools { + font-size: 0.625rem; + font-weight: bold; + padding-left: 0; + float: right; + position: relative; + margin-top: -48px; +} + +.object-tools li { + display: block; + float: left; + margin-left: 5px; + height: 1rem; +} + +.object-tools a { + border-radius: 15px; +} + +.object-tools a:link, .object-tools a:visited { + display: block; + float: left; + padding: 3px 12px; + background: var(--object-tools-bg); + color: var(--object-tools-fg); + font-weight: 400; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.object-tools a:focus, .object-tools a:hover { + background-color: var(--object-tools-hover-bg); +} + +.object-tools a:focus{ + text-decoration: none; +} + +.object-tools a.viewsitelink, .object-tools a.addlink { + background-repeat: no-repeat; + background-position: right 7px center; + padding-right: 26px; +} + +.object-tools a.viewsitelink { + background-image: url(../img/tooltag-arrowright.svg); +} + +.object-tools a.addlink { + background-image: url(../img/tooltag-add.svg); +} + +/* OBJECT HISTORY */ + +#change-history table { + width: 100%; +} + +#change-history table tbody th { + width: 16em; +} + +#change-history .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* PAGE STRUCTURE */ + +#container { + position: relative; + width: 100%; + min-width: 980px; + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +#container > div { + flex-shrink: 0; +} + +#container > .main { + display: flex; + flex: 1 0 auto; +} + +.main > .content { + flex: 1 0; + max-width: 100%; +} + +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + +#content { + padding: 20px 40px; +} + +.dashboard #content { + width: 600px; +} + +#content-main { + float: left; + width: 100%; +} + +#content-related { + float: right; + width: 260px; + position: relative; + margin-right: -300px; +} + +#footer { + clear: both; + padding: 10px; +} + +/* COLUMN TYPES */ + +.colMS { + margin-right: 300px; +} + +.colSM { + margin-left: 300px; +} + +.colSM #content-related { + float: left; + margin-right: 0; + margin-left: -300px; +} + +.colSM #content-main { + float: right; +} + +.popup .colM { + width: auto; +} + +/* HEADER */ + +#header { + width: auto; + height: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 40px; + background: var(--header-bg); + color: var(--header-color); + overflow: hidden; +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--header-link-color); +} + +#header a:focus , #header a:hover { + text-decoration: underline; +} + +#branding { + display: flex; +} + +#branding h1 { + padding: 0; + margin: 0; + margin-inline-end: 20px; + font-weight: 300; + font-size: 1.5rem; + color: var(--header-branding-color); +} + +#branding h1 a:link, #branding h1 a:visited { + color: var(--accent); +} + +#branding h2 { + padding: 0 10px; + font-size: 0.875rem; + margin: -8px 0 8px 0; + font-weight: normal; + color: var(--header-color); +} + +#branding a:hover { + text-decoration: none; +} + +#logout-form { + display: inline; +} + +#logout-form button { + background: none; + border: 0; + cursor: pointer; + font-family: var(--font-family-primary); +} + +#user-tools { + float: right; + margin: 0 0 0 20px; + text-align: right; +} + +#user-tools, #logout-form button{ + padding: 0; + font-weight: 300; + font-size: 0.6875rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +#user-tools a, #logout-form button { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); +} + +#user-tools a:focus, #user-tools a:hover, +#logout-form button:active, #logout-form button:hover { + text-decoration: none; + border-bottom: 0; +} + +#logout-form button:active, #logout-form button:hover { + margin-bottom: 1px; +} + +/* SIDEBAR */ + +#content-related { + background: var(--darkened-bg); +} + +#content-related .module { + background: none; +} + +#content-related h3 { + color: var(--body-quiet-color); + padding: 0 16px; + margin: 0 0 16px; +} + +#content-related h4 { + font-size: 0.8125rem; +} + +#content-related p { + padding-left: 16px; + padding-right: 16px; +} + +#content-related .actionlist { + padding: 0; + margin: 16px; +} + +#content-related .actionlist li { + line-height: 1.2; + margin-bottom: 10px; + padding-left: 18px; +} + +#content-related .module h2 { + background: none; + padding: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--hairline-color); + font-size: 1.125rem; + color: var(--body-fg); +} + +.delete-confirmation form input[type="submit"] { + background: var(--delete-button-bg); + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); +} + +.delete-confirmation form input[type="submit"]:active, +.delete-confirmation form input[type="submit"]:focus, +.delete-confirmation form input[type="submit"]:hover { + background: var(--delete-button-hover-bg); +} + +.delete-confirmation form .cancel-link { + display: inline-block; + vertical-align: middle; + height: 0.9375rem; + line-height: 0.9375rem; + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); + background: var(--close-button-bg); + margin: 0 0 0 10px; +} + +.delete-confirmation form .cancel-link:active, +.delete-confirmation form .cancel-link:focus, +.delete-confirmation form .cancel-link:hover { + background: var(--close-button-hover-bg); +} + +/* POPUP */ +.popup #content { + padding: 20px; +} + +.popup #container { + min-width: 0; +} + +.popup #header { + padding: 10px 20px; +} + +/* PAGINATOR */ + +.paginator { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8125rem; + padding-top: 10px; + padding-bottom: 10px; + line-height: 22px; + margin: 0; + border-top: 1px solid var(--hairline-color); + width: 100%; +} + +.paginator a:link, .paginator a:visited { + padding: 2px 6px; + background: var(--button-bg); + text-decoration: none; + color: var(--button-fg); +} + +.paginator a.showall { + border: none; + background: none; + color: var(--link-fg); +} + +.paginator a.showall:focus, .paginator a.showall:hover { + background: none; + color: var(--link-hover-color); +} + +.paginator .end { + margin-right: 6px; +} + +.paginator .this-page { + padding: 2px 6px; + font-weight: bold; + font-size: 0.8125rem; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +.paginator input { + margin-left: auto; +} + +.base-svgs { + display: none; +} diff --git a/backend/staticfiles/admin/css/base.css.gz b/backend/staticfiles/admin/css/base.css.gz new file mode 100644 index 0000000..a4753c5 Binary files /dev/null and b/backend/staticfiles/admin/css/base.css.gz differ diff --git a/backend/staticfiles/admin/css/changelists.9237a1ac391b.css b/backend/staticfiles/admin/css/changelists.9237a1ac391b.css new file mode 100644 index 0000000..a754513 --- /dev/null +++ b/backend/staticfiles/admin/css/changelists.9237a1ac391b.css @@ -0,0 +1,328 @@ +/* CHANGELISTS */ + +#changelist { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +#changelist .changelist-form-container { + flex: 1 1 auto; + min-width: 0; +} + +#changelist table { + width: 100%; +} + +.change-list .hiddenfields { display:none; } + +.change-list .filtered table { + border-right: none; +} + +.change-list .filtered { + min-height: 400px; +} + +.change-list .filtered .results, .change-list .filtered .paginator, +.filtered #toolbar, .filtered div.xfull { + width: auto; +} + +.change-list .filtered table tbody th { + padding-right: 1em; +} + +#changelist-form .results { + overflow-x: auto; + width: 100%; +} + +#changelist .toplinks { + border-bottom: 1px solid var(--hairline-color); +} + +#changelist .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* CHANGELIST TABLES */ + +#changelist table thead th { + padding: 0; + white-space: nowrap; + vertical-align: middle; +} + +#changelist table thead th.action-checkbox-column { + width: 1.5em; + text-align: center; +} + +#changelist table tbody td.action-checkbox { + text-align: center; +} + +#changelist table tfoot { + color: var(--body-quiet-color); +} + +/* TOOLBAR */ + +#toolbar { + padding: 8px 10px; + margin-bottom: 15px; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +#toolbar form input { + border-radius: 4px; + font-size: 0.875rem; + padding: 5px; + color: var(--body-fg); +} + +#toolbar #searchbar { + height: 1.1875rem; + border: 1px solid var(--border-color); + padding: 2px 5px; + margin: 0; + vertical-align: top; + font-size: 0.8125rem; + max-width: 100%; +} + +#toolbar #searchbar:focus { + border-color: var(--body-quiet-color); +} + +#toolbar form input[type="submit"] { + border: 1px solid var(--border-color); + font-size: 0.8125rem; + padding: 4px 8px; + margin: 0; + vertical-align: middle; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + color: var(--body-fg); +} + +#toolbar form input[type="submit"]:focus, +#toolbar form input[type="submit"]:hover { + border-color: var(--body-quiet-color); +} + +#changelist-search img { + vertical-align: middle; + margin-right: 4px; +} + +#changelist-search .help { + word-break: break-word; +} + +/* FILTER COLUMN */ + +#changelist-filter { + flex: 0 0 240px; + order: 1; + background: var(--darkened-bg); + border-left: none; + margin: 0 0 0 30px; +} + +#changelist-filter h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 5px 15px; + margin-bottom: 12px; + border-bottom: none; +} + +#changelist-filter h3, +#changelist-filter details summary { + font-weight: 400; + padding: 0 15px; + margin-bottom: 10px; +} + +#changelist-filter details summary > * { + display: inline; +} + +#changelist-filter details > summary { + list-style-type: none; +} + +#changelist-filter details > summary::-webkit-details-marker { + display: none; +} + +#changelist-filter details > summary::before { + content: '→'; + font-weight: bold; + color: var(--link-hover-color); +} + +#changelist-filter details[open] > summary::before { + content: '↓'; +} + +#changelist-filter ul { + margin: 5px 0; + padding: 0 15px 15px; + border-bottom: 1px solid var(--hairline-color); +} + +#changelist-filter ul:last-child { + border-bottom: none; +} + +#changelist-filter li { + list-style-type: none; + margin-left: 0; + padding-left: 0; +} + +#changelist-filter a { + display: block; + color: var(--body-quiet-color); + word-break: break-word; +} + +#changelist-filter li.selected { + border-left: 5px solid var(--hairline-color); + padding-left: 10px; + margin-left: -15px; +} + +#changelist-filter li.selected a { + color: var(--link-selected-fg); +} + +#changelist-filter a:focus, #changelist-filter a:hover, +#changelist-filter li.selected a:focus, +#changelist-filter li.selected a:hover { + color: var(--link-hover-color); +} + +#changelist-filter #changelist-filter-clear a { + font-size: 0.8125rem; + padding-bottom: 10px; + border-bottom: 1px solid var(--hairline-color); +} + +/* DATE DRILLDOWN */ + +.change-list .toplinks { + display: flex; + padding-bottom: 5px; + flex-wrap: wrap; + gap: 3px 17px; + font-weight: bold; +} + +.change-list .toplinks a { + font-size: 0.8125rem; +} + +.change-list .toplinks .date-back { + color: var(--body-quiet-color); +} + +.change-list .toplinks .date-back:focus, +.change-list .toplinks .date-back:hover { + color: var(--link-hover-color); +} + +/* ACTIONS */ + +.filtered .actions { + border-right: none; +} + +#changelist table input { + margin: 0; + vertical-align: baseline; +} + +/* Once the :has() pseudo-class is supported by all browsers, the tr.selected + selector and the JS adding the class can be removed. */ +#changelist tbody tr.selected { + background-color: var(--selected-row); +} + +#changelist tbody tr:has(.action-select:checked) { + background-color: var(--selected-row); +} + +#changelist .actions { + padding: 10px; + background: var(--body-bg); + border-top: none; + border-bottom: none; + line-height: 1.5rem; + color: var(--body-quiet-color); + width: 100%; +} + +#changelist .actions span.all, +#changelist .actions span.action-counter, +#changelist .actions span.clear, +#changelist .actions span.question { + font-size: 0.8125rem; + margin: 0 0.5em; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + vertical-align: top; + height: 1.5rem; + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + padding: 0 0 0 4px; + margin: 0; + margin-left: 10px; +} + +#changelist .actions select:focus { + border-color: var(--body-quiet-color); +} + +#changelist .actions label { + display: inline-block; + vertical-align: middle; + font-size: 0.8125rem; +} + +#changelist .actions .button { + font-size: 0.8125rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + height: 1.5rem; + line-height: 1; + padding: 4px 8px; + margin: 0; + color: var(--body-fg); +} + +#changelist .actions .button:focus, #changelist .actions .button:hover { + border-color: var(--body-quiet-color); +} diff --git a/backend/staticfiles/admin/css/changelists.9237a1ac391b.css.gz b/backend/staticfiles/admin/css/changelists.9237a1ac391b.css.gz new file mode 100644 index 0000000..108028f Binary files /dev/null and b/backend/staticfiles/admin/css/changelists.9237a1ac391b.css.gz differ diff --git a/backend/staticfiles/admin/css/changelists.css b/backend/staticfiles/admin/css/changelists.css new file mode 100644 index 0000000..a754513 --- /dev/null +++ b/backend/staticfiles/admin/css/changelists.css @@ -0,0 +1,328 @@ +/* CHANGELISTS */ + +#changelist { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +#changelist .changelist-form-container { + flex: 1 1 auto; + min-width: 0; +} + +#changelist table { + width: 100%; +} + +.change-list .hiddenfields { display:none; } + +.change-list .filtered table { + border-right: none; +} + +.change-list .filtered { + min-height: 400px; +} + +.change-list .filtered .results, .change-list .filtered .paginator, +.filtered #toolbar, .filtered div.xfull { + width: auto; +} + +.change-list .filtered table tbody th { + padding-right: 1em; +} + +#changelist-form .results { + overflow-x: auto; + width: 100%; +} + +#changelist .toplinks { + border-bottom: 1px solid var(--hairline-color); +} + +#changelist .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* CHANGELIST TABLES */ + +#changelist table thead th { + padding: 0; + white-space: nowrap; + vertical-align: middle; +} + +#changelist table thead th.action-checkbox-column { + width: 1.5em; + text-align: center; +} + +#changelist table tbody td.action-checkbox { + text-align: center; +} + +#changelist table tfoot { + color: var(--body-quiet-color); +} + +/* TOOLBAR */ + +#toolbar { + padding: 8px 10px; + margin-bottom: 15px; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +#toolbar form input { + border-radius: 4px; + font-size: 0.875rem; + padding: 5px; + color: var(--body-fg); +} + +#toolbar #searchbar { + height: 1.1875rem; + border: 1px solid var(--border-color); + padding: 2px 5px; + margin: 0; + vertical-align: top; + font-size: 0.8125rem; + max-width: 100%; +} + +#toolbar #searchbar:focus { + border-color: var(--body-quiet-color); +} + +#toolbar form input[type="submit"] { + border: 1px solid var(--border-color); + font-size: 0.8125rem; + padding: 4px 8px; + margin: 0; + vertical-align: middle; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + color: var(--body-fg); +} + +#toolbar form input[type="submit"]:focus, +#toolbar form input[type="submit"]:hover { + border-color: var(--body-quiet-color); +} + +#changelist-search img { + vertical-align: middle; + margin-right: 4px; +} + +#changelist-search .help { + word-break: break-word; +} + +/* FILTER COLUMN */ + +#changelist-filter { + flex: 0 0 240px; + order: 1; + background: var(--darkened-bg); + border-left: none; + margin: 0 0 0 30px; +} + +#changelist-filter h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 5px 15px; + margin-bottom: 12px; + border-bottom: none; +} + +#changelist-filter h3, +#changelist-filter details summary { + font-weight: 400; + padding: 0 15px; + margin-bottom: 10px; +} + +#changelist-filter details summary > * { + display: inline; +} + +#changelist-filter details > summary { + list-style-type: none; +} + +#changelist-filter details > summary::-webkit-details-marker { + display: none; +} + +#changelist-filter details > summary::before { + content: '→'; + font-weight: bold; + color: var(--link-hover-color); +} + +#changelist-filter details[open] > summary::before { + content: '↓'; +} + +#changelist-filter ul { + margin: 5px 0; + padding: 0 15px 15px; + border-bottom: 1px solid var(--hairline-color); +} + +#changelist-filter ul:last-child { + border-bottom: none; +} + +#changelist-filter li { + list-style-type: none; + margin-left: 0; + padding-left: 0; +} + +#changelist-filter a { + display: block; + color: var(--body-quiet-color); + word-break: break-word; +} + +#changelist-filter li.selected { + border-left: 5px solid var(--hairline-color); + padding-left: 10px; + margin-left: -15px; +} + +#changelist-filter li.selected a { + color: var(--link-selected-fg); +} + +#changelist-filter a:focus, #changelist-filter a:hover, +#changelist-filter li.selected a:focus, +#changelist-filter li.selected a:hover { + color: var(--link-hover-color); +} + +#changelist-filter #changelist-filter-clear a { + font-size: 0.8125rem; + padding-bottom: 10px; + border-bottom: 1px solid var(--hairline-color); +} + +/* DATE DRILLDOWN */ + +.change-list .toplinks { + display: flex; + padding-bottom: 5px; + flex-wrap: wrap; + gap: 3px 17px; + font-weight: bold; +} + +.change-list .toplinks a { + font-size: 0.8125rem; +} + +.change-list .toplinks .date-back { + color: var(--body-quiet-color); +} + +.change-list .toplinks .date-back:focus, +.change-list .toplinks .date-back:hover { + color: var(--link-hover-color); +} + +/* ACTIONS */ + +.filtered .actions { + border-right: none; +} + +#changelist table input { + margin: 0; + vertical-align: baseline; +} + +/* Once the :has() pseudo-class is supported by all browsers, the tr.selected + selector and the JS adding the class can be removed. */ +#changelist tbody tr.selected { + background-color: var(--selected-row); +} + +#changelist tbody tr:has(.action-select:checked) { + background-color: var(--selected-row); +} + +#changelist .actions { + padding: 10px; + background: var(--body-bg); + border-top: none; + border-bottom: none; + line-height: 1.5rem; + color: var(--body-quiet-color); + width: 100%; +} + +#changelist .actions span.all, +#changelist .actions span.action-counter, +#changelist .actions span.clear, +#changelist .actions span.question { + font-size: 0.8125rem; + margin: 0 0.5em; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + vertical-align: top; + height: 1.5rem; + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + padding: 0 0 0 4px; + margin: 0; + margin-left: 10px; +} + +#changelist .actions select:focus { + border-color: var(--body-quiet-color); +} + +#changelist .actions label { + display: inline-block; + vertical-align: middle; + font-size: 0.8125rem; +} + +#changelist .actions .button { + font-size: 0.8125rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + height: 1.5rem; + line-height: 1; + padding: 4px 8px; + margin: 0; + color: var(--body-fg); +} + +#changelist .actions .button:focus, #changelist .actions .button:hover { + border-color: var(--body-quiet-color); +} diff --git a/backend/staticfiles/admin/css/changelists.css.gz b/backend/staticfiles/admin/css/changelists.css.gz new file mode 100644 index 0000000..108028f Binary files /dev/null and b/backend/staticfiles/admin/css/changelists.css.gz differ diff --git a/backend/staticfiles/admin/css/dark_mode.css b/backend/staticfiles/admin/css/dark_mode.css new file mode 100644 index 0000000..6d08233 --- /dev/null +++ b/backend/staticfiles/admin/css/dark_mode.css @@ -0,0 +1,137 @@ +@media (prefers-color-scheme: dark) { + :root { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + } + } + + +html[data-theme="dark"] { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; +} + +/* THEME SWITCH */ +.theme-toggle { + cursor: pointer; + border: none; + padding: 0; + background: transparent; + vertical-align: middle; + margin-inline-start: 5px; + margin-top: -1px; +} + +.theme-toggle svg { + vertical-align: middle; + height: 1rem; + width: 1rem; + display: none; +} + +/* +Fully hide screen reader text so we only show the one matching the current +theme. +*/ +.theme-toggle .visually-hidden { + display: none; +} + +html[data-theme="auto"] .theme-toggle .theme-label-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle .theme-label-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle .theme-label-when-light { + display: block; +} + +/* ICONS */ +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--header-link-color); + color: var(--header-bg); +} + +html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { + display: block; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/backend/staticfiles/admin/css/dark_mode.css.gz b/backend/staticfiles/admin/css/dark_mode.css.gz new file mode 100644 index 0000000..faebcce Binary files /dev/null and b/backend/staticfiles/admin/css/dark_mode.css.gz differ diff --git a/backend/staticfiles/admin/css/dark_mode.ef27a31af300.css b/backend/staticfiles/admin/css/dark_mode.ef27a31af300.css new file mode 100644 index 0000000..6d08233 --- /dev/null +++ b/backend/staticfiles/admin/css/dark_mode.ef27a31af300.css @@ -0,0 +1,137 @@ +@media (prefers-color-scheme: dark) { + :root { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + } + } + + +html[data-theme="dark"] { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; +} + +/* THEME SWITCH */ +.theme-toggle { + cursor: pointer; + border: none; + padding: 0; + background: transparent; + vertical-align: middle; + margin-inline-start: 5px; + margin-top: -1px; +} + +.theme-toggle svg { + vertical-align: middle; + height: 1rem; + width: 1rem; + display: none; +} + +/* +Fully hide screen reader text so we only show the one matching the current +theme. +*/ +.theme-toggle .visually-hidden { + display: none; +} + +html[data-theme="auto"] .theme-toggle .theme-label-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle .theme-label-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle .theme-label-when-light { + display: block; +} + +/* ICONS */ +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--header-link-color); + color: var(--header-bg); +} + +html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { + display: block; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/backend/staticfiles/admin/css/dark_mode.ef27a31af300.css.gz b/backend/staticfiles/admin/css/dark_mode.ef27a31af300.css.gz new file mode 100644 index 0000000..faebcce Binary files /dev/null and b/backend/staticfiles/admin/css/dark_mode.ef27a31af300.css.gz differ diff --git a/backend/staticfiles/admin/css/dashboard.css b/backend/staticfiles/admin/css/dashboard.css new file mode 100644 index 0000000..242b81a --- /dev/null +++ b/backend/staticfiles/admin/css/dashboard.css @@ -0,0 +1,29 @@ +/* DASHBOARD */ +.dashboard td, .dashboard th { + word-break: break-word; +} + +.dashboard .module table th { + width: 100%; +} + +.dashboard .module table td { + white-space: nowrap; +} + +.dashboard .module table td a { + display: block; + padding-right: .6em; +} + +/* RECENT ACTIONS MODULE */ + +.module ul.actionlist { + margin-left: 0; +} + +ul.actionlist li { + list-style-type: none; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/backend/staticfiles/admin/css/dashboard.css.gz b/backend/staticfiles/admin/css/dashboard.css.gz new file mode 100644 index 0000000..06db5fd Binary files /dev/null and b/backend/staticfiles/admin/css/dashboard.css.gz differ diff --git a/backend/staticfiles/admin/css/dashboard.e90f2068217b.css b/backend/staticfiles/admin/css/dashboard.e90f2068217b.css new file mode 100644 index 0000000..242b81a --- /dev/null +++ b/backend/staticfiles/admin/css/dashboard.e90f2068217b.css @@ -0,0 +1,29 @@ +/* DASHBOARD */ +.dashboard td, .dashboard th { + word-break: break-word; +} + +.dashboard .module table th { + width: 100%; +} + +.dashboard .module table td { + white-space: nowrap; +} + +.dashboard .module table td a { + display: block; + padding-right: .6em; +} + +/* RECENT ACTIONS MODULE */ + +.module ul.actionlist { + margin-left: 0; +} + +ul.actionlist li { + list-style-type: none; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/backend/staticfiles/admin/css/dashboard.e90f2068217b.css.gz b/backend/staticfiles/admin/css/dashboard.e90f2068217b.css.gz new file mode 100644 index 0000000..06db5fd Binary files /dev/null and b/backend/staticfiles/admin/css/dashboard.e90f2068217b.css.gz differ diff --git a/backend/staticfiles/admin/css/forms.c14e1cb06392.css b/backend/staticfiles/admin/css/forms.c14e1cb06392.css new file mode 100644 index 0000000..0f6f334 --- /dev/null +++ b/backend/staticfiles/admin/css/forms.c14e1cb06392.css @@ -0,0 +1,534 @@ +@import url("widgets.ee33ab26c7c2.css"); + +/* FORM ROWS */ + +.form-row { + overflow: hidden; + padding: 10px; + font-size: 0.8125rem; + border-bottom: 1px solid var(--hairline-color); +} + +.form-row img, .form-row input { + vertical-align: middle; +} + +.form-row label input[type="checkbox"] { + margin-top: 0; + vertical-align: 0; +} + +form .form-row p { + padding-left: 0; +} + +.flex-container { + display: flex; +} + +.form-multiline { + flex-wrap: wrap; +} + +.form-multiline > div { + padding-bottom: 10px; +} + +/* FORM LABELS */ + +label { + font-weight: normal; + color: var(--body-quiet-color); + font-size: 0.8125rem; +} + +.required label, label.required { + font-weight: bold; + color: var(--body-fg); +} + +/* RADIO BUTTONS */ + +form div.radiolist div { + padding-right: 7px; +} + +form div.radiolist.inline div { + display: inline-block; +} + +form div.radiolist label { + width: auto; +} + +form div.radiolist input[type="radio"] { + margin: -2px 4px 0 0; + padding: 0; +} + +form ul.inline { + margin-left: 0; + padding: 0; +} + +form ul.inline li { + float: left; + padding-right: 7px; +} + +/* ALIGNED FIELDSETS */ + +.aligned label { + display: block; + padding: 4px 10px 0 0; + min-width: 160px; + width: 160px; + word-wrap: break-word; + line-height: 1; +} + +.aligned label:not(.vCheckboxLabel):after { + content: ''; + display: inline-block; + vertical-align: middle; + height: 1.625rem; +} + +.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { + padding: 6px 0; + margin-top: 0; + margin-bottom: 0; + margin-left: 0; + overflow-wrap: break-word; +} + +.aligned ul label { + display: inline; + float: none; + width: auto; +} + +.aligned .form-row input { + margin-bottom: 0; +} + +.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { + width: 350px; +} + +form .aligned ul { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned div.radiolist { + display: inline-block; + margin: 0; + padding: 0; +} + +form .aligned p.help, +form .aligned div.help { + margin-top: 0; + margin-left: 160px; + padding-left: 10px; +} + +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-left: 0; + padding-left: 0; + font-weight: normal; +} + +form .aligned p.help:last-child, +form .aligned div.help:last-child { + margin-bottom: 0; + padding-bottom: 0; +} + +form .aligned input + p.help, +form .aligned textarea + p.help, +form .aligned select + p.help, +form .aligned input + div.help, +form .aligned textarea + div.help, +form .aligned select + div.help { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned ul li { + list-style: none; +} + +form .aligned table p { + margin-left: 0; + padding-left: 0; +} + +.aligned .vCheckboxLabel { + float: none; + width: auto; + display: inline-block; + vertical-align: -3px; + padding: 0 0 5px 5px; +} + +.aligned .vCheckboxLabel + p.help, +.aligned .vCheckboxLabel + div.help { + margin-top: -4px; +} + +.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { + width: 610px; +} + +fieldset .fieldBox { + margin-right: 20px; +} + +/* WIDE FIELDSETS */ + +.wide label { + width: 200px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-left: 200px; +} + +form .wide p.help, +form .wide div.help { + padding-left: 50px; +} + +form div.help ul { + padding-left: 0; + margin-left: 0; +} + +.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { + width: 450px; +} + +/* COLLAPSED FIELDSETS */ + +fieldset.collapsed * { + display: none; +} + +fieldset.collapsed h2, fieldset.collapsed { + display: block; +} + +fieldset.collapsed { + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; +} + +fieldset.collapsed h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +fieldset .collapse-toggle { + color: var(--header-link-color); +} + +fieldset.collapsed .collapse-toggle { + background: transparent; + display: inline; + color: var(--link-fg); +} + +/* MONOSPACE TEXTAREAS */ + +fieldset.monospace textarea { + font-family: var(--font-family-monospace); +} + +/* SUBMIT ROW */ + +.submit-row { + padding: 12px 14px 12px; + margin: 0 0 20px; + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +body.popup .submit-row { + overflow: auto; +} + +.submit-row input { + height: 2.1875rem; + line-height: 0.9375rem; +} + +.submit-row input, .submit-row a { + margin: 0; +} + +.submit-row input.default { + text-transform: uppercase; +} + +.submit-row a.deletelink { + margin-left: auto; +} + +.submit-row a.deletelink { + display: block; + background: var(--delete-button-bg); + border-radius: 4px; + padding: 0.625rem 0.9375rem; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.closelink { + display: inline-block; + background: var(--close-button-bg); + border-radius: 4px; + padding: 10px 15px; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.deletelink:focus, +.submit-row a.deletelink:hover, +.submit-row a.deletelink:active { + background: var(--delete-button-hover-bg); + text-decoration: none; +} + +.submit-row a.closelink:focus, +.submit-row a.closelink:hover, +.submit-row a.closelink:active { + background: var(--close-button-hover-bg); + text-decoration: none; +} + +/* CUSTOM FORM FIELDS */ + +.vSelectMultipleField { + vertical-align: top; +} + +.vCheckboxField { + border: none; +} + +.vDateField, .vTimeField { + margin-right: 2px; + margin-bottom: 4px; +} + +.vDateField { + min-width: 6.85em; +} + +.vTimeField { + min-width: 4.7em; +} + +.vURLField { + width: 30em; +} + +.vLargeTextField, .vXMLLargeTextField { + width: 48em; +} + +.flatpages-flatpage #id_content { + height: 40.2em; +} + +.module table .vPositiveSmallIntegerField { + width: 2.2em; +} + +.vIntegerField { + width: 5em; +} + +.vBigIntegerField { + width: 10em; +} + +.vForeignKeyRawIdAdminField { + width: 5em; +} + +.vTextField, .vUUIDField { + width: 20em; +} + +/* INLINES */ + +.inline-group { + padding: 0; + margin: 0 0 30px; +} + +.inline-group thead th { + padding: 8px 10px; +} + +.inline-group .aligned label { + width: 160px; +} + +.inline-related { + position: relative; +} + +.inline-related h3 { + margin: 0; + color: var(--body-quiet-color); + padding: 5px; + font-size: 0.8125rem; + background: var(--darkened-bg); + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +.inline-related h3 span.delete { + float: right; +} + +.inline-related h3 span.delete label { + margin-left: 2px; + font-size: 0.6875rem; +} + +.inline-related fieldset { + margin: 0; + background: var(--body-bg); + border: none; + width: 100%; +} + +.inline-related fieldset.module h3 { + margin: 0; + padding: 2px 5px 3px 5px; + font-size: 0.6875rem; + text-align: left; + font-weight: bold; + background: #bcd; + color: var(--body-bg); +} + +.inline-group .tabular fieldset.module { + border: none; +} + +.inline-related.tabular fieldset.module table { + width: 100%; + overflow-x: scroll; +} + +.last-related fieldset { + border: none; +} + +.inline-group .tabular tr.has_original td { + padding-top: 2em; +} + +.inline-group .tabular tr td.original { + padding: 2px 0 0 0; + width: 0; + _position: relative; +} + +.inline-group .tabular th.original { + width: 0px; + padding: 0; +} + +.inline-group .tabular td.original p { + position: absolute; + left: 0; + height: 1.1em; + padding: 2px 9px; + overflow: hidden; + font-size: 0.5625rem; + font-weight: bold; + color: var(--body-quiet-color); + _width: 700px; +} + +.inline-group ul.tools { + padding: 0; + margin: 0; + list-style: none; +} + +.inline-group ul.tools li { + display: inline; + padding: 0 5px; +} + +.inline-group div.add-row, +.inline-group .tabular tr.add-row td { + color: var(--body-quiet-color); + background: var(--darkened-bg); + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group .tabular tr.add-row td { + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group ul.tools a.add, +.inline-group div.add-row a, +.inline-group .tabular tr.add-row td a { + background: url("../img/icon-addlink.d519b3bab011.svg") 0 1px no-repeat; + padding-left: 16px; + font-size: 0.75rem; +} + +.empty-form { + display: none; +} + +/* RELATED FIELD ADD ONE / LOOKUP */ + +.related-lookup { + margin-left: 5px; + display: inline-block; + vertical-align: middle; + background-repeat: no-repeat; + background-size: 14px; +} + +.related-lookup { + width: 1rem; + height: 1rem; + background-image: url("../img/search.7cf54ff789c6.svg"); +} + +form .related-widget-wrapper ul { + display: inline-block; + margin-left: 0; + padding-left: 0; +} + +.clearable-file-input input { + margin-top: 0; +} diff --git a/backend/staticfiles/admin/css/forms.c14e1cb06392.css.gz b/backend/staticfiles/admin/css/forms.c14e1cb06392.css.gz new file mode 100644 index 0000000..1cc0c35 Binary files /dev/null and b/backend/staticfiles/admin/css/forms.c14e1cb06392.css.gz differ diff --git a/backend/staticfiles/admin/css/forms.css b/backend/staticfiles/admin/css/forms.css new file mode 100644 index 0000000..9a8dad0 --- /dev/null +++ b/backend/staticfiles/admin/css/forms.css @@ -0,0 +1,534 @@ +@import url('widgets.css'); + +/* FORM ROWS */ + +.form-row { + overflow: hidden; + padding: 10px; + font-size: 0.8125rem; + border-bottom: 1px solid var(--hairline-color); +} + +.form-row img, .form-row input { + vertical-align: middle; +} + +.form-row label input[type="checkbox"] { + margin-top: 0; + vertical-align: 0; +} + +form .form-row p { + padding-left: 0; +} + +.flex-container { + display: flex; +} + +.form-multiline { + flex-wrap: wrap; +} + +.form-multiline > div { + padding-bottom: 10px; +} + +/* FORM LABELS */ + +label { + font-weight: normal; + color: var(--body-quiet-color); + font-size: 0.8125rem; +} + +.required label, label.required { + font-weight: bold; + color: var(--body-fg); +} + +/* RADIO BUTTONS */ + +form div.radiolist div { + padding-right: 7px; +} + +form div.radiolist.inline div { + display: inline-block; +} + +form div.radiolist label { + width: auto; +} + +form div.radiolist input[type="radio"] { + margin: -2px 4px 0 0; + padding: 0; +} + +form ul.inline { + margin-left: 0; + padding: 0; +} + +form ul.inline li { + float: left; + padding-right: 7px; +} + +/* ALIGNED FIELDSETS */ + +.aligned label { + display: block; + padding: 4px 10px 0 0; + min-width: 160px; + width: 160px; + word-wrap: break-word; + line-height: 1; +} + +.aligned label:not(.vCheckboxLabel):after { + content: ''; + display: inline-block; + vertical-align: middle; + height: 1.625rem; +} + +.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { + padding: 6px 0; + margin-top: 0; + margin-bottom: 0; + margin-left: 0; + overflow-wrap: break-word; +} + +.aligned ul label { + display: inline; + float: none; + width: auto; +} + +.aligned .form-row input { + margin-bottom: 0; +} + +.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { + width: 350px; +} + +form .aligned ul { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned div.radiolist { + display: inline-block; + margin: 0; + padding: 0; +} + +form .aligned p.help, +form .aligned div.help { + margin-top: 0; + margin-left: 160px; + padding-left: 10px; +} + +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-left: 0; + padding-left: 0; + font-weight: normal; +} + +form .aligned p.help:last-child, +form .aligned div.help:last-child { + margin-bottom: 0; + padding-bottom: 0; +} + +form .aligned input + p.help, +form .aligned textarea + p.help, +form .aligned select + p.help, +form .aligned input + div.help, +form .aligned textarea + div.help, +form .aligned select + div.help { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned ul li { + list-style: none; +} + +form .aligned table p { + margin-left: 0; + padding-left: 0; +} + +.aligned .vCheckboxLabel { + float: none; + width: auto; + display: inline-block; + vertical-align: -3px; + padding: 0 0 5px 5px; +} + +.aligned .vCheckboxLabel + p.help, +.aligned .vCheckboxLabel + div.help { + margin-top: -4px; +} + +.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { + width: 610px; +} + +fieldset .fieldBox { + margin-right: 20px; +} + +/* WIDE FIELDSETS */ + +.wide label { + width: 200px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-left: 200px; +} + +form .wide p.help, +form .wide div.help { + padding-left: 50px; +} + +form div.help ul { + padding-left: 0; + margin-left: 0; +} + +.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { + width: 450px; +} + +/* COLLAPSED FIELDSETS */ + +fieldset.collapsed * { + display: none; +} + +fieldset.collapsed h2, fieldset.collapsed { + display: block; +} + +fieldset.collapsed { + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; +} + +fieldset.collapsed h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +fieldset .collapse-toggle { + color: var(--header-link-color); +} + +fieldset.collapsed .collapse-toggle { + background: transparent; + display: inline; + color: var(--link-fg); +} + +/* MONOSPACE TEXTAREAS */ + +fieldset.monospace textarea { + font-family: var(--font-family-monospace); +} + +/* SUBMIT ROW */ + +.submit-row { + padding: 12px 14px 12px; + margin: 0 0 20px; + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +body.popup .submit-row { + overflow: auto; +} + +.submit-row input { + height: 2.1875rem; + line-height: 0.9375rem; +} + +.submit-row input, .submit-row a { + margin: 0; +} + +.submit-row input.default { + text-transform: uppercase; +} + +.submit-row a.deletelink { + margin-left: auto; +} + +.submit-row a.deletelink { + display: block; + background: var(--delete-button-bg); + border-radius: 4px; + padding: 0.625rem 0.9375rem; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.closelink { + display: inline-block; + background: var(--close-button-bg); + border-radius: 4px; + padding: 10px 15px; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.deletelink:focus, +.submit-row a.deletelink:hover, +.submit-row a.deletelink:active { + background: var(--delete-button-hover-bg); + text-decoration: none; +} + +.submit-row a.closelink:focus, +.submit-row a.closelink:hover, +.submit-row a.closelink:active { + background: var(--close-button-hover-bg); + text-decoration: none; +} + +/* CUSTOM FORM FIELDS */ + +.vSelectMultipleField { + vertical-align: top; +} + +.vCheckboxField { + border: none; +} + +.vDateField, .vTimeField { + margin-right: 2px; + margin-bottom: 4px; +} + +.vDateField { + min-width: 6.85em; +} + +.vTimeField { + min-width: 4.7em; +} + +.vURLField { + width: 30em; +} + +.vLargeTextField, .vXMLLargeTextField { + width: 48em; +} + +.flatpages-flatpage #id_content { + height: 40.2em; +} + +.module table .vPositiveSmallIntegerField { + width: 2.2em; +} + +.vIntegerField { + width: 5em; +} + +.vBigIntegerField { + width: 10em; +} + +.vForeignKeyRawIdAdminField { + width: 5em; +} + +.vTextField, .vUUIDField { + width: 20em; +} + +/* INLINES */ + +.inline-group { + padding: 0; + margin: 0 0 30px; +} + +.inline-group thead th { + padding: 8px 10px; +} + +.inline-group .aligned label { + width: 160px; +} + +.inline-related { + position: relative; +} + +.inline-related h3 { + margin: 0; + color: var(--body-quiet-color); + padding: 5px; + font-size: 0.8125rem; + background: var(--darkened-bg); + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +.inline-related h3 span.delete { + float: right; +} + +.inline-related h3 span.delete label { + margin-left: 2px; + font-size: 0.6875rem; +} + +.inline-related fieldset { + margin: 0; + background: var(--body-bg); + border: none; + width: 100%; +} + +.inline-related fieldset.module h3 { + margin: 0; + padding: 2px 5px 3px 5px; + font-size: 0.6875rem; + text-align: left; + font-weight: bold; + background: #bcd; + color: var(--body-bg); +} + +.inline-group .tabular fieldset.module { + border: none; +} + +.inline-related.tabular fieldset.module table { + width: 100%; + overflow-x: scroll; +} + +.last-related fieldset { + border: none; +} + +.inline-group .tabular tr.has_original td { + padding-top: 2em; +} + +.inline-group .tabular tr td.original { + padding: 2px 0 0 0; + width: 0; + _position: relative; +} + +.inline-group .tabular th.original { + width: 0px; + padding: 0; +} + +.inline-group .tabular td.original p { + position: absolute; + left: 0; + height: 1.1em; + padding: 2px 9px; + overflow: hidden; + font-size: 0.5625rem; + font-weight: bold; + color: var(--body-quiet-color); + _width: 700px; +} + +.inline-group ul.tools { + padding: 0; + margin: 0; + list-style: none; +} + +.inline-group ul.tools li { + display: inline; + padding: 0 5px; +} + +.inline-group div.add-row, +.inline-group .tabular tr.add-row td { + color: var(--body-quiet-color); + background: var(--darkened-bg); + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group .tabular tr.add-row td { + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group ul.tools a.add, +.inline-group div.add-row a, +.inline-group .tabular tr.add-row td a { + background: url(../img/icon-addlink.svg) 0 1px no-repeat; + padding-left: 16px; + font-size: 0.75rem; +} + +.empty-form { + display: none; +} + +/* RELATED FIELD ADD ONE / LOOKUP */ + +.related-lookup { + margin-left: 5px; + display: inline-block; + vertical-align: middle; + background-repeat: no-repeat; + background-size: 14px; +} + +.related-lookup { + width: 1rem; + height: 1rem; + background-image: url(../img/search.svg); +} + +form .related-widget-wrapper ul { + display: inline-block; + margin-left: 0; + padding-left: 0; +} + +.clearable-file-input input { + margin-top: 0; +} diff --git a/backend/staticfiles/admin/css/forms.css.gz b/backend/staticfiles/admin/css/forms.css.gz new file mode 100644 index 0000000..da29e86 Binary files /dev/null and b/backend/staticfiles/admin/css/forms.css.gz differ diff --git a/backend/staticfiles/admin/css/login.586129c60a93.css b/backend/staticfiles/admin/css/login.586129c60a93.css new file mode 100644 index 0000000..389772f --- /dev/null +++ b/backend/staticfiles/admin/css/login.586129c60a93.css @@ -0,0 +1,61 @@ +/* LOGIN FORM */ + +.login { + background: var(--darkened-bg); + height: auto; +} + +.login #header { + height: auto; + padding: 15px 16px; + justify-content: center; +} + +.login #header h1 { + font-size: 1.125rem; + margin: 0; +} + +.login #header h1 a { + color: var(--header-link-color); +} + +.login #content { + padding: 20px 20px 0; +} + +.login #container { + background: var(--body-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + width: 28em; + min-width: 300px; + margin: 100px auto; + height: auto; +} + +.login .form-row { + padding: 4px 0; +} + +.login .form-row label { + display: block; + line-height: 2em; +} + +.login .form-row #id_username, .login .form-row #id_password { + padding: 8px; + width: 100%; + box-sizing: border-box; +} + +.login .submit-row { + padding: 1em 0 0 0; + margin: 0; + text-align: center; +} + +.login .password-reset-link { + text-align: center; +} diff --git a/backend/staticfiles/admin/css/login.586129c60a93.css.gz b/backend/staticfiles/admin/css/login.586129c60a93.css.gz new file mode 100644 index 0000000..ca9d533 Binary files /dev/null and b/backend/staticfiles/admin/css/login.586129c60a93.css.gz differ diff --git a/backend/staticfiles/admin/css/login.css b/backend/staticfiles/admin/css/login.css new file mode 100644 index 0000000..389772f --- /dev/null +++ b/backend/staticfiles/admin/css/login.css @@ -0,0 +1,61 @@ +/* LOGIN FORM */ + +.login { + background: var(--darkened-bg); + height: auto; +} + +.login #header { + height: auto; + padding: 15px 16px; + justify-content: center; +} + +.login #header h1 { + font-size: 1.125rem; + margin: 0; +} + +.login #header h1 a { + color: var(--header-link-color); +} + +.login #content { + padding: 20px 20px 0; +} + +.login #container { + background: var(--body-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + width: 28em; + min-width: 300px; + margin: 100px auto; + height: auto; +} + +.login .form-row { + padding: 4px 0; +} + +.login .form-row label { + display: block; + line-height: 2em; +} + +.login .form-row #id_username, .login .form-row #id_password { + padding: 8px; + width: 100%; + box-sizing: border-box; +} + +.login .submit-row { + padding: 1em 0 0 0; + margin: 0; + text-align: center; +} + +.login .password-reset-link { + text-align: center; +} diff --git a/backend/staticfiles/admin/css/login.css.gz b/backend/staticfiles/admin/css/login.css.gz new file mode 100644 index 0000000..ca9d533 Binary files /dev/null and b/backend/staticfiles/admin/css/login.css.gz differ diff --git a/backend/staticfiles/admin/css/nav_sidebar.269a1bd44627.css b/backend/staticfiles/admin/css/nav_sidebar.269a1bd44627.css new file mode 100644 index 0000000..f76e6ce --- /dev/null +++ b/backend/staticfiles/admin/css/nav_sidebar.269a1bd44627.css @@ -0,0 +1,144 @@ +.sticky { + position: sticky; + top: 0; + max-height: 100vh; +} + +.toggle-nav-sidebar { + z-index: 20; + left: 0; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 23px; + width: 23px; + border: 0; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + cursor: pointer; + font-size: 1.25rem; + color: var(--link-fg); + padding: 0; +} + +[dir="rtl"] .toggle-nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; +} + +.toggle-nav-sidebar:hover, +.toggle-nav-sidebar:focus { + background-color: var(--darkened-bg); +} + +#nav-sidebar { + z-index: 15; + flex: 0 0 275px; + left: -276px; + margin-left: -276px; + border-top: 1px solid transparent; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + overflow: auto; +} + +[dir="rtl"] #nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; + left: 0; + margin-left: 0; + right: -276px; + margin-right: -276px; +} + +.toggle-nav-sidebar::before { + content: '\00BB'; +} + +.main.shifted .toggle-nav-sidebar::before { + content: '\00AB'; +} + +.main > #nav-sidebar { + visibility: hidden; +} + +.main.shifted > #nav-sidebar { + margin-left: 0; + visibility: visible; +} + +[dir="rtl"] .main.shifted > #nav-sidebar { + margin-right: 0; +} + +#nav-sidebar .module th { + width: 100%; + overflow-wrap: anywhere; +} + +#nav-sidebar .module th, +#nav-sidebar .module caption { + padding-left: 16px; +} + +#nav-sidebar .module td { + white-space: nowrap; +} + +[dir="rtl"] #nav-sidebar .module th, +[dir="rtl"] #nav-sidebar .module caption { + padding-left: 8px; + padding-right: 16px; +} + +#nav-sidebar .current-app .section:link, +#nav-sidebar .current-app .section:visited { + color: var(--header-color); + font-weight: bold; +} + +#nav-sidebar .current-model { + background: var(--selected-row); +} + +.main > #nav-sidebar + .content { + max-width: calc(100% - 23px); +} + +.main.shifted > #nav-sidebar + .content { + max-width: calc(100% - 299px); +} + +@media (max-width: 767px) { + #nav-sidebar, #toggle-nav-sidebar { + display: none; + } + + .main > #nav-sidebar + .content, + .main.shifted > #nav-sidebar + .content { + max-width: 100%; + } +} + +#nav-filter { + width: 100%; + box-sizing: border-box; + padding: 2px 5px; + margin: 5px 0; + border: 1px solid var(--border-color); + background-color: var(--darkened-bg); + color: var(--body-fg); +} + +#nav-filter:focus { + border-color: var(--body-quiet-color); +} + +#nav-filter.no-results { + background: var(--message-error-bg); +} + +#nav-sidebar table { + width: 100%; +} diff --git a/backend/staticfiles/admin/css/nav_sidebar.269a1bd44627.css.gz b/backend/staticfiles/admin/css/nav_sidebar.269a1bd44627.css.gz new file mode 100644 index 0000000..7398c4d Binary files /dev/null and b/backend/staticfiles/admin/css/nav_sidebar.269a1bd44627.css.gz differ diff --git a/backend/staticfiles/admin/css/nav_sidebar.css b/backend/staticfiles/admin/css/nav_sidebar.css new file mode 100644 index 0000000..f76e6ce --- /dev/null +++ b/backend/staticfiles/admin/css/nav_sidebar.css @@ -0,0 +1,144 @@ +.sticky { + position: sticky; + top: 0; + max-height: 100vh; +} + +.toggle-nav-sidebar { + z-index: 20; + left: 0; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 23px; + width: 23px; + border: 0; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + cursor: pointer; + font-size: 1.25rem; + color: var(--link-fg); + padding: 0; +} + +[dir="rtl"] .toggle-nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; +} + +.toggle-nav-sidebar:hover, +.toggle-nav-sidebar:focus { + background-color: var(--darkened-bg); +} + +#nav-sidebar { + z-index: 15; + flex: 0 0 275px; + left: -276px; + margin-left: -276px; + border-top: 1px solid transparent; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + overflow: auto; +} + +[dir="rtl"] #nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; + left: 0; + margin-left: 0; + right: -276px; + margin-right: -276px; +} + +.toggle-nav-sidebar::before { + content: '\00BB'; +} + +.main.shifted .toggle-nav-sidebar::before { + content: '\00AB'; +} + +.main > #nav-sidebar { + visibility: hidden; +} + +.main.shifted > #nav-sidebar { + margin-left: 0; + visibility: visible; +} + +[dir="rtl"] .main.shifted > #nav-sidebar { + margin-right: 0; +} + +#nav-sidebar .module th { + width: 100%; + overflow-wrap: anywhere; +} + +#nav-sidebar .module th, +#nav-sidebar .module caption { + padding-left: 16px; +} + +#nav-sidebar .module td { + white-space: nowrap; +} + +[dir="rtl"] #nav-sidebar .module th, +[dir="rtl"] #nav-sidebar .module caption { + padding-left: 8px; + padding-right: 16px; +} + +#nav-sidebar .current-app .section:link, +#nav-sidebar .current-app .section:visited { + color: var(--header-color); + font-weight: bold; +} + +#nav-sidebar .current-model { + background: var(--selected-row); +} + +.main > #nav-sidebar + .content { + max-width: calc(100% - 23px); +} + +.main.shifted > #nav-sidebar + .content { + max-width: calc(100% - 299px); +} + +@media (max-width: 767px) { + #nav-sidebar, #toggle-nav-sidebar { + display: none; + } + + .main > #nav-sidebar + .content, + .main.shifted > #nav-sidebar + .content { + max-width: 100%; + } +} + +#nav-filter { + width: 100%; + box-sizing: border-box; + padding: 2px 5px; + margin: 5px 0; + border: 1px solid var(--border-color); + background-color: var(--darkened-bg); + color: var(--body-fg); +} + +#nav-filter:focus { + border-color: var(--body-quiet-color); +} + +#nav-filter.no-results { + background: var(--message-error-bg); +} + +#nav-sidebar table { + width: 100%; +} diff --git a/backend/staticfiles/admin/css/nav_sidebar.css.gz b/backend/staticfiles/admin/css/nav_sidebar.css.gz new file mode 100644 index 0000000..7398c4d Binary files /dev/null and b/backend/staticfiles/admin/css/nav_sidebar.css.gz differ diff --git a/backend/staticfiles/admin/css/responsive.css b/backend/staticfiles/admin/css/responsive.css new file mode 100644 index 0000000..1d0a188 --- /dev/null +++ b/backend/staticfiles/admin/css/responsive.css @@ -0,0 +1,999 @@ +/* Tablets */ + +input[type="submit"], button { + -webkit-appearance: none; + appearance: none; +} + +@media (max-width: 1024px) { + /* Basic */ + + html { + -webkit-text-size-adjust: 100%; + } + + td, th { + padding: 10px; + font-size: 0.875rem; + } + + .small { + font-size: 0.75rem; + } + + /* Layout */ + + #container { + min-width: 0; + } + + #content { + padding: 15px 20px 20px; + } + + div.breadcrumbs { + padding: 10px 30px; + } + + /* Header */ + + #header { + flex-direction: column; + padding: 15px 30px; + justify-content: flex-start; + } + + #branding h1 { + margin: 0 0 8px; + line-height: 1.2; + } + + #user-tools { + margin: 0; + font-weight: 400; + line-height: 1.85; + text-align: left; + } + + #user-tools a { + display: inline-block; + line-height: 1.4; + } + + /* Dashboard */ + + .dashboard #content { + width: auto; + } + + #content-related { + margin-right: -290px; + } + + .colSM #content-related { + margin-left: -290px; + } + + .colMS { + margin-right: 290px; + } + + .colSM { + margin-left: 290px; + } + + .dashboard .module table td a { + padding-right: 0; + } + + td .changelink, td .addlink { + font-size: 0.8125rem; + } + + /* Changelist */ + + #toolbar { + border: none; + padding: 15px; + } + + #changelist-search > div { + display: flex; + flex-wrap: nowrap; + max-width: 480px; + } + + #changelist-search label { + line-height: 1.375rem; + } + + #toolbar form #searchbar { + flex: 1 0 auto; + width: 0; + height: 1.375rem; + margin: 0 10px 0 6px; + } + + #toolbar form input[type=submit] { + flex: 0 1 auto; + } + + #changelist-search .quiet { + width: 0; + flex: 1 0 auto; + margin: 5px 0 0 25px; + } + + #changelist .actions { + display: flex; + flex-wrap: wrap; + padding: 15px 0; + } + + #changelist .actions label { + display: flex; + } + + #changelist .actions select { + background: var(--body-bg); + } + + #changelist .actions .button { + min-width: 48px; + margin: 0 10px; + } + + #changelist .actions span.all, + #changelist .actions span.clear, + #changelist .actions span.question, + #changelist .actions span.action-counter { + font-size: 0.6875rem; + margin: 0 10px 0 0; + } + + #changelist-filter { + flex-basis: 200px; + } + + .change-list .filtered .results, + .change-list .filtered .paginator, + .filtered #toolbar, + .filtered .actions, + + #changelist .paginator { + border-top-color: var(--hairline-color); /* XXX Is this used at all? */ + } + + #changelist .results + .paginator { + border-top: none; + } + + /* Forms */ + + label { + font-size: 0.875rem; + } + + .form-row input[type=text], + .form-row input[type=password], + .form-row input[type=email], + .form-row input[type=url], + .form-row input[type=tel], + .form-row input[type=number], + .form-row textarea, + .form-row select, + .form-row .vTextField { + box-sizing: border-box; + margin: 0; + padding: 6px 8px; + min-height: 2.25rem; + font-size: 0.875rem; + } + + .form-row select { + height: 2.25rem; + } + + .form-row select[multiple] { + height: auto; + min-height: 0; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--hairline-color); + } + + textarea { + max-width: 100%; + max-height: 120px; + } + + .aligned label { + padding-top: 6px; + } + + .aligned .related-lookup, + .aligned .datetimeshortcuts, + .aligned .related-lookup + strong { + align-self: center; + margin-left: 15px; + } + + form .aligned div.radiolist { + margin-left: 2px; + } + + .submit-row { + padding: 8px; + } + + .submit-row a.deletelink { + padding: 10px 7px; + } + + .button, input[type=submit], input[type=button], .submit-row input, a.button { + padding: 7px; + } + + /* Related widget */ + + .related-widget-wrapper { + float: none; + } + + .related-widget-wrapper-link + .selector { + max-width: calc(100% - 30px); + margin-right: 15px; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 10px; + } + + /* Selector */ + + .selector { + display: flex; + width: 100%; + } + + .selector .selector-filter { + display: flex; + align-items: center; + } + + .selector .selector-filter label { + margin: 0 8px 0 0; + } + + .selector .selector-filter input { + width: auto; + min-height: 0; + flex: 1 1; + } + + .selector-available, .selector-chosen { + width: auto; + flex: 1 1; + display: flex; + flex-direction: column; + } + + .selector select { + width: 100%; + flex: 1 0 auto; + margin-bottom: 5px; + } + + .selector ul.selector-chooser { + width: 26px; + height: 52px; + padding: 2px 0; + margin: auto 15px; + border-radius: 20px; + transform: translateY(-10px); + } + + .selector-add, .selector-remove { + width: 20px; + height: 20px; + background-size: 20px auto; + } + + .selector-add { + background-position: 0 -120px; + } + + .selector-remove { + background-position: 0 -80px; + } + + a.selector-chooseall, a.selector-clearall { + align-self: center; + } + + .stacked { + flex-direction: column; + max-width: 480px; + } + + .stacked > * { + flex: 0 1 auto; + } + + .stacked select { + margin-bottom: 0; + } + + .stacked .selector-available, .stacked .selector-chosen { + width: auto; + } + + .stacked ul.selector-chooser { + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto; + transform: none; + } + + .stacked .selector-chooser li { + padding: 3px; + } + + .stacked .selector-add, .stacked .selector-remove { + background-size: 20px auto; + } + + .stacked .selector-add { + background-position: 0 -40px; + } + + .stacked .active.selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -140px; + } + + .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -60px; + } + + .stacked .selector-remove { + background-position: 0 0; + } + + .stacked .active.selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -100px; + } + + .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -20px; + } + + .help-tooltip, .selector .help-icon { + display: none; + } + + .datetime input { + width: 50%; + max-width: 120px; + } + + .datetime span { + font-size: 0.8125rem; + } + + .datetime .timezonewarning { + display: block; + font-size: 0.6875rem; + color: var(--body-quiet-color); + } + + .datetimeshortcuts { + color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ + } + + .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + width: 75%; + } + + .inline-group { + overflow: auto; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 55px; + background-position: 30px 12px; + } + + ul.messagelist li.error { + background-position: 30px 12px; + } + + ul.messagelist li.warning { + background-position: 30px 14px; + } + + /* Login */ + + .login #header { + padding: 15px 20px; + } + + .login #branding h1 { + margin: 0; + } + + /* GIS */ + + div.olMap { + max-width: calc(100vw - 30px); + max-height: 300px; + } + + .olMap + .clear_features { + display: block; + margin-top: 10px; + } + + /* Docs */ + + .module table.xfull { + width: 100%; + } + + pre.literal-block { + overflow: auto; + } +} + +/* Mobile */ + +@media (max-width: 767px) { + /* Layout */ + + #header, #content, #footer { + padding: 15px; + } + + #footer:empty { + padding: 0; + } + + div.breadcrumbs { + padding: 10px 15px; + } + + /* Dashboard */ + + .colMS, .colSM { + margin: 0; + } + + #content-related, .colSM #content-related { + width: 100%; + margin: 0; + } + + #content-related .module { + margin-bottom: 0; + } + + #content-related .module h2 { + padding: 10px 15px; + font-size: 1rem; + } + + /* Changelist */ + + #changelist { + align-items: stretch; + flex-direction: column; + } + + #toolbar { + padding: 10px; + } + + #changelist-filter { + margin-left: 0; + } + + #changelist .actions label { + flex: 1 1; + } + + #changelist .actions select { + flex: 1 0; + width: 100%; + } + + #changelist .actions span { + flex: 1 0 100%; + } + + #changelist-filter { + position: static; + width: auto; + margin-top: 30px; + } + + .object-tools { + float: none; + margin: 0 0 15px; + padding: 0; + overflow: hidden; + } + + .object-tools li { + height: auto; + margin-left: 0; + } + + .object-tools li + li { + margin-left: 15px; + } + + /* Forms */ + + .form-row { + padding: 15px 0; + } + + .aligned .form-row, + .aligned .form-row > div { + max-width: 100vw; + } + + .aligned .form-row > div { + width: calc(100vw - 30px); + } + + .flex-container { + flex-flow: column; + } + + .flex-container.checkbox-row { + flex-flow: row; + } + + textarea { + max-width: none; + } + + .vURLField { + width: auto; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 15px; + padding-top: 15px; + } + + fieldset.collapsed .form-row { + display: none; + } + + .aligned label { + width: 100%; + min-width: auto; + padding: 0 0 10px; + } + + .aligned label:after { + max-height: 0; + } + + .aligned .form-row input, + .aligned .form-row select, + .aligned .form-row textarea { + flex: 1 1 auto; + max-width: 100%; + } + + .aligned .checkbox-row input { + flex: 0 1 auto; + margin: 0; + } + + .aligned .vCheckboxLabel { + flex: 1 0; + padding: 1px 0 0 5px; + } + + .aligned label + p, + .aligned label + div.help, + .aligned label + div.readonly { + padding: 0; + margin-left: 0; + } + + .aligned p.file-upload { + font-size: 0.8125rem; + } + + span.clearable-file-input { + margin-left: 15px; + } + + span.clearable-file-input label { + font-size: 0.8125rem; + padding-bottom: 0; + } + + .aligned .timezonewarning { + flex: 1 0 100%; + margin-top: 5px; + } + + form .aligned .form-row div.help { + width: 100%; + margin: 5px 0 0; + padding: 0; + } + + form .aligned ul, + form .aligned ul.errorlist { + margin-left: 0; + padding-left: 0; + } + + form .aligned div.radiolist { + margin-top: 5px; + margin-right: 15px; + margin-bottom: -3px; + } + + form .aligned div.radiolist:not(.inline) div + div { + margin-top: 5px; + } + + /* Related widget */ + + .related-widget-wrapper { + width: 100%; + display: flex; + align-items: flex-start; + } + + .related-widget-wrapper .selector { + order: 1; + } + + .related-widget-wrapper > a { + order: 2; + } + + .related-widget-wrapper .radiolist ~ a { + align-self: flex-end; + } + + .related-widget-wrapper > select ~ a { + align-self: center; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 15px; + } + + /* Selector */ + + .selector { + flex-direction: column; + } + + .selector > * { + float: none; + } + + .selector-available, .selector-chosen { + margin-bottom: 0; + flex: 1 1 auto; + } + + .selector select { + max-height: 96px; + } + + .selector ul.selector-chooser { + display: block; + float: none; + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto 20px; + transform: none; + } + + .selector ul.selector-chooser li { + float: left; + } + + .selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -20px; + } + + .selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -60px; + } + + /* Inlines */ + + .inline-group[data-inline-type="stacked"] .inline-related { + border: 1px solid var(--hairline-color); + border-radius: 4px; + margin-top: 15px; + overflow: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related > * { + box-sizing: border-box; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module { + padding: 0 10px; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { + border-top: 1px solid var(--hairline-color); + border-bottom: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { + border-top: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 { + padding: 10px; + border-top-width: 0; + border-bottom-width: 2px; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { + margin-right: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { + float: none; + flex: 1 1 100%; + margin-top: 5px; + } + + .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] .aligned label { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] div.add-row { + margin-top: 15px; + border: 1px solid var(--hairline-color); + border-radius: 4px; + } + + .inline-group div.add-row, + .inline-group .tabular tr.add-row td { + padding: 0; + } + + .inline-group div.add-row a, + .inline-group .tabular tr.add-row td a { + display: block; + padding: 8px 10px 8px 26px; + background-position: 8px 9px; + } + + /* Submit row */ + + .submit-row { + padding: 10px; + margin: 0 0 15px; + flex-direction: column; + gap: 8px; + } + + .submit-row input, .submit-row input.default, .submit-row a { + text-align: center; + } + + .submit-row a.closelink { + padding: 10px 0; + text-align: center; + } + + .submit-row a.deletelink { + margin: 0; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 40px; + background-position: 15px 12px; + } + + ul.messagelist li.error { + background-position: 15px 12px; + } + + ul.messagelist li.warning { + background-position: 15px 14px; + } + + /* Paginator */ + + .paginator .this-page, .paginator a:link, .paginator a:visited { + padding: 4px 10px; + } + + /* Login */ + + body.login { + padding: 0 15px; + } + + .login #container { + width: auto; + max-width: 480px; + margin: 50px auto; + } + + .login #header, + .login #content { + padding: 15px; + } + + .login #content-main { + float: none; + } + + .login .form-row { + padding: 0; + } + + .login .form-row + .form-row { + margin-top: 15px; + } + + .login .form-row label { + margin: 0 0 5px; + line-height: 1.2; + } + + .login .submit-row { + padding: 15px 0 0; + } + + .login br { + display: none; + } + + .login .submit-row input { + margin: 0; + text-transform: uppercase; + } + + .errornote { + margin: 0 0 20px; + padding: 8px 12px; + font-size: 0.8125rem; + } + + /* Calendar and clock */ + + .calendarbox, .clockbox { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); + margin: 0; + border: none; + overflow: visible; + } + + .calendarbox:before, .clockbox:before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + transform: translate(-50%, -50%); + } + + .calendarbox > *, .clockbox > * { + position: relative; + z-index: 1; + } + + .calendarbox > div:first-child { + z-index: 2; + } + + .calendarbox .calendar, .clockbox h2 { + border-radius: 4px 4px 0 0; + overflow: hidden; + } + + .calendarbox .calendar-cancel, .clockbox .calendar-cancel { + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + .calendar-shortcuts { + padding: 10px 0; + font-size: 0.75rem; + line-height: 0.75rem; + } + + .calendar-shortcuts a { + margin: 0 4px; + } + + .timelist a { + background: var(--body-bg); + padding: 4px; + } + + .calendar-cancel { + padding: 8px 10px; + } + + .clockbox h2 { + padding: 8px 15px; + } + + .calendar caption { + padding: 10px; + } + + .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + z-index: 1; + top: 10px; + } + + /* History */ + + table#change-history tbody th, table#change-history tbody td { + font-size: 0.8125rem; + word-break: break-word; + } + + table#change-history tbody th { + width: auto; + } + + /* Docs */ + + table.model tbody th, table.model tbody td { + font-size: 0.8125rem; + word-break: break-word; + } +} diff --git a/backend/staticfiles/admin/css/responsive.css.gz b/backend/staticfiles/admin/css/responsive.css.gz new file mode 100644 index 0000000..fe370fb Binary files /dev/null and b/backend/staticfiles/admin/css/responsive.css.gz differ diff --git a/backend/staticfiles/admin/css/responsive.f6533dab034d.css b/backend/staticfiles/admin/css/responsive.f6533dab034d.css new file mode 100644 index 0000000..1d0a188 --- /dev/null +++ b/backend/staticfiles/admin/css/responsive.f6533dab034d.css @@ -0,0 +1,999 @@ +/* Tablets */ + +input[type="submit"], button { + -webkit-appearance: none; + appearance: none; +} + +@media (max-width: 1024px) { + /* Basic */ + + html { + -webkit-text-size-adjust: 100%; + } + + td, th { + padding: 10px; + font-size: 0.875rem; + } + + .small { + font-size: 0.75rem; + } + + /* Layout */ + + #container { + min-width: 0; + } + + #content { + padding: 15px 20px 20px; + } + + div.breadcrumbs { + padding: 10px 30px; + } + + /* Header */ + + #header { + flex-direction: column; + padding: 15px 30px; + justify-content: flex-start; + } + + #branding h1 { + margin: 0 0 8px; + line-height: 1.2; + } + + #user-tools { + margin: 0; + font-weight: 400; + line-height: 1.85; + text-align: left; + } + + #user-tools a { + display: inline-block; + line-height: 1.4; + } + + /* Dashboard */ + + .dashboard #content { + width: auto; + } + + #content-related { + margin-right: -290px; + } + + .colSM #content-related { + margin-left: -290px; + } + + .colMS { + margin-right: 290px; + } + + .colSM { + margin-left: 290px; + } + + .dashboard .module table td a { + padding-right: 0; + } + + td .changelink, td .addlink { + font-size: 0.8125rem; + } + + /* Changelist */ + + #toolbar { + border: none; + padding: 15px; + } + + #changelist-search > div { + display: flex; + flex-wrap: nowrap; + max-width: 480px; + } + + #changelist-search label { + line-height: 1.375rem; + } + + #toolbar form #searchbar { + flex: 1 0 auto; + width: 0; + height: 1.375rem; + margin: 0 10px 0 6px; + } + + #toolbar form input[type=submit] { + flex: 0 1 auto; + } + + #changelist-search .quiet { + width: 0; + flex: 1 0 auto; + margin: 5px 0 0 25px; + } + + #changelist .actions { + display: flex; + flex-wrap: wrap; + padding: 15px 0; + } + + #changelist .actions label { + display: flex; + } + + #changelist .actions select { + background: var(--body-bg); + } + + #changelist .actions .button { + min-width: 48px; + margin: 0 10px; + } + + #changelist .actions span.all, + #changelist .actions span.clear, + #changelist .actions span.question, + #changelist .actions span.action-counter { + font-size: 0.6875rem; + margin: 0 10px 0 0; + } + + #changelist-filter { + flex-basis: 200px; + } + + .change-list .filtered .results, + .change-list .filtered .paginator, + .filtered #toolbar, + .filtered .actions, + + #changelist .paginator { + border-top-color: var(--hairline-color); /* XXX Is this used at all? */ + } + + #changelist .results + .paginator { + border-top: none; + } + + /* Forms */ + + label { + font-size: 0.875rem; + } + + .form-row input[type=text], + .form-row input[type=password], + .form-row input[type=email], + .form-row input[type=url], + .form-row input[type=tel], + .form-row input[type=number], + .form-row textarea, + .form-row select, + .form-row .vTextField { + box-sizing: border-box; + margin: 0; + padding: 6px 8px; + min-height: 2.25rem; + font-size: 0.875rem; + } + + .form-row select { + height: 2.25rem; + } + + .form-row select[multiple] { + height: auto; + min-height: 0; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--hairline-color); + } + + textarea { + max-width: 100%; + max-height: 120px; + } + + .aligned label { + padding-top: 6px; + } + + .aligned .related-lookup, + .aligned .datetimeshortcuts, + .aligned .related-lookup + strong { + align-self: center; + margin-left: 15px; + } + + form .aligned div.radiolist { + margin-left: 2px; + } + + .submit-row { + padding: 8px; + } + + .submit-row a.deletelink { + padding: 10px 7px; + } + + .button, input[type=submit], input[type=button], .submit-row input, a.button { + padding: 7px; + } + + /* Related widget */ + + .related-widget-wrapper { + float: none; + } + + .related-widget-wrapper-link + .selector { + max-width: calc(100% - 30px); + margin-right: 15px; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 10px; + } + + /* Selector */ + + .selector { + display: flex; + width: 100%; + } + + .selector .selector-filter { + display: flex; + align-items: center; + } + + .selector .selector-filter label { + margin: 0 8px 0 0; + } + + .selector .selector-filter input { + width: auto; + min-height: 0; + flex: 1 1; + } + + .selector-available, .selector-chosen { + width: auto; + flex: 1 1; + display: flex; + flex-direction: column; + } + + .selector select { + width: 100%; + flex: 1 0 auto; + margin-bottom: 5px; + } + + .selector ul.selector-chooser { + width: 26px; + height: 52px; + padding: 2px 0; + margin: auto 15px; + border-radius: 20px; + transform: translateY(-10px); + } + + .selector-add, .selector-remove { + width: 20px; + height: 20px; + background-size: 20px auto; + } + + .selector-add { + background-position: 0 -120px; + } + + .selector-remove { + background-position: 0 -80px; + } + + a.selector-chooseall, a.selector-clearall { + align-self: center; + } + + .stacked { + flex-direction: column; + max-width: 480px; + } + + .stacked > * { + flex: 0 1 auto; + } + + .stacked select { + margin-bottom: 0; + } + + .stacked .selector-available, .stacked .selector-chosen { + width: auto; + } + + .stacked ul.selector-chooser { + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto; + transform: none; + } + + .stacked .selector-chooser li { + padding: 3px; + } + + .stacked .selector-add, .stacked .selector-remove { + background-size: 20px auto; + } + + .stacked .selector-add { + background-position: 0 -40px; + } + + .stacked .active.selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -140px; + } + + .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -60px; + } + + .stacked .selector-remove { + background-position: 0 0; + } + + .stacked .active.selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -100px; + } + + .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -20px; + } + + .help-tooltip, .selector .help-icon { + display: none; + } + + .datetime input { + width: 50%; + max-width: 120px; + } + + .datetime span { + font-size: 0.8125rem; + } + + .datetime .timezonewarning { + display: block; + font-size: 0.6875rem; + color: var(--body-quiet-color); + } + + .datetimeshortcuts { + color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ + } + + .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + width: 75%; + } + + .inline-group { + overflow: auto; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 55px; + background-position: 30px 12px; + } + + ul.messagelist li.error { + background-position: 30px 12px; + } + + ul.messagelist li.warning { + background-position: 30px 14px; + } + + /* Login */ + + .login #header { + padding: 15px 20px; + } + + .login #branding h1 { + margin: 0; + } + + /* GIS */ + + div.olMap { + max-width: calc(100vw - 30px); + max-height: 300px; + } + + .olMap + .clear_features { + display: block; + margin-top: 10px; + } + + /* Docs */ + + .module table.xfull { + width: 100%; + } + + pre.literal-block { + overflow: auto; + } +} + +/* Mobile */ + +@media (max-width: 767px) { + /* Layout */ + + #header, #content, #footer { + padding: 15px; + } + + #footer:empty { + padding: 0; + } + + div.breadcrumbs { + padding: 10px 15px; + } + + /* Dashboard */ + + .colMS, .colSM { + margin: 0; + } + + #content-related, .colSM #content-related { + width: 100%; + margin: 0; + } + + #content-related .module { + margin-bottom: 0; + } + + #content-related .module h2 { + padding: 10px 15px; + font-size: 1rem; + } + + /* Changelist */ + + #changelist { + align-items: stretch; + flex-direction: column; + } + + #toolbar { + padding: 10px; + } + + #changelist-filter { + margin-left: 0; + } + + #changelist .actions label { + flex: 1 1; + } + + #changelist .actions select { + flex: 1 0; + width: 100%; + } + + #changelist .actions span { + flex: 1 0 100%; + } + + #changelist-filter { + position: static; + width: auto; + margin-top: 30px; + } + + .object-tools { + float: none; + margin: 0 0 15px; + padding: 0; + overflow: hidden; + } + + .object-tools li { + height: auto; + margin-left: 0; + } + + .object-tools li + li { + margin-left: 15px; + } + + /* Forms */ + + .form-row { + padding: 15px 0; + } + + .aligned .form-row, + .aligned .form-row > div { + max-width: 100vw; + } + + .aligned .form-row > div { + width: calc(100vw - 30px); + } + + .flex-container { + flex-flow: column; + } + + .flex-container.checkbox-row { + flex-flow: row; + } + + textarea { + max-width: none; + } + + .vURLField { + width: auto; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 15px; + padding-top: 15px; + } + + fieldset.collapsed .form-row { + display: none; + } + + .aligned label { + width: 100%; + min-width: auto; + padding: 0 0 10px; + } + + .aligned label:after { + max-height: 0; + } + + .aligned .form-row input, + .aligned .form-row select, + .aligned .form-row textarea { + flex: 1 1 auto; + max-width: 100%; + } + + .aligned .checkbox-row input { + flex: 0 1 auto; + margin: 0; + } + + .aligned .vCheckboxLabel { + flex: 1 0; + padding: 1px 0 0 5px; + } + + .aligned label + p, + .aligned label + div.help, + .aligned label + div.readonly { + padding: 0; + margin-left: 0; + } + + .aligned p.file-upload { + font-size: 0.8125rem; + } + + span.clearable-file-input { + margin-left: 15px; + } + + span.clearable-file-input label { + font-size: 0.8125rem; + padding-bottom: 0; + } + + .aligned .timezonewarning { + flex: 1 0 100%; + margin-top: 5px; + } + + form .aligned .form-row div.help { + width: 100%; + margin: 5px 0 0; + padding: 0; + } + + form .aligned ul, + form .aligned ul.errorlist { + margin-left: 0; + padding-left: 0; + } + + form .aligned div.radiolist { + margin-top: 5px; + margin-right: 15px; + margin-bottom: -3px; + } + + form .aligned div.radiolist:not(.inline) div + div { + margin-top: 5px; + } + + /* Related widget */ + + .related-widget-wrapper { + width: 100%; + display: flex; + align-items: flex-start; + } + + .related-widget-wrapper .selector { + order: 1; + } + + .related-widget-wrapper > a { + order: 2; + } + + .related-widget-wrapper .radiolist ~ a { + align-self: flex-end; + } + + .related-widget-wrapper > select ~ a { + align-self: center; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 15px; + } + + /* Selector */ + + .selector { + flex-direction: column; + } + + .selector > * { + float: none; + } + + .selector-available, .selector-chosen { + margin-bottom: 0; + flex: 1 1 auto; + } + + .selector select { + max-height: 96px; + } + + .selector ul.selector-chooser { + display: block; + float: none; + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto 20px; + transform: none; + } + + .selector ul.selector-chooser li { + float: left; + } + + .selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -20px; + } + + .selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -60px; + } + + /* Inlines */ + + .inline-group[data-inline-type="stacked"] .inline-related { + border: 1px solid var(--hairline-color); + border-radius: 4px; + margin-top: 15px; + overflow: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related > * { + box-sizing: border-box; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module { + padding: 0 10px; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { + border-top: 1px solid var(--hairline-color); + border-bottom: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { + border-top: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 { + padding: 10px; + border-top-width: 0; + border-bottom-width: 2px; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { + margin-right: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { + float: none; + flex: 1 1 100%; + margin-top: 5px; + } + + .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] .aligned label { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] div.add-row { + margin-top: 15px; + border: 1px solid var(--hairline-color); + border-radius: 4px; + } + + .inline-group div.add-row, + .inline-group .tabular tr.add-row td { + padding: 0; + } + + .inline-group div.add-row a, + .inline-group .tabular tr.add-row td a { + display: block; + padding: 8px 10px 8px 26px; + background-position: 8px 9px; + } + + /* Submit row */ + + .submit-row { + padding: 10px; + margin: 0 0 15px; + flex-direction: column; + gap: 8px; + } + + .submit-row input, .submit-row input.default, .submit-row a { + text-align: center; + } + + .submit-row a.closelink { + padding: 10px 0; + text-align: center; + } + + .submit-row a.deletelink { + margin: 0; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 40px; + background-position: 15px 12px; + } + + ul.messagelist li.error { + background-position: 15px 12px; + } + + ul.messagelist li.warning { + background-position: 15px 14px; + } + + /* Paginator */ + + .paginator .this-page, .paginator a:link, .paginator a:visited { + padding: 4px 10px; + } + + /* Login */ + + body.login { + padding: 0 15px; + } + + .login #container { + width: auto; + max-width: 480px; + margin: 50px auto; + } + + .login #header, + .login #content { + padding: 15px; + } + + .login #content-main { + float: none; + } + + .login .form-row { + padding: 0; + } + + .login .form-row + .form-row { + margin-top: 15px; + } + + .login .form-row label { + margin: 0 0 5px; + line-height: 1.2; + } + + .login .submit-row { + padding: 15px 0 0; + } + + .login br { + display: none; + } + + .login .submit-row input { + margin: 0; + text-transform: uppercase; + } + + .errornote { + margin: 0 0 20px; + padding: 8px 12px; + font-size: 0.8125rem; + } + + /* Calendar and clock */ + + .calendarbox, .clockbox { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); + margin: 0; + border: none; + overflow: visible; + } + + .calendarbox:before, .clockbox:before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + transform: translate(-50%, -50%); + } + + .calendarbox > *, .clockbox > * { + position: relative; + z-index: 1; + } + + .calendarbox > div:first-child { + z-index: 2; + } + + .calendarbox .calendar, .clockbox h2 { + border-radius: 4px 4px 0 0; + overflow: hidden; + } + + .calendarbox .calendar-cancel, .clockbox .calendar-cancel { + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + .calendar-shortcuts { + padding: 10px 0; + font-size: 0.75rem; + line-height: 0.75rem; + } + + .calendar-shortcuts a { + margin: 0 4px; + } + + .timelist a { + background: var(--body-bg); + padding: 4px; + } + + .calendar-cancel { + padding: 8px 10px; + } + + .clockbox h2 { + padding: 8px 15px; + } + + .calendar caption { + padding: 10px; + } + + .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + z-index: 1; + top: 10px; + } + + /* History */ + + table#change-history tbody th, table#change-history tbody td { + font-size: 0.8125rem; + word-break: break-word; + } + + table#change-history tbody th { + width: auto; + } + + /* Docs */ + + table.model tbody th, table.model tbody td { + font-size: 0.8125rem; + word-break: break-word; + } +} diff --git a/backend/staticfiles/admin/css/responsive.f6533dab034d.css.gz b/backend/staticfiles/admin/css/responsive.f6533dab034d.css.gz new file mode 100644 index 0000000..fe370fb Binary files /dev/null and b/backend/staticfiles/admin/css/responsive.f6533dab034d.css.gz differ diff --git a/backend/staticfiles/admin/css/responsive_rtl.7d1130848605.css b/backend/staticfiles/admin/css/responsive_rtl.7d1130848605.css new file mode 100644 index 0000000..31dc8ff --- /dev/null +++ b/backend/staticfiles/admin/css/responsive_rtl.7d1130848605.css @@ -0,0 +1,84 @@ +/* TABLETS */ + +@media (max-width: 1024px) { + [dir="rtl"] .colMS { + margin-right: 0; + } + + [dir="rtl"] #user-tools { + text-align: right; + } + + [dir="rtl"] #changelist .actions label { + padding-left: 10px; + padding-right: 0; + } + + [dir="rtl"] #changelist .actions select { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .change-list .filtered .results, + [dir="rtl"] .change-list .filtered .paginator, + [dir="rtl"] .filtered #toolbar, + [dir="rtl"] .filtered div.xfull, + [dir="rtl"] .filtered .actions, + [dir="rtl"] #changelist-filter { + margin-left: 0; + } + + [dir="rtl"] .inline-group ul.tools a.add, + [dir="rtl"] .inline-group div.add-row a, + [dir="rtl"] .inline-group .tabular tr.add-row td a { + padding: 8px 26px 8px 10px; + background-position: calc(100% - 8px) 9px; + } + + [dir="rtl"] .related-widget-wrapper-link + .selector { + margin-right: 0; + margin-left: 15px; + } + + [dir="rtl"] .selector .selector-filter label { + margin-right: 0; + margin-left: 8px; + } + + [dir="rtl"] .object-tools li { + float: right; + } + + [dir="rtl"] .object-tools li + li { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .dashboard .module table td a { + padding-left: 0; + padding-right: 16px; + } +} + +/* MOBILE */ + +@media (max-width: 767px) { + [dir="rtl"] .aligned .related-lookup, + [dir="rtl"] .aligned .datetimeshortcuts { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .aligned ul, + [dir="rtl"] form .aligned ul.errorlist { + margin-right: 0; + } + + [dir="rtl"] #changelist-filter { + margin-left: 0; + margin-right: 0; + } + [dir="rtl"] .aligned .vCheckboxLabel { + padding: 1px 5px 0 0; + } +} diff --git a/backend/staticfiles/admin/css/responsive_rtl.7d1130848605.css.gz b/backend/staticfiles/admin/css/responsive_rtl.7d1130848605.css.gz new file mode 100644 index 0000000..3e2886b Binary files /dev/null and b/backend/staticfiles/admin/css/responsive_rtl.7d1130848605.css.gz differ diff --git a/backend/staticfiles/admin/css/responsive_rtl.css b/backend/staticfiles/admin/css/responsive_rtl.css new file mode 100644 index 0000000..31dc8ff --- /dev/null +++ b/backend/staticfiles/admin/css/responsive_rtl.css @@ -0,0 +1,84 @@ +/* TABLETS */ + +@media (max-width: 1024px) { + [dir="rtl"] .colMS { + margin-right: 0; + } + + [dir="rtl"] #user-tools { + text-align: right; + } + + [dir="rtl"] #changelist .actions label { + padding-left: 10px; + padding-right: 0; + } + + [dir="rtl"] #changelist .actions select { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .change-list .filtered .results, + [dir="rtl"] .change-list .filtered .paginator, + [dir="rtl"] .filtered #toolbar, + [dir="rtl"] .filtered div.xfull, + [dir="rtl"] .filtered .actions, + [dir="rtl"] #changelist-filter { + margin-left: 0; + } + + [dir="rtl"] .inline-group ul.tools a.add, + [dir="rtl"] .inline-group div.add-row a, + [dir="rtl"] .inline-group .tabular tr.add-row td a { + padding: 8px 26px 8px 10px; + background-position: calc(100% - 8px) 9px; + } + + [dir="rtl"] .related-widget-wrapper-link + .selector { + margin-right: 0; + margin-left: 15px; + } + + [dir="rtl"] .selector .selector-filter label { + margin-right: 0; + margin-left: 8px; + } + + [dir="rtl"] .object-tools li { + float: right; + } + + [dir="rtl"] .object-tools li + li { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .dashboard .module table td a { + padding-left: 0; + padding-right: 16px; + } +} + +/* MOBILE */ + +@media (max-width: 767px) { + [dir="rtl"] .aligned .related-lookup, + [dir="rtl"] .aligned .datetimeshortcuts { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .aligned ul, + [dir="rtl"] form .aligned ul.errorlist { + margin-right: 0; + } + + [dir="rtl"] #changelist-filter { + margin-left: 0; + margin-right: 0; + } + [dir="rtl"] .aligned .vCheckboxLabel { + padding: 1px 5px 0 0; + } +} diff --git a/backend/staticfiles/admin/css/responsive_rtl.css.gz b/backend/staticfiles/admin/css/responsive_rtl.css.gz new file mode 100644 index 0000000..3e2886b Binary files /dev/null and b/backend/staticfiles/admin/css/responsive_rtl.css.gz differ diff --git a/backend/staticfiles/admin/css/rtl.512d4b53fc59.css b/backend/staticfiles/admin/css/rtl.512d4b53fc59.css new file mode 100644 index 0000000..d40e573 --- /dev/null +++ b/backend/staticfiles/admin/css/rtl.512d4b53fc59.css @@ -0,0 +1,298 @@ +/* GLOBAL */ + +th { + text-align: right; +} + +.module h2, .module caption { + text-align: right; +} + +.module ul, .module ol { + margin-left: 0; + margin-right: 1.5em; +} + +.viewlink, .addlink, .changelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.deletelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.object-tools { + float: left; +} + +thead th:first-child, +tfoot td:first-child { + border-left: none; +} + +/* LAYOUT */ + +#user-tools { + right: auto; + left: 0; + text-align: left; +} + +div.breadcrumbs { + text-align: right; +} + +#content-main { + float: right; +} + +#content-related { + float: left; + margin-left: -300px; + margin-right: auto; +} + +.colMS { + margin-left: 300px; + margin-right: 0; +} + +/* SORTABLE TABLES */ + +table thead th.sorted .sortoptions { + float: left; +} + +thead th.sorted .text { + padding-right: 0; + padding-left: 42px; +} + +/* dashboard styles */ + +.dashboard .module table td a { + padding-left: .6em; + padding-right: 16px; +} + +/* changelists styles */ + +.change-list .filtered table { + border-left: none; + border-right: 0px none; +} + +#changelist-filter { + border-left: none; + border-right: none; + margin-left: 0; + margin-right: 30px; +} + +#changelist-filter li.selected { + border-left: none; + padding-left: 10px; + margin-left: 0; + border-right: 5px solid var(--hairline-color); + padding-right: 10px; + margin-right: -15px; +} + +#changelist table tbody td:first-child, #changelist table tbody th:first-child { + border-right: none; + border-left: none; +} + +.paginator .end { + margin-left: 6px; + margin-right: 0; +} + +.paginator input { + margin-left: 0; + margin-right: auto; +} + +/* FORMS */ + +.aligned label { + padding: 0 0 3px 1em; +} + +.submit-row a.deletelink { + margin-left: 0; + margin-right: auto; +} + +.vDateField, .vTimeField { + margin-left: 2px; +} + +.aligned .form-row input { + margin-left: 5px; +} + +form .aligned ul { + margin-right: 163px; + padding-right: 10px; + margin-left: 0; + padding-left: 0; +} + +form ul.inline li { + float: right; + padding-right: 0; + padding-left: 7px; +} + +form .aligned p.help, +form .aligned div.help { + margin-right: 160px; + padding-right: 10px; +} + +form div.help ul, +form .aligned .checkbox-row + .help, +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-right: 0; + padding-right: 0; +} + +form .wide p.help, form .wide div.help { + padding-left: 0; + padding-right: 50px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-right: 200px; + margin-left: 0px; +} + +.submit-row { + text-align: right; +} + +fieldset .fieldBox { + margin-left: 20px; + margin-right: 0; +} + +.errorlist li { + background-position: 100% 12px; + padding: 0; +} + +.errornote { + background-position: 100% 12px; + padding: 10px 12px; +} + +/* WIDGETS */ + +.calendarnav-previous { + top: 0; + left: auto; + right: 10px; + background: url("../img/calendar-icons.39b290681a8b.svg") 0 -30px no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -45px; +} + +.calendarnav-next { + top: 0; + right: auto; + left: 10px; + background: url("../img/calendar-icons.39b290681a8b.svg") 0 0 no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -15px; +} + +.calendar caption, .calendarbox h2 { + text-align: center; +} + +.selector { + float: right; +} + +.selector .selector-filter { + text-align: right; +} + +.selector-add { + background: url("../img/selector-icons.b4555096cea2.svg") 0 -64px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -80px; +} + +.selector-remove { + background: url("../img/selector-icons.b4555096cea2.svg") 0 -96px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -112px; +} + +a.selector-chooseall { + background: url("../img/selector-icons.b4555096cea2.svg") right -128px no-repeat; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -144px; +} + +a.selector-clearall { + background: url("../img/selector-icons.b4555096cea2.svg") 0 -160px no-repeat; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -176px; +} + +.inline-deletelink { + float: left; +} + +form .form-row p.datetime { + overflow: hidden; +} + +.related-widget-wrapper { + float: right; +} + +/* MISC */ + +.inline-related h2, .inline-group h2 { + text-align: right +} + +.inline-related h3 span.delete { + padding-right: 20px; + padding-left: inherit; + left: 10px; + right: inherit; + float:left; +} + +.inline-related h3 span.delete label { + margin-left: inherit; + margin-right: 2px; +} diff --git a/backend/staticfiles/admin/css/rtl.512d4b53fc59.css.gz b/backend/staticfiles/admin/css/rtl.512d4b53fc59.css.gz new file mode 100644 index 0000000..f2503b9 Binary files /dev/null and b/backend/staticfiles/admin/css/rtl.512d4b53fc59.css.gz differ diff --git a/backend/staticfiles/admin/css/rtl.css b/backend/staticfiles/admin/css/rtl.css new file mode 100644 index 0000000..c349a93 --- /dev/null +++ b/backend/staticfiles/admin/css/rtl.css @@ -0,0 +1,298 @@ +/* GLOBAL */ + +th { + text-align: right; +} + +.module h2, .module caption { + text-align: right; +} + +.module ul, .module ol { + margin-left: 0; + margin-right: 1.5em; +} + +.viewlink, .addlink, .changelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.deletelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.object-tools { + float: left; +} + +thead th:first-child, +tfoot td:first-child { + border-left: none; +} + +/* LAYOUT */ + +#user-tools { + right: auto; + left: 0; + text-align: left; +} + +div.breadcrumbs { + text-align: right; +} + +#content-main { + float: right; +} + +#content-related { + float: left; + margin-left: -300px; + margin-right: auto; +} + +.colMS { + margin-left: 300px; + margin-right: 0; +} + +/* SORTABLE TABLES */ + +table thead th.sorted .sortoptions { + float: left; +} + +thead th.sorted .text { + padding-right: 0; + padding-left: 42px; +} + +/* dashboard styles */ + +.dashboard .module table td a { + padding-left: .6em; + padding-right: 16px; +} + +/* changelists styles */ + +.change-list .filtered table { + border-left: none; + border-right: 0px none; +} + +#changelist-filter { + border-left: none; + border-right: none; + margin-left: 0; + margin-right: 30px; +} + +#changelist-filter li.selected { + border-left: none; + padding-left: 10px; + margin-left: 0; + border-right: 5px solid var(--hairline-color); + padding-right: 10px; + margin-right: -15px; +} + +#changelist table tbody td:first-child, #changelist table tbody th:first-child { + border-right: none; + border-left: none; +} + +.paginator .end { + margin-left: 6px; + margin-right: 0; +} + +.paginator input { + margin-left: 0; + margin-right: auto; +} + +/* FORMS */ + +.aligned label { + padding: 0 0 3px 1em; +} + +.submit-row a.deletelink { + margin-left: 0; + margin-right: auto; +} + +.vDateField, .vTimeField { + margin-left: 2px; +} + +.aligned .form-row input { + margin-left: 5px; +} + +form .aligned ul { + margin-right: 163px; + padding-right: 10px; + margin-left: 0; + padding-left: 0; +} + +form ul.inline li { + float: right; + padding-right: 0; + padding-left: 7px; +} + +form .aligned p.help, +form .aligned div.help { + margin-right: 160px; + padding-right: 10px; +} + +form div.help ul, +form .aligned .checkbox-row + .help, +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-right: 0; + padding-right: 0; +} + +form .wide p.help, form .wide div.help { + padding-left: 0; + padding-right: 50px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-right: 200px; + margin-left: 0px; +} + +.submit-row { + text-align: right; +} + +fieldset .fieldBox { + margin-left: 20px; + margin-right: 0; +} + +.errorlist li { + background-position: 100% 12px; + padding: 0; +} + +.errornote { + background-position: 100% 12px; + padding: 10px 12px; +} + +/* WIDGETS */ + +.calendarnav-previous { + top: 0; + left: auto; + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -45px; +} + +.calendarnav-next { + top: 0; + right: auto; + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -15px; +} + +.calendar caption, .calendarbox h2 { + text-align: center; +} + +.selector { + float: right; +} + +.selector .selector-filter { + text-align: right; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -80px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -112px; +} + +a.selector-chooseall { + background: url(../img/selector-icons.svg) right -128px no-repeat; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -144px; +} + +a.selector-clearall { + background: url(../img/selector-icons.svg) 0 -160px no-repeat; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -176px; +} + +.inline-deletelink { + float: left; +} + +form .form-row p.datetime { + overflow: hidden; +} + +.related-widget-wrapper { + float: right; +} + +/* MISC */ + +.inline-related h2, .inline-group h2 { + text-align: right +} + +.inline-related h3 span.delete { + padding-right: 20px; + padding-left: inherit; + left: 10px; + right: inherit; + float:left; +} + +.inline-related h3 span.delete label { + margin-left: inherit; + margin-right: 2px; +} diff --git a/backend/staticfiles/admin/css/rtl.css.gz b/backend/staticfiles/admin/css/rtl.css.gz new file mode 100644 index 0000000..5aa1049 Binary files /dev/null and b/backend/staticfiles/admin/css/rtl.css.gz differ diff --git a/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.f94142512c91.md b/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.f94142512c91.md new file mode 100644 index 0000000..8cb8a2b --- /dev/null +++ b/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.f94142512c91.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.f94142512c91.md.gz b/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.f94142512c91.md.gz new file mode 100644 index 0000000..1b83921 Binary files /dev/null and b/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.f94142512c91.md.gz differ diff --git a/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md b/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md new file mode 100644 index 0000000..8cb8a2b --- /dev/null +++ b/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md.gz b/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md.gz new file mode 100644 index 0000000..1b83921 Binary files /dev/null and b/backend/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md.gz differ diff --git a/backend/staticfiles/admin/css/vendor/select2/select2.a2194c262648.css b/backend/staticfiles/admin/css/vendor/select2/select2.a2194c262648.css new file mode 100644 index 0000000..750b320 --- /dev/null +++ b/backend/staticfiles/admin/css/vendor/select2/select2.a2194c262648.css @@ -0,0 +1,481 @@ +.select2-container { + box-sizing: border-box; + display: inline-block; + margin: 0; + position: relative; + vertical-align: middle; } + .select2-container .select2-selection--single { + box-sizing: border-box; + cursor: pointer; + display: block; + height: 28px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--single .select2-selection__rendered { + display: block; + padding-left: 8px; + padding-right: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-selection--single .select2-selection__clear { + position: relative; } + .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { + padding-right: 8px; + padding-left: 20px; } + .select2-container .select2-selection--multiple { + box-sizing: border-box; + cursor: pointer; + display: block; + min-height: 32px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--multiple .select2-selection__rendered { + display: inline-block; + overflow: hidden; + padding-left: 8px; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-search--inline { + float: left; } + .select2-container .select2-search--inline .select2-search__field { + box-sizing: border-box; + border: none; + font-size: 100%; + margin-top: 5px; + padding: 0; } + .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + +.select2-dropdown { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + box-sizing: border-box; + display: block; + position: absolute; + left: -100000px; + width: 100%; + z-index: 1051; } + +.select2-results { + display: block; } + +.select2-results__options { + list-style: none; + margin: 0; + padding: 0; } + +.select2-results__option { + padding: 6px; + user-select: none; + -webkit-user-select: none; } + .select2-results__option[aria-selected] { + cursor: pointer; } + +.select2-container--open .select2-dropdown { + left: 0; } + +.select2-container--open .select2-dropdown--above { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--open .select2-dropdown--below { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-search--dropdown { + display: block; + padding: 4px; } + .select2-search--dropdown .select2-search__field { + padding: 4px; + width: 100%; + box-sizing: border-box; } + .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + .select2-search--dropdown.select2-search--hide { + display: none; } + +.select2-close-mask { + border: 0; + margin: 0; + padding: 0; + display: block; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 99; + background-color: #fff; + filter: alpha(opacity=0); } + +.select2-hidden-accessible { + border: 0 !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; } + +.select2-container--default .select2-selection--single { + background-color: #fff; + border: 1px solid #aaa; + border-radius: 4px; } + .select2-container--default .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--default .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; } + .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; } + .select2-container--default .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; } + +.select2-container--default.select2-container--disabled .select2-selection--single { + background-color: #eee; + cursor: default; } + .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; } + +.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--default .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 5px; + width: 100%; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered li { + list-style: none; } + .select2-container--default .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-top: 5px; + margin-right: 10px; + padding: 1px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: #999; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #333; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--default.select2-container--focus .select2-selection--multiple { + border: solid black 1px; + outline: 0; } + +.select2-container--default.select2-container--disabled .select2-selection--multiple { + background-color: #eee; + cursor: default; } + +.select2-container--default.select2-container--disabled .select2-selection__choice__remove { + display: none; } + +.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; } + +.select2-container--default .select2-search--inline .select2-search__field { + background: transparent; + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; } + +.select2-container--default .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--default .select2-results__option[role=group] { + padding: 0; } + +.select2-container--default .select2-results__option[aria-disabled=true] { + color: #999; } + +.select2-container--default .select2-results__option[aria-selected=true] { + background-color: #ddd; } + +.select2-container--default .select2-results__option .select2-results__option { + padding-left: 1em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; } + +.select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: #5897fb; + color: white; } + +.select2-container--default .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic .select2-selection--single { + background-color: #f7f7f7; + border: 1px solid #aaa; + border-radius: 4px; + outline: 0; + background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + .select2-container--classic .select2-selection--single:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--classic .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-right: 10px; } + .select2-container--classic .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--classic .select2-selection--single .select2-selection__arrow { + background-color: #ddd; + border: none; + border-left: 1px solid #aaa; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); } + .select2-container--classic .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow { + border: none; + border-right: 1px solid #aaa; + border-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + left: 1px; + right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--single { + border: 1px solid #5897fb; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow { + background: transparent; + border: none; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); } + +.select2-container--classic .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; + outline: 0; } + .select2-container--classic .select2-selection--multiple:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--multiple .select2-selection__rendered { + list-style: none; + margin: 0; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__clear { + display: none; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove { + color: #888; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #555; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + float: right; + margin-left: 5px; + margin-right: auto; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--multiple { + border: 1px solid #5897fb; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--classic .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; + outline: 0; } + +.select2-container--classic .select2-search--inline .select2-search__field { + outline: 0; + box-shadow: none; } + +.select2-container--classic .select2-dropdown { + background-color: white; + border: 1px solid transparent; } + +.select2-container--classic .select2-dropdown--above { + border-bottom: none; } + +.select2-container--classic .select2-dropdown--below { + border-top: none; } + +.select2-container--classic .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--classic .select2-results__option[role=group] { + padding: 0; } + +.select2-container--classic .select2-results__option[aria-disabled=true] { + color: grey; } + +.select2-container--classic .select2-results__option--highlighted[aria-selected] { + background-color: #3875d7; + color: white; } + +.select2-container--classic .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic.select2-container--open .select2-dropdown { + border-color: #5897fb; } diff --git a/backend/staticfiles/admin/css/vendor/select2/select2.a2194c262648.css.gz b/backend/staticfiles/admin/css/vendor/select2/select2.a2194c262648.css.gz new file mode 100644 index 0000000..eb81702 Binary files /dev/null and b/backend/staticfiles/admin/css/vendor/select2/select2.a2194c262648.css.gz differ diff --git a/backend/staticfiles/admin/css/vendor/select2/select2.css b/backend/staticfiles/admin/css/vendor/select2/select2.css new file mode 100644 index 0000000..750b320 --- /dev/null +++ b/backend/staticfiles/admin/css/vendor/select2/select2.css @@ -0,0 +1,481 @@ +.select2-container { + box-sizing: border-box; + display: inline-block; + margin: 0; + position: relative; + vertical-align: middle; } + .select2-container .select2-selection--single { + box-sizing: border-box; + cursor: pointer; + display: block; + height: 28px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--single .select2-selection__rendered { + display: block; + padding-left: 8px; + padding-right: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-selection--single .select2-selection__clear { + position: relative; } + .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { + padding-right: 8px; + padding-left: 20px; } + .select2-container .select2-selection--multiple { + box-sizing: border-box; + cursor: pointer; + display: block; + min-height: 32px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--multiple .select2-selection__rendered { + display: inline-block; + overflow: hidden; + padding-left: 8px; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-search--inline { + float: left; } + .select2-container .select2-search--inline .select2-search__field { + box-sizing: border-box; + border: none; + font-size: 100%; + margin-top: 5px; + padding: 0; } + .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + +.select2-dropdown { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + box-sizing: border-box; + display: block; + position: absolute; + left: -100000px; + width: 100%; + z-index: 1051; } + +.select2-results { + display: block; } + +.select2-results__options { + list-style: none; + margin: 0; + padding: 0; } + +.select2-results__option { + padding: 6px; + user-select: none; + -webkit-user-select: none; } + .select2-results__option[aria-selected] { + cursor: pointer; } + +.select2-container--open .select2-dropdown { + left: 0; } + +.select2-container--open .select2-dropdown--above { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--open .select2-dropdown--below { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-search--dropdown { + display: block; + padding: 4px; } + .select2-search--dropdown .select2-search__field { + padding: 4px; + width: 100%; + box-sizing: border-box; } + .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + .select2-search--dropdown.select2-search--hide { + display: none; } + +.select2-close-mask { + border: 0; + margin: 0; + padding: 0; + display: block; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 99; + background-color: #fff; + filter: alpha(opacity=0); } + +.select2-hidden-accessible { + border: 0 !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; } + +.select2-container--default .select2-selection--single { + background-color: #fff; + border: 1px solid #aaa; + border-radius: 4px; } + .select2-container--default .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--default .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; } + .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; } + .select2-container--default .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; } + +.select2-container--default.select2-container--disabled .select2-selection--single { + background-color: #eee; + cursor: default; } + .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; } + +.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--default .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 5px; + width: 100%; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered li { + list-style: none; } + .select2-container--default .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-top: 5px; + margin-right: 10px; + padding: 1px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: #999; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #333; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--default.select2-container--focus .select2-selection--multiple { + border: solid black 1px; + outline: 0; } + +.select2-container--default.select2-container--disabled .select2-selection--multiple { + background-color: #eee; + cursor: default; } + +.select2-container--default.select2-container--disabled .select2-selection__choice__remove { + display: none; } + +.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; } + +.select2-container--default .select2-search--inline .select2-search__field { + background: transparent; + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; } + +.select2-container--default .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--default .select2-results__option[role=group] { + padding: 0; } + +.select2-container--default .select2-results__option[aria-disabled=true] { + color: #999; } + +.select2-container--default .select2-results__option[aria-selected=true] { + background-color: #ddd; } + +.select2-container--default .select2-results__option .select2-results__option { + padding-left: 1em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; } + +.select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: #5897fb; + color: white; } + +.select2-container--default .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic .select2-selection--single { + background-color: #f7f7f7; + border: 1px solid #aaa; + border-radius: 4px; + outline: 0; + background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + .select2-container--classic .select2-selection--single:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--classic .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-right: 10px; } + .select2-container--classic .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--classic .select2-selection--single .select2-selection__arrow { + background-color: #ddd; + border: none; + border-left: 1px solid #aaa; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); } + .select2-container--classic .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow { + border: none; + border-right: 1px solid #aaa; + border-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + left: 1px; + right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--single { + border: 1px solid #5897fb; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow { + background: transparent; + border: none; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); } + +.select2-container--classic .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; + outline: 0; } + .select2-container--classic .select2-selection--multiple:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--multiple .select2-selection__rendered { + list-style: none; + margin: 0; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__clear { + display: none; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove { + color: #888; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #555; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + float: right; + margin-left: 5px; + margin-right: auto; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--multiple { + border: 1px solid #5897fb; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--classic .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; + outline: 0; } + +.select2-container--classic .select2-search--inline .select2-search__field { + outline: 0; + box-shadow: none; } + +.select2-container--classic .select2-dropdown { + background-color: white; + border: 1px solid transparent; } + +.select2-container--classic .select2-dropdown--above { + border-bottom: none; } + +.select2-container--classic .select2-dropdown--below { + border-top: none; } + +.select2-container--classic .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--classic .select2-results__option[role=group] { + padding: 0; } + +.select2-container--classic .select2-results__option[aria-disabled=true] { + color: grey; } + +.select2-container--classic .select2-results__option--highlighted[aria-selected] { + background-color: #3875d7; + color: white; } + +.select2-container--classic .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic.select2-container--open .select2-dropdown { + border-color: #5897fb; } diff --git a/backend/staticfiles/admin/css/vendor/select2/select2.css.gz b/backend/staticfiles/admin/css/vendor/select2/select2.css.gz new file mode 100644 index 0000000..eb81702 Binary files /dev/null and b/backend/staticfiles/admin/css/vendor/select2/select2.css.gz differ diff --git a/backend/staticfiles/admin/css/vendor/select2/select2.min.9f54e6414f87.css b/backend/staticfiles/admin/css/vendor/select2/select2.min.9f54e6414f87.css new file mode 100644 index 0000000..7c18ad5 --- /dev/null +++ b/backend/staticfiles/admin/css/vendor/select2/select2.min.9f54e6414f87.css @@ -0,0 +1 @@ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/backend/staticfiles/admin/css/vendor/select2/select2.min.9f54e6414f87.css.gz b/backend/staticfiles/admin/css/vendor/select2/select2.min.9f54e6414f87.css.gz new file mode 100644 index 0000000..a2861a0 Binary files /dev/null and b/backend/staticfiles/admin/css/vendor/select2/select2.min.9f54e6414f87.css.gz differ diff --git a/backend/staticfiles/admin/css/vendor/select2/select2.min.css b/backend/staticfiles/admin/css/vendor/select2/select2.min.css new file mode 100644 index 0000000..7c18ad5 --- /dev/null +++ b/backend/staticfiles/admin/css/vendor/select2/select2.min.css @@ -0,0 +1 @@ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/backend/staticfiles/admin/css/vendor/select2/select2.min.css.gz b/backend/staticfiles/admin/css/vendor/select2/select2.min.css.gz new file mode 100644 index 0000000..a2861a0 Binary files /dev/null and b/backend/staticfiles/admin/css/vendor/select2/select2.min.css.gz differ diff --git a/backend/staticfiles/admin/css/widgets.css b/backend/staticfiles/admin/css/widgets.css new file mode 100644 index 0000000..1104e8b --- /dev/null +++ b/backend/staticfiles/admin/css/widgets.css @@ -0,0 +1,604 @@ +/* SELECTOR (FILTER INTERFACE) */ + +.selector { + width: 800px; + float: left; + display: flex; +} + +.selector select { + width: 380px; + height: 17.2em; + flex: 1 0 auto; +} + +.selector-available, .selector-chosen { + width: 380px; + text-align: center; + margin-bottom: 5px; + display: flex; + flex-direction: column; +} + +.selector-available h2, .selector-chosen h2 { + border: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; +} + +.selector-chosen .list-footer-display { + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0 0 10px; + padding: 8px; + text-align: center; + background: var(--primary); + color: var(--header-link-color); + cursor: pointer; +} +.selector-chosen .list-footer-display__clear { + color: var(--breadcrumbs-fg); +} + +.selector-chosen h2 { + background: var(--primary); + color: var(--header-link-color); +} + +.selector .selector-available h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +.selector .selector-filter { + border: 1px solid var(--border-color); + border-width: 0 1px; + padding: 8px; + color: var(--body-quiet-color); + font-size: 0.625rem; + margin: 0; + text-align: left; +} + +.selector .selector-filter label, +.inline-group .aligned .selector .selector-filter label { + float: left; + margin: 7px 0 0; + width: 18px; + height: 18px; + padding: 0; + overflow: hidden; + line-height: 1; + min-width: auto; +} + +.selector .selector-available input, +.selector .selector-chosen input { + width: 320px; + margin-left: 8px; +} + +.selector ul.selector-chooser { + align-self: center; + width: 22px; + background-color: var(--selected-bg); + border-radius: 10px; + margin: 0 5px; + padding: 0; + transform: translateY(-17px); +} + +.selector-chooser li { + margin: 0; + padding: 3px; + list-style-type: none; +} + +.selector select { + padding: 0 10px; + margin: 0 0 10px; + border-radius: 0 0 4px 4px; +} +.selector .selector-chosen--with-filtered select { + margin: 0; + border-radius: 0; + height: 14em; +} + +.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { + display: none; +} + +.selector-add, .selector-remove { + width: 16px; + height: 16px; + display: block; + text-indent: -3000px; + overflow: hidden; + cursor: default; + opacity: 0.55; +} + +.active.selector-add, .active.selector-remove { + opacity: 1; +} + +.active.selector-add:hover, .active.selector-remove:hover { + cursor: pointer; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -112px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -80px; +} + +a.selector-chooseall, a.selector-clearall { + display: inline-block; + height: 16px; + text-align: left; + margin: 1px auto 3px; + overflow: hidden; + font-weight: bold; + line-height: 16px; + color: var(--body-quiet-color); + text-decoration: none; + opacity: 0.55; +} + +a.active.selector-chooseall:focus, a.active.selector-clearall:focus, +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + color: var(--link-fg); +} + +a.active.selector-chooseall, a.active.selector-clearall { + opacity: 1; +} + +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + cursor: pointer; +} + +a.selector-chooseall { + padding: 0 18px 0 0; + background: url(../img/selector-icons.svg) right -160px no-repeat; + cursor: default; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -176px; +} + +a.selector-clearall { + padding: 0 0 0 18px; + background: url(../img/selector-icons.svg) 0 -128px no-repeat; + cursor: default; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -144px; +} + +/* STACKED SELECTORS */ + +.stacked { + float: left; + width: 490px; + display: block; +} + +.stacked select { + width: 480px; + height: 10.1em; +} + +.stacked .selector-available, .stacked .selector-chosen { + width: 480px; +} + +.stacked .selector-available { + margin-bottom: 0; +} + +.stacked .selector-available input { + width: 422px; +} + +.stacked ul.selector-chooser { + height: 22px; + width: 50px; + margin: 0 0 10px 40%; + background-color: #eee; + border-radius: 10px; + transform: none; +} + +.stacked .selector-chooser li { + float: left; + padding: 3px 3px 3px 5px; +} + +.stacked .selector-chooseall, .stacked .selector-clearall { + display: none; +} + +.stacked .selector-add { + background: url(../img/selector-icons.svg) 0 -32px no-repeat; + cursor: default; +} + +.stacked .active.selector-add { + background-position: 0 -32px; + cursor: pointer; +} + +.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -48px; + cursor: pointer; +} + +.stacked .selector-remove { + background: url(../img/selector-icons.svg) 0 0 no-repeat; + cursor: default; +} + +.stacked .active.selector-remove { + background-position: 0 0px; + cursor: pointer; +} + +.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -16px; + cursor: pointer; +} + +.selector .help-icon { + background: url(../img/icon-unknown.svg) 0 0 no-repeat; + display: inline-block; + vertical-align: middle; + margin: -2px 0 0 2px; + width: 13px; + height: 13px; +} + +.selector .selector-chosen .help-icon { + background: url(../img/icon-unknown-alt.svg) 0 0 no-repeat; +} + +.selector .search-label-icon { + background: url(../img/search.svg) 0 0 no-repeat; + display: inline-block; + height: 1.125rem; + width: 1.125rem; +} + +/* DATE AND TIME */ + +p.datetime { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +.datetime span { + white-space: nowrap; + font-weight: normal; + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + margin-left: 5px; + margin-bottom: 4px; +} + +table p.datetime { + font-size: 0.6875rem; + margin-left: 0; + padding-left: 0; +} + +.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon { + position: relative; + display: inline-block; + vertical-align: middle; + height: 16px; + width: 16px; + overflow: hidden; +} + +.datetimeshortcuts .clock-icon { + background: url(../img/icon-clock.svg) 0 0 no-repeat; +} + +.datetimeshortcuts a:focus .clock-icon, +.datetimeshortcuts a:hover .clock-icon { + background-position: 0 -16px; +} + +.datetimeshortcuts .date-icon { + background: url(../img/icon-calendar.svg) 0 0 no-repeat; + top: -1px; +} + +.datetimeshortcuts a:focus .date-icon, +.datetimeshortcuts a:hover .date-icon { + background-position: 0 -16px; +} + +.timezonewarning { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +/* URL */ + +p.url { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.url a { + font-weight: normal; +} + +/* FILE UPLOADS */ + +p.file-upload { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.file-upload a { + font-weight: normal; +} + +.file-upload .deletelink { + margin-left: 5px; +} + +span.clearable-file-input label { + color: var(--body-fg); + font-size: 0.6875rem; + display: inline; + float: none; +} + +/* CALENDARS & CLOCKS */ + +.calendarbox, .clockbox { + margin: 5px auto; + font-size: 0.75rem; + width: 19em; + text-align: center; + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + overflow: hidden; + position: relative; +} + +.clockbox { + width: auto; +} + +.calendar { + margin: 0; + padding: 0; +} + +.calendar table { + margin: 0; + padding: 0; + border-collapse: collapse; + background: white; + width: 100%; +} + +.calendar caption, .calendarbox h2 { + margin: 0; + text-align: center; + border-top: none; + font-weight: 700; + font-size: 0.75rem; + color: #333; + background: var(--accent); +} + +.calendar th { + padding: 8px 5px; + background: var(--darkened-bg); + border-bottom: 1px solid var(--border-color); + font-weight: 400; + font-size: 0.75rem; + text-align: center; + color: var(--body-quiet-color); +} + +.calendar td { + font-weight: 400; + font-size: 0.75rem; + text-align: center; + padding: 0; + border-top: 1px solid var(--hairline-color); + border-bottom: none; +} + +.calendar td.selected a { + background: var(--primary); + color: var(--button-fg); +} + +.calendar td.nonday { + background: var(--darkened-bg); +} + +.calendar td.today a { + font-weight: 700; +} + +.calendar td a, .timelist a { + display: block; + font-weight: 400; + padding: 6px; + text-decoration: none; + color: var(--body-quiet-color); +} + +.calendar td a:focus, .timelist a:focus, +.calendar td a:hover, .timelist a:hover { + background: var(--primary); + color: white; +} + +.calendar td a:active, .timelist a:active { + background: var(--header-bg); + color: white; +} + +.calendarnav { + font-size: 0.625rem; + text-align: center; + color: #ccc; + margin: 0; + padding: 1px 3px; +} + +.calendarnav a:link, #calendarnav a:visited, +#calendarnav a:focus, #calendarnav a:hover { + color: var(--body-quiet-color); +} + +.calendar-shortcuts { + background: var(--body-bg); + color: var(--body-quiet-color); + font-size: 0.6875rem; + line-height: 0.6875rem; + border-top: 1px solid var(--hairline-color); + padding: 8px 0; +} + +.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + display: block; + position: absolute; + top: 8px; + width: 15px; + height: 15px; + text-indent: -9999px; + padding: 0; +} + +.calendarnav-previous { + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -15px; +} + +.calendarnav-next { + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -45px; +} + +.calendar-cancel { + margin: 0; + padding: 4px 0; + font-size: 0.75rem; + background: #eee; + border-top: 1px solid var(--border-color); + color: var(--body-fg); +} + +.calendar-cancel:focus, .calendar-cancel:hover { + background: #ddd; +} + +.calendar-cancel a { + color: black; + display: block; +} + +ul.timelist, .timelist li { + list-style-type: none; + margin: 0; + padding: 0; +} + +.timelist a { + padding: 2px; +} + +/* EDIT INLINE */ + +.inline-deletelink { + float: right; + text-indent: -9999px; + background: url(../img/inline-delete.svg) 0 0 no-repeat; + width: 16px; + height: 16px; + border: 0px none; +} + +.inline-deletelink:focus, .inline-deletelink:hover { + cursor: pointer; +} + +/* RELATED WIDGET WRAPPER */ +.related-widget-wrapper { + float: left; /* display properly in form rows with multiple fields */ + overflow: hidden; /* clear floated contents */ +} + +.related-widget-wrapper-link { + opacity: 0.3; +} + +.related-widget-wrapper-link:link { + opacity: .8; +} + +.related-widget-wrapper-link:link:focus, +.related-widget-wrapper-link:link:hover { + opacity: 1; +} + +select + .related-widget-wrapper-link, +.related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 7px; +} + +/* GIS MAPS */ +.dj_map { + width: 600px; + height: 400px; +} diff --git a/backend/staticfiles/admin/css/widgets.css.gz b/backend/staticfiles/admin/css/widgets.css.gz new file mode 100644 index 0000000..1e77c39 Binary files /dev/null and b/backend/staticfiles/admin/css/widgets.css.gz differ diff --git a/backend/staticfiles/admin/css/widgets.ee33ab26c7c2.css b/backend/staticfiles/admin/css/widgets.ee33ab26c7c2.css new file mode 100644 index 0000000..5694766 --- /dev/null +++ b/backend/staticfiles/admin/css/widgets.ee33ab26c7c2.css @@ -0,0 +1,604 @@ +/* SELECTOR (FILTER INTERFACE) */ + +.selector { + width: 800px; + float: left; + display: flex; +} + +.selector select { + width: 380px; + height: 17.2em; + flex: 1 0 auto; +} + +.selector-available, .selector-chosen { + width: 380px; + text-align: center; + margin-bottom: 5px; + display: flex; + flex-direction: column; +} + +.selector-available h2, .selector-chosen h2 { + border: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; +} + +.selector-chosen .list-footer-display { + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0 0 10px; + padding: 8px; + text-align: center; + background: var(--primary); + color: var(--header-link-color); + cursor: pointer; +} +.selector-chosen .list-footer-display__clear { + color: var(--breadcrumbs-fg); +} + +.selector-chosen h2 { + background: var(--primary); + color: var(--header-link-color); +} + +.selector .selector-available h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +.selector .selector-filter { + border: 1px solid var(--border-color); + border-width: 0 1px; + padding: 8px; + color: var(--body-quiet-color); + font-size: 0.625rem; + margin: 0; + text-align: left; +} + +.selector .selector-filter label, +.inline-group .aligned .selector .selector-filter label { + float: left; + margin: 7px 0 0; + width: 18px; + height: 18px; + padding: 0; + overflow: hidden; + line-height: 1; + min-width: auto; +} + +.selector .selector-available input, +.selector .selector-chosen input { + width: 320px; + margin-left: 8px; +} + +.selector ul.selector-chooser { + align-self: center; + width: 22px; + background-color: var(--selected-bg); + border-radius: 10px; + margin: 0 5px; + padding: 0; + transform: translateY(-17px); +} + +.selector-chooser li { + margin: 0; + padding: 3px; + list-style-type: none; +} + +.selector select { + padding: 0 10px; + margin: 0 0 10px; + border-radius: 0 0 4px 4px; +} +.selector .selector-chosen--with-filtered select { + margin: 0; + border-radius: 0; + height: 14em; +} + +.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { + display: none; +} + +.selector-add, .selector-remove { + width: 16px; + height: 16px; + display: block; + text-indent: -3000px; + overflow: hidden; + cursor: default; + opacity: 0.55; +} + +.active.selector-add, .active.selector-remove { + opacity: 1; +} + +.active.selector-add:hover, .active.selector-remove:hover { + cursor: pointer; +} + +.selector-add { + background: url("../img/selector-icons.b4555096cea2.svg") 0 -96px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -112px; +} + +.selector-remove { + background: url("../img/selector-icons.b4555096cea2.svg") 0 -64px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -80px; +} + +a.selector-chooseall, a.selector-clearall { + display: inline-block; + height: 16px; + text-align: left; + margin: 1px auto 3px; + overflow: hidden; + font-weight: bold; + line-height: 16px; + color: var(--body-quiet-color); + text-decoration: none; + opacity: 0.55; +} + +a.active.selector-chooseall:focus, a.active.selector-clearall:focus, +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + color: var(--link-fg); +} + +a.active.selector-chooseall, a.active.selector-clearall { + opacity: 1; +} + +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + cursor: pointer; +} + +a.selector-chooseall { + padding: 0 18px 0 0; + background: url("../img/selector-icons.b4555096cea2.svg") right -160px no-repeat; + cursor: default; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -176px; +} + +a.selector-clearall { + padding: 0 0 0 18px; + background: url("../img/selector-icons.b4555096cea2.svg") 0 -128px no-repeat; + cursor: default; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -144px; +} + +/* STACKED SELECTORS */ + +.stacked { + float: left; + width: 490px; + display: block; +} + +.stacked select { + width: 480px; + height: 10.1em; +} + +.stacked .selector-available, .stacked .selector-chosen { + width: 480px; +} + +.stacked .selector-available { + margin-bottom: 0; +} + +.stacked .selector-available input { + width: 422px; +} + +.stacked ul.selector-chooser { + height: 22px; + width: 50px; + margin: 0 0 10px 40%; + background-color: #eee; + border-radius: 10px; + transform: none; +} + +.stacked .selector-chooser li { + float: left; + padding: 3px 3px 3px 5px; +} + +.stacked .selector-chooseall, .stacked .selector-clearall { + display: none; +} + +.stacked .selector-add { + background: url("../img/selector-icons.b4555096cea2.svg") 0 -32px no-repeat; + cursor: default; +} + +.stacked .active.selector-add { + background-position: 0 -32px; + cursor: pointer; +} + +.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -48px; + cursor: pointer; +} + +.stacked .selector-remove { + background: url("../img/selector-icons.b4555096cea2.svg") 0 0 no-repeat; + cursor: default; +} + +.stacked .active.selector-remove { + background-position: 0 0px; + cursor: pointer; +} + +.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -16px; + cursor: pointer; +} + +.selector .help-icon { + background: url("../img/icon-unknown.a18cb4398978.svg") 0 0 no-repeat; + display: inline-block; + vertical-align: middle; + margin: -2px 0 0 2px; + width: 13px; + height: 13px; +} + +.selector .selector-chosen .help-icon { + background: url("../img/icon-unknown-alt.81536e128bb6.svg") 0 0 no-repeat; +} + +.selector .search-label-icon { + background: url("../img/search.7cf54ff789c6.svg") 0 0 no-repeat; + display: inline-block; + height: 1.125rem; + width: 1.125rem; +} + +/* DATE AND TIME */ + +p.datetime { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +.datetime span { + white-space: nowrap; + font-weight: normal; + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + margin-left: 5px; + margin-bottom: 4px; +} + +table p.datetime { + font-size: 0.6875rem; + margin-left: 0; + padding-left: 0; +} + +.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon { + position: relative; + display: inline-block; + vertical-align: middle; + height: 16px; + width: 16px; + overflow: hidden; +} + +.datetimeshortcuts .clock-icon { + background: url("../img/icon-clock.e1d4dfac3f2b.svg") 0 0 no-repeat; +} + +.datetimeshortcuts a:focus .clock-icon, +.datetimeshortcuts a:hover .clock-icon { + background-position: 0 -16px; +} + +.datetimeshortcuts .date-icon { + background: url("../img/icon-calendar.ac7aea671bea.svg") 0 0 no-repeat; + top: -1px; +} + +.datetimeshortcuts a:focus .date-icon, +.datetimeshortcuts a:hover .date-icon { + background-position: 0 -16px; +} + +.timezonewarning { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +/* URL */ + +p.url { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.url a { + font-weight: normal; +} + +/* FILE UPLOADS */ + +p.file-upload { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.file-upload a { + font-weight: normal; +} + +.file-upload .deletelink { + margin-left: 5px; +} + +span.clearable-file-input label { + color: var(--body-fg); + font-size: 0.6875rem; + display: inline; + float: none; +} + +/* CALENDARS & CLOCKS */ + +.calendarbox, .clockbox { + margin: 5px auto; + font-size: 0.75rem; + width: 19em; + text-align: center; + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + overflow: hidden; + position: relative; +} + +.clockbox { + width: auto; +} + +.calendar { + margin: 0; + padding: 0; +} + +.calendar table { + margin: 0; + padding: 0; + border-collapse: collapse; + background: white; + width: 100%; +} + +.calendar caption, .calendarbox h2 { + margin: 0; + text-align: center; + border-top: none; + font-weight: 700; + font-size: 0.75rem; + color: #333; + background: var(--accent); +} + +.calendar th { + padding: 8px 5px; + background: var(--darkened-bg); + border-bottom: 1px solid var(--border-color); + font-weight: 400; + font-size: 0.75rem; + text-align: center; + color: var(--body-quiet-color); +} + +.calendar td { + font-weight: 400; + font-size: 0.75rem; + text-align: center; + padding: 0; + border-top: 1px solid var(--hairline-color); + border-bottom: none; +} + +.calendar td.selected a { + background: var(--primary); + color: var(--button-fg); +} + +.calendar td.nonday { + background: var(--darkened-bg); +} + +.calendar td.today a { + font-weight: 700; +} + +.calendar td a, .timelist a { + display: block; + font-weight: 400; + padding: 6px; + text-decoration: none; + color: var(--body-quiet-color); +} + +.calendar td a:focus, .timelist a:focus, +.calendar td a:hover, .timelist a:hover { + background: var(--primary); + color: white; +} + +.calendar td a:active, .timelist a:active { + background: var(--header-bg); + color: white; +} + +.calendarnav { + font-size: 0.625rem; + text-align: center; + color: #ccc; + margin: 0; + padding: 1px 3px; +} + +.calendarnav a:link, #calendarnav a:visited, +#calendarnav a:focus, #calendarnav a:hover { + color: var(--body-quiet-color); +} + +.calendar-shortcuts { + background: var(--body-bg); + color: var(--body-quiet-color); + font-size: 0.6875rem; + line-height: 0.6875rem; + border-top: 1px solid var(--hairline-color); + padding: 8px 0; +} + +.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + display: block; + position: absolute; + top: 8px; + width: 15px; + height: 15px; + text-indent: -9999px; + padding: 0; +} + +.calendarnav-previous { + left: 10px; + background: url("../img/calendar-icons.39b290681a8b.svg") 0 0 no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -15px; +} + +.calendarnav-next { + right: 10px; + background: url("../img/calendar-icons.39b290681a8b.svg") 0 -30px no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -45px; +} + +.calendar-cancel { + margin: 0; + padding: 4px 0; + font-size: 0.75rem; + background: #eee; + border-top: 1px solid var(--border-color); + color: var(--body-fg); +} + +.calendar-cancel:focus, .calendar-cancel:hover { + background: #ddd; +} + +.calendar-cancel a { + color: black; + display: block; +} + +ul.timelist, .timelist li { + list-style-type: none; + margin: 0; + padding: 0; +} + +.timelist a { + padding: 2px; +} + +/* EDIT INLINE */ + +.inline-deletelink { + float: right; + text-indent: -9999px; + background: url("../img/inline-delete.fec1b761f254.svg") 0 0 no-repeat; + width: 16px; + height: 16px; + border: 0px none; +} + +.inline-deletelink:focus, .inline-deletelink:hover { + cursor: pointer; +} + +/* RELATED WIDGET WRAPPER */ +.related-widget-wrapper { + float: left; /* display properly in form rows with multiple fields */ + overflow: hidden; /* clear floated contents */ +} + +.related-widget-wrapper-link { + opacity: 0.3; +} + +.related-widget-wrapper-link:link { + opacity: .8; +} + +.related-widget-wrapper-link:link:focus, +.related-widget-wrapper-link:link:hover { + opacity: 1; +} + +select + .related-widget-wrapper-link, +.related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 7px; +} + +/* GIS MAPS */ +.dj_map { + width: 600px; + height: 400px; +} diff --git a/backend/staticfiles/admin/css/widgets.ee33ab26c7c2.css.gz b/backend/staticfiles/admin/css/widgets.ee33ab26c7c2.css.gz new file mode 100644 index 0000000..e69e056 Binary files /dev/null and b/backend/staticfiles/admin/css/widgets.ee33ab26c7c2.css.gz differ diff --git a/backend/staticfiles/admin/img/LICENSE b/backend/staticfiles/admin/img/LICENSE new file mode 100644 index 0000000..a4faaa1 --- /dev/null +++ b/backend/staticfiles/admin/img/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Code Charm Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/backend/staticfiles/admin/img/LICENSE.2c54f4e1ca1c b/backend/staticfiles/admin/img/LICENSE.2c54f4e1ca1c new file mode 100644 index 0000000..a4faaa1 --- /dev/null +++ b/backend/staticfiles/admin/img/LICENSE.2c54f4e1ca1c @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Code Charm Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/backend/staticfiles/admin/img/LICENSE.2c54f4e1ca1c.gz b/backend/staticfiles/admin/img/LICENSE.2c54f4e1ca1c.gz new file mode 100644 index 0000000..9306e29 Binary files /dev/null and b/backend/staticfiles/admin/img/LICENSE.2c54f4e1ca1c.gz differ diff --git a/backend/staticfiles/admin/img/LICENSE.gz b/backend/staticfiles/admin/img/LICENSE.gz new file mode 100644 index 0000000..9306e29 Binary files /dev/null and b/backend/staticfiles/admin/img/LICENSE.gz differ diff --git a/backend/staticfiles/admin/img/README.a70711a38d87.txt b/backend/staticfiles/admin/img/README.a70711a38d87.txt new file mode 100644 index 0000000..4eb2e49 --- /dev/null +++ b/backend/staticfiles/admin/img/README.a70711a38d87.txt @@ -0,0 +1,7 @@ +All icons are taken from Font Awesome (http://fontawesome.io/) project. +The Font Awesome font is licensed under the SIL OFL 1.1: +- https://scripts.sil.org/OFL + +SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG +Font-Awesome-SVG-PNG is licensed under the MIT license (see file license +in current folder). diff --git a/backend/staticfiles/admin/img/README.a70711a38d87.txt.gz b/backend/staticfiles/admin/img/README.a70711a38d87.txt.gz new file mode 100644 index 0000000..d3afcbd Binary files /dev/null and b/backend/staticfiles/admin/img/README.a70711a38d87.txt.gz differ diff --git a/backend/staticfiles/admin/img/README.txt b/backend/staticfiles/admin/img/README.txt new file mode 100644 index 0000000..4eb2e49 --- /dev/null +++ b/backend/staticfiles/admin/img/README.txt @@ -0,0 +1,7 @@ +All icons are taken from Font Awesome (http://fontawesome.io/) project. +The Font Awesome font is licensed under the SIL OFL 1.1: +- https://scripts.sil.org/OFL + +SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG +Font-Awesome-SVG-PNG is licensed under the MIT license (see file license +in current folder). diff --git a/backend/staticfiles/admin/img/README.txt.gz b/backend/staticfiles/admin/img/README.txt.gz new file mode 100644 index 0000000..d3afcbd Binary files /dev/null and b/backend/staticfiles/admin/img/README.txt.gz differ diff --git a/backend/staticfiles/admin/img/calendar-icons.39b290681a8b.svg b/backend/staticfiles/admin/img/calendar-icons.39b290681a8b.svg new file mode 100644 index 0000000..dbf21c3 --- /dev/null +++ b/backend/staticfiles/admin/img/calendar-icons.39b290681a8b.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/backend/staticfiles/admin/img/calendar-icons.39b290681a8b.svg.gz b/backend/staticfiles/admin/img/calendar-icons.39b290681a8b.svg.gz new file mode 100644 index 0000000..bef7ce1 Binary files /dev/null and b/backend/staticfiles/admin/img/calendar-icons.39b290681a8b.svg.gz differ diff --git a/backend/staticfiles/admin/img/calendar-icons.svg b/backend/staticfiles/admin/img/calendar-icons.svg new file mode 100644 index 0000000..dbf21c3 --- /dev/null +++ b/backend/staticfiles/admin/img/calendar-icons.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/backend/staticfiles/admin/img/calendar-icons.svg.gz b/backend/staticfiles/admin/img/calendar-icons.svg.gz new file mode 100644 index 0000000..bef7ce1 Binary files /dev/null and b/backend/staticfiles/admin/img/calendar-icons.svg.gz differ diff --git a/backend/staticfiles/admin/img/gis/move_vertex_off.7a23bf31ef8a.svg b/backend/staticfiles/admin/img/gis/move_vertex_off.7a23bf31ef8a.svg new file mode 100644 index 0000000..228854f --- /dev/null +++ b/backend/staticfiles/admin/img/gis/move_vertex_off.7a23bf31ef8a.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/staticfiles/admin/img/gis/move_vertex_off.7a23bf31ef8a.svg.gz b/backend/staticfiles/admin/img/gis/move_vertex_off.7a23bf31ef8a.svg.gz new file mode 100644 index 0000000..f17ecfb Binary files /dev/null and b/backend/staticfiles/admin/img/gis/move_vertex_off.7a23bf31ef8a.svg.gz differ diff --git a/backend/staticfiles/admin/img/gis/move_vertex_off.svg b/backend/staticfiles/admin/img/gis/move_vertex_off.svg new file mode 100644 index 0000000..228854f --- /dev/null +++ b/backend/staticfiles/admin/img/gis/move_vertex_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/staticfiles/admin/img/gis/move_vertex_off.svg.gz b/backend/staticfiles/admin/img/gis/move_vertex_off.svg.gz new file mode 100644 index 0000000..f17ecfb Binary files /dev/null and b/backend/staticfiles/admin/img/gis/move_vertex_off.svg.gz differ diff --git a/backend/staticfiles/admin/img/gis/move_vertex_on.0047eba25b67.svg b/backend/staticfiles/admin/img/gis/move_vertex_on.0047eba25b67.svg new file mode 100644 index 0000000..96b87fd --- /dev/null +++ b/backend/staticfiles/admin/img/gis/move_vertex_on.0047eba25b67.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/staticfiles/admin/img/gis/move_vertex_on.0047eba25b67.svg.gz b/backend/staticfiles/admin/img/gis/move_vertex_on.0047eba25b67.svg.gz new file mode 100644 index 0000000..8047dfd Binary files /dev/null and b/backend/staticfiles/admin/img/gis/move_vertex_on.0047eba25b67.svg.gz differ diff --git a/backend/staticfiles/admin/img/gis/move_vertex_on.svg b/backend/staticfiles/admin/img/gis/move_vertex_on.svg new file mode 100644 index 0000000..96b87fd --- /dev/null +++ b/backend/staticfiles/admin/img/gis/move_vertex_on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/staticfiles/admin/img/gis/move_vertex_on.svg.gz b/backend/staticfiles/admin/img/gis/move_vertex_on.svg.gz new file mode 100644 index 0000000..8047dfd Binary files /dev/null and b/backend/staticfiles/admin/img/gis/move_vertex_on.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-addlink.d519b3bab011.svg b/backend/staticfiles/admin/img/icon-addlink.d519b3bab011.svg new file mode 100644 index 0000000..e004fb1 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-addlink.d519b3bab011.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-addlink.d519b3bab011.svg.gz b/backend/staticfiles/admin/img/icon-addlink.d519b3bab011.svg.gz new file mode 100644 index 0000000..81cea0d Binary files /dev/null and b/backend/staticfiles/admin/img/icon-addlink.d519b3bab011.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-addlink.svg b/backend/staticfiles/admin/img/icon-addlink.svg new file mode 100644 index 0000000..e004fb1 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-addlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-addlink.svg.gz b/backend/staticfiles/admin/img/icon-addlink.svg.gz new file mode 100644 index 0000000..81cea0d Binary files /dev/null and b/backend/staticfiles/admin/img/icon-addlink.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-alert.034cc7d8a67f.svg b/backend/staticfiles/admin/img/icon-alert.034cc7d8a67f.svg new file mode 100644 index 0000000..e51ea83 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-alert.034cc7d8a67f.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-alert.034cc7d8a67f.svg.gz b/backend/staticfiles/admin/img/icon-alert.034cc7d8a67f.svg.gz new file mode 100644 index 0000000..a7e64ac Binary files /dev/null and b/backend/staticfiles/admin/img/icon-alert.034cc7d8a67f.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-alert.svg b/backend/staticfiles/admin/img/icon-alert.svg new file mode 100644 index 0000000..e51ea83 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-alert.svg.gz b/backend/staticfiles/admin/img/icon-alert.svg.gz new file mode 100644 index 0000000..a7e64ac Binary files /dev/null and b/backend/staticfiles/admin/img/icon-alert.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-calendar.ac7aea671bea.svg b/backend/staticfiles/admin/img/icon-calendar.ac7aea671bea.svg new file mode 100644 index 0000000..97910a9 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-calendar.ac7aea671bea.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/backend/staticfiles/admin/img/icon-calendar.ac7aea671bea.svg.gz b/backend/staticfiles/admin/img/icon-calendar.ac7aea671bea.svg.gz new file mode 100644 index 0000000..18b5080 Binary files /dev/null and b/backend/staticfiles/admin/img/icon-calendar.ac7aea671bea.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-calendar.svg b/backend/staticfiles/admin/img/icon-calendar.svg new file mode 100644 index 0000000..97910a9 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-calendar.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/backend/staticfiles/admin/img/icon-calendar.svg.gz b/backend/staticfiles/admin/img/icon-calendar.svg.gz new file mode 100644 index 0000000..18b5080 Binary files /dev/null and b/backend/staticfiles/admin/img/icon-calendar.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-changelink.18d2fd706348.svg b/backend/staticfiles/admin/img/icon-changelink.18d2fd706348.svg new file mode 100644 index 0000000..bbb137a --- /dev/null +++ b/backend/staticfiles/admin/img/icon-changelink.18d2fd706348.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-changelink.18d2fd706348.svg.gz b/backend/staticfiles/admin/img/icon-changelink.18d2fd706348.svg.gz new file mode 100644 index 0000000..48960ff Binary files /dev/null and b/backend/staticfiles/admin/img/icon-changelink.18d2fd706348.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-changelink.svg b/backend/staticfiles/admin/img/icon-changelink.svg new file mode 100644 index 0000000..bbb137a --- /dev/null +++ b/backend/staticfiles/admin/img/icon-changelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-changelink.svg.gz b/backend/staticfiles/admin/img/icon-changelink.svg.gz new file mode 100644 index 0000000..48960ff Binary files /dev/null and b/backend/staticfiles/admin/img/icon-changelink.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-clock.e1d4dfac3f2b.svg b/backend/staticfiles/admin/img/icon-clock.e1d4dfac3f2b.svg new file mode 100644 index 0000000..bf9985d --- /dev/null +++ b/backend/staticfiles/admin/img/icon-clock.e1d4dfac3f2b.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/backend/staticfiles/admin/img/icon-clock.e1d4dfac3f2b.svg.gz b/backend/staticfiles/admin/img/icon-clock.e1d4dfac3f2b.svg.gz new file mode 100644 index 0000000..c21e7e7 Binary files /dev/null and b/backend/staticfiles/admin/img/icon-clock.e1d4dfac3f2b.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-clock.svg b/backend/staticfiles/admin/img/icon-clock.svg new file mode 100644 index 0000000..bf9985d --- /dev/null +++ b/backend/staticfiles/admin/img/icon-clock.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/backend/staticfiles/admin/img/icon-clock.svg.gz b/backend/staticfiles/admin/img/icon-clock.svg.gz new file mode 100644 index 0000000..c21e7e7 Binary files /dev/null and b/backend/staticfiles/admin/img/icon-clock.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-deletelink.564ef9dc3854.svg b/backend/staticfiles/admin/img/icon-deletelink.564ef9dc3854.svg new file mode 100644 index 0000000..4059b15 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-deletelink.564ef9dc3854.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-deletelink.564ef9dc3854.svg.gz b/backend/staticfiles/admin/img/icon-deletelink.564ef9dc3854.svg.gz new file mode 100644 index 0000000..4b93e9f Binary files /dev/null and b/backend/staticfiles/admin/img/icon-deletelink.564ef9dc3854.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-deletelink.svg b/backend/staticfiles/admin/img/icon-deletelink.svg new file mode 100644 index 0000000..4059b15 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-deletelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-deletelink.svg.gz b/backend/staticfiles/admin/img/icon-deletelink.svg.gz new file mode 100644 index 0000000..4b93e9f Binary files /dev/null and b/backend/staticfiles/admin/img/icon-deletelink.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-no.439e821418cd.svg b/backend/staticfiles/admin/img/icon-no.439e821418cd.svg new file mode 100644 index 0000000..2e0d383 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-no.439e821418cd.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-no.439e821418cd.svg.gz b/backend/staticfiles/admin/img/icon-no.439e821418cd.svg.gz new file mode 100644 index 0000000..251b7df Binary files /dev/null and b/backend/staticfiles/admin/img/icon-no.439e821418cd.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-no.svg b/backend/staticfiles/admin/img/icon-no.svg new file mode 100644 index 0000000..2e0d383 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-no.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-no.svg.gz b/backend/staticfiles/admin/img/icon-no.svg.gz new file mode 100644 index 0000000..251b7df Binary files /dev/null and b/backend/staticfiles/admin/img/icon-no.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-unknown-alt.81536e128bb6.svg b/backend/staticfiles/admin/img/icon-unknown-alt.81536e128bb6.svg new file mode 100644 index 0000000..1c6b99f --- /dev/null +++ b/backend/staticfiles/admin/img/icon-unknown-alt.81536e128bb6.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-unknown-alt.81536e128bb6.svg.gz b/backend/staticfiles/admin/img/icon-unknown-alt.81536e128bb6.svg.gz new file mode 100644 index 0000000..3f254ad Binary files /dev/null and b/backend/staticfiles/admin/img/icon-unknown-alt.81536e128bb6.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-unknown-alt.svg b/backend/staticfiles/admin/img/icon-unknown-alt.svg new file mode 100644 index 0000000..1c6b99f --- /dev/null +++ b/backend/staticfiles/admin/img/icon-unknown-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-unknown-alt.svg.gz b/backend/staticfiles/admin/img/icon-unknown-alt.svg.gz new file mode 100644 index 0000000..3f254ad Binary files /dev/null and b/backend/staticfiles/admin/img/icon-unknown-alt.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-unknown.a18cb4398978.svg b/backend/staticfiles/admin/img/icon-unknown.a18cb4398978.svg new file mode 100644 index 0000000..50b4f97 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-unknown.a18cb4398978.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-unknown.a18cb4398978.svg.gz b/backend/staticfiles/admin/img/icon-unknown.a18cb4398978.svg.gz new file mode 100644 index 0000000..5710ee7 Binary files /dev/null and b/backend/staticfiles/admin/img/icon-unknown.a18cb4398978.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-unknown.svg b/backend/staticfiles/admin/img/icon-unknown.svg new file mode 100644 index 0000000..50b4f97 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-unknown.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-unknown.svg.gz b/backend/staticfiles/admin/img/icon-unknown.svg.gz new file mode 100644 index 0000000..5710ee7 Binary files /dev/null and b/backend/staticfiles/admin/img/icon-unknown.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-viewlink.41eb31f7826e.svg b/backend/staticfiles/admin/img/icon-viewlink.41eb31f7826e.svg new file mode 100644 index 0000000..a1ca1d3 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-viewlink.41eb31f7826e.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-viewlink.41eb31f7826e.svg.gz b/backend/staticfiles/admin/img/icon-viewlink.41eb31f7826e.svg.gz new file mode 100644 index 0000000..a965b09 Binary files /dev/null and b/backend/staticfiles/admin/img/icon-viewlink.41eb31f7826e.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-viewlink.svg b/backend/staticfiles/admin/img/icon-viewlink.svg new file mode 100644 index 0000000..a1ca1d3 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-viewlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-viewlink.svg.gz b/backend/staticfiles/admin/img/icon-viewlink.svg.gz new file mode 100644 index 0000000..a965b09 Binary files /dev/null and b/backend/staticfiles/admin/img/icon-viewlink.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-yes.d2f9f035226a.svg b/backend/staticfiles/admin/img/icon-yes.d2f9f035226a.svg new file mode 100644 index 0000000..5883d87 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-yes.d2f9f035226a.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-yes.d2f9f035226a.svg.gz b/backend/staticfiles/admin/img/icon-yes.d2f9f035226a.svg.gz new file mode 100644 index 0000000..c35e8df Binary files /dev/null and b/backend/staticfiles/admin/img/icon-yes.d2f9f035226a.svg.gz differ diff --git a/backend/staticfiles/admin/img/icon-yes.svg b/backend/staticfiles/admin/img/icon-yes.svg new file mode 100644 index 0000000..5883d87 --- /dev/null +++ b/backend/staticfiles/admin/img/icon-yes.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/icon-yes.svg.gz b/backend/staticfiles/admin/img/icon-yes.svg.gz new file mode 100644 index 0000000..c35e8df Binary files /dev/null and b/backend/staticfiles/admin/img/icon-yes.svg.gz differ diff --git a/backend/staticfiles/admin/img/inline-delete.fec1b761f254.svg b/backend/staticfiles/admin/img/inline-delete.fec1b761f254.svg new file mode 100644 index 0000000..17d1ad6 --- /dev/null +++ b/backend/staticfiles/admin/img/inline-delete.fec1b761f254.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/inline-delete.fec1b761f254.svg.gz b/backend/staticfiles/admin/img/inline-delete.fec1b761f254.svg.gz new file mode 100644 index 0000000..75f97b7 Binary files /dev/null and b/backend/staticfiles/admin/img/inline-delete.fec1b761f254.svg.gz differ diff --git a/backend/staticfiles/admin/img/inline-delete.svg b/backend/staticfiles/admin/img/inline-delete.svg new file mode 100644 index 0000000..17d1ad6 --- /dev/null +++ b/backend/staticfiles/admin/img/inline-delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/inline-delete.svg.gz b/backend/staticfiles/admin/img/inline-delete.svg.gz new file mode 100644 index 0000000..75f97b7 Binary files /dev/null and b/backend/staticfiles/admin/img/inline-delete.svg.gz differ diff --git a/backend/staticfiles/admin/img/search.7cf54ff789c6.svg b/backend/staticfiles/admin/img/search.7cf54ff789c6.svg new file mode 100644 index 0000000..c8c69b2 --- /dev/null +++ b/backend/staticfiles/admin/img/search.7cf54ff789c6.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/search.7cf54ff789c6.svg.gz b/backend/staticfiles/admin/img/search.7cf54ff789c6.svg.gz new file mode 100644 index 0000000..28c49b8 Binary files /dev/null and b/backend/staticfiles/admin/img/search.7cf54ff789c6.svg.gz differ diff --git a/backend/staticfiles/admin/img/search.svg b/backend/staticfiles/admin/img/search.svg new file mode 100644 index 0000000..c8c69b2 --- /dev/null +++ b/backend/staticfiles/admin/img/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/search.svg.gz b/backend/staticfiles/admin/img/search.svg.gz new file mode 100644 index 0000000..28c49b8 Binary files /dev/null and b/backend/staticfiles/admin/img/search.svg.gz differ diff --git a/backend/staticfiles/admin/img/selector-icons.b4555096cea2.svg b/backend/staticfiles/admin/img/selector-icons.b4555096cea2.svg new file mode 100644 index 0000000..926b8e2 --- /dev/null +++ b/backend/staticfiles/admin/img/selector-icons.b4555096cea2.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/staticfiles/admin/img/selector-icons.b4555096cea2.svg.gz b/backend/staticfiles/admin/img/selector-icons.b4555096cea2.svg.gz new file mode 100644 index 0000000..99fe621 Binary files /dev/null and b/backend/staticfiles/admin/img/selector-icons.b4555096cea2.svg.gz differ diff --git a/backend/staticfiles/admin/img/selector-icons.svg b/backend/staticfiles/admin/img/selector-icons.svg new file mode 100644 index 0000000..926b8e2 --- /dev/null +++ b/backend/staticfiles/admin/img/selector-icons.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/staticfiles/admin/img/selector-icons.svg.gz b/backend/staticfiles/admin/img/selector-icons.svg.gz new file mode 100644 index 0000000..99fe621 Binary files /dev/null and b/backend/staticfiles/admin/img/selector-icons.svg.gz differ diff --git a/backend/staticfiles/admin/img/sorting-icons.3a097b59f104.svg b/backend/staticfiles/admin/img/sorting-icons.3a097b59f104.svg new file mode 100644 index 0000000..7c31ec9 --- /dev/null +++ b/backend/staticfiles/admin/img/sorting-icons.3a097b59f104.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/backend/staticfiles/admin/img/sorting-icons.3a097b59f104.svg.gz b/backend/staticfiles/admin/img/sorting-icons.3a097b59f104.svg.gz new file mode 100644 index 0000000..f5a302d Binary files /dev/null and b/backend/staticfiles/admin/img/sorting-icons.3a097b59f104.svg.gz differ diff --git a/backend/staticfiles/admin/img/sorting-icons.svg b/backend/staticfiles/admin/img/sorting-icons.svg new file mode 100644 index 0000000..7c31ec9 --- /dev/null +++ b/backend/staticfiles/admin/img/sorting-icons.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/backend/staticfiles/admin/img/sorting-icons.svg.gz b/backend/staticfiles/admin/img/sorting-icons.svg.gz new file mode 100644 index 0000000..f5a302d Binary files /dev/null and b/backend/staticfiles/admin/img/sorting-icons.svg.gz differ diff --git a/backend/staticfiles/admin/img/tooltag-add.e59d620a9742.svg b/backend/staticfiles/admin/img/tooltag-add.e59d620a9742.svg new file mode 100644 index 0000000..1ca64ae --- /dev/null +++ b/backend/staticfiles/admin/img/tooltag-add.e59d620a9742.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/tooltag-add.e59d620a9742.svg.gz b/backend/staticfiles/admin/img/tooltag-add.e59d620a9742.svg.gz new file mode 100644 index 0000000..dcd1df1 Binary files /dev/null and b/backend/staticfiles/admin/img/tooltag-add.e59d620a9742.svg.gz differ diff --git a/backend/staticfiles/admin/img/tooltag-add.svg b/backend/staticfiles/admin/img/tooltag-add.svg new file mode 100644 index 0000000..1ca64ae --- /dev/null +++ b/backend/staticfiles/admin/img/tooltag-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/tooltag-add.svg.gz b/backend/staticfiles/admin/img/tooltag-add.svg.gz new file mode 100644 index 0000000..dcd1df1 Binary files /dev/null and b/backend/staticfiles/admin/img/tooltag-add.svg.gz differ diff --git a/backend/staticfiles/admin/img/tooltag-arrowright.bbfb788a849e.svg b/backend/staticfiles/admin/img/tooltag-arrowright.bbfb788a849e.svg new file mode 100644 index 0000000..b664d61 --- /dev/null +++ b/backend/staticfiles/admin/img/tooltag-arrowright.bbfb788a849e.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/tooltag-arrowright.bbfb788a849e.svg.gz b/backend/staticfiles/admin/img/tooltag-arrowright.bbfb788a849e.svg.gz new file mode 100644 index 0000000..54b1c3f Binary files /dev/null and b/backend/staticfiles/admin/img/tooltag-arrowright.bbfb788a849e.svg.gz differ diff --git a/backend/staticfiles/admin/img/tooltag-arrowright.svg b/backend/staticfiles/admin/img/tooltag-arrowright.svg new file mode 100644 index 0000000..b664d61 --- /dev/null +++ b/backend/staticfiles/admin/img/tooltag-arrowright.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/staticfiles/admin/img/tooltag-arrowright.svg.gz b/backend/staticfiles/admin/img/tooltag-arrowright.svg.gz new file mode 100644 index 0000000..54b1c3f Binary files /dev/null and b/backend/staticfiles/admin/img/tooltag-arrowright.svg.gz differ diff --git a/backend/staticfiles/admin/js/SelectBox.7d3ce5a98007.js b/backend/staticfiles/admin/js/SelectBox.7d3ce5a98007.js new file mode 100644 index 0000000..3db4ec7 --- /dev/null +++ b/backend/staticfiles/admin/js/SelectBox.7d3ce5a98007.js @@ -0,0 +1,116 @@ +'use strict'; +{ + const SelectBox = { + cache: {}, + init: function(id) { + const box = document.getElementById(id); + SelectBox.cache[id] = []; + const cache = SelectBox.cache[id]; + for (const node of box.options) { + cache.push({value: node.value, text: node.text, displayed: 1}); + } + }, + redisplay: function(id) { + // Repopulate HTML select box from cache + const box = document.getElementById(id); + const scroll_value_from_top = box.scrollTop; + box.innerHTML = ''; + for (const node of SelectBox.cache[id]) { + if (node.displayed) { + const new_option = new Option(node.text, node.value, false, false); + // Shows a tooltip when hovering over the option + new_option.title = node.text; + box.appendChild(new_option); + } + } + box.scrollTop = scroll_value_from_top; + }, + filter: function(id, text) { + // Redisplay the HTML select box, displaying only the choices containing ALL + // the words in text. (It's an AND search.) + const tokens = text.toLowerCase().split(/\s+/); + for (const node of SelectBox.cache[id]) { + node.displayed = 1; + const node_text = node.text.toLowerCase(); + for (const token of tokens) { + if (!node_text.includes(token)) { + node.displayed = 0; + break; // Once the first token isn't found we're done + } + } + } + SelectBox.redisplay(id); + }, + get_hidden_node_count(id) { + const cache = SelectBox.cache[id] || []; + return cache.filter(node => node.displayed === 0).length; + }, + delete_from_cache: function(id, value) { + let delete_index = null; + const cache = SelectBox.cache[id]; + for (const [i, node] of cache.entries()) { + if (node.value === value) { + delete_index = i; + break; + } + } + cache.splice(delete_index, 1); + }, + add_to_cache: function(id, option) { + SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); + }, + cache_contains: function(id, value) { + // Check if an item is contained in the cache + for (const node of SelectBox.cache[id]) { + if (node.value === value) { + return true; + } + } + return false; + }, + move: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (option.selected && SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + move_all: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + sort: function(id) { + SelectBox.cache[id].sort(function(a, b) { + a = a.text.toLowerCase(); + b = b.text.toLowerCase(); + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + return 0; + } ); + }, + select_all: function(id) { + const box = document.getElementById(id); + for (const option of box.options) { + option.selected = true; + } + } + }; + window.SelectBox = SelectBox; +} diff --git a/backend/staticfiles/admin/js/SelectBox.7d3ce5a98007.js.gz b/backend/staticfiles/admin/js/SelectBox.7d3ce5a98007.js.gz new file mode 100644 index 0000000..8ee916c Binary files /dev/null and b/backend/staticfiles/admin/js/SelectBox.7d3ce5a98007.js.gz differ diff --git a/backend/staticfiles/admin/js/SelectBox.js b/backend/staticfiles/admin/js/SelectBox.js new file mode 100644 index 0000000..3db4ec7 --- /dev/null +++ b/backend/staticfiles/admin/js/SelectBox.js @@ -0,0 +1,116 @@ +'use strict'; +{ + const SelectBox = { + cache: {}, + init: function(id) { + const box = document.getElementById(id); + SelectBox.cache[id] = []; + const cache = SelectBox.cache[id]; + for (const node of box.options) { + cache.push({value: node.value, text: node.text, displayed: 1}); + } + }, + redisplay: function(id) { + // Repopulate HTML select box from cache + const box = document.getElementById(id); + const scroll_value_from_top = box.scrollTop; + box.innerHTML = ''; + for (const node of SelectBox.cache[id]) { + if (node.displayed) { + const new_option = new Option(node.text, node.value, false, false); + // Shows a tooltip when hovering over the option + new_option.title = node.text; + box.appendChild(new_option); + } + } + box.scrollTop = scroll_value_from_top; + }, + filter: function(id, text) { + // Redisplay the HTML select box, displaying only the choices containing ALL + // the words in text. (It's an AND search.) + const tokens = text.toLowerCase().split(/\s+/); + for (const node of SelectBox.cache[id]) { + node.displayed = 1; + const node_text = node.text.toLowerCase(); + for (const token of tokens) { + if (!node_text.includes(token)) { + node.displayed = 0; + break; // Once the first token isn't found we're done + } + } + } + SelectBox.redisplay(id); + }, + get_hidden_node_count(id) { + const cache = SelectBox.cache[id] || []; + return cache.filter(node => node.displayed === 0).length; + }, + delete_from_cache: function(id, value) { + let delete_index = null; + const cache = SelectBox.cache[id]; + for (const [i, node] of cache.entries()) { + if (node.value === value) { + delete_index = i; + break; + } + } + cache.splice(delete_index, 1); + }, + add_to_cache: function(id, option) { + SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); + }, + cache_contains: function(id, value) { + // Check if an item is contained in the cache + for (const node of SelectBox.cache[id]) { + if (node.value === value) { + return true; + } + } + return false; + }, + move: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (option.selected && SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + move_all: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + sort: function(id) { + SelectBox.cache[id].sort(function(a, b) { + a = a.text.toLowerCase(); + b = b.text.toLowerCase(); + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + return 0; + } ); + }, + select_all: function(id) { + const box = document.getElementById(id); + for (const option of box.options) { + option.selected = true; + } + } + }; + window.SelectBox = SelectBox; +} diff --git a/backend/staticfiles/admin/js/SelectBox.js.gz b/backend/staticfiles/admin/js/SelectBox.js.gz new file mode 100644 index 0000000..8ee916c Binary files /dev/null and b/backend/staticfiles/admin/js/SelectBox.js.gz differ diff --git a/backend/staticfiles/admin/js/SelectFilter2.bdb8d0cc579e.js b/backend/staticfiles/admin/js/SelectFilter2.bdb8d0cc579e.js new file mode 100644 index 0000000..9a4e0a3 --- /dev/null +++ b/backend/staticfiles/admin/js/SelectFilter2.bdb8d0cc579e.js @@ -0,0 +1,283 @@ +/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/ +/* +SelectFilter2 - Turns a multiple-select box into a filter interface. + +Requires core.js and SelectBox.js. +*/ +'use strict'; +{ + window.SelectFilter = { + init: function(field_id, field_name, is_stacked) { + if (field_id.match(/__prefix__/)) { + // Don't initialize on empty forms. + return; + } + const from_box = document.getElementById(field_id); + from_box.id += '_from'; // change its ID + from_box.className = 'filtered'; + + for (const p of from_box.parentNode.getElementsByTagName('p')) { + if (p.classList.contains("info")) { + // Remove

, because it just gets in the way. + from_box.parentNode.removeChild(p); + } else if (p.classList.contains("help")) { + // Move help text up to the top so it isn't below the select + // boxes or wrapped off on the side to the right of the add + // button: + from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); + } + } + + //

or
+ const selector_div = quickElement('div', from_box.parentNode); + selector_div.className = is_stacked ? 'selector stacked' : 'selector'; + + //
+ const selector_available = quickElement('div', selector_div); + selector_available.className = 'selector-available'; + const title_available = quickElement('h2', selector_available, interpolate(gettext('Available %s') + ' ', [field_name])); + quickElement( + 'span', title_available, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of available %s. You may choose some by ' + + 'selecting them in the box below and then clicking the ' + + '"Choose" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); + filter_p.className = 'selector-filter'; + + const search_filter_label = quickElement('label', filter_p, '', 'for', field_id + '_input'); + + quickElement( + 'span', search_filter_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of available %s."), [field_name]) + ); + + filter_p.appendChild(document.createTextNode(' ')); + + const filter_input = quickElement('input', filter_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_input.id = field_id + '_input'; + + selector_available.appendChild(from_box); + const choose_all = quickElement('a', selector_available, gettext('Choose all'), 'title', interpolate(gettext('Click to choose all %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_add_all_link'); + choose_all.className = 'selector-chooseall'; + + //
    + const selector_chooser = quickElement('ul', selector_div); + selector_chooser.className = 'selector-chooser'; + const add_link = quickElement('a', quickElement('li', selector_chooser), gettext('Choose'), 'title', gettext('Choose'), 'href', '#', 'id', field_id + '_add_link'); + add_link.className = 'selector-add'; + const remove_link = quickElement('a', quickElement('li', selector_chooser), gettext('Remove'), 'title', gettext('Remove'), 'href', '#', 'id', field_id + '_remove_link'); + remove_link.className = 'selector-remove'; + + //
    + const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); + selector_chosen.className = 'selector-chosen'; + const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); + quickElement( + 'span', title_chosen, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of chosen %s. You may remove some by ' + + 'selecting them in the box below and then clicking the ' + + '"Remove" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); + filter_selected_p.className = 'selector-filter'; + + const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); + + quickElement( + 'span', search_filter_selected_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) + ); + + filter_selected_p.appendChild(document.createTextNode(' ')); + + const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_selected_input.id = field_id + '_selected_input'; + + const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); + to_box.className = 'filtered'; + + const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); + quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); + quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); + + const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link'); + clear_all.className = 'selector-clearall'; + + from_box.name = from_box.name + '_old'; + + // Set up the JavaScript event handlers for the select box filter interface + const move_selection = function(e, elem, move_func, from, to) { + if (elem.classList.contains('active')) { + move_func(from, to); + SelectFilter.refresh_icons(field_id); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + } + e.preventDefault(); + }; + choose_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_from', field_id + '_to'); + }); + add_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to'); + }); + remove_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_to', field_id + '_from'); + }); + clear_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); + }); + warning_footer.addEventListener('click', function(e) { + filter_selected_input.value = ''; + SelectBox.filter(field_id + '_to', ''); + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }); + filter_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_from', '_to'); + }); + filter_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_from'); + }); + filter_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_from', '_to'); + }); + filter_selected_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_to', '_from'); + }); + filter_selected_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); + }); + filter_selected_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_to', '_from'); + }); + selector_div.addEventListener('change', function(e) { + if (e.target.tagName === 'SELECT') { + SelectFilter.refresh_icons(field_id); + } + }); + selector_div.addEventListener('dblclick', function(e) { + if (e.target.tagName === 'OPTION') { + if (e.target.closest('select').id === field_id + '_to') { + SelectBox.move(field_id + '_to', field_id + '_from'); + } else { + SelectBox.move(field_id + '_from', field_id + '_to'); + } + SelectFilter.refresh_icons(field_id); + } + }); + from_box.closest('form').addEventListener('submit', function() { + SelectBox.filter(field_id + '_to', ''); + SelectBox.select_all(field_id + '_to'); + }); + SelectBox.init(field_id + '_from'); + SelectBox.init(field_id + '_to'); + // Move selected from_box options to to_box + SelectBox.move(field_id + '_from', field_id + '_to'); + + // Initial icon refresh + SelectFilter.refresh_icons(field_id); + }, + any_selected: function(field) { + // Temporarily add the required attribute and check validity. + field.required = true; + const any_selected = field.checkValidity(); + field.required = false; + return any_selected; + }, + refresh_filtered_warning: function(field_id) { + const count = SelectBox.get_hidden_node_count(field_id + '_to'); + const selector = document.getElementById(field_id + '_selector_chosen'); + const warning = document.getElementById(field_id + '_list-footer-display-text'); + selector.className = selector.className.replace('selector-chosen--with-filtered', ''); + warning.textContent = interpolate(ngettext( + '%s selected option not visible', + '%s selected options not visible', + count + ), [count]); + if(count > 0) { + selector.className += ' selector-chosen--with-filtered'; + } + }, + refresh_filtered_selects: function(field_id) { + SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); + SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); + }, + refresh_icons: function(field_id) { + const from = document.getElementById(field_id + '_from'); + const to = document.getElementById(field_id + '_to'); + // Active if at least one item is selected + document.getElementById(field_id + '_add_link').classList.toggle('active', SelectFilter.any_selected(from)); + document.getElementById(field_id + '_remove_link').classList.toggle('active', SelectFilter.any_selected(to)); + // Active if the corresponding box isn't empty + document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); + document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); + SelectFilter.refresh_filtered_warning(field_id); + }, + filter_key_press: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // don't submit form if user pressed Enter + if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { + source_box.selectedIndex = 0; + SelectBox.move(field_id + source, field_id + target); + source_box.selectedIndex = 0; + event.preventDefault(); + } + }, + filter_key_up: function(event, field_id, source, filter_input) { + const input = filter_input || '_input'; + const source_box = document.getElementById(field_id + source); + const temp = source_box.selectedIndex; + SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); + source_box.selectedIndex = temp; + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }, + filter_key_down: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // right key (39) or left key (37) + const direction = source === '_from' ? 39 : 37; + // right arrow -- move across + if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { + const old_index = source_box.selectedIndex; + SelectBox.move(field_id + source, field_id + target); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; + return; + } + // down arrow -- wrap around + if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { + source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; + } + // up arrow -- wrap around + if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { + source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; + } + } + }; + + window.addEventListener('load', function(e) { + document.querySelectorAll('select.selectfilter, select.selectfilterstacked').forEach(function(el) { + const data = el.dataset; + SelectFilter.init(el.id, data.fieldName, parseInt(data.isStacked, 10)); + }); + }); +} diff --git a/backend/staticfiles/admin/js/SelectFilter2.bdb8d0cc579e.js.gz b/backend/staticfiles/admin/js/SelectFilter2.bdb8d0cc579e.js.gz new file mode 100644 index 0000000..67e3386 Binary files /dev/null and b/backend/staticfiles/admin/js/SelectFilter2.bdb8d0cc579e.js.gz differ diff --git a/backend/staticfiles/admin/js/SelectFilter2.js b/backend/staticfiles/admin/js/SelectFilter2.js new file mode 100644 index 0000000..9a4e0a3 --- /dev/null +++ b/backend/staticfiles/admin/js/SelectFilter2.js @@ -0,0 +1,283 @@ +/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/ +/* +SelectFilter2 - Turns a multiple-select box into a filter interface. + +Requires core.js and SelectBox.js. +*/ +'use strict'; +{ + window.SelectFilter = { + init: function(field_id, field_name, is_stacked) { + if (field_id.match(/__prefix__/)) { + // Don't initialize on empty forms. + return; + } + const from_box = document.getElementById(field_id); + from_box.id += '_from'; // change its ID + from_box.className = 'filtered'; + + for (const p of from_box.parentNode.getElementsByTagName('p')) { + if (p.classList.contains("info")) { + // Remove

    , because it just gets in the way. + from_box.parentNode.removeChild(p); + } else if (p.classList.contains("help")) { + // Move help text up to the top so it isn't below the select + // boxes or wrapped off on the side to the right of the add + // button: + from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); + } + } + + //

    or
    + const selector_div = quickElement('div', from_box.parentNode); + selector_div.className = is_stacked ? 'selector stacked' : 'selector'; + + //
    + const selector_available = quickElement('div', selector_div); + selector_available.className = 'selector-available'; + const title_available = quickElement('h2', selector_available, interpolate(gettext('Available %s') + ' ', [field_name])); + quickElement( + 'span', title_available, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of available %s. You may choose some by ' + + 'selecting them in the box below and then clicking the ' + + '"Choose" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); + filter_p.className = 'selector-filter'; + + const search_filter_label = quickElement('label', filter_p, '', 'for', field_id + '_input'); + + quickElement( + 'span', search_filter_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of available %s."), [field_name]) + ); + + filter_p.appendChild(document.createTextNode(' ')); + + const filter_input = quickElement('input', filter_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_input.id = field_id + '_input'; + + selector_available.appendChild(from_box); + const choose_all = quickElement('a', selector_available, gettext('Choose all'), 'title', interpolate(gettext('Click to choose all %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_add_all_link'); + choose_all.className = 'selector-chooseall'; + + //
      + const selector_chooser = quickElement('ul', selector_div); + selector_chooser.className = 'selector-chooser'; + const add_link = quickElement('a', quickElement('li', selector_chooser), gettext('Choose'), 'title', gettext('Choose'), 'href', '#', 'id', field_id + '_add_link'); + add_link.className = 'selector-add'; + const remove_link = quickElement('a', quickElement('li', selector_chooser), gettext('Remove'), 'title', gettext('Remove'), 'href', '#', 'id', field_id + '_remove_link'); + remove_link.className = 'selector-remove'; + + //
      + const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); + selector_chosen.className = 'selector-chosen'; + const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); + quickElement( + 'span', title_chosen, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of chosen %s. You may remove some by ' + + 'selecting them in the box below and then clicking the ' + + '"Remove" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); + filter_selected_p.className = 'selector-filter'; + + const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); + + quickElement( + 'span', search_filter_selected_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) + ); + + filter_selected_p.appendChild(document.createTextNode(' ')); + + const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_selected_input.id = field_id + '_selected_input'; + + const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); + to_box.className = 'filtered'; + + const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); + quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); + quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); + + const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link'); + clear_all.className = 'selector-clearall'; + + from_box.name = from_box.name + '_old'; + + // Set up the JavaScript event handlers for the select box filter interface + const move_selection = function(e, elem, move_func, from, to) { + if (elem.classList.contains('active')) { + move_func(from, to); + SelectFilter.refresh_icons(field_id); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + } + e.preventDefault(); + }; + choose_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_from', field_id + '_to'); + }); + add_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to'); + }); + remove_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_to', field_id + '_from'); + }); + clear_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); + }); + warning_footer.addEventListener('click', function(e) { + filter_selected_input.value = ''; + SelectBox.filter(field_id + '_to', ''); + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }); + filter_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_from', '_to'); + }); + filter_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_from'); + }); + filter_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_from', '_to'); + }); + filter_selected_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_to', '_from'); + }); + filter_selected_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); + }); + filter_selected_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_to', '_from'); + }); + selector_div.addEventListener('change', function(e) { + if (e.target.tagName === 'SELECT') { + SelectFilter.refresh_icons(field_id); + } + }); + selector_div.addEventListener('dblclick', function(e) { + if (e.target.tagName === 'OPTION') { + if (e.target.closest('select').id === field_id + '_to') { + SelectBox.move(field_id + '_to', field_id + '_from'); + } else { + SelectBox.move(field_id + '_from', field_id + '_to'); + } + SelectFilter.refresh_icons(field_id); + } + }); + from_box.closest('form').addEventListener('submit', function() { + SelectBox.filter(field_id + '_to', ''); + SelectBox.select_all(field_id + '_to'); + }); + SelectBox.init(field_id + '_from'); + SelectBox.init(field_id + '_to'); + // Move selected from_box options to to_box + SelectBox.move(field_id + '_from', field_id + '_to'); + + // Initial icon refresh + SelectFilter.refresh_icons(field_id); + }, + any_selected: function(field) { + // Temporarily add the required attribute and check validity. + field.required = true; + const any_selected = field.checkValidity(); + field.required = false; + return any_selected; + }, + refresh_filtered_warning: function(field_id) { + const count = SelectBox.get_hidden_node_count(field_id + '_to'); + const selector = document.getElementById(field_id + '_selector_chosen'); + const warning = document.getElementById(field_id + '_list-footer-display-text'); + selector.className = selector.className.replace('selector-chosen--with-filtered', ''); + warning.textContent = interpolate(ngettext( + '%s selected option not visible', + '%s selected options not visible', + count + ), [count]); + if(count > 0) { + selector.className += ' selector-chosen--with-filtered'; + } + }, + refresh_filtered_selects: function(field_id) { + SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); + SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); + }, + refresh_icons: function(field_id) { + const from = document.getElementById(field_id + '_from'); + const to = document.getElementById(field_id + '_to'); + // Active if at least one item is selected + document.getElementById(field_id + '_add_link').classList.toggle('active', SelectFilter.any_selected(from)); + document.getElementById(field_id + '_remove_link').classList.toggle('active', SelectFilter.any_selected(to)); + // Active if the corresponding box isn't empty + document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); + document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); + SelectFilter.refresh_filtered_warning(field_id); + }, + filter_key_press: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // don't submit form if user pressed Enter + if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { + source_box.selectedIndex = 0; + SelectBox.move(field_id + source, field_id + target); + source_box.selectedIndex = 0; + event.preventDefault(); + } + }, + filter_key_up: function(event, field_id, source, filter_input) { + const input = filter_input || '_input'; + const source_box = document.getElementById(field_id + source); + const temp = source_box.selectedIndex; + SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); + source_box.selectedIndex = temp; + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }, + filter_key_down: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // right key (39) or left key (37) + const direction = source === '_from' ? 39 : 37; + // right arrow -- move across + if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { + const old_index = source_box.selectedIndex; + SelectBox.move(field_id + source, field_id + target); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; + return; + } + // down arrow -- wrap around + if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { + source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; + } + // up arrow -- wrap around + if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { + source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; + } + } + }; + + window.addEventListener('load', function(e) { + document.querySelectorAll('select.selectfilter, select.selectfilterstacked').forEach(function(el) { + const data = el.dataset; + SelectFilter.init(el.id, data.fieldName, parseInt(data.isStacked, 10)); + }); + }); +} diff --git a/backend/staticfiles/admin/js/SelectFilter2.js.gz b/backend/staticfiles/admin/js/SelectFilter2.js.gz new file mode 100644 index 0000000..67e3386 Binary files /dev/null and b/backend/staticfiles/admin/js/SelectFilter2.js.gz differ diff --git a/backend/staticfiles/admin/js/actions.eac7e3441574.js b/backend/staticfiles/admin/js/actions.eac7e3441574.js new file mode 100644 index 0000000..20a5c14 --- /dev/null +++ b/backend/staticfiles/admin/js/actions.eac7e3441574.js @@ -0,0 +1,201 @@ +/*global gettext, interpolate, ngettext*/ +'use strict'; +{ + function show(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.remove('hidden'); + }); + } + + function hide(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.add('hidden'); + }); + } + + function showQuestion(options) { + hide(options.acrossClears); + show(options.acrossQuestions); + hide(options.allContainer); + } + + function showClear(options) { + show(options.acrossClears); + hide(options.acrossQuestions); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + show(options.allContainer); + hide(options.counterContainer); + } + + function reset(options) { + hide(options.acrossClears); + hide(options.acrossQuestions); + hide(options.allContainer); + show(options.counterContainer); + } + + function clearAcross(options) { + reset(options); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 0; + }); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + } + + function checker(actionCheckboxes, options, checked) { + if (checked) { + showQuestion(options); + } else { + reset(options); + } + actionCheckboxes.forEach(function(el) { + el.checked = checked; + el.closest('tr').classList.toggle(options.selectedClass, checked); + }); + } + + function updateCounter(actionCheckboxes, options) { + const sel = Array.from(actionCheckboxes).filter(function(el) { + return el.checked; + }).length; + const counter = document.querySelector(options.counterContainer); + // data-actions-icnt is defined in the generated HTML + // and contains the total amount of objects in the queryset + const actions_icnt = Number(counter.dataset.actionsIcnt); + counter.textContent = interpolate( + ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { + sel: sel, + cnt: actions_icnt + }, true); + const allToggle = document.getElementById(options.allToggleId); + allToggle.checked = sel === actionCheckboxes.length; + if (allToggle.checked) { + showQuestion(options); + } else { + clearAcross(options); + } + } + + const defaults = { + actionContainer: "div.actions", + counterContainer: "span.action-counter", + allContainer: "div.actions span.all", + acrossInput: "div.actions input.select-across", + acrossQuestions: "div.actions span.question", + acrossClears: "div.actions span.clear", + allToggleId: "action-toggle", + selectedClass: "selected" + }; + + window.Actions = function(actionCheckboxes, options) { + options = Object.assign({}, defaults, options); + let list_editable_changed = false; + let lastChecked = null; + let shiftPressed = false; + + document.addEventListener('keydown', (event) => { + shiftPressed = event.shiftKey; + }); + + document.addEventListener('keyup', (event) => { + shiftPressed = event.shiftKey; + }); + + document.getElementById(options.allToggleId).addEventListener('click', function(event) { + checker(actionCheckboxes, options, this.checked); + updateCounter(actionCheckboxes, options); + }); + + document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 1; + }); + showClear(options); + }); + }); + + document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + document.getElementById(options.allToggleId).checked = false; + clearAcross(options); + checker(actionCheckboxes, options, false); + updateCounter(actionCheckboxes, options); + }); + }); + + function affectedCheckboxes(target, withModifier) { + const multiSelect = (lastChecked && withModifier && lastChecked !== target); + if (!multiSelect) { + return [target]; + } + const checkboxes = Array.from(actionCheckboxes); + const targetIndex = checkboxes.findIndex(el => el === target); + const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked); + const startIndex = Math.min(targetIndex, lastCheckedIndex); + const endIndex = Math.max(targetIndex, lastCheckedIndex); + const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); + return filtered; + }; + + Array.from(document.getElementById('result_list').tBodies).forEach(function(el) { + el.addEventListener('change', function(event) { + const target = event.target; + if (target.classList.contains('action-select')) { + const checkboxes = affectedCheckboxes(target, shiftPressed); + checker(checkboxes, options, target.checked); + updateCounter(actionCheckboxes, options); + lastChecked = target; + } else { + list_editable_changed = true; + } + }); + }); + + document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { + if (list_editable_changed) { + const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); + if (!confirmed) { + event.preventDefault(); + } + } + }); + + const el = document.querySelector('#changelist-form input[name=_save]'); + // The button does not exist if no fields are editable. + if (el) { + el.addEventListener('click', function(event) { + if (document.querySelector('[name=action]').value) { + const text = list_editable_changed + ? gettext("You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action.") + : gettext("You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button."); + if (!confirm(text)) { + event.preventDefault(); + } + } + }); + } + }; + + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + const actionsEls = document.querySelectorAll('tr input.action-select'); + if (actionsEls.length > 0) { + Actions(actionsEls); + } + }); +} diff --git a/backend/staticfiles/admin/js/actions.eac7e3441574.js.gz b/backend/staticfiles/admin/js/actions.eac7e3441574.js.gz new file mode 100644 index 0000000..a86a6c9 Binary files /dev/null and b/backend/staticfiles/admin/js/actions.eac7e3441574.js.gz differ diff --git a/backend/staticfiles/admin/js/actions.js b/backend/staticfiles/admin/js/actions.js new file mode 100644 index 0000000..20a5c14 --- /dev/null +++ b/backend/staticfiles/admin/js/actions.js @@ -0,0 +1,201 @@ +/*global gettext, interpolate, ngettext*/ +'use strict'; +{ + function show(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.remove('hidden'); + }); + } + + function hide(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.add('hidden'); + }); + } + + function showQuestion(options) { + hide(options.acrossClears); + show(options.acrossQuestions); + hide(options.allContainer); + } + + function showClear(options) { + show(options.acrossClears); + hide(options.acrossQuestions); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + show(options.allContainer); + hide(options.counterContainer); + } + + function reset(options) { + hide(options.acrossClears); + hide(options.acrossQuestions); + hide(options.allContainer); + show(options.counterContainer); + } + + function clearAcross(options) { + reset(options); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 0; + }); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + } + + function checker(actionCheckboxes, options, checked) { + if (checked) { + showQuestion(options); + } else { + reset(options); + } + actionCheckboxes.forEach(function(el) { + el.checked = checked; + el.closest('tr').classList.toggle(options.selectedClass, checked); + }); + } + + function updateCounter(actionCheckboxes, options) { + const sel = Array.from(actionCheckboxes).filter(function(el) { + return el.checked; + }).length; + const counter = document.querySelector(options.counterContainer); + // data-actions-icnt is defined in the generated HTML + // and contains the total amount of objects in the queryset + const actions_icnt = Number(counter.dataset.actionsIcnt); + counter.textContent = interpolate( + ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { + sel: sel, + cnt: actions_icnt + }, true); + const allToggle = document.getElementById(options.allToggleId); + allToggle.checked = sel === actionCheckboxes.length; + if (allToggle.checked) { + showQuestion(options); + } else { + clearAcross(options); + } + } + + const defaults = { + actionContainer: "div.actions", + counterContainer: "span.action-counter", + allContainer: "div.actions span.all", + acrossInput: "div.actions input.select-across", + acrossQuestions: "div.actions span.question", + acrossClears: "div.actions span.clear", + allToggleId: "action-toggle", + selectedClass: "selected" + }; + + window.Actions = function(actionCheckboxes, options) { + options = Object.assign({}, defaults, options); + let list_editable_changed = false; + let lastChecked = null; + let shiftPressed = false; + + document.addEventListener('keydown', (event) => { + shiftPressed = event.shiftKey; + }); + + document.addEventListener('keyup', (event) => { + shiftPressed = event.shiftKey; + }); + + document.getElementById(options.allToggleId).addEventListener('click', function(event) { + checker(actionCheckboxes, options, this.checked); + updateCounter(actionCheckboxes, options); + }); + + document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 1; + }); + showClear(options); + }); + }); + + document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + document.getElementById(options.allToggleId).checked = false; + clearAcross(options); + checker(actionCheckboxes, options, false); + updateCounter(actionCheckboxes, options); + }); + }); + + function affectedCheckboxes(target, withModifier) { + const multiSelect = (lastChecked && withModifier && lastChecked !== target); + if (!multiSelect) { + return [target]; + } + const checkboxes = Array.from(actionCheckboxes); + const targetIndex = checkboxes.findIndex(el => el === target); + const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked); + const startIndex = Math.min(targetIndex, lastCheckedIndex); + const endIndex = Math.max(targetIndex, lastCheckedIndex); + const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); + return filtered; + }; + + Array.from(document.getElementById('result_list').tBodies).forEach(function(el) { + el.addEventListener('change', function(event) { + const target = event.target; + if (target.classList.contains('action-select')) { + const checkboxes = affectedCheckboxes(target, shiftPressed); + checker(checkboxes, options, target.checked); + updateCounter(actionCheckboxes, options); + lastChecked = target; + } else { + list_editable_changed = true; + } + }); + }); + + document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { + if (list_editable_changed) { + const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); + if (!confirmed) { + event.preventDefault(); + } + } + }); + + const el = document.querySelector('#changelist-form input[name=_save]'); + // The button does not exist if no fields are editable. + if (el) { + el.addEventListener('click', function(event) { + if (document.querySelector('[name=action]').value) { + const text = list_editable_changed + ? gettext("You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action.") + : gettext("You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button."); + if (!confirm(text)) { + event.preventDefault(); + } + } + }); + } + }; + + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + const actionsEls = document.querySelectorAll('tr input.action-select'); + if (actionsEls.length > 0) { + Actions(actionsEls); + } + }); +} diff --git a/backend/staticfiles/admin/js/actions.js.gz b/backend/staticfiles/admin/js/actions.js.gz new file mode 100644 index 0000000..a86a6c9 Binary files /dev/null and b/backend/staticfiles/admin/js/actions.js.gz differ diff --git a/backend/staticfiles/admin/js/admin/DateTimeShortcuts.9f6e209cebca.js b/backend/staticfiles/admin/js/admin/DateTimeShortcuts.9f6e209cebca.js new file mode 100644 index 0000000..aa1cae9 --- /dev/null +++ b/backend/staticfiles/admin/js/admin/DateTimeShortcuts.9f6e209cebca.js @@ -0,0 +1,408 @@ +/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/ +// Inserts shortcut buttons after all of the following: +// +// +'use strict'; +{ + const DateTimeShortcuts = { + calendars: [], + calendarInputs: [], + clockInputs: [], + clockHours: { + default_: [ + [gettext_noop('Now'), -1], + [gettext_noop('Midnight'), 0], + [gettext_noop('6 a.m.'), 6], + [gettext_noop('Noon'), 12], + [gettext_noop('6 p.m.'), 18] + ] + }, + dismissClockFunc: [], + dismissCalendarFunc: [], + calendarDivName1: 'calendarbox', // name of calendar
      that gets toggled + calendarDivName2: 'calendarin', // name of
      that contains calendar + calendarLinkName: 'calendarlink', // name of the link that is used to toggle + clockDivName: 'clockbox', // name of clock
      that gets toggled + clockLinkName: 'clocklink', // name of the link that is used to toggle + shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts + timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch + timezoneOffset: 0, + init: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localOffset = new Date().getTimezoneOffset() * -60; + DateTimeShortcuts.timezoneOffset = localOffset - serverOffset; + } + + for (const inp of document.getElementsByTagName('input')) { + if (inp.type === 'text' && inp.classList.contains('vTimeField')) { + DateTimeShortcuts.addClock(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + else if (inp.type === 'text' && inp.classList.contains('vDateField')) { + DateTimeShortcuts.addCalendar(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + } + }, + // Return the current time while accounting for the server timezone. + now: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localNow = new Date(); + const localOffset = localNow.getTimezoneOffset() * -60; + localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset)); + return localNow; + } else { + return new Date(); + } + }, + // Add a warning when the time zone in the browser and backend do not match. + addTimezoneWarning: function(inp) { + const warningClass = DateTimeShortcuts.timezoneWarningClass; + let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600; + + // Only warn if there is a time zone mismatch. + if (!timezoneOffset) { + return; + } + + // Check if warning is already there. + if (inp.parentNode.querySelectorAll('.' + warningClass).length) { + return; + } + + let message; + if (timezoneOffset > 0) { + message = ngettext( + 'Note: You are %s hour ahead of server time.', + 'Note: You are %s hours ahead of server time.', + timezoneOffset + ); + } + else { + timezoneOffset *= -1; + message = ngettext( + 'Note: You are %s hour behind server time.', + 'Note: You are %s hours behind server time.', + timezoneOffset + ); + } + message = interpolate(message, [timezoneOffset]); + + const warning = document.createElement('div'); + warning.classList.add('help', warningClass); + warning.textContent = message; + inp.parentNode.appendChild(warning); + }, + // Add clock widget to a given field + addClock: function(inp) { + const num = DateTimeShortcuts.clockInputs.length; + DateTimeShortcuts.clockInputs[num] = inp; + DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; }; + + // Shortcut links (clock icon and "Now" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const now_link = document.createElement('a'); + now_link.href = "#"; + now_link.textContent = gettext('Now'); + now_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, -1); + }); + const clock_link = document.createElement('a'); + clock_link.href = '#'; + clock_link.id = DateTimeShortcuts.clockLinkName + num; + clock_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the clock + e.stopPropagation(); + DateTimeShortcuts.openClock(num); + }); + + quickElement( + 'span', clock_link, '', + 'class', 'clock-icon', + 'title', gettext('Choose a Time') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(now_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(clock_link); + + // Create clock link div + // + // Markup looks like: + //
      + //

      Choose a time

      + // + //

      Cancel

      + //
      + + const clock_box = document.createElement('div'); + clock_box.style.display = 'none'; + clock_box.style.position = 'absolute'; + clock_box.className = 'clockbox module'; + clock_box.id = DateTimeShortcuts.clockDivName + num; + document.body.appendChild(clock_box); + clock_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + quickElement('h2', clock_box, gettext('Choose a time')); + const time_list = quickElement('ul', clock_box); + time_list.className = 'timelist'; + // The list of choices can be overridden in JavaScript like this: + // DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]]; + // where name is the name attribute of the . + const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name; + DateTimeShortcuts.clockHours[name].forEach(function(element) { + const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#'); + time_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, element[1]); + }); + }); + + const cancel_p = quickElement('p', clock_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissClock(num); + }); + + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissClock(num); + event.preventDefault(); + } + }); + }, + openClock: function(num) { + const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num); + const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num); + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + clock_box.style.left = findPosX(clock_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + clock_box.style.left = findPosX(clock_link) - 110 + 'px'; + } + clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px'; + + // Show the clock box + clock_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + dismissClock: function(num) { + document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + handleClockQuicklink: function(num, val) { + let d; + if (val === -1) { + d = DateTimeShortcuts.now(); + } + else { + d = new Date(1970, 1, 1, val, 0, 0, 0); + } + DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]); + DateTimeShortcuts.clockInputs[num].focus(); + DateTimeShortcuts.dismissClock(num); + }, + // Add calendar widget to a given field. + addCalendar: function(inp) { + const num = DateTimeShortcuts.calendars.length; + + DateTimeShortcuts.calendarInputs[num] = inp; + DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; }; + + // Shortcut links (calendar icon and "Today" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const today_link = document.createElement('a'); + today_link.href = '#'; + today_link.appendChild(document.createTextNode(gettext('Today'))); + today_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + const cal_link = document.createElement('a'); + cal_link.href = '#'; + cal_link.id = DateTimeShortcuts.calendarLinkName + num; + cal_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the calendar + e.stopPropagation(); + DateTimeShortcuts.openCalendar(num); + }); + quickElement( + 'span', cal_link, '', + 'class', 'date-icon', + 'title', gettext('Choose a Date') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(today_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(cal_link); + + // Create calendarbox div. + // + // Markup looks like: + // + //
      + //

      + // + // February 2003 + //

      + //
      + // + //
      + //
      + // Yesterday | Today | Tomorrow + //
      + //

      Cancel

      + //
      + const cal_box = document.createElement('div'); + cal_box.style.display = 'none'; + cal_box.style.position = 'absolute'; + cal_box.className = 'calendarbox module'; + cal_box.id = DateTimeShortcuts.calendarDivName1 + num; + document.body.appendChild(cal_box); + cal_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + // next-prev links + const cal_nav = quickElement('div', cal_box); + const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#'); + cal_nav_prev.className = 'calendarnav-previous'; + cal_nav_prev.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawPrev(num); + }); + + const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#'); + cal_nav_next.className = 'calendarnav-next'; + cal_nav_next.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawNext(num); + }); + + // main box + const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num); + cal_main.className = 'calendar'; + DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num)); + DateTimeShortcuts.calendars[num].drawCurrent(); + + // calendar shortcuts + const shortcuts = quickElement('div', cal_box); + shortcuts.className = 'calendar-shortcuts'; + let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, -1); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, +1); + }); + + // cancel bar + const cancel_p = quickElement('p', cal_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissCalendar(num); + }); + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissCalendar(num); + event.preventDefault(); + } + }); + }, + openCalendar: function(num) { + const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num); + const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num); + const inp = DateTimeShortcuts.calendarInputs[num]; + + // Determine if the current value in the input has a valid date. + // If so, draw the calendar with that date's year and month. + if (inp.value) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + const selected = inp.value.strptime(format); + const year = selected.getUTCFullYear(); + const month = selected.getUTCMonth() + 1; + const re = /\d{4}/; + if (re.test(year.toString()) && month >= 1 && month <= 12) { + DateTimeShortcuts.calendars[num].drawDate(month, year, selected); + } + } + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + cal_box.style.left = findPosX(cal_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + cal_box.style.left = findPosX(cal_link) - 180 + 'px'; + } + cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px'; + + cal_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + dismissCalendar: function(num) { + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + drawPrev: function(num) { + DateTimeShortcuts.calendars[num].drawPreviousMonth(); + }, + drawNext: function(num) { + DateTimeShortcuts.calendars[num].drawNextMonth(); + }, + handleCalendarCallback: function(num) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + return function(y, m, d) { + DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); + DateTimeShortcuts.calendarInputs[num].focus(); + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + }; + }, + handleCalendarQuickLink: function(num, offset) { + const d = DateTimeShortcuts.now(); + d.setDate(d.getDate() + offset); + DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]); + DateTimeShortcuts.calendarInputs[num].focus(); + DateTimeShortcuts.dismissCalendar(num); + } + }; + + window.addEventListener('load', DateTimeShortcuts.init); + window.DateTimeShortcuts = DateTimeShortcuts; +} diff --git a/backend/staticfiles/admin/js/admin/DateTimeShortcuts.9f6e209cebca.js.gz b/backend/staticfiles/admin/js/admin/DateTimeShortcuts.9f6e209cebca.js.gz new file mode 100644 index 0000000..d184bad Binary files /dev/null and b/backend/staticfiles/admin/js/admin/DateTimeShortcuts.9f6e209cebca.js.gz differ diff --git a/backend/staticfiles/admin/js/admin/DateTimeShortcuts.js b/backend/staticfiles/admin/js/admin/DateTimeShortcuts.js new file mode 100644 index 0000000..aa1cae9 --- /dev/null +++ b/backend/staticfiles/admin/js/admin/DateTimeShortcuts.js @@ -0,0 +1,408 @@ +/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/ +// Inserts shortcut buttons after all of the following: +// +// +'use strict'; +{ + const DateTimeShortcuts = { + calendars: [], + calendarInputs: [], + clockInputs: [], + clockHours: { + default_: [ + [gettext_noop('Now'), -1], + [gettext_noop('Midnight'), 0], + [gettext_noop('6 a.m.'), 6], + [gettext_noop('Noon'), 12], + [gettext_noop('6 p.m.'), 18] + ] + }, + dismissClockFunc: [], + dismissCalendarFunc: [], + calendarDivName1: 'calendarbox', // name of calendar
      that gets toggled + calendarDivName2: 'calendarin', // name of
      that contains calendar + calendarLinkName: 'calendarlink', // name of the link that is used to toggle + clockDivName: 'clockbox', // name of clock
      that gets toggled + clockLinkName: 'clocklink', // name of the link that is used to toggle + shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts + timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch + timezoneOffset: 0, + init: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localOffset = new Date().getTimezoneOffset() * -60; + DateTimeShortcuts.timezoneOffset = localOffset - serverOffset; + } + + for (const inp of document.getElementsByTagName('input')) { + if (inp.type === 'text' && inp.classList.contains('vTimeField')) { + DateTimeShortcuts.addClock(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + else if (inp.type === 'text' && inp.classList.contains('vDateField')) { + DateTimeShortcuts.addCalendar(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + } + }, + // Return the current time while accounting for the server timezone. + now: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localNow = new Date(); + const localOffset = localNow.getTimezoneOffset() * -60; + localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset)); + return localNow; + } else { + return new Date(); + } + }, + // Add a warning when the time zone in the browser and backend do not match. + addTimezoneWarning: function(inp) { + const warningClass = DateTimeShortcuts.timezoneWarningClass; + let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600; + + // Only warn if there is a time zone mismatch. + if (!timezoneOffset) { + return; + } + + // Check if warning is already there. + if (inp.parentNode.querySelectorAll('.' + warningClass).length) { + return; + } + + let message; + if (timezoneOffset > 0) { + message = ngettext( + 'Note: You are %s hour ahead of server time.', + 'Note: You are %s hours ahead of server time.', + timezoneOffset + ); + } + else { + timezoneOffset *= -1; + message = ngettext( + 'Note: You are %s hour behind server time.', + 'Note: You are %s hours behind server time.', + timezoneOffset + ); + } + message = interpolate(message, [timezoneOffset]); + + const warning = document.createElement('div'); + warning.classList.add('help', warningClass); + warning.textContent = message; + inp.parentNode.appendChild(warning); + }, + // Add clock widget to a given field + addClock: function(inp) { + const num = DateTimeShortcuts.clockInputs.length; + DateTimeShortcuts.clockInputs[num] = inp; + DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; }; + + // Shortcut links (clock icon and "Now" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const now_link = document.createElement('a'); + now_link.href = "#"; + now_link.textContent = gettext('Now'); + now_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, -1); + }); + const clock_link = document.createElement('a'); + clock_link.href = '#'; + clock_link.id = DateTimeShortcuts.clockLinkName + num; + clock_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the clock + e.stopPropagation(); + DateTimeShortcuts.openClock(num); + }); + + quickElement( + 'span', clock_link, '', + 'class', 'clock-icon', + 'title', gettext('Choose a Time') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(now_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(clock_link); + + // Create clock link div + // + // Markup looks like: + //
      + //

      Choose a time

      + // + //

      Cancel

      + //
      + + const clock_box = document.createElement('div'); + clock_box.style.display = 'none'; + clock_box.style.position = 'absolute'; + clock_box.className = 'clockbox module'; + clock_box.id = DateTimeShortcuts.clockDivName + num; + document.body.appendChild(clock_box); + clock_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + quickElement('h2', clock_box, gettext('Choose a time')); + const time_list = quickElement('ul', clock_box); + time_list.className = 'timelist'; + // The list of choices can be overridden in JavaScript like this: + // DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]]; + // where name is the name attribute of the . + const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name; + DateTimeShortcuts.clockHours[name].forEach(function(element) { + const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#'); + time_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, element[1]); + }); + }); + + const cancel_p = quickElement('p', clock_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissClock(num); + }); + + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissClock(num); + event.preventDefault(); + } + }); + }, + openClock: function(num) { + const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num); + const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num); + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + clock_box.style.left = findPosX(clock_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + clock_box.style.left = findPosX(clock_link) - 110 + 'px'; + } + clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px'; + + // Show the clock box + clock_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + dismissClock: function(num) { + document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + handleClockQuicklink: function(num, val) { + let d; + if (val === -1) { + d = DateTimeShortcuts.now(); + } + else { + d = new Date(1970, 1, 1, val, 0, 0, 0); + } + DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]); + DateTimeShortcuts.clockInputs[num].focus(); + DateTimeShortcuts.dismissClock(num); + }, + // Add calendar widget to a given field. + addCalendar: function(inp) { + const num = DateTimeShortcuts.calendars.length; + + DateTimeShortcuts.calendarInputs[num] = inp; + DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; }; + + // Shortcut links (calendar icon and "Today" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const today_link = document.createElement('a'); + today_link.href = '#'; + today_link.appendChild(document.createTextNode(gettext('Today'))); + today_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + const cal_link = document.createElement('a'); + cal_link.href = '#'; + cal_link.id = DateTimeShortcuts.calendarLinkName + num; + cal_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the calendar + e.stopPropagation(); + DateTimeShortcuts.openCalendar(num); + }); + quickElement( + 'span', cal_link, '', + 'class', 'date-icon', + 'title', gettext('Choose a Date') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(today_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(cal_link); + + // Create calendarbox div. + // + // Markup looks like: + // + //
      + //

      + // + // February 2003 + //

      + //
      + // + //
      + //
      + // Yesterday | Today | Tomorrow + //
      + //

      Cancel

      + //
      + const cal_box = document.createElement('div'); + cal_box.style.display = 'none'; + cal_box.style.position = 'absolute'; + cal_box.className = 'calendarbox module'; + cal_box.id = DateTimeShortcuts.calendarDivName1 + num; + document.body.appendChild(cal_box); + cal_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + // next-prev links + const cal_nav = quickElement('div', cal_box); + const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#'); + cal_nav_prev.className = 'calendarnav-previous'; + cal_nav_prev.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawPrev(num); + }); + + const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#'); + cal_nav_next.className = 'calendarnav-next'; + cal_nav_next.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawNext(num); + }); + + // main box + const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num); + cal_main.className = 'calendar'; + DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num)); + DateTimeShortcuts.calendars[num].drawCurrent(); + + // calendar shortcuts + const shortcuts = quickElement('div', cal_box); + shortcuts.className = 'calendar-shortcuts'; + let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, -1); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, +1); + }); + + // cancel bar + const cancel_p = quickElement('p', cal_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissCalendar(num); + }); + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissCalendar(num); + event.preventDefault(); + } + }); + }, + openCalendar: function(num) { + const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num); + const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num); + const inp = DateTimeShortcuts.calendarInputs[num]; + + // Determine if the current value in the input has a valid date. + // If so, draw the calendar with that date's year and month. + if (inp.value) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + const selected = inp.value.strptime(format); + const year = selected.getUTCFullYear(); + const month = selected.getUTCMonth() + 1; + const re = /\d{4}/; + if (re.test(year.toString()) && month >= 1 && month <= 12) { + DateTimeShortcuts.calendars[num].drawDate(month, year, selected); + } + } + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + cal_box.style.left = findPosX(cal_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + cal_box.style.left = findPosX(cal_link) - 180 + 'px'; + } + cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px'; + + cal_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + dismissCalendar: function(num) { + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + drawPrev: function(num) { + DateTimeShortcuts.calendars[num].drawPreviousMonth(); + }, + drawNext: function(num) { + DateTimeShortcuts.calendars[num].drawNextMonth(); + }, + handleCalendarCallback: function(num) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + return function(y, m, d) { + DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); + DateTimeShortcuts.calendarInputs[num].focus(); + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + }; + }, + handleCalendarQuickLink: function(num, offset) { + const d = DateTimeShortcuts.now(); + d.setDate(d.getDate() + offset); + DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]); + DateTimeShortcuts.calendarInputs[num].focus(); + DateTimeShortcuts.dismissCalendar(num); + } + }; + + window.addEventListener('load', DateTimeShortcuts.init); + window.DateTimeShortcuts = DateTimeShortcuts; +} diff --git a/backend/staticfiles/admin/js/admin/DateTimeShortcuts.js.gz b/backend/staticfiles/admin/js/admin/DateTimeShortcuts.js.gz new file mode 100644 index 0000000..d184bad Binary files /dev/null and b/backend/staticfiles/admin/js/admin/DateTimeShortcuts.js.gz differ diff --git a/backend/staticfiles/admin/js/admin/RelatedObjectLookups.8609f99b9ab2.js b/backend/staticfiles/admin/js/admin/RelatedObjectLookups.8609f99b9ab2.js new file mode 100644 index 0000000..afb6b66 --- /dev/null +++ b/backend/staticfiles/admin/js/admin/RelatedObjectLookups.8609f99b9ab2.js @@ -0,0 +1,238 @@ +/*global SelectBox, interpolate*/ +// Handles related-objects functionality: lookup link for raw_id_fields +// and Add Another links. +'use strict'; +{ + const $ = django.jQuery; + let popupIndex = 0; + const relatedWindows = []; + + function dismissChildPopups() { + relatedWindows.forEach(function(win) { + if(!win.closed) { + win.dismissChildPopups(); + win.close(); + } + }); + } + + function setPopupIndex() { + if(document.getElementsByName("_popup").length > 0) { + const index = window.name.lastIndexOf("__") + 2; + popupIndex = parseInt(window.name.substring(index)); + } else { + popupIndex = 0; + } + } + + function addPopupIndex(name) { + return name + "__" + (popupIndex + 1); + } + + function removePopupIndex(name) { + return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), ''); + } + + function showAdminPopup(triggeringLink, name_regexp, add_popup) { + const name = addPopupIndex(triggeringLink.id.replace(name_regexp, '')); + const href = new URL(triggeringLink.href); + if (add_popup) { + href.searchParams.set('_popup', 1); + } + const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); + relatedWindows.push(win); + win.focus(); + return false; + } + + function showRelatedObjectLookupPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^lookup_/, true); + } + + function dismissRelatedLookupPopup(win, chosenId) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + chosenId; + } else { + document.getElementById(name).value = chosenId; + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function showRelatedObjectPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false); + } + + function updateRelatedObjectLinks(triggeringLink) { + const $this = $(triggeringLink); + const siblings = $this.nextAll('.view-related, .change-related, .delete-related'); + if (!siblings.length) { + return; + } + const value = $this.val(); + if (value) { + siblings.each(function() { + const elm = $(this); + elm.attr('href', elm.attr('data-href-template').replace('__fk__', value)); + }); + } else { + siblings.removeAttr('href'); + } + } + + function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId) { + // After create/edit a model from the options next to the current + // select (+ or :pencil:) update ForeignKey PK of the rest of selects + // in the page. + + const path = win.location.pathname; + // Extract the model from the popup url '...//add/' or + // '...///change/' depending the action (add or change). + const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)]; + // Exclude autocomplete selects. + const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`); + + selectsRelated.forEach(function(select) { + if (currentSelect === select) { + return; + } + + let option = select.querySelector(`option[value="${objId}"]`); + + if (!option) { + option = new Option(newRepr, newId); + select.options.add(option); + return; + } + + option.textContent = newRepr; + option.value = newId; + }); + } + + function dismissAddRelatedObjectPopup(win, newId, newRepr) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem) { + const elemName = elem.nodeName.toUpperCase(); + if (elemName === 'SELECT') { + elem.options[elem.options.length] = new Option(newRepr, newId, true, true); + updateRelatedSelectsOptions(elem, win, null, newRepr, newId); + } else if (elemName === 'INPUT') { + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + newId; + } else { + elem.value = newId; + } + } + // Trigger a change event to update related links if required. + $(elem).trigger('change'); + } else { + const toId = name + "_to"; + const o = new Option(newRepr, newId); + SelectBox.add_to_cache(toId, o); + SelectBox.redisplay(toId); + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) { + const id = removePopupIndex(win.name.replace(/^edit_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + this.textContent = newRepr; + this.value = newId; + } + }).trigger('change'); + updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId); + selects.next().find('.select2-selection__rendered').each(function() { + // The element can have a clear button as a child. + // Use the lastChild to modify only the displayed value. + this.lastChild.textContent = newRepr; + this.title = newRepr; + }); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissDeleteRelatedObjectPopup(win, objId) { + const id = removePopupIndex(win.name.replace(/^delete_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + $(this).remove(); + } + }).trigger('change'); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup; + window.dismissRelatedLookupPopup = dismissRelatedLookupPopup; + window.showRelatedObjectPopup = showRelatedObjectPopup; + window.updateRelatedObjectLinks = updateRelatedObjectLinks; + window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; + window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup; + window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup; + window.dismissChildPopups = dismissChildPopups; + + // Kept for backward compatibility + window.showAddAnotherPopup = showRelatedObjectPopup; + window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup; + + window.addEventListener('unload', function(evt) { + window.dismissChildPopups(); + }); + + $(document).ready(function() { + setPopupIndex(); + $("a[data-popup-opener]").on('click', function(event) { + event.preventDefault(); + opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); + }); + $('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) { + e.preventDefault(); + if (this.href) { + const event = $.Event('django:show-related', {href: this.href}); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectPopup(this); + } + } + }); + $('body').on('change', '.related-widget-wrapper select', function(e) { + const event = $.Event('django:update-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + updateRelatedObjectLinks(this); + } + }); + $('.related-widget-wrapper select').trigger('change'); + $('body').on('click', '.related-lookup', function(e) { + e.preventDefault(); + const event = $.Event('django:lookup-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectLookupPopup(this); + } + }); + }); +} diff --git a/backend/staticfiles/admin/js/admin/RelatedObjectLookups.8609f99b9ab2.js.gz b/backend/staticfiles/admin/js/admin/RelatedObjectLookups.8609f99b9ab2.js.gz new file mode 100644 index 0000000..2c550cc Binary files /dev/null and b/backend/staticfiles/admin/js/admin/RelatedObjectLookups.8609f99b9ab2.js.gz differ diff --git a/backend/staticfiles/admin/js/admin/RelatedObjectLookups.js b/backend/staticfiles/admin/js/admin/RelatedObjectLookups.js new file mode 100644 index 0000000..afb6b66 --- /dev/null +++ b/backend/staticfiles/admin/js/admin/RelatedObjectLookups.js @@ -0,0 +1,238 @@ +/*global SelectBox, interpolate*/ +// Handles related-objects functionality: lookup link for raw_id_fields +// and Add Another links. +'use strict'; +{ + const $ = django.jQuery; + let popupIndex = 0; + const relatedWindows = []; + + function dismissChildPopups() { + relatedWindows.forEach(function(win) { + if(!win.closed) { + win.dismissChildPopups(); + win.close(); + } + }); + } + + function setPopupIndex() { + if(document.getElementsByName("_popup").length > 0) { + const index = window.name.lastIndexOf("__") + 2; + popupIndex = parseInt(window.name.substring(index)); + } else { + popupIndex = 0; + } + } + + function addPopupIndex(name) { + return name + "__" + (popupIndex + 1); + } + + function removePopupIndex(name) { + return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), ''); + } + + function showAdminPopup(triggeringLink, name_regexp, add_popup) { + const name = addPopupIndex(triggeringLink.id.replace(name_regexp, '')); + const href = new URL(triggeringLink.href); + if (add_popup) { + href.searchParams.set('_popup', 1); + } + const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); + relatedWindows.push(win); + win.focus(); + return false; + } + + function showRelatedObjectLookupPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^lookup_/, true); + } + + function dismissRelatedLookupPopup(win, chosenId) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + chosenId; + } else { + document.getElementById(name).value = chosenId; + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function showRelatedObjectPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false); + } + + function updateRelatedObjectLinks(triggeringLink) { + const $this = $(triggeringLink); + const siblings = $this.nextAll('.view-related, .change-related, .delete-related'); + if (!siblings.length) { + return; + } + const value = $this.val(); + if (value) { + siblings.each(function() { + const elm = $(this); + elm.attr('href', elm.attr('data-href-template').replace('__fk__', value)); + }); + } else { + siblings.removeAttr('href'); + } + } + + function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId) { + // After create/edit a model from the options next to the current + // select (+ or :pencil:) update ForeignKey PK of the rest of selects + // in the page. + + const path = win.location.pathname; + // Extract the model from the popup url '...//add/' or + // '...///change/' depending the action (add or change). + const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)]; + // Exclude autocomplete selects. + const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`); + + selectsRelated.forEach(function(select) { + if (currentSelect === select) { + return; + } + + let option = select.querySelector(`option[value="${objId}"]`); + + if (!option) { + option = new Option(newRepr, newId); + select.options.add(option); + return; + } + + option.textContent = newRepr; + option.value = newId; + }); + } + + function dismissAddRelatedObjectPopup(win, newId, newRepr) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem) { + const elemName = elem.nodeName.toUpperCase(); + if (elemName === 'SELECT') { + elem.options[elem.options.length] = new Option(newRepr, newId, true, true); + updateRelatedSelectsOptions(elem, win, null, newRepr, newId); + } else if (elemName === 'INPUT') { + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + newId; + } else { + elem.value = newId; + } + } + // Trigger a change event to update related links if required. + $(elem).trigger('change'); + } else { + const toId = name + "_to"; + const o = new Option(newRepr, newId); + SelectBox.add_to_cache(toId, o); + SelectBox.redisplay(toId); + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) { + const id = removePopupIndex(win.name.replace(/^edit_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + this.textContent = newRepr; + this.value = newId; + } + }).trigger('change'); + updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId); + selects.next().find('.select2-selection__rendered').each(function() { + // The element can have a clear button as a child. + // Use the lastChild to modify only the displayed value. + this.lastChild.textContent = newRepr; + this.title = newRepr; + }); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissDeleteRelatedObjectPopup(win, objId) { + const id = removePopupIndex(win.name.replace(/^delete_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + $(this).remove(); + } + }).trigger('change'); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup; + window.dismissRelatedLookupPopup = dismissRelatedLookupPopup; + window.showRelatedObjectPopup = showRelatedObjectPopup; + window.updateRelatedObjectLinks = updateRelatedObjectLinks; + window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; + window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup; + window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup; + window.dismissChildPopups = dismissChildPopups; + + // Kept for backward compatibility + window.showAddAnotherPopup = showRelatedObjectPopup; + window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup; + + window.addEventListener('unload', function(evt) { + window.dismissChildPopups(); + }); + + $(document).ready(function() { + setPopupIndex(); + $("a[data-popup-opener]").on('click', function(event) { + event.preventDefault(); + opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); + }); + $('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) { + e.preventDefault(); + if (this.href) { + const event = $.Event('django:show-related', {href: this.href}); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectPopup(this); + } + } + }); + $('body').on('change', '.related-widget-wrapper select', function(e) { + const event = $.Event('django:update-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + updateRelatedObjectLinks(this); + } + }); + $('.related-widget-wrapper select').trigger('change'); + $('body').on('click', '.related-lookup', function(e) { + e.preventDefault(); + const event = $.Event('django:lookup-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectLookupPopup(this); + } + }); + }); +} diff --git a/backend/staticfiles/admin/js/admin/RelatedObjectLookups.js.gz b/backend/staticfiles/admin/js/admin/RelatedObjectLookups.js.gz new file mode 100644 index 0000000..2c550cc Binary files /dev/null and b/backend/staticfiles/admin/js/admin/RelatedObjectLookups.js.gz differ diff --git a/backend/staticfiles/admin/js/autocomplete.01591ab27be7.js b/backend/staticfiles/admin/js/autocomplete.01591ab27be7.js new file mode 100644 index 0000000..d3daeab --- /dev/null +++ b/backend/staticfiles/admin/js/autocomplete.01591ab27be7.js @@ -0,0 +1,33 @@ +'use strict'; +{ + const $ = django.jQuery; + + $.fn.djangoAdminSelect2 = function() { + $.each(this, function(i, element) { + $(element).select2({ + ajax: { + data: (params) => { + return { + term: params.term, + page: params.page, + app_label: element.dataset.appLabel, + model_name: element.dataset.modelName, + field_name: element.dataset.fieldName + }; + } + } + }); + }); + return this; + }; + + $(function() { + // Initialize all autocomplete widgets except the one in the template + // form used when a new formset is added. + $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); + }); + + document.addEventListener('formset:added', (event) => { + $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); + }); +} diff --git a/backend/staticfiles/admin/js/autocomplete.01591ab27be7.js.gz b/backend/staticfiles/admin/js/autocomplete.01591ab27be7.js.gz new file mode 100644 index 0000000..1ed616d Binary files /dev/null and b/backend/staticfiles/admin/js/autocomplete.01591ab27be7.js.gz differ diff --git a/backend/staticfiles/admin/js/autocomplete.js b/backend/staticfiles/admin/js/autocomplete.js new file mode 100644 index 0000000..d3daeab --- /dev/null +++ b/backend/staticfiles/admin/js/autocomplete.js @@ -0,0 +1,33 @@ +'use strict'; +{ + const $ = django.jQuery; + + $.fn.djangoAdminSelect2 = function() { + $.each(this, function(i, element) { + $(element).select2({ + ajax: { + data: (params) => { + return { + term: params.term, + page: params.page, + app_label: element.dataset.appLabel, + model_name: element.dataset.modelName, + field_name: element.dataset.fieldName + }; + } + } + }); + }); + return this; + }; + + $(function() { + // Initialize all autocomplete widgets except the one in the template + // form used when a new formset is added. + $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); + }); + + document.addEventListener('formset:added', (event) => { + $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); + }); +} diff --git a/backend/staticfiles/admin/js/autocomplete.js.gz b/backend/staticfiles/admin/js/autocomplete.js.gz new file mode 100644 index 0000000..1ed616d Binary files /dev/null and b/backend/staticfiles/admin/js/autocomplete.js.gz differ diff --git a/backend/staticfiles/admin/js/calendar.f8a5d055eb33.js b/backend/staticfiles/admin/js/calendar.f8a5d055eb33.js new file mode 100644 index 0000000..a62d10a --- /dev/null +++ b/backend/staticfiles/admin/js/calendar.f8a5d055eb33.js @@ -0,0 +1,221 @@ +/*global gettext, pgettext, get_format, quickElement, removeChildren*/ +/* +calendar.js - Calendar functions by Adrian Holovaty +depends on core.js for utility functions like removeChildren or quickElement +*/ +'use strict'; +{ + // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions + const CalendarNamespace = { + monthsOfYear: [ + gettext('January'), + gettext('February'), + gettext('March'), + gettext('April'), + gettext('May'), + gettext('June'), + gettext('July'), + gettext('August'), + gettext('September'), + gettext('October'), + gettext('November'), + gettext('December') + ], + monthsOfYearAbbrev: [ + pgettext('abbrev. month January', 'Jan'), + pgettext('abbrev. month February', 'Feb'), + pgettext('abbrev. month March', 'Mar'), + pgettext('abbrev. month April', 'Apr'), + pgettext('abbrev. month May', 'May'), + pgettext('abbrev. month June', 'Jun'), + pgettext('abbrev. month July', 'Jul'), + pgettext('abbrev. month August', 'Aug'), + pgettext('abbrev. month September', 'Sep'), + pgettext('abbrev. month October', 'Oct'), + pgettext('abbrev. month November', 'Nov'), + pgettext('abbrev. month December', 'Dec') + ], + daysOfWeek: [ + pgettext('one letter Sunday', 'S'), + pgettext('one letter Monday', 'M'), + pgettext('one letter Tuesday', 'T'), + pgettext('one letter Wednesday', 'W'), + pgettext('one letter Thursday', 'T'), + pgettext('one letter Friday', 'F'), + pgettext('one letter Saturday', 'S') + ], + firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')), + isLeapYear: function(year) { + return (((year % 4) === 0) && ((year % 100) !== 0 ) || ((year % 400) === 0)); + }, + getDaysInMonth: function(month, year) { + let days; + if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8 || month === 10 || month === 12) { + days = 31; + } + else if (month === 4 || month === 6 || month === 9 || month === 11) { + days = 30; + } + else if (month === 2 && CalendarNamespace.isLeapYear(year)) { + days = 29; + } + else { + days = 28; + } + return days; + }, + draw: function(month, year, div_id, callback, selected) { // month = 1-12, year = 1-9999 + const today = new Date(); + const todayDay = today.getDate(); + const todayMonth = today.getMonth() + 1; + const todayYear = today.getFullYear(); + let todayClass = ''; + + // Use UTC functions here because the date field does not contain time + // and using the UTC function variants prevent the local time offset + // from altering the date, specifically the day field. For example: + // + // ``` + // var x = new Date('2013-10-02'); + // var day = x.getDate(); + // ``` + // + // The day variable above will be 1 instead of 2 in, say, US Pacific time + // zone. + let isSelectedMonth = false; + if (typeof selected !== 'undefined') { + isSelectedMonth = (selected.getUTCFullYear() === year && (selected.getUTCMonth() + 1) === month); + } + + month = parseInt(month); + year = parseInt(year); + const calDiv = document.getElementById(div_id); + removeChildren(calDiv); + const calTable = document.createElement('table'); + quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month - 1] + ' ' + year); + const tableBody = quickElement('tbody', calTable); + + // Draw days-of-week header + let tableRow = quickElement('tr', tableBody); + for (let i = 0; i < 7; i++) { + quickElement('th', tableRow, CalendarNamespace.daysOfWeek[(i + CalendarNamespace.firstDayOfWeek) % 7]); + } + + const startingPos = new Date(year, month - 1, 1 - CalendarNamespace.firstDayOfWeek).getDay(); + const days = CalendarNamespace.getDaysInMonth(month, year); + + let nonDayCell; + + // Draw blanks before first of month + tableRow = quickElement('tr', tableBody); + for (let i = 0; i < startingPos; i++) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + function calendarMonth(y, m) { + function onClick(e) { + e.preventDefault(); + callback(y, m, this.textContent); + } + return onClick; + } + + // Draw days of month + let currentDay = 1; + for (let i = startingPos; currentDay <= days; i++) { + if (i % 7 === 0 && currentDay !== 1) { + tableRow = quickElement('tr', tableBody); + } + if ((currentDay === todayDay) && (month === todayMonth) && (year === todayYear)) { + todayClass = 'today'; + } else { + todayClass = ''; + } + + // use UTC function; see above for explanation. + if (isSelectedMonth && currentDay === selected.getUTCDate()) { + if (todayClass !== '') { + todayClass += " "; + } + todayClass += "selected"; + } + + const cell = quickElement('td', tableRow, '', 'class', todayClass); + const link = quickElement('a', cell, currentDay, 'href', '#'); + link.addEventListener('click', calendarMonth(year, month)); + currentDay++; + } + + // Draw blanks after end of month (optional, but makes for valid code) + while (tableRow.childNodes.length < 7) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + calDiv.appendChild(calTable); + } + }; + + // Calendar -- A calendar instance + function Calendar(div_id, callback, selected) { + // div_id (string) is the ID of the element in which the calendar will + // be displayed + // callback (string) is the name of a JavaScript function that will be + // called with the parameters (year, month, day) when a day in the + // calendar is clicked + this.div_id = div_id; + this.callback = callback; + this.today = new Date(); + this.currentMonth = this.today.getMonth() + 1; + this.currentYear = this.today.getFullYear(); + if (typeof selected !== 'undefined') { + this.selected = selected; + } + } + Calendar.prototype = { + drawCurrent: function() { + CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback, this.selected); + }, + drawDate: function(month, year, selected) { + this.currentMonth = month; + this.currentYear = year; + + if(selected) { + this.selected = selected; + } + + this.drawCurrent(); + }, + drawPreviousMonth: function() { + if (this.currentMonth === 1) { + this.currentMonth = 12; + this.currentYear--; + } + else { + this.currentMonth--; + } + this.drawCurrent(); + }, + drawNextMonth: function() { + if (this.currentMonth === 12) { + this.currentMonth = 1; + this.currentYear++; + } + else { + this.currentMonth++; + } + this.drawCurrent(); + }, + drawPreviousYear: function() { + this.currentYear--; + this.drawCurrent(); + }, + drawNextYear: function() { + this.currentYear++; + this.drawCurrent(); + } + }; + window.Calendar = Calendar; + window.CalendarNamespace = CalendarNamespace; +} diff --git a/backend/staticfiles/admin/js/calendar.f8a5d055eb33.js.gz b/backend/staticfiles/admin/js/calendar.f8a5d055eb33.js.gz new file mode 100644 index 0000000..8f7f5c5 Binary files /dev/null and b/backend/staticfiles/admin/js/calendar.f8a5d055eb33.js.gz differ diff --git a/backend/staticfiles/admin/js/calendar.js b/backend/staticfiles/admin/js/calendar.js new file mode 100644 index 0000000..a62d10a --- /dev/null +++ b/backend/staticfiles/admin/js/calendar.js @@ -0,0 +1,221 @@ +/*global gettext, pgettext, get_format, quickElement, removeChildren*/ +/* +calendar.js - Calendar functions by Adrian Holovaty +depends on core.js for utility functions like removeChildren or quickElement +*/ +'use strict'; +{ + // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions + const CalendarNamespace = { + monthsOfYear: [ + gettext('January'), + gettext('February'), + gettext('March'), + gettext('April'), + gettext('May'), + gettext('June'), + gettext('July'), + gettext('August'), + gettext('September'), + gettext('October'), + gettext('November'), + gettext('December') + ], + monthsOfYearAbbrev: [ + pgettext('abbrev. month January', 'Jan'), + pgettext('abbrev. month February', 'Feb'), + pgettext('abbrev. month March', 'Mar'), + pgettext('abbrev. month April', 'Apr'), + pgettext('abbrev. month May', 'May'), + pgettext('abbrev. month June', 'Jun'), + pgettext('abbrev. month July', 'Jul'), + pgettext('abbrev. month August', 'Aug'), + pgettext('abbrev. month September', 'Sep'), + pgettext('abbrev. month October', 'Oct'), + pgettext('abbrev. month November', 'Nov'), + pgettext('abbrev. month December', 'Dec') + ], + daysOfWeek: [ + pgettext('one letter Sunday', 'S'), + pgettext('one letter Monday', 'M'), + pgettext('one letter Tuesday', 'T'), + pgettext('one letter Wednesday', 'W'), + pgettext('one letter Thursday', 'T'), + pgettext('one letter Friday', 'F'), + pgettext('one letter Saturday', 'S') + ], + firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')), + isLeapYear: function(year) { + return (((year % 4) === 0) && ((year % 100) !== 0 ) || ((year % 400) === 0)); + }, + getDaysInMonth: function(month, year) { + let days; + if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8 || month === 10 || month === 12) { + days = 31; + } + else if (month === 4 || month === 6 || month === 9 || month === 11) { + days = 30; + } + else if (month === 2 && CalendarNamespace.isLeapYear(year)) { + days = 29; + } + else { + days = 28; + } + return days; + }, + draw: function(month, year, div_id, callback, selected) { // month = 1-12, year = 1-9999 + const today = new Date(); + const todayDay = today.getDate(); + const todayMonth = today.getMonth() + 1; + const todayYear = today.getFullYear(); + let todayClass = ''; + + // Use UTC functions here because the date field does not contain time + // and using the UTC function variants prevent the local time offset + // from altering the date, specifically the day field. For example: + // + // ``` + // var x = new Date('2013-10-02'); + // var day = x.getDate(); + // ``` + // + // The day variable above will be 1 instead of 2 in, say, US Pacific time + // zone. + let isSelectedMonth = false; + if (typeof selected !== 'undefined') { + isSelectedMonth = (selected.getUTCFullYear() === year && (selected.getUTCMonth() + 1) === month); + } + + month = parseInt(month); + year = parseInt(year); + const calDiv = document.getElementById(div_id); + removeChildren(calDiv); + const calTable = document.createElement('table'); + quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month - 1] + ' ' + year); + const tableBody = quickElement('tbody', calTable); + + // Draw days-of-week header + let tableRow = quickElement('tr', tableBody); + for (let i = 0; i < 7; i++) { + quickElement('th', tableRow, CalendarNamespace.daysOfWeek[(i + CalendarNamespace.firstDayOfWeek) % 7]); + } + + const startingPos = new Date(year, month - 1, 1 - CalendarNamespace.firstDayOfWeek).getDay(); + const days = CalendarNamespace.getDaysInMonth(month, year); + + let nonDayCell; + + // Draw blanks before first of month + tableRow = quickElement('tr', tableBody); + for (let i = 0; i < startingPos; i++) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + function calendarMonth(y, m) { + function onClick(e) { + e.preventDefault(); + callback(y, m, this.textContent); + } + return onClick; + } + + // Draw days of month + let currentDay = 1; + for (let i = startingPos; currentDay <= days; i++) { + if (i % 7 === 0 && currentDay !== 1) { + tableRow = quickElement('tr', tableBody); + } + if ((currentDay === todayDay) && (month === todayMonth) && (year === todayYear)) { + todayClass = 'today'; + } else { + todayClass = ''; + } + + // use UTC function; see above for explanation. + if (isSelectedMonth && currentDay === selected.getUTCDate()) { + if (todayClass !== '') { + todayClass += " "; + } + todayClass += "selected"; + } + + const cell = quickElement('td', tableRow, '', 'class', todayClass); + const link = quickElement('a', cell, currentDay, 'href', '#'); + link.addEventListener('click', calendarMonth(year, month)); + currentDay++; + } + + // Draw blanks after end of month (optional, but makes for valid code) + while (tableRow.childNodes.length < 7) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + calDiv.appendChild(calTable); + } + }; + + // Calendar -- A calendar instance + function Calendar(div_id, callback, selected) { + // div_id (string) is the ID of the element in which the calendar will + // be displayed + // callback (string) is the name of a JavaScript function that will be + // called with the parameters (year, month, day) when a day in the + // calendar is clicked + this.div_id = div_id; + this.callback = callback; + this.today = new Date(); + this.currentMonth = this.today.getMonth() + 1; + this.currentYear = this.today.getFullYear(); + if (typeof selected !== 'undefined') { + this.selected = selected; + } + } + Calendar.prototype = { + drawCurrent: function() { + CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback, this.selected); + }, + drawDate: function(month, year, selected) { + this.currentMonth = month; + this.currentYear = year; + + if(selected) { + this.selected = selected; + } + + this.drawCurrent(); + }, + drawPreviousMonth: function() { + if (this.currentMonth === 1) { + this.currentMonth = 12; + this.currentYear--; + } + else { + this.currentMonth--; + } + this.drawCurrent(); + }, + drawNextMonth: function() { + if (this.currentMonth === 12) { + this.currentMonth = 1; + this.currentYear++; + } + else { + this.currentMonth++; + } + this.drawCurrent(); + }, + drawPreviousYear: function() { + this.currentYear--; + this.drawCurrent(); + }, + drawNextYear: function() { + this.currentYear++; + this.drawCurrent(); + } + }; + window.Calendar = Calendar; + window.CalendarNamespace = CalendarNamespace; +} diff --git a/backend/staticfiles/admin/js/calendar.js.gz b/backend/staticfiles/admin/js/calendar.js.gz new file mode 100644 index 0000000..8f7f5c5 Binary files /dev/null and b/backend/staticfiles/admin/js/calendar.js.gz differ diff --git a/backend/staticfiles/admin/js/cancel.ecc4c5ca7b32.js b/backend/staticfiles/admin/js/cancel.ecc4c5ca7b32.js new file mode 100644 index 0000000..3069c6f --- /dev/null +++ b/backend/staticfiles/admin/js/cancel.ecc4c5ca7b32.js @@ -0,0 +1,29 @@ +'use strict'; +{ + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + function handleClick(event) { + event.preventDefault(); + const params = new URLSearchParams(window.location.search); + if (params.has('_popup')) { + window.close(); // Close the popup. + } else { + window.history.back(); // Otherwise, go back. + } + } + + document.querySelectorAll('.cancel-link').forEach(function(el) { + el.addEventListener('click', handleClick); + }); + }); +} diff --git a/backend/staticfiles/admin/js/cancel.ecc4c5ca7b32.js.gz b/backend/staticfiles/admin/js/cancel.ecc4c5ca7b32.js.gz new file mode 100644 index 0000000..e0c8869 Binary files /dev/null and b/backend/staticfiles/admin/js/cancel.ecc4c5ca7b32.js.gz differ diff --git a/backend/staticfiles/admin/js/cancel.js b/backend/staticfiles/admin/js/cancel.js new file mode 100644 index 0000000..3069c6f --- /dev/null +++ b/backend/staticfiles/admin/js/cancel.js @@ -0,0 +1,29 @@ +'use strict'; +{ + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + function handleClick(event) { + event.preventDefault(); + const params = new URLSearchParams(window.location.search); + if (params.has('_popup')) { + window.close(); // Close the popup. + } else { + window.history.back(); // Otherwise, go back. + } + } + + document.querySelectorAll('.cancel-link').forEach(function(el) { + el.addEventListener('click', handleClick); + }); + }); +} diff --git a/backend/staticfiles/admin/js/cancel.js.gz b/backend/staticfiles/admin/js/cancel.js.gz new file mode 100644 index 0000000..e0c8869 Binary files /dev/null and b/backend/staticfiles/admin/js/cancel.js.gz differ diff --git a/backend/staticfiles/admin/js/change_form.9d8ca4f96b75.js b/backend/staticfiles/admin/js/change_form.9d8ca4f96b75.js new file mode 100644 index 0000000..96a4c62 --- /dev/null +++ b/backend/staticfiles/admin/js/change_form.9d8ca4f96b75.js @@ -0,0 +1,16 @@ +'use strict'; +{ + const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; + const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName; + if (modelName) { + const form = document.getElementById(modelName + '_form'); + for (const element of form.elements) { + // HTMLElement.offsetParent returns null when the element is not + // rendered. + if (inputTags.includes(element.tagName) && !element.disabled && element.offsetParent) { + element.focus(); + break; + } + } + } +} diff --git a/backend/staticfiles/admin/js/change_form.9d8ca4f96b75.js.gz b/backend/staticfiles/admin/js/change_form.9d8ca4f96b75.js.gz new file mode 100644 index 0000000..755f1fb Binary files /dev/null and b/backend/staticfiles/admin/js/change_form.9d8ca4f96b75.js.gz differ diff --git a/backend/staticfiles/admin/js/change_form.js b/backend/staticfiles/admin/js/change_form.js new file mode 100644 index 0000000..96a4c62 --- /dev/null +++ b/backend/staticfiles/admin/js/change_form.js @@ -0,0 +1,16 @@ +'use strict'; +{ + const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; + const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName; + if (modelName) { + const form = document.getElementById(modelName + '_form'); + for (const element of form.elements) { + // HTMLElement.offsetParent returns null when the element is not + // rendered. + if (inputTags.includes(element.tagName) && !element.disabled && element.offsetParent) { + element.focus(); + break; + } + } + } +} diff --git a/backend/staticfiles/admin/js/change_form.js.gz b/backend/staticfiles/admin/js/change_form.js.gz new file mode 100644 index 0000000..755f1fb Binary files /dev/null and b/backend/staticfiles/admin/js/change_form.js.gz differ diff --git a/backend/staticfiles/admin/js/collapse.f84e7410290f.js b/backend/staticfiles/admin/js/collapse.f84e7410290f.js new file mode 100644 index 0000000..c6c7b0f --- /dev/null +++ b/backend/staticfiles/admin/js/collapse.f84e7410290f.js @@ -0,0 +1,43 @@ +/*global gettext*/ +'use strict'; +{ + window.addEventListener('load', function() { + // Add anchor tag for Show/Hide link + const fieldsets = document.querySelectorAll('fieldset.collapse'); + for (const [i, elem] of fieldsets.entries()) { + // Don't hide if fields in this fieldset have errors + if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { + elem.classList.add('collapsed'); + const h2 = elem.querySelector('h2'); + const link = document.createElement('a'); + link.id = 'fieldsetcollapser' + i; + link.className = 'collapse-toggle'; + link.href = '#'; + link.textContent = gettext('Show'); + h2.appendChild(document.createTextNode(' (')); + h2.appendChild(link); + h2.appendChild(document.createTextNode(')')); + } + } + // Add toggle to hide/show anchor tag + const toggleFunc = function(ev) { + if (ev.target.matches('.collapse-toggle')) { + ev.preventDefault(); + ev.stopPropagation(); + const fieldset = ev.target.closest('fieldset'); + if (fieldset.classList.contains('collapsed')) { + // Show + ev.target.textContent = gettext('Hide'); + fieldset.classList.remove('collapsed'); + } else { + // Hide + ev.target.textContent = gettext('Show'); + fieldset.classList.add('collapsed'); + } + } + }; + document.querySelectorAll('fieldset.module').forEach(function(el) { + el.addEventListener('click', toggleFunc); + }); + }); +} diff --git a/backend/staticfiles/admin/js/collapse.f84e7410290f.js.gz b/backend/staticfiles/admin/js/collapse.f84e7410290f.js.gz new file mode 100644 index 0000000..9770c13 Binary files /dev/null and b/backend/staticfiles/admin/js/collapse.f84e7410290f.js.gz differ diff --git a/backend/staticfiles/admin/js/collapse.js b/backend/staticfiles/admin/js/collapse.js new file mode 100644 index 0000000..c6c7b0f --- /dev/null +++ b/backend/staticfiles/admin/js/collapse.js @@ -0,0 +1,43 @@ +/*global gettext*/ +'use strict'; +{ + window.addEventListener('load', function() { + // Add anchor tag for Show/Hide link + const fieldsets = document.querySelectorAll('fieldset.collapse'); + for (const [i, elem] of fieldsets.entries()) { + // Don't hide if fields in this fieldset have errors + if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { + elem.classList.add('collapsed'); + const h2 = elem.querySelector('h2'); + const link = document.createElement('a'); + link.id = 'fieldsetcollapser' + i; + link.className = 'collapse-toggle'; + link.href = '#'; + link.textContent = gettext('Show'); + h2.appendChild(document.createTextNode(' (')); + h2.appendChild(link); + h2.appendChild(document.createTextNode(')')); + } + } + // Add toggle to hide/show anchor tag + const toggleFunc = function(ev) { + if (ev.target.matches('.collapse-toggle')) { + ev.preventDefault(); + ev.stopPropagation(); + const fieldset = ev.target.closest('fieldset'); + if (fieldset.classList.contains('collapsed')) { + // Show + ev.target.textContent = gettext('Hide'); + fieldset.classList.remove('collapsed'); + } else { + // Hide + ev.target.textContent = gettext('Show'); + fieldset.classList.add('collapsed'); + } + } + }; + document.querySelectorAll('fieldset.module').forEach(function(el) { + el.addEventListener('click', toggleFunc); + }); + }); +} diff --git a/backend/staticfiles/admin/js/collapse.js.gz b/backend/staticfiles/admin/js/collapse.js.gz new file mode 100644 index 0000000..9770c13 Binary files /dev/null and b/backend/staticfiles/admin/js/collapse.js.gz differ diff --git a/backend/staticfiles/admin/js/core.cf103cd04ebf.js b/backend/staticfiles/admin/js/core.cf103cd04ebf.js new file mode 100644 index 0000000..0344a13 --- /dev/null +++ b/backend/staticfiles/admin/js/core.cf103cd04ebf.js @@ -0,0 +1,170 @@ +// Core JavaScript helper functions +'use strict'; + +// quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]); +function quickElement() { + const obj = document.createElement(arguments[0]); + if (arguments[2]) { + const textNode = document.createTextNode(arguments[2]); + obj.appendChild(textNode); + } + const len = arguments.length; + for (let i = 3; i < len; i += 2) { + obj.setAttribute(arguments[i], arguments[i + 1]); + } + arguments[1].appendChild(obj); + return obj; +} + +// "a" is reference to an object +function removeChildren(a) { + while (a.hasChildNodes()) { + a.removeChild(a.lastChild); + } +} + +// ---------------------------------------------------------------------------- +// Find-position functions by PPK +// See https://www.quirksmode.org/js/findpos.html +// ---------------------------------------------------------------------------- +function findPosX(obj) { + let curleft = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curleft += obj.offsetLeft - obj.scrollLeft; + obj = obj.offsetParent; + } + } else if (obj.x) { + curleft += obj.x; + } + return curleft; +} + +function findPosY(obj) { + let curtop = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curtop += obj.offsetTop - obj.scrollTop; + obj = obj.offsetParent; + } + } else if (obj.y) { + curtop += obj.y; + } + return curtop; +} + +//----------------------------------------------------------------------------- +// Date object extensions +// ---------------------------------------------------------------------------- +{ + Date.prototype.getTwelveHours = function() { + return this.getHours() % 12 || 12; + }; + + Date.prototype.getTwoDigitMonth = function() { + return (this.getMonth() < 9) ? '0' + (this.getMonth() + 1) : (this.getMonth() + 1); + }; + + Date.prototype.getTwoDigitDate = function() { + return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate(); + }; + + Date.prototype.getTwoDigitTwelveHour = function() { + return (this.getTwelveHours() < 10) ? '0' + this.getTwelveHours() : this.getTwelveHours(); + }; + + Date.prototype.getTwoDigitHour = function() { + return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours(); + }; + + Date.prototype.getTwoDigitMinute = function() { + return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes(); + }; + + Date.prototype.getTwoDigitSecond = function() { + return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds(); + }; + + Date.prototype.getAbbrevMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYearAbbrev[this.getMonth()]; + }; + + Date.prototype.getFullMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYear[this.getMonth()]; + }; + + Date.prototype.strftime = function(format) { + const fields = { + b: this.getAbbrevMonthName(), + B: this.getFullMonthName(), + c: this.toString(), + d: this.getTwoDigitDate(), + H: this.getTwoDigitHour(), + I: this.getTwoDigitTwelveHour(), + m: this.getTwoDigitMonth(), + M: this.getTwoDigitMinute(), + p: (this.getHours() >= 12) ? 'PM' : 'AM', + S: this.getTwoDigitSecond(), + w: '0' + this.getDay(), + x: this.toLocaleDateString(), + X: this.toLocaleTimeString(), + y: ('' + this.getFullYear()).substr(2, 4), + Y: '' + this.getFullYear(), + '%': '%' + }; + let result = '', i = 0; + while (i < format.length) { + if (format.charAt(i) === '%') { + result += fields[format.charAt(i + 1)]; + ++i; + } + else { + result += format.charAt(i); + } + ++i; + } + return result; + }; + + // ---------------------------------------------------------------------------- + // String object extensions + // ---------------------------------------------------------------------------- + String.prototype.strptime = function(format) { + const split_format = format.split(/[.\-/]/); + const date = this.split(/[.\-/]/); + let i = 0; + let day, month, year; + while (i < split_format.length) { + switch (split_format[i]) { + case "%d": + day = date[i]; + break; + case "%m": + month = date[i] - 1; + break; + case "%Y": + year = date[i]; + break; + case "%y": + // A %y value in the range of [00, 68] is in the current + // century, while [69, 99] is in the previous century, + // according to the Open Group Specification. + if (parseInt(date[i], 10) >= 69) { + year = date[i]; + } else { + year = (new Date(Date.UTC(date[i], 0))).getUTCFullYear() + 100; + } + break; + } + ++i; + } + // Create Date object from UTC since the parsed value is supposed to be + // in UTC, not local time. Also, the calendar uses UTC functions for + // date extraction. + return new Date(Date.UTC(year, month, day)); + }; +} diff --git a/backend/staticfiles/admin/js/core.cf103cd04ebf.js.gz b/backend/staticfiles/admin/js/core.cf103cd04ebf.js.gz new file mode 100644 index 0000000..39bab4a Binary files /dev/null and b/backend/staticfiles/admin/js/core.cf103cd04ebf.js.gz differ diff --git a/backend/staticfiles/admin/js/core.js b/backend/staticfiles/admin/js/core.js new file mode 100644 index 0000000..0344a13 --- /dev/null +++ b/backend/staticfiles/admin/js/core.js @@ -0,0 +1,170 @@ +// Core JavaScript helper functions +'use strict'; + +// quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]); +function quickElement() { + const obj = document.createElement(arguments[0]); + if (arguments[2]) { + const textNode = document.createTextNode(arguments[2]); + obj.appendChild(textNode); + } + const len = arguments.length; + for (let i = 3; i < len; i += 2) { + obj.setAttribute(arguments[i], arguments[i + 1]); + } + arguments[1].appendChild(obj); + return obj; +} + +// "a" is reference to an object +function removeChildren(a) { + while (a.hasChildNodes()) { + a.removeChild(a.lastChild); + } +} + +// ---------------------------------------------------------------------------- +// Find-position functions by PPK +// See https://www.quirksmode.org/js/findpos.html +// ---------------------------------------------------------------------------- +function findPosX(obj) { + let curleft = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curleft += obj.offsetLeft - obj.scrollLeft; + obj = obj.offsetParent; + } + } else if (obj.x) { + curleft += obj.x; + } + return curleft; +} + +function findPosY(obj) { + let curtop = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curtop += obj.offsetTop - obj.scrollTop; + obj = obj.offsetParent; + } + } else if (obj.y) { + curtop += obj.y; + } + return curtop; +} + +//----------------------------------------------------------------------------- +// Date object extensions +// ---------------------------------------------------------------------------- +{ + Date.prototype.getTwelveHours = function() { + return this.getHours() % 12 || 12; + }; + + Date.prototype.getTwoDigitMonth = function() { + return (this.getMonth() < 9) ? '0' + (this.getMonth() + 1) : (this.getMonth() + 1); + }; + + Date.prototype.getTwoDigitDate = function() { + return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate(); + }; + + Date.prototype.getTwoDigitTwelveHour = function() { + return (this.getTwelveHours() < 10) ? '0' + this.getTwelveHours() : this.getTwelveHours(); + }; + + Date.prototype.getTwoDigitHour = function() { + return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours(); + }; + + Date.prototype.getTwoDigitMinute = function() { + return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes(); + }; + + Date.prototype.getTwoDigitSecond = function() { + return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds(); + }; + + Date.prototype.getAbbrevMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYearAbbrev[this.getMonth()]; + }; + + Date.prototype.getFullMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYear[this.getMonth()]; + }; + + Date.prototype.strftime = function(format) { + const fields = { + b: this.getAbbrevMonthName(), + B: this.getFullMonthName(), + c: this.toString(), + d: this.getTwoDigitDate(), + H: this.getTwoDigitHour(), + I: this.getTwoDigitTwelveHour(), + m: this.getTwoDigitMonth(), + M: this.getTwoDigitMinute(), + p: (this.getHours() >= 12) ? 'PM' : 'AM', + S: this.getTwoDigitSecond(), + w: '0' + this.getDay(), + x: this.toLocaleDateString(), + X: this.toLocaleTimeString(), + y: ('' + this.getFullYear()).substr(2, 4), + Y: '' + this.getFullYear(), + '%': '%' + }; + let result = '', i = 0; + while (i < format.length) { + if (format.charAt(i) === '%') { + result += fields[format.charAt(i + 1)]; + ++i; + } + else { + result += format.charAt(i); + } + ++i; + } + return result; + }; + + // ---------------------------------------------------------------------------- + // String object extensions + // ---------------------------------------------------------------------------- + String.prototype.strptime = function(format) { + const split_format = format.split(/[.\-/]/); + const date = this.split(/[.\-/]/); + let i = 0; + let day, month, year; + while (i < split_format.length) { + switch (split_format[i]) { + case "%d": + day = date[i]; + break; + case "%m": + month = date[i] - 1; + break; + case "%Y": + year = date[i]; + break; + case "%y": + // A %y value in the range of [00, 68] is in the current + // century, while [69, 99] is in the previous century, + // according to the Open Group Specification. + if (parseInt(date[i], 10) >= 69) { + year = date[i]; + } else { + year = (new Date(Date.UTC(date[i], 0))).getUTCFullYear() + 100; + } + break; + } + ++i; + } + // Create Date object from UTC since the parsed value is supposed to be + // in UTC, not local time. Also, the calendar uses UTC functions for + // date extraction. + return new Date(Date.UTC(year, month, day)); + }; +} diff --git a/backend/staticfiles/admin/js/core.js.gz b/backend/staticfiles/admin/js/core.js.gz new file mode 100644 index 0000000..39bab4a Binary files /dev/null and b/backend/staticfiles/admin/js/core.js.gz differ diff --git a/backend/staticfiles/admin/js/filters.0e360b7a9f80.js b/backend/staticfiles/admin/js/filters.0e360b7a9f80.js new file mode 100644 index 0000000..f5536eb --- /dev/null +++ b/backend/staticfiles/admin/js/filters.0e360b7a9f80.js @@ -0,0 +1,30 @@ +/** + * Persist changelist filters state (collapsed/expanded). + */ +'use strict'; +{ + // Init filters. + let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState')); + + if (!filters) { + filters = {}; + } + + Object.entries(filters).forEach(([key, value]) => { + const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); + + // Check if the filter is present, it could be from other view. + if (detailElement) { + value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open'); + } + }); + + // Save filter state when clicks. + const details = document.querySelectorAll('details'); + details.forEach(detail => { + detail.addEventListener('toggle', event => { + filters[`${event.target.dataset.filterTitle}`] = detail.open; + sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters)); + }); + }); +} diff --git a/backend/staticfiles/admin/js/filters.0e360b7a9f80.js.gz b/backend/staticfiles/admin/js/filters.0e360b7a9f80.js.gz new file mode 100644 index 0000000..5bb1833 Binary files /dev/null and b/backend/staticfiles/admin/js/filters.0e360b7a9f80.js.gz differ diff --git a/backend/staticfiles/admin/js/filters.js b/backend/staticfiles/admin/js/filters.js new file mode 100644 index 0000000..f5536eb --- /dev/null +++ b/backend/staticfiles/admin/js/filters.js @@ -0,0 +1,30 @@ +/** + * Persist changelist filters state (collapsed/expanded). + */ +'use strict'; +{ + // Init filters. + let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState')); + + if (!filters) { + filters = {}; + } + + Object.entries(filters).forEach(([key, value]) => { + const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); + + // Check if the filter is present, it could be from other view. + if (detailElement) { + value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open'); + } + }); + + // Save filter state when clicks. + const details = document.querySelectorAll('details'); + details.forEach(detail => { + detail.addEventListener('toggle', event => { + filters[`${event.target.dataset.filterTitle}`] = detail.open; + sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters)); + }); + }); +} diff --git a/backend/staticfiles/admin/js/filters.js.gz b/backend/staticfiles/admin/js/filters.js.gz new file mode 100644 index 0000000..5bb1833 Binary files /dev/null and b/backend/staticfiles/admin/js/filters.js.gz differ diff --git a/backend/staticfiles/admin/js/inlines.22d4d93c00b4.js b/backend/staticfiles/admin/js/inlines.22d4d93c00b4.js new file mode 100644 index 0000000..e9a1dfe --- /dev/null +++ b/backend/staticfiles/admin/js/inlines.22d4d93c00b4.js @@ -0,0 +1,359 @@ +/*global DateTimeShortcuts, SelectFilter*/ +/** + * Django admin inlines + * + * Based on jQuery Formset 1.1 + * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) + * @requires jQuery 1.2.6 or later + * + * Copyright (c) 2009, Stanislaus Madueke + * All rights reserved. + * + * Spiced up with Code from Zain Memon's GSoC project 2009 + * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. + * + * Licensed under the New BSD License + * See: https://opensource.org/licenses/bsd-license.php + */ +'use strict'; +{ + const $ = django.jQuery; + $.fn.formset = function(opts) { + const options = $.extend({}, $.fn.formset.defaults, opts); + const $this = $(this); + const $parent = $this.parent(); + const updateElementIndex = function(el, prefix, ndx) { + const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); + const replacement = prefix + "-" + ndx; + if ($(el).prop("for")) { + $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); + } + if (el.id) { + el.id = el.id.replace(id_regex, replacement); + } + if (el.name) { + el.name = el.name.replace(id_regex, replacement); + } + }; + const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); + let nextIndex = parseInt(totalForms.val(), 10); + const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); + const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off"); + let addButton; + + /** + * The "Add another MyModel" button below the inline forms. + */ + const addInlineAddButton = function() { + if (addButton === null) { + if ($this.prop("tagName") === "TR") { + // If forms are laid out as table rows, insert the + // "add" button in a new table row: + const numCols = $this.eq(-1).children().length; + $parent.append('' + options.addText + ""); + addButton = $parent.find("tr:last a"); + } else { + // Otherwise, insert it immediately after the last form: + $this.filter(":last").after('"); + addButton = $this.filter(":last").next().find("a"); + } + } + addButton.on('click', addInlineClickHandler); + }; + + const addInlineClickHandler = function(e) { + e.preventDefault(); + const template = $("#" + options.prefix + "-empty"); + const row = template.clone(true); + row.removeClass(options.emptyCssClass) + .addClass(options.formCssClass) + .attr("id", options.prefix + "-" + nextIndex); + addInlineDeleteButton(row); + row.find("*").each(function() { + updateElementIndex(this, options.prefix, totalForms.val()); + }); + // Insert the new form when it has been fully edited. + row.insertBefore($(template)); + // Update number of total forms. + $(totalForms).val(parseInt(totalForms.val(), 10) + 1); + nextIndex += 1; + // Hide the add button if there's a limit and it's been reached. + if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { + addButton.parent().hide(); + } + // Show the remove buttons if there are more than min_num. + toggleDeleteButtonVisibility(row.closest('.inline-group')); + + // Pass the new form to the post-add callback, if provided. + if (options.added) { + options.added(row); + } + row.get(0).dispatchEvent(new CustomEvent("formset:added", { + bubbles: true, + detail: { + formsetName: options.prefix + } + })); + }; + + /** + * The "X" button that is part of every unsaved inline. + * (When saved, it is replaced with a "Delete" checkbox.) + */ + const addInlineDeleteButton = function(row) { + if (row.is("tr")) { + // If the forms are laid out in table rows, insert + // the remove button into the last table cell: + row.children(":last").append('"); + } else if (row.is("ul") || row.is("ol")) { + // If they're laid out as an ordered/unordered list, + // insert an
    • after the last list item: + row.append('
    • ' + options.deleteText + "
    • "); + } else { + // Otherwise, just insert the remove button as the + // last child element of the form's container: + row.children(":first").append('' + options.deleteText + ""); + } + // Add delete handler for each row. + row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this)); + }; + + const inlineDeleteHandler = function(e1) { + e1.preventDefault(); + const deleteButton = $(e1.target); + const row = deleteButton.closest('.' + options.formCssClass); + const inlineGroup = row.closest('.inline-group'); + // Remove the parent form containing this button, + // and also remove the relevant row with non-field errors: + const prevRow = row.prev(); + if (prevRow.length && prevRow.hasClass('row-form-errors')) { + prevRow.remove(); + } + row.remove(); + nextIndex -= 1; + // Pass the deleted form to the post-delete callback, if provided. + if (options.removed) { + options.removed(row); + } + document.dispatchEvent(new CustomEvent("formset:removed", { + detail: { + formsetName: options.prefix + } + })); + // Update the TOTAL_FORMS form count. + const forms = $("." + options.formCssClass); + $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); + // Show add button again once below maximum number. + if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { + addButton.parent().show(); + } + // Hide the remove buttons if at min_num. + toggleDeleteButtonVisibility(inlineGroup); + // Also, update names and ids for all remaining form controls so + // they remain in sequence: + let i, formCount; + const updateElementCallback = function() { + updateElementIndex(this, options.prefix, i); + }; + for (i = 0, formCount = forms.length; i < formCount; i++) { + updateElementIndex($(forms).get(i), options.prefix, i); + $(forms.get(i)).find("*").each(updateElementCallback); + } + }; + + const toggleDeleteButtonVisibility = function(inlineGroup) { + if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) { + inlineGroup.find('.inline-deletelink').hide(); + } else { + inlineGroup.find('.inline-deletelink').show(); + } + }; + + $this.each(function(i) { + $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); + }); + + // Create the delete buttons for all unsaved inlines: + $this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() { + addInlineDeleteButton($(this)); + }); + toggleDeleteButtonVisibility($this); + + // Create the add button, initially hidden. + addButton = options.addButton; + addInlineAddButton(); + + // Show the add button if allowed to add more items. + // Note that max_num = None translates to a blank string. + const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0; + if ($this.length && showAddButton) { + addButton.parent().show(); + } else { + addButton.parent().hide(); + } + + return this; + }; + + /* Setup plugin defaults */ + $.fn.formset.defaults = { + prefix: "form", // The form prefix for your django formset + addText: "add another", // Text for the add link + deleteText: "remove", // Text for the delete link + addCssClass: "add-row", // CSS class applied to the add link + deleteCssClass: "delete-row", // CSS class applied to the delete link + emptyCssClass: "empty-row", // CSS class applied to the empty row + formCssClass: "dynamic-form", // CSS class applied to each form in a formset + added: null, // Function called each time a new form is added + removed: null, // Function called each time a form is deleted + addButton: null // Existing add button to use + }; + + + // Tabular inlines --------------------------------------------------------- + $.fn.tabularFormset = function(selector, options) { + const $rows = $(this); + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets are a part of the new form, + // instantiate a new SelectFilter instance for it. + if (typeof SelectFilter !== 'undefined') { + $('.selectfilter').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $('.selectfilterstacked').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + }, + addButton: options.addButton + }); + + return $rows; + }; + + // Stacked inlines --------------------------------------------------------- + $.fn.stackedFormset = function(selector, options) { + const $rows = $(this); + const updateInlineLabel = function(row) { + $(selector).find(".inline_label").each(function(i) { + const count = i + 1; + $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); + }); + }; + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force, yuck. + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets were added, instantiate a new instance. + if (typeof SelectFilter !== "undefined") { + $(".selectfilter").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $(".selectfilterstacked").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + // Dependency in a fieldset. + let field_element = row.find('.form-row .field-' + field_name); + // Dependency without a fieldset. + if (!field_element.length) { + field_element = row.find('.form-row.field-' + field_name); + } + dependencies.push('#' + field_element.find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + removed: updateInlineLabel, + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + updateInlineLabel(row); + }, + addButton: options.addButton + }); + + return $rows; + }; + + $(document).ready(function() { + $(".js-inline-admin-formset").each(function() { + const data = $(this).data(), + inlineOptions = data.inlineFormset; + let selector; + switch(data.inlineType) { + case "stacked": + selector = inlineOptions.name + "-group .inline-related"; + $(selector).stackedFormset(selector, inlineOptions.options); + break; + case "tabular": + selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row"; + $(selector).tabularFormset(selector, inlineOptions.options); + break; + } + }); + }); +} diff --git a/backend/staticfiles/admin/js/inlines.22d4d93c00b4.js.gz b/backend/staticfiles/admin/js/inlines.22d4d93c00b4.js.gz new file mode 100644 index 0000000..f0be39d Binary files /dev/null and b/backend/staticfiles/admin/js/inlines.22d4d93c00b4.js.gz differ diff --git a/backend/staticfiles/admin/js/inlines.js b/backend/staticfiles/admin/js/inlines.js new file mode 100644 index 0000000..e9a1dfe --- /dev/null +++ b/backend/staticfiles/admin/js/inlines.js @@ -0,0 +1,359 @@ +/*global DateTimeShortcuts, SelectFilter*/ +/** + * Django admin inlines + * + * Based on jQuery Formset 1.1 + * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) + * @requires jQuery 1.2.6 or later + * + * Copyright (c) 2009, Stanislaus Madueke + * All rights reserved. + * + * Spiced up with Code from Zain Memon's GSoC project 2009 + * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. + * + * Licensed under the New BSD License + * See: https://opensource.org/licenses/bsd-license.php + */ +'use strict'; +{ + const $ = django.jQuery; + $.fn.formset = function(opts) { + const options = $.extend({}, $.fn.formset.defaults, opts); + const $this = $(this); + const $parent = $this.parent(); + const updateElementIndex = function(el, prefix, ndx) { + const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); + const replacement = prefix + "-" + ndx; + if ($(el).prop("for")) { + $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); + } + if (el.id) { + el.id = el.id.replace(id_regex, replacement); + } + if (el.name) { + el.name = el.name.replace(id_regex, replacement); + } + }; + const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); + let nextIndex = parseInt(totalForms.val(), 10); + const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); + const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off"); + let addButton; + + /** + * The "Add another MyModel" button below the inline forms. + */ + const addInlineAddButton = function() { + if (addButton === null) { + if ($this.prop("tagName") === "TR") { + // If forms are laid out as table rows, insert the + // "add" button in a new table row: + const numCols = $this.eq(-1).children().length; + $parent.append('' + options.addText + ""); + addButton = $parent.find("tr:last a"); + } else { + // Otherwise, insert it immediately after the last form: + $this.filter(":last").after('"); + addButton = $this.filter(":last").next().find("a"); + } + } + addButton.on('click', addInlineClickHandler); + }; + + const addInlineClickHandler = function(e) { + e.preventDefault(); + const template = $("#" + options.prefix + "-empty"); + const row = template.clone(true); + row.removeClass(options.emptyCssClass) + .addClass(options.formCssClass) + .attr("id", options.prefix + "-" + nextIndex); + addInlineDeleteButton(row); + row.find("*").each(function() { + updateElementIndex(this, options.prefix, totalForms.val()); + }); + // Insert the new form when it has been fully edited. + row.insertBefore($(template)); + // Update number of total forms. + $(totalForms).val(parseInt(totalForms.val(), 10) + 1); + nextIndex += 1; + // Hide the add button if there's a limit and it's been reached. + if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { + addButton.parent().hide(); + } + // Show the remove buttons if there are more than min_num. + toggleDeleteButtonVisibility(row.closest('.inline-group')); + + // Pass the new form to the post-add callback, if provided. + if (options.added) { + options.added(row); + } + row.get(0).dispatchEvent(new CustomEvent("formset:added", { + bubbles: true, + detail: { + formsetName: options.prefix + } + })); + }; + + /** + * The "X" button that is part of every unsaved inline. + * (When saved, it is replaced with a "Delete" checkbox.) + */ + const addInlineDeleteButton = function(row) { + if (row.is("tr")) { + // If the forms are laid out in table rows, insert + // the remove button into the last table cell: + row.children(":last").append('"); + } else if (row.is("ul") || row.is("ol")) { + // If they're laid out as an ordered/unordered list, + // insert an
    • after the last list item: + row.append('
    • ' + options.deleteText + "
    • "); + } else { + // Otherwise, just insert the remove button as the + // last child element of the form's container: + row.children(":first").append('' + options.deleteText + ""); + } + // Add delete handler for each row. + row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this)); + }; + + const inlineDeleteHandler = function(e1) { + e1.preventDefault(); + const deleteButton = $(e1.target); + const row = deleteButton.closest('.' + options.formCssClass); + const inlineGroup = row.closest('.inline-group'); + // Remove the parent form containing this button, + // and also remove the relevant row with non-field errors: + const prevRow = row.prev(); + if (prevRow.length && prevRow.hasClass('row-form-errors')) { + prevRow.remove(); + } + row.remove(); + nextIndex -= 1; + // Pass the deleted form to the post-delete callback, if provided. + if (options.removed) { + options.removed(row); + } + document.dispatchEvent(new CustomEvent("formset:removed", { + detail: { + formsetName: options.prefix + } + })); + // Update the TOTAL_FORMS form count. + const forms = $("." + options.formCssClass); + $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); + // Show add button again once below maximum number. + if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { + addButton.parent().show(); + } + // Hide the remove buttons if at min_num. + toggleDeleteButtonVisibility(inlineGroup); + // Also, update names and ids for all remaining form controls so + // they remain in sequence: + let i, formCount; + const updateElementCallback = function() { + updateElementIndex(this, options.prefix, i); + }; + for (i = 0, formCount = forms.length; i < formCount; i++) { + updateElementIndex($(forms).get(i), options.prefix, i); + $(forms.get(i)).find("*").each(updateElementCallback); + } + }; + + const toggleDeleteButtonVisibility = function(inlineGroup) { + if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) { + inlineGroup.find('.inline-deletelink').hide(); + } else { + inlineGroup.find('.inline-deletelink').show(); + } + }; + + $this.each(function(i) { + $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); + }); + + // Create the delete buttons for all unsaved inlines: + $this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() { + addInlineDeleteButton($(this)); + }); + toggleDeleteButtonVisibility($this); + + // Create the add button, initially hidden. + addButton = options.addButton; + addInlineAddButton(); + + // Show the add button if allowed to add more items. + // Note that max_num = None translates to a blank string. + const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0; + if ($this.length && showAddButton) { + addButton.parent().show(); + } else { + addButton.parent().hide(); + } + + return this; + }; + + /* Setup plugin defaults */ + $.fn.formset.defaults = { + prefix: "form", // The form prefix for your django formset + addText: "add another", // Text for the add link + deleteText: "remove", // Text for the delete link + addCssClass: "add-row", // CSS class applied to the add link + deleteCssClass: "delete-row", // CSS class applied to the delete link + emptyCssClass: "empty-row", // CSS class applied to the empty row + formCssClass: "dynamic-form", // CSS class applied to each form in a formset + added: null, // Function called each time a new form is added + removed: null, // Function called each time a form is deleted + addButton: null // Existing add button to use + }; + + + // Tabular inlines --------------------------------------------------------- + $.fn.tabularFormset = function(selector, options) { + const $rows = $(this); + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets are a part of the new form, + // instantiate a new SelectFilter instance for it. + if (typeof SelectFilter !== 'undefined') { + $('.selectfilter').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $('.selectfilterstacked').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + }, + addButton: options.addButton + }); + + return $rows; + }; + + // Stacked inlines --------------------------------------------------------- + $.fn.stackedFormset = function(selector, options) { + const $rows = $(this); + const updateInlineLabel = function(row) { + $(selector).find(".inline_label").each(function(i) { + const count = i + 1; + $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); + }); + }; + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force, yuck. + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets were added, instantiate a new instance. + if (typeof SelectFilter !== "undefined") { + $(".selectfilter").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $(".selectfilterstacked").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + // Dependency in a fieldset. + let field_element = row.find('.form-row .field-' + field_name); + // Dependency without a fieldset. + if (!field_element.length) { + field_element = row.find('.form-row.field-' + field_name); + } + dependencies.push('#' + field_element.find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + removed: updateInlineLabel, + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + updateInlineLabel(row); + }, + addButton: options.addButton + }); + + return $rows; + }; + + $(document).ready(function() { + $(".js-inline-admin-formset").each(function() { + const data = $(this).data(), + inlineOptions = data.inlineFormset; + let selector; + switch(data.inlineType) { + case "stacked": + selector = inlineOptions.name + "-group .inline-related"; + $(selector).stackedFormset(selector, inlineOptions.options); + break; + case "tabular": + selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row"; + $(selector).tabularFormset(selector, inlineOptions.options); + break; + } + }); + }); +} diff --git a/backend/staticfiles/admin/js/inlines.js.gz b/backend/staticfiles/admin/js/inlines.js.gz new file mode 100644 index 0000000..f0be39d Binary files /dev/null and b/backend/staticfiles/admin/js/inlines.js.gz differ diff --git a/backend/staticfiles/admin/js/jquery.init.b7781a0897fc.js b/backend/staticfiles/admin/js/jquery.init.b7781a0897fc.js new file mode 100644 index 0000000..f40b27f --- /dev/null +++ b/backend/staticfiles/admin/js/jquery.init.b7781a0897fc.js @@ -0,0 +1,8 @@ +/*global jQuery:false*/ +'use strict'; +/* Puts the included jQuery into our own namespace using noConflict and passing + * it 'true'. This ensures that the included jQuery doesn't pollute the global + * namespace (i.e. this preserves pre-existing values for both window.$ and + * window.jQuery). + */ +window.django = {jQuery: jQuery.noConflict(true)}; diff --git a/backend/staticfiles/admin/js/jquery.init.b7781a0897fc.js.gz b/backend/staticfiles/admin/js/jquery.init.b7781a0897fc.js.gz new file mode 100644 index 0000000..c3560ae Binary files /dev/null and b/backend/staticfiles/admin/js/jquery.init.b7781a0897fc.js.gz differ diff --git a/backend/staticfiles/admin/js/jquery.init.js b/backend/staticfiles/admin/js/jquery.init.js new file mode 100644 index 0000000..f40b27f --- /dev/null +++ b/backend/staticfiles/admin/js/jquery.init.js @@ -0,0 +1,8 @@ +/*global jQuery:false*/ +'use strict'; +/* Puts the included jQuery into our own namespace using noConflict and passing + * it 'true'. This ensures that the included jQuery doesn't pollute the global + * namespace (i.e. this preserves pre-existing values for both window.$ and + * window.jQuery). + */ +window.django = {jQuery: jQuery.noConflict(true)}; diff --git a/backend/staticfiles/admin/js/jquery.init.js.gz b/backend/staticfiles/admin/js/jquery.init.js.gz new file mode 100644 index 0000000..c3560ae Binary files /dev/null and b/backend/staticfiles/admin/js/jquery.init.js.gz differ diff --git a/backend/staticfiles/admin/js/nav_sidebar.3b9190d420b1.js b/backend/staticfiles/admin/js/nav_sidebar.3b9190d420b1.js new file mode 100644 index 0000000..7e735db --- /dev/null +++ b/backend/staticfiles/admin/js/nav_sidebar.3b9190d420b1.js @@ -0,0 +1,79 @@ +'use strict'; +{ + const toggleNavSidebar = document.getElementById('toggle-nav-sidebar'); + if (toggleNavSidebar !== null) { + const navSidebar = document.getElementById('nav-sidebar'); + const main = document.getElementById('main'); + let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen'); + if (navSidebarIsOpen === null) { + navSidebarIsOpen = 'true'; + } + main.classList.toggle('shifted', navSidebarIsOpen === 'true'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + + toggleNavSidebar.addEventListener('click', function() { + if (navSidebarIsOpen === 'true') { + navSidebarIsOpen = 'false'; + } else { + navSidebarIsOpen = 'true'; + } + localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen); + main.classList.toggle('shifted'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + }); + } + + function initSidebarQuickFilter() { + const options = []; + const navSidebar = document.getElementById('nav-sidebar'); + if (!navSidebar) { + return; + } + navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => { + options.push({title: container.innerHTML, node: container}); + }); + + function checkValue(event) { + let filterValue = event.target.value; + if (filterValue) { + filterValue = filterValue.toLowerCase(); + } + if (event.key === 'Escape') { + filterValue = ''; + event.target.value = ''; // clear input + } + let matches = false; + for (const o of options) { + let displayValue = ''; + if (filterValue) { + if (o.title.toLowerCase().indexOf(filterValue) === -1) { + displayValue = 'none'; + } else { + matches = true; + } + } + // show/hide parent + o.node.parentNode.parentNode.style.display = displayValue; + } + if (!filterValue || matches) { + event.target.classList.remove('no-results'); + } else { + event.target.classList.add('no-results'); + } + sessionStorage.setItem('django.admin.navSidebarFilterValue', filterValue); + } + + const nav = document.getElementById('nav-filter'); + nav.addEventListener('change', checkValue, false); + nav.addEventListener('input', checkValue, false); + nav.addEventListener('keyup', checkValue, false); + + const storedValue = sessionStorage.getItem('django.admin.navSidebarFilterValue'); + if (storedValue) { + nav.value = storedValue; + checkValue({target: nav, key: ''}); + } + } + window.initSidebarQuickFilter = initSidebarQuickFilter; + initSidebarQuickFilter(); +} diff --git a/backend/staticfiles/admin/js/nav_sidebar.3b9190d420b1.js.gz b/backend/staticfiles/admin/js/nav_sidebar.3b9190d420b1.js.gz new file mode 100644 index 0000000..c666ede Binary files /dev/null and b/backend/staticfiles/admin/js/nav_sidebar.3b9190d420b1.js.gz differ diff --git a/backend/staticfiles/admin/js/nav_sidebar.js b/backend/staticfiles/admin/js/nav_sidebar.js new file mode 100644 index 0000000..7e735db --- /dev/null +++ b/backend/staticfiles/admin/js/nav_sidebar.js @@ -0,0 +1,79 @@ +'use strict'; +{ + const toggleNavSidebar = document.getElementById('toggle-nav-sidebar'); + if (toggleNavSidebar !== null) { + const navSidebar = document.getElementById('nav-sidebar'); + const main = document.getElementById('main'); + let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen'); + if (navSidebarIsOpen === null) { + navSidebarIsOpen = 'true'; + } + main.classList.toggle('shifted', navSidebarIsOpen === 'true'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + + toggleNavSidebar.addEventListener('click', function() { + if (navSidebarIsOpen === 'true') { + navSidebarIsOpen = 'false'; + } else { + navSidebarIsOpen = 'true'; + } + localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen); + main.classList.toggle('shifted'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + }); + } + + function initSidebarQuickFilter() { + const options = []; + const navSidebar = document.getElementById('nav-sidebar'); + if (!navSidebar) { + return; + } + navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => { + options.push({title: container.innerHTML, node: container}); + }); + + function checkValue(event) { + let filterValue = event.target.value; + if (filterValue) { + filterValue = filterValue.toLowerCase(); + } + if (event.key === 'Escape') { + filterValue = ''; + event.target.value = ''; // clear input + } + let matches = false; + for (const o of options) { + let displayValue = ''; + if (filterValue) { + if (o.title.toLowerCase().indexOf(filterValue) === -1) { + displayValue = 'none'; + } else { + matches = true; + } + } + // show/hide parent + o.node.parentNode.parentNode.style.display = displayValue; + } + if (!filterValue || matches) { + event.target.classList.remove('no-results'); + } else { + event.target.classList.add('no-results'); + } + sessionStorage.setItem('django.admin.navSidebarFilterValue', filterValue); + } + + const nav = document.getElementById('nav-filter'); + nav.addEventListener('change', checkValue, false); + nav.addEventListener('input', checkValue, false); + nav.addEventListener('keyup', checkValue, false); + + const storedValue = sessionStorage.getItem('django.admin.navSidebarFilterValue'); + if (storedValue) { + nav.value = storedValue; + checkValue({target: nav, key: ''}); + } + } + window.initSidebarQuickFilter = initSidebarQuickFilter; + initSidebarQuickFilter(); +} diff --git a/backend/staticfiles/admin/js/nav_sidebar.js.gz b/backend/staticfiles/admin/js/nav_sidebar.js.gz new file mode 100644 index 0000000..c666ede Binary files /dev/null and b/backend/staticfiles/admin/js/nav_sidebar.js.gz differ diff --git a/backend/staticfiles/admin/js/popup_response.c6cc78ea5551.js b/backend/staticfiles/admin/js/popup_response.c6cc78ea5551.js new file mode 100644 index 0000000..2b1d3dd --- /dev/null +++ b/backend/staticfiles/admin/js/popup_response.c6cc78ea5551.js @@ -0,0 +1,16 @@ +/*global opener */ +'use strict'; +{ + const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse); + switch(initData.action) { + case 'change': + opener.dismissChangeRelatedObjectPopup(window, initData.value, initData.obj, initData.new_value); + break; + case 'delete': + opener.dismissDeleteRelatedObjectPopup(window, initData.value); + break; + default: + opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj); + break; + } +} diff --git a/backend/staticfiles/admin/js/popup_response.c6cc78ea5551.js.gz b/backend/staticfiles/admin/js/popup_response.c6cc78ea5551.js.gz new file mode 100644 index 0000000..095fc38 Binary files /dev/null and b/backend/staticfiles/admin/js/popup_response.c6cc78ea5551.js.gz differ diff --git a/backend/staticfiles/admin/js/popup_response.js b/backend/staticfiles/admin/js/popup_response.js new file mode 100644 index 0000000..2b1d3dd --- /dev/null +++ b/backend/staticfiles/admin/js/popup_response.js @@ -0,0 +1,16 @@ +/*global opener */ +'use strict'; +{ + const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse); + switch(initData.action) { + case 'change': + opener.dismissChangeRelatedObjectPopup(window, initData.value, initData.obj, initData.new_value); + break; + case 'delete': + opener.dismissDeleteRelatedObjectPopup(window, initData.value); + break; + default: + opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj); + break; + } +} diff --git a/backend/staticfiles/admin/js/popup_response.js.gz b/backend/staticfiles/admin/js/popup_response.js.gz new file mode 100644 index 0000000..095fc38 Binary files /dev/null and b/backend/staticfiles/admin/js/popup_response.js.gz differ diff --git a/backend/staticfiles/admin/js/prepopulate.bd2361dfd64d.js b/backend/staticfiles/admin/js/prepopulate.bd2361dfd64d.js new file mode 100644 index 0000000..89e95ab --- /dev/null +++ b/backend/staticfiles/admin/js/prepopulate.bd2361dfd64d.js @@ -0,0 +1,43 @@ +/*global URLify*/ +'use strict'; +{ + const $ = django.jQuery; + $.fn.prepopulate = function(dependencies, maxLength, allowUnicode) { + /* + Depends on urlify.js + Populates a selected field with the values of the dependent fields, + URLifies and shortens the string. + dependencies - array of dependent fields ids + maxLength - maximum length of the URLify'd string + allowUnicode - Unicode support of the URLify'd string + */ + return this.each(function() { + const prepopulatedField = $(this); + + const populate = function() { + // Bail if the field's value has been changed by the user + if (prepopulatedField.data('_changed')) { + return; + } + + const values = []; + $.each(dependencies, function(i, field) { + field = $(field); + if (field.val().length > 0) { + values.push(field.val()); + } + }); + prepopulatedField.val(URLify(values.join(' '), maxLength, allowUnicode)); + }; + + prepopulatedField.data('_changed', false); + prepopulatedField.on('change', function() { + prepopulatedField.data('_changed', true); + }); + + if (!prepopulatedField.val()) { + $(dependencies.join(',')).on('keyup change focus', populate); + } + }); + }; +} diff --git a/backend/staticfiles/admin/js/prepopulate.bd2361dfd64d.js.gz b/backend/staticfiles/admin/js/prepopulate.bd2361dfd64d.js.gz new file mode 100644 index 0000000..0ad31cf Binary files /dev/null and b/backend/staticfiles/admin/js/prepopulate.bd2361dfd64d.js.gz differ diff --git a/backend/staticfiles/admin/js/prepopulate.js b/backend/staticfiles/admin/js/prepopulate.js new file mode 100644 index 0000000..89e95ab --- /dev/null +++ b/backend/staticfiles/admin/js/prepopulate.js @@ -0,0 +1,43 @@ +/*global URLify*/ +'use strict'; +{ + const $ = django.jQuery; + $.fn.prepopulate = function(dependencies, maxLength, allowUnicode) { + /* + Depends on urlify.js + Populates a selected field with the values of the dependent fields, + URLifies and shortens the string. + dependencies - array of dependent fields ids + maxLength - maximum length of the URLify'd string + allowUnicode - Unicode support of the URLify'd string + */ + return this.each(function() { + const prepopulatedField = $(this); + + const populate = function() { + // Bail if the field's value has been changed by the user + if (prepopulatedField.data('_changed')) { + return; + } + + const values = []; + $.each(dependencies, function(i, field) { + field = $(field); + if (field.val().length > 0) { + values.push(field.val()); + } + }); + prepopulatedField.val(URLify(values.join(' '), maxLength, allowUnicode)); + }; + + prepopulatedField.data('_changed', false); + prepopulatedField.on('change', function() { + prepopulatedField.data('_changed', true); + }); + + if (!prepopulatedField.val()) { + $(dependencies.join(',')).on('keyup change focus', populate); + } + }); + }; +} diff --git a/backend/staticfiles/admin/js/prepopulate.js.gz b/backend/staticfiles/admin/js/prepopulate.js.gz new file mode 100644 index 0000000..0ad31cf Binary files /dev/null and b/backend/staticfiles/admin/js/prepopulate.js.gz differ diff --git a/backend/staticfiles/admin/js/prepopulate_init.6cac7f3105b8.js b/backend/staticfiles/admin/js/prepopulate_init.6cac7f3105b8.js new file mode 100644 index 0000000..a58841f --- /dev/null +++ b/backend/staticfiles/admin/js/prepopulate_init.6cac7f3105b8.js @@ -0,0 +1,15 @@ +'use strict'; +{ + const $ = django.jQuery; + const fields = $('#django-admin-prepopulated-fields-constants').data('prepopulatedFields'); + $.each(fields, function(index, field) { + $( + '.empty-form .form-row .field-' + field.name + + ', .empty-form.form-row .field-' + field.name + + ', .empty-form .form-row.field-' + field.name + ).addClass('prepopulated_field'); + $(field.id).data('dependency_list', field.dependency_list).prepopulate( + field.dependency_ids, field.maxLength, field.allowUnicode + ); + }); +} diff --git a/backend/staticfiles/admin/js/prepopulate_init.6cac7f3105b8.js.gz b/backend/staticfiles/admin/js/prepopulate_init.6cac7f3105b8.js.gz new file mode 100644 index 0000000..b000b60 Binary files /dev/null and b/backend/staticfiles/admin/js/prepopulate_init.6cac7f3105b8.js.gz differ diff --git a/backend/staticfiles/admin/js/prepopulate_init.js b/backend/staticfiles/admin/js/prepopulate_init.js new file mode 100644 index 0000000..a58841f --- /dev/null +++ b/backend/staticfiles/admin/js/prepopulate_init.js @@ -0,0 +1,15 @@ +'use strict'; +{ + const $ = django.jQuery; + const fields = $('#django-admin-prepopulated-fields-constants').data('prepopulatedFields'); + $.each(fields, function(index, field) { + $( + '.empty-form .form-row .field-' + field.name + + ', .empty-form.form-row .field-' + field.name + + ', .empty-form .form-row.field-' + field.name + ).addClass('prepopulated_field'); + $(field.id).data('dependency_list', field.dependency_list).prepopulate( + field.dependency_ids, field.maxLength, field.allowUnicode + ); + }); +} diff --git a/backend/staticfiles/admin/js/prepopulate_init.js.gz b/backend/staticfiles/admin/js/prepopulate_init.js.gz new file mode 100644 index 0000000..b000b60 Binary files /dev/null and b/backend/staticfiles/admin/js/prepopulate_init.js.gz differ diff --git a/backend/staticfiles/admin/js/theme.ab270f56bb9c.js b/backend/staticfiles/admin/js/theme.ab270f56bb9c.js new file mode 100644 index 0000000..794cd15 --- /dev/null +++ b/backend/staticfiles/admin/js/theme.ab270f56bb9c.js @@ -0,0 +1,56 @@ +'use strict'; +{ + window.addEventListener('load', function(e) { + + function setTheme(mode) { + if (mode !== "light" && mode !== "dark" && mode !== "auto") { + console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); + mode = "auto"; + } + document.documentElement.dataset.theme = mode; + localStorage.setItem("theme", mode); + } + + function cycleTheme() { + const currentTheme = localStorage.getItem("theme") || "auto"; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + + if (prefersDark) { + // Auto (dark) -> Light -> Dark + if (currentTheme === "auto") { + setTheme("light"); + } else if (currentTheme === "light") { + setTheme("dark"); + } else { + setTheme("auto"); + } + } else { + // Auto (light) -> Dark -> Light + if (currentTheme === "auto") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("light"); + } else { + setTheme("auto"); + } + } + } + + function initTheme() { + // set theme defined in localStorage if there is one, or fallback to auto mode + const currentTheme = localStorage.getItem("theme"); + currentTheme ? setTheme(currentTheme) : setTheme("auto"); + } + + function setupTheme() { + // Attach event handlers for toggling themes + const buttons = document.getElementsByClassName("theme-toggle"); + Array.from(buttons).forEach((btn) => { + btn.addEventListener("click", cycleTheme); + }); + initTheme(); + } + + setupTheme(); + }); +} diff --git a/backend/staticfiles/admin/js/theme.ab270f56bb9c.js.gz b/backend/staticfiles/admin/js/theme.ab270f56bb9c.js.gz new file mode 100644 index 0000000..1e4a36a Binary files /dev/null and b/backend/staticfiles/admin/js/theme.ab270f56bb9c.js.gz differ diff --git a/backend/staticfiles/admin/js/theme.js b/backend/staticfiles/admin/js/theme.js new file mode 100644 index 0000000..794cd15 --- /dev/null +++ b/backend/staticfiles/admin/js/theme.js @@ -0,0 +1,56 @@ +'use strict'; +{ + window.addEventListener('load', function(e) { + + function setTheme(mode) { + if (mode !== "light" && mode !== "dark" && mode !== "auto") { + console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); + mode = "auto"; + } + document.documentElement.dataset.theme = mode; + localStorage.setItem("theme", mode); + } + + function cycleTheme() { + const currentTheme = localStorage.getItem("theme") || "auto"; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + + if (prefersDark) { + // Auto (dark) -> Light -> Dark + if (currentTheme === "auto") { + setTheme("light"); + } else if (currentTheme === "light") { + setTheme("dark"); + } else { + setTheme("auto"); + } + } else { + // Auto (light) -> Dark -> Light + if (currentTheme === "auto") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("light"); + } else { + setTheme("auto"); + } + } + } + + function initTheme() { + // set theme defined in localStorage if there is one, or fallback to auto mode + const currentTheme = localStorage.getItem("theme"); + currentTheme ? setTheme(currentTheme) : setTheme("auto"); + } + + function setupTheme() { + // Attach event handlers for toggling themes + const buttons = document.getElementsByClassName("theme-toggle"); + Array.from(buttons).forEach((btn) => { + btn.addEventListener("click", cycleTheme); + }); + initTheme(); + } + + setupTheme(); + }); +} diff --git a/backend/staticfiles/admin/js/theme.js.gz b/backend/staticfiles/admin/js/theme.js.gz new file mode 100644 index 0000000..1e4a36a Binary files /dev/null and b/backend/staticfiles/admin/js/theme.js.gz differ diff --git a/backend/staticfiles/admin/js/urlify.ae970a820212.js b/backend/staticfiles/admin/js/urlify.ae970a820212.js new file mode 100644 index 0000000..9fc0409 --- /dev/null +++ b/backend/staticfiles/admin/js/urlify.ae970a820212.js @@ -0,0 +1,169 @@ +/*global XRegExp*/ +'use strict'; +{ + const LATIN_MAP = { + 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', + 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', + 'Î': 'I', 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', + 'Õ': 'O', 'Ö': 'O', 'Ő': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', + 'Ü': 'U', 'Ű': 'U', 'Ý': 'Y', 'Þ': 'TH', 'Ÿ': 'Y', 'ß': 'ss', 'à': 'a', + 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', + 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', + 'ï': 'i', 'ð': 'd', 'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', + 'ö': 'o', 'ő': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', + 'ű': 'u', 'ý': 'y', 'þ': 'th', 'ÿ': 'y' + }; + const LATIN_SYMBOLS_MAP = { + '©': '(c)' + }; + const GREEK_MAP = { + 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'ε': 'e', 'ζ': 'z', 'η': 'h', + 'θ': '8', 'ι': 'i', 'κ': 'k', 'λ': 'l', 'μ': 'm', 'ν': 'n', 'ξ': '3', + 'ο': 'o', 'π': 'p', 'ρ': 'r', 'σ': 's', 'τ': 't', 'υ': 'y', 'φ': 'f', + 'χ': 'x', 'ψ': 'ps', 'ω': 'w', 'ά': 'a', 'έ': 'e', 'ί': 'i', 'ό': 'o', + 'ύ': 'y', 'ή': 'h', 'ώ': 'w', 'ς': 's', 'ϊ': 'i', 'ΰ': 'y', 'ϋ': 'y', + 'ΐ': 'i', 'Α': 'A', 'Β': 'B', 'Γ': 'G', 'Δ': 'D', 'Ε': 'E', 'Ζ': 'Z', + 'Η': 'H', 'Θ': '8', 'Ι': 'I', 'Κ': 'K', 'Λ': 'L', 'Μ': 'M', 'Ν': 'N', + 'Ξ': '3', 'Ο': 'O', 'Π': 'P', 'Ρ': 'R', 'Σ': 'S', 'Τ': 'T', 'Υ': 'Y', + 'Φ': 'F', 'Χ': 'X', 'Ψ': 'PS', 'Ω': 'W', 'Ά': 'A', 'Έ': 'E', 'Ί': 'I', + 'Ό': 'O', 'Ύ': 'Y', 'Ή': 'H', 'Ώ': 'W', 'Ϊ': 'I', 'Ϋ': 'Y' + }; + const TURKISH_MAP = { + 'ş': 's', 'Ş': 'S', 'ı': 'i', 'İ': 'I', 'ç': 'c', 'Ç': 'C', 'ü': 'u', + 'Ü': 'U', 'ö': 'o', 'Ö': 'O', 'ğ': 'g', 'Ğ': 'G' + }; + const ROMANIAN_MAP = { + 'ă': 'a', 'î': 'i', 'ș': 's', 'ț': 't', 'â': 'a', + 'Ă': 'A', 'Î': 'I', 'Ș': 'S', 'Ț': 'T', 'Â': 'A' + }; + const RUSSIAN_MAP = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', + 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', + 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' + }; + const UKRAINIAN_MAP = { + 'Є': 'Ye', 'І': 'I', 'Ї': 'Yi', 'Ґ': 'G', 'є': 'ye', 'і': 'i', + 'ї': 'yi', 'ґ': 'g' + }; + const CZECH_MAP = { + 'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', + 'ů': 'u', 'ž': 'z', 'Č': 'C', 'Ď': 'D', 'Ě': 'E', 'Ň': 'N', 'Ř': 'R', + 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ž': 'Z' + }; + const SLOVAK_MAP = { + 'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'í': 'i', 'ľ': 'l', + 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ŕ': 'r', 'š': 's', 'ť': 't', + 'ú': 'u', 'ý': 'y', 'ž': 'z', + 'Á': 'a', 'Ä': 'A', 'Č': 'C', 'Ď': 'D', 'É': 'E', 'Í': 'I', 'Ľ': 'L', + 'Ĺ': 'L', 'Ň': 'N', 'Ó': 'O', 'Ô': 'O', 'Ŕ': 'R', 'Š': 'S', 'Ť': 'T', + 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z' + }; + const POLISH_MAP = { + 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', + 'ź': 'z', 'ż': 'z', + 'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', + 'Ź': 'Z', 'Ż': 'Z' + }; + const LATVIAN_MAP = { + 'ā': 'a', 'č': 'c', 'ē': 'e', 'ģ': 'g', 'ī': 'i', 'ķ': 'k', 'ļ': 'l', + 'ņ': 'n', 'š': 's', 'ū': 'u', 'ž': 'z', + 'Ā': 'A', 'Č': 'C', 'Ē': 'E', 'Ģ': 'G', 'Ī': 'I', 'Ķ': 'K', 'Ļ': 'L', + 'Ņ': 'N', 'Š': 'S', 'Ū': 'U', 'Ž': 'Z' + }; + const ARABIC_MAP = { + 'أ': 'a', 'ب': 'b', 'ت': 't', 'ث': 'th', 'ج': 'g', 'ح': 'h', 'خ': 'kh', 'د': 'd', + 'ذ': 'th', 'ر': 'r', 'ز': 'z', 'س': 's', 'ش': 'sh', 'ص': 's', 'ض': 'd', 'ط': 't', + 'ظ': 'th', 'ع': 'aa', 'غ': 'gh', 'ف': 'f', 'ق': 'k', 'ك': 'k', 'ل': 'l', 'م': 'm', + 'ن': 'n', 'ه': 'h', 'و': 'o', 'ي': 'y' + }; + const LITHUANIAN_MAP = { + 'ą': 'a', 'č': 'c', 'ę': 'e', 'ė': 'e', 'į': 'i', 'š': 's', 'ų': 'u', + 'ū': 'u', 'ž': 'z', + 'Ą': 'A', 'Č': 'C', 'Ę': 'E', 'Ė': 'E', 'Į': 'I', 'Š': 'S', 'Ų': 'U', + 'Ū': 'U', 'Ž': 'Z' + }; + const SERBIAN_MAP = { + 'ђ': 'dj', 'ј': 'j', 'љ': 'lj', 'њ': 'nj', 'ћ': 'c', 'џ': 'dz', + 'đ': 'dj', 'Ђ': 'Dj', 'Ј': 'j', 'Љ': 'Lj', 'Њ': 'Nj', 'Ћ': 'C', + 'Џ': 'Dz', 'Đ': 'Dj' + }; + const AZERBAIJANI_MAP = { + 'ç': 'c', 'ə': 'e', 'ğ': 'g', 'ı': 'i', 'ö': 'o', 'ş': 's', 'ü': 'u', + 'Ç': 'C', 'Ə': 'E', 'Ğ': 'G', 'İ': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U' + }; + const GEORGIAN_MAP = { + 'ა': 'a', 'ბ': 'b', 'გ': 'g', 'დ': 'd', 'ე': 'e', 'ვ': 'v', 'ზ': 'z', + 'თ': 't', 'ი': 'i', 'კ': 'k', 'ლ': 'l', 'მ': 'm', 'ნ': 'n', 'ო': 'o', + 'პ': 'p', 'ჟ': 'j', 'რ': 'r', 'ს': 's', 'ტ': 't', 'უ': 'u', 'ფ': 'f', + 'ქ': 'q', 'ღ': 'g', 'ყ': 'y', 'შ': 'sh', 'ჩ': 'ch', 'ც': 'c', 'ძ': 'dz', + 'წ': 'w', 'ჭ': 'ch', 'ხ': 'x', 'ჯ': 'j', 'ჰ': 'h' + }; + + const ALL_DOWNCODE_MAPS = [ + LATIN_MAP, + LATIN_SYMBOLS_MAP, + GREEK_MAP, + TURKISH_MAP, + ROMANIAN_MAP, + RUSSIAN_MAP, + UKRAINIAN_MAP, + CZECH_MAP, + SLOVAK_MAP, + POLISH_MAP, + LATVIAN_MAP, + ARABIC_MAP, + LITHUANIAN_MAP, + SERBIAN_MAP, + AZERBAIJANI_MAP, + GEORGIAN_MAP + ]; + + const Downcoder = { + 'Initialize': function() { + if (Downcoder.map) { // already made + return; + } + Downcoder.map = {}; + for (const lookup of ALL_DOWNCODE_MAPS) { + Object.assign(Downcoder.map, lookup); + } + Downcoder.regex = new RegExp(Object.keys(Downcoder.map).join('|'), 'g'); + } + }; + + function downcode(slug) { + Downcoder.Initialize(); + return slug.replace(Downcoder.regex, function(m) { + return Downcoder.map[m]; + }); + } + + + function URLify(s, num_chars, allowUnicode) { + // changes, e.g., "Petty theft" to "petty-theft" + if (!allowUnicode) { + s = downcode(s); + } + s = s.toLowerCase(); // convert to lowercase + // if downcode doesn't hit, the char will be stripped here + if (allowUnicode) { + // Keep Unicode letters including both lowercase and uppercase + // characters, whitespace, and dash; remove other characters. + s = XRegExp.replace(s, XRegExp('[^-_\\p{L}\\p{N}\\s]', 'g'), ''); + } else { + s = s.replace(/[^-\w\s]/g, ''); // remove unneeded chars + } + s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces + s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens + s = s.substring(0, num_chars); // trim to first num_chars chars + return s.replace(/-+$/g, ''); // trim any trailing hyphens + } + window.URLify = URLify; +} diff --git a/backend/staticfiles/admin/js/urlify.ae970a820212.js.gz b/backend/staticfiles/admin/js/urlify.ae970a820212.js.gz new file mode 100644 index 0000000..34e9629 Binary files /dev/null and b/backend/staticfiles/admin/js/urlify.ae970a820212.js.gz differ diff --git a/backend/staticfiles/admin/js/urlify.js b/backend/staticfiles/admin/js/urlify.js new file mode 100644 index 0000000..9fc0409 --- /dev/null +++ b/backend/staticfiles/admin/js/urlify.js @@ -0,0 +1,169 @@ +/*global XRegExp*/ +'use strict'; +{ + const LATIN_MAP = { + 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', + 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', + 'Î': 'I', 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', + 'Õ': 'O', 'Ö': 'O', 'Ő': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', + 'Ü': 'U', 'Ű': 'U', 'Ý': 'Y', 'Þ': 'TH', 'Ÿ': 'Y', 'ß': 'ss', 'à': 'a', + 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', + 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', + 'ï': 'i', 'ð': 'd', 'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', + 'ö': 'o', 'ő': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', + 'ű': 'u', 'ý': 'y', 'þ': 'th', 'ÿ': 'y' + }; + const LATIN_SYMBOLS_MAP = { + '©': '(c)' + }; + const GREEK_MAP = { + 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'ε': 'e', 'ζ': 'z', 'η': 'h', + 'θ': '8', 'ι': 'i', 'κ': 'k', 'λ': 'l', 'μ': 'm', 'ν': 'n', 'ξ': '3', + 'ο': 'o', 'π': 'p', 'ρ': 'r', 'σ': 's', 'τ': 't', 'υ': 'y', 'φ': 'f', + 'χ': 'x', 'ψ': 'ps', 'ω': 'w', 'ά': 'a', 'έ': 'e', 'ί': 'i', 'ό': 'o', + 'ύ': 'y', 'ή': 'h', 'ώ': 'w', 'ς': 's', 'ϊ': 'i', 'ΰ': 'y', 'ϋ': 'y', + 'ΐ': 'i', 'Α': 'A', 'Β': 'B', 'Γ': 'G', 'Δ': 'D', 'Ε': 'E', 'Ζ': 'Z', + 'Η': 'H', 'Θ': '8', 'Ι': 'I', 'Κ': 'K', 'Λ': 'L', 'Μ': 'M', 'Ν': 'N', + 'Ξ': '3', 'Ο': 'O', 'Π': 'P', 'Ρ': 'R', 'Σ': 'S', 'Τ': 'T', 'Υ': 'Y', + 'Φ': 'F', 'Χ': 'X', 'Ψ': 'PS', 'Ω': 'W', 'Ά': 'A', 'Έ': 'E', 'Ί': 'I', + 'Ό': 'O', 'Ύ': 'Y', 'Ή': 'H', 'Ώ': 'W', 'Ϊ': 'I', 'Ϋ': 'Y' + }; + const TURKISH_MAP = { + 'ş': 's', 'Ş': 'S', 'ı': 'i', 'İ': 'I', 'ç': 'c', 'Ç': 'C', 'ü': 'u', + 'Ü': 'U', 'ö': 'o', 'Ö': 'O', 'ğ': 'g', 'Ğ': 'G' + }; + const ROMANIAN_MAP = { + 'ă': 'a', 'î': 'i', 'ș': 's', 'ț': 't', 'â': 'a', + 'Ă': 'A', 'Î': 'I', 'Ș': 'S', 'Ț': 'T', 'Â': 'A' + }; + const RUSSIAN_MAP = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', + 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', + 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' + }; + const UKRAINIAN_MAP = { + 'Є': 'Ye', 'І': 'I', 'Ї': 'Yi', 'Ґ': 'G', 'є': 'ye', 'і': 'i', + 'ї': 'yi', 'ґ': 'g' + }; + const CZECH_MAP = { + 'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', + 'ů': 'u', 'ž': 'z', 'Č': 'C', 'Ď': 'D', 'Ě': 'E', 'Ň': 'N', 'Ř': 'R', + 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ž': 'Z' + }; + const SLOVAK_MAP = { + 'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'í': 'i', 'ľ': 'l', + 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ŕ': 'r', 'š': 's', 'ť': 't', + 'ú': 'u', 'ý': 'y', 'ž': 'z', + 'Á': 'a', 'Ä': 'A', 'Č': 'C', 'Ď': 'D', 'É': 'E', 'Í': 'I', 'Ľ': 'L', + 'Ĺ': 'L', 'Ň': 'N', 'Ó': 'O', 'Ô': 'O', 'Ŕ': 'R', 'Š': 'S', 'Ť': 'T', + 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z' + }; + const POLISH_MAP = { + 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', + 'ź': 'z', 'ż': 'z', + 'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', + 'Ź': 'Z', 'Ż': 'Z' + }; + const LATVIAN_MAP = { + 'ā': 'a', 'č': 'c', 'ē': 'e', 'ģ': 'g', 'ī': 'i', 'ķ': 'k', 'ļ': 'l', + 'ņ': 'n', 'š': 's', 'ū': 'u', 'ž': 'z', + 'Ā': 'A', 'Č': 'C', 'Ē': 'E', 'Ģ': 'G', 'Ī': 'I', 'Ķ': 'K', 'Ļ': 'L', + 'Ņ': 'N', 'Š': 'S', 'Ū': 'U', 'Ž': 'Z' + }; + const ARABIC_MAP = { + 'أ': 'a', 'ب': 'b', 'ت': 't', 'ث': 'th', 'ج': 'g', 'ح': 'h', 'خ': 'kh', 'د': 'd', + 'ذ': 'th', 'ر': 'r', 'ز': 'z', 'س': 's', 'ش': 'sh', 'ص': 's', 'ض': 'd', 'ط': 't', + 'ظ': 'th', 'ع': 'aa', 'غ': 'gh', 'ف': 'f', 'ق': 'k', 'ك': 'k', 'ل': 'l', 'م': 'm', + 'ن': 'n', 'ه': 'h', 'و': 'o', 'ي': 'y' + }; + const LITHUANIAN_MAP = { + 'ą': 'a', 'č': 'c', 'ę': 'e', 'ė': 'e', 'į': 'i', 'š': 's', 'ų': 'u', + 'ū': 'u', 'ž': 'z', + 'Ą': 'A', 'Č': 'C', 'Ę': 'E', 'Ė': 'E', 'Į': 'I', 'Š': 'S', 'Ų': 'U', + 'Ū': 'U', 'Ž': 'Z' + }; + const SERBIAN_MAP = { + 'ђ': 'dj', 'ј': 'j', 'љ': 'lj', 'њ': 'nj', 'ћ': 'c', 'џ': 'dz', + 'đ': 'dj', 'Ђ': 'Dj', 'Ј': 'j', 'Љ': 'Lj', 'Њ': 'Nj', 'Ћ': 'C', + 'Џ': 'Dz', 'Đ': 'Dj' + }; + const AZERBAIJANI_MAP = { + 'ç': 'c', 'ə': 'e', 'ğ': 'g', 'ı': 'i', 'ö': 'o', 'ş': 's', 'ü': 'u', + 'Ç': 'C', 'Ə': 'E', 'Ğ': 'G', 'İ': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U' + }; + const GEORGIAN_MAP = { + 'ა': 'a', 'ბ': 'b', 'გ': 'g', 'დ': 'd', 'ე': 'e', 'ვ': 'v', 'ზ': 'z', + 'თ': 't', 'ი': 'i', 'კ': 'k', 'ლ': 'l', 'მ': 'm', 'ნ': 'n', 'ო': 'o', + 'პ': 'p', 'ჟ': 'j', 'რ': 'r', 'ს': 's', 'ტ': 't', 'უ': 'u', 'ფ': 'f', + 'ქ': 'q', 'ღ': 'g', 'ყ': 'y', 'შ': 'sh', 'ჩ': 'ch', 'ც': 'c', 'ძ': 'dz', + 'წ': 'w', 'ჭ': 'ch', 'ხ': 'x', 'ჯ': 'j', 'ჰ': 'h' + }; + + const ALL_DOWNCODE_MAPS = [ + LATIN_MAP, + LATIN_SYMBOLS_MAP, + GREEK_MAP, + TURKISH_MAP, + ROMANIAN_MAP, + RUSSIAN_MAP, + UKRAINIAN_MAP, + CZECH_MAP, + SLOVAK_MAP, + POLISH_MAP, + LATVIAN_MAP, + ARABIC_MAP, + LITHUANIAN_MAP, + SERBIAN_MAP, + AZERBAIJANI_MAP, + GEORGIAN_MAP + ]; + + const Downcoder = { + 'Initialize': function() { + if (Downcoder.map) { // already made + return; + } + Downcoder.map = {}; + for (const lookup of ALL_DOWNCODE_MAPS) { + Object.assign(Downcoder.map, lookup); + } + Downcoder.regex = new RegExp(Object.keys(Downcoder.map).join('|'), 'g'); + } + }; + + function downcode(slug) { + Downcoder.Initialize(); + return slug.replace(Downcoder.regex, function(m) { + return Downcoder.map[m]; + }); + } + + + function URLify(s, num_chars, allowUnicode) { + // changes, e.g., "Petty theft" to "petty-theft" + if (!allowUnicode) { + s = downcode(s); + } + s = s.toLowerCase(); // convert to lowercase + // if downcode doesn't hit, the char will be stripped here + if (allowUnicode) { + // Keep Unicode letters including both lowercase and uppercase + // characters, whitespace, and dash; remove other characters. + s = XRegExp.replace(s, XRegExp('[^-_\\p{L}\\p{N}\\s]', 'g'), ''); + } else { + s = s.replace(/[^-\w\s]/g, ''); // remove unneeded chars + } + s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces + s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens + s = s.substring(0, num_chars); // trim to first num_chars chars + return s.replace(/-+$/g, ''); // trim any trailing hyphens + } + window.URLify = URLify; +} diff --git a/backend/staticfiles/admin/js/urlify.js.gz b/backend/staticfiles/admin/js/urlify.js.gz new file mode 100644 index 0000000..34e9629 Binary files /dev/null and b/backend/staticfiles/admin/js/urlify.js.gz differ diff --git a/backend/staticfiles/admin/js/vendor/jquery/LICENSE.de877aa6d744.txt b/backend/staticfiles/admin/js/vendor/jquery/LICENSE.de877aa6d744.txt new file mode 100644 index 0000000..f642c3f --- /dev/null +++ b/backend/staticfiles/admin/js/vendor/jquery/LICENSE.de877aa6d744.txt @@ -0,0 +1,20 @@ +Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/backend/staticfiles/admin/js/vendor/jquery/LICENSE.de877aa6d744.txt.gz b/backend/staticfiles/admin/js/vendor/jquery/LICENSE.de877aa6d744.txt.gz new file mode 100644 index 0000000..484afc0 Binary files /dev/null and b/backend/staticfiles/admin/js/vendor/jquery/LICENSE.de877aa6d744.txt.gz differ diff --git a/backend/staticfiles/admin/js/vendor/jquery/LICENSE.txt b/backend/staticfiles/admin/js/vendor/jquery/LICENSE.txt new file mode 100644 index 0000000..f642c3f --- /dev/null +++ b/backend/staticfiles/admin/js/vendor/jquery/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/backend/staticfiles/admin/js/vendor/jquery/LICENSE.txt.gz b/backend/staticfiles/admin/js/vendor/jquery/LICENSE.txt.gz new file mode 100644 index 0000000..484afc0 Binary files /dev/null and b/backend/staticfiles/admin/js/vendor/jquery/LICENSE.txt.gz differ diff --git a/backend/staticfiles/admin/js/vendor/jquery/jquery.0208b96062ba.js b/backend/staticfiles/admin/js/vendor/jquery/jquery.0208b96062ba.js new file mode 100644 index 0000000..7f35c11 --- /dev/null +++ b/backend/staticfiles/admin/js/vendor/jquery/jquery.0208b96062ba.js @@ -0,0 +1,10965 @@ +/*! + * jQuery JavaScript Library v3.6.4 + * https://jquery.com/ + * + * Includes Sizzle.js + * https://sizzlejs.com/ + * + * Copyright OpenJS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2023-03-08T15:28Z + */ +( function( global, factory ) { + + "use strict"; + + if ( typeof module === "object" && typeof module.exports === "object" ) { + + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket trac-14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 +// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode +// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common +// enough that all such attempts are guarded in a try block. +"use strict"; + +var arr = []; + +var getProto = Object.getPrototypeOf; + +var slice = arr.slice; + +var flat = arr.flat ? function( array ) { + return arr.flat.call( array ); +} : function( array ) { + return arr.concat.apply( [], array ); +}; + + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var fnToString = hasOwn.toString; + +var ObjectFunctionString = fnToString.call( Object ); + +var support = {}; + +var isFunction = function isFunction( obj ) { + + // Support: Chrome <=57, Firefox <=52 + // In some browsers, typeof returns "function" for HTML elements + // (i.e., `typeof document.createElement( "object" ) === "function"`). + // We don't want to classify *any* DOM node as a function. + // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5 + // Plus for old WebKit, typeof returns "function" for HTML collections + // (e.g., `typeof document.getElementsByTagName("div") === "function"`). (gh-4756) + return typeof obj === "function" && typeof obj.nodeType !== "number" && + typeof obj.item !== "function"; + }; + + +var isWindow = function isWindow( obj ) { + return obj != null && obj === obj.window; + }; + + +var document = window.document; + + + + var preservedScriptAttributes = { + type: true, + src: true, + nonce: true, + noModule: true + }; + + function DOMEval( code, node, doc ) { + doc = doc || document; + + var i, val, + script = doc.createElement( "script" ); + + script.text = code; + if ( node ) { + for ( i in preservedScriptAttributes ) { + + // Support: Firefox 64+, Edge 18+ + // Some browsers don't support the "nonce" property on scripts. + // On the other hand, just using `getAttribute` is not enough as + // the `nonce` attribute is reset to an empty string whenever it + // becomes browsing-context connected. + // See https://github.com/whatwg/html/issues/2369 + // See https://html.spec.whatwg.org/#nonce-attributes + // The `node.getAttribute` check was added for the sake of + // `jQuery.globalEval` so that it can fake a nonce-containing node + // via an object. + val = node[ i ] || node.getAttribute && node.getAttribute( i ); + if ( val ) { + script.setAttribute( i, val ); + } + } + } + doc.head.appendChild( script ).parentNode.removeChild( script ); + } + + +function toType( obj ) { + if ( obj == null ) { + return obj + ""; + } + + // Support: Android <=2.3 only (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; +} +/* global Symbol */ +// Defining this global in .eslintrc.json would create a danger of using the global +// unguarded in another place, it seems safer to define global only for this module + + + +var + version = "3.6.4", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }; + +jQuery.fn = jQuery.prototype = { + + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + + // Return all the elements in a clean array + if ( num == null ) { + return slice.call( this ); + } + + // Return just the one element from the set + return num < 0 ? this[ num + this.length ] : this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + each: function( callback ) { + return jQuery.each( this, callback ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + even: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return ( i + 1 ) % 2; + } ) ); + }, + + odd: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return i % 2; + } ) ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !isFunction( target ) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + + // Extend the base object + for ( name in options ) { + copy = options[ name ]; + + // Prevent Object.prototype pollution + // Prevent never-ending loop + if ( name === "__proto__" || target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = Array.isArray( copy ) ) ) ) { + src = target[ name ]; + + // Ensure proper type for the source value + if ( copyIsArray && !Array.isArray( src ) ) { + clone = []; + } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { + clone = {}; + } else { + clone = src; + } + copyIsArray = false; + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend( { + + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isPlainObject: function( obj ) { + var proto, Ctor; + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if ( !obj || toString.call( obj ) !== "[object Object]" ) { + return false; + } + + proto = getProto( obj ); + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if ( !proto ) { + return true; + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; + return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; + }, + + isEmptyObject: function( obj ) { + var name; + + for ( name in obj ) { + return false; + } + return true; + }, + + // Evaluates a script in a provided context; falls back to the global one + // if not specified. + globalEval: function( code, options, doc ) { + DOMEval( code, { nonce: options && options.nonce }, doc ); + }, + + each: function( obj, callback ) { + var length, i = 0; + + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } else { + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } + + return obj; + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArrayLike( Object( arr ) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var length, value, + i = 0, + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return flat( ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); + +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} + +// Populate the class2type map +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), + function( _i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); + } ); + +function isArrayLike( obj ) { + + // Support: real iOS 8.2 only (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, + type = toType( obj ); + + if ( isFunction( obj ) || isWindow( obj ) ) { + return false; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.3.10 + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://js.foundation/ + * + * Date: 2023-02-14 + */ +( function( window ) { +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + nonnativeSelectorCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // Instance methods + hasOwn = ( {} ).hasOwnProperty, + arr = [], + pop = arr.pop, + pushNative = arr.push, + push = arr.push, + slice = arr.slice, + + // Use a stripped-down indexOf as it's faster than native + // https://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[ i ] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|" + + "ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + identifier = "(?:\\\\[\\da-fA-F]{1,6}" + whitespace + + "?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+", + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + + // "Attribute values must be CSS identifiers [capture 5] + // or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + + whitespace + "*\\]", + + pseudos = ":(" + identifier + ")(?:\\((" + + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rleadingCombinator = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + + "*" ), + rdescend = new RegExp( whitespace + "|>" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + + whitespace + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + + whitespace + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rhtml = /HTML$/i, + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + + // CSS escapes + // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\[\\da-fA-F]{1,6}" + whitespace + "?|\\\\([^\\r\\n\\f])", "g" ), + funescape = function( escape, nonHex ) { + var high = "0x" + escape.slice( 1 ) - 0x10000; + + return nonHex ? + + // Strip the backslash prefix from a non-hex escape sequence + nonHex : + + // Replace a hexadecimal escape sequence with the encoded Unicode code point + // Support: IE <=11+ + // For values outside the Basic Multilingual Plane (BMP), manually construct a + // surrogate pair + high < 0 ? + String.fromCharCode( high + 0x10000 ) : + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // CSS string/identifier serialization + // https://drafts.csswg.org/cssom/#common-serializing-idioms + rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, + fcssescape = function( ch, asCodePoint ) { + if ( asCodePoint ) { + + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if ( ch === "\0" ) { + return "\uFFFD"; + } + + // Control characters and (dependent upon position) numbers get escaped as code points + return ch.slice( 0, -1 ) + "\\" + + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; + } + + // Other potentially-special ASCII characters get backslash-escaped + return "\\" + ch; + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }, + + inDisabledFieldset = addCombinator( + function( elem ) { + return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; + }, + { dir: "parentNode", next: "legend" } + ); + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + ( arr = slice.call( preferredDoc.childNodes ) ), + preferredDoc.childNodes + ); + + // Support: Android<4.0 + // Detect silently failing push.apply + // eslint-disable-next-line no-unused-expressions + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + pushNative.apply( target, slice.call( els ) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + + // Can't trust NodeList.length + while ( ( target[ j++ ] = els[ i++ ] ) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + setDocument( context ); + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && ( match = rquickExpr.exec( selector ) ) ) { + + // ID selector + if ( ( m = match[ 1 ] ) ) { + + // Document context + if ( nodeType === 9 ) { + if ( ( elem = context.getElementById( m ) ) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && ( elem = newContext.getElementById( m ) ) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[ 2 ] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( ( m = match[ 3 ] ) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !nonnativeSelectorCache[ selector + " " ] && + ( !rbuggyQSA || !rbuggyQSA.test( selector ) ) && + + // Support: IE 8 only + // Exclude object elements + ( nodeType !== 1 || context.nodeName.toLowerCase() !== "object" ) ) { + + newSelector = selector; + newContext = context; + + // qSA considers elements outside a scoping root when evaluating child or + // descendant combinators, which is not what we want. + // In such cases, we work around the behavior by prefixing every selector in the + // list with an ID selector referencing the scope context. + // The technique has to be used as well when a leading combinator is used + // as such selectors are not recognized by querySelectorAll. + // Thanks to Andrew Dupont for this technique. + if ( nodeType === 1 && + ( rdescend.test( selector ) || rleadingCombinator.test( selector ) ) ) { + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + + // We can use :scope instead of the ID hack if the browser + // supports it & if we're not changing the context. + if ( newContext !== context || !support.scope ) { + + // Capture the context ID, setting it first if necessary + if ( ( nid = context.getAttribute( "id" ) ) ) { + nid = nid.replace( rcssescape, fcssescape ); + } else { + context.setAttribute( "id", ( nid = expando ) ); + } + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + while ( i-- ) { + groups[ i ] = ( nid ? "#" + nid : ":scope" ) + " " + + toSelector( groups[ i ] ); + } + newSelector = groups.join( "," ); + } + + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + nonnativeSelectorCache( selector, true ); + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {function(string, object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return ( cache[ key + " " ] = value ); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created element and returns a boolean result + */ +function assert( fn ) { + var el = document.createElement( "fieldset" ); + + try { + return !!fn( el ); + } catch ( e ) { + return false; + } finally { + + // Remove from its parent by default + if ( el.parentNode ) { + el.parentNode.removeChild( el ); + } + + // release memory in IE + el = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split( "|" ), + i = arr.length; + + while ( i-- ) { + Expr.attrHandle[ arr[ i ] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + a.sourceIndex - b.sourceIndex; + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( ( cur = cur.nextSibling ) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return ( name === "input" || name === "button" ) && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for :enabled/:disabled + * @param {Boolean} disabled true for :disabled; false for :enabled + */ +function createDisabledPseudo( disabled ) { + + // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable + return function( elem ) { + + // Only certain elements can match :enabled or :disabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled + if ( "form" in elem ) { + + // Check for inherited disabledness on relevant non-disabled elements: + // * listed form-associated elements in a disabled fieldset + // https://html.spec.whatwg.org/multipage/forms.html#category-listed + // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled + // * option elements in a disabled optgroup + // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled + // All such elements have a "form" property. + if ( elem.parentNode && elem.disabled === false ) { + + // Option elements defer to a parent optgroup if present + if ( "label" in elem ) { + if ( "label" in elem.parentNode ) { + return elem.parentNode.disabled === disabled; + } else { + return elem.disabled === disabled; + } + } + + // Support: IE 6 - 11 + // Use the isDisabled shortcut property to check for disabled fieldset ancestors + return elem.isDisabled === disabled || + + // Where there is no isDisabled, check manually + /* jshint -W018 */ + elem.isDisabled !== !disabled && + inDisabledFieldset( elem ) === disabled; + } + + return elem.disabled === disabled; + + // Try to winnow out elements that can't be disabled before trusting the disabled property. + // Some victims get caught in our net (label, legend, menu, track), but it shouldn't + // even exist on them, let alone have a boolean value. + } else if ( "label" in elem ) { + return elem.disabled === disabled; + } + + // Remaining elements are neither :enabled nor :disabled + return false; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction( function( argument ) { + argument = +argument; + return markFunction( function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ ( j = matchIndexes[ i ] ) ] ) { + seed[ j ] = !( matches[ j ] = seed[ j ] ); + } + } + } ); + } ); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + var namespace = elem && elem.namespaceURI, + docElem = elem && ( elem.ownerDocument || elem ).documentElement; + + // Support: IE <=8 + // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes + // https://bugs.jquery.com/ticket/4833 + return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, subWindow, + doc = node ? node.ownerDocument || node : preferredDoc; + + // Return early if doc is invalid or already selected + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( doc == document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Update global variables + document = doc; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); + + // Support: IE 9 - 11+, Edge 12 - 18+ + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( preferredDoc != document && + ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) { + + // Support: IE 11, Edge + if ( subWindow.addEventListener ) { + subWindow.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( subWindow.attachEvent ) { + subWindow.attachEvent( "onunload", unloadHandler ); + } + } + + // Support: IE 8 - 11+, Edge 12 - 18+, Chrome <=16 - 25 only, Firefox <=3.6 - 31 only, + // Safari 4 - 5 only, Opera <=11.6 - 12.x only + // IE/Edge & older browsers don't support the :scope pseudo-class. + // Support: Safari 6.0 only + // Safari 6.0 supports :scope but it's an alias of :root there. + support.scope = assert( function( el ) { + docElem.appendChild( el ).appendChild( document.createElement( "div" ) ); + return typeof el.querySelectorAll !== "undefined" && + !el.querySelectorAll( ":scope fieldset div" ).length; + } ); + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Make sure the the `:has()` argument is parsed unforgivingly. + // We include `*` in the test to detect buggy implementations that are + // _selectively_ forgiving (specifically when the list includes at least + // one valid selector). + // Note that we treat complete lack of support for `:has()` as if it were + // spec-compliant support, which is fine because use of `:has()` in such + // environments will fail in the qSA path and fall back to jQuery traversal + // anyway. + support.cssHas = assert( function() { + try { + document.querySelector( ":has(*,:jqfake)" ); + return false; + } catch ( e ) { + return true; + } + } ); + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert( function( el ) { + el.className = "i"; + return !el.getAttribute( "className" ); + } ); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert( function( el ) { + el.appendChild( document.createComment( "" ) ); + return !el.getElementsByTagName( "*" ).length; + } ); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programmatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert( function( el ) { + docElem.appendChild( el ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; + } ); + + // ID filter and find + if ( support.getById ) { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute( "id" ) === attrId; + }; + }; + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var elem = context.getElementById( id ); + return elem ? [ elem ] : []; + } + }; + } else { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode( "id" ); + return node && node.value === attrId; + }; + }; + + // Support: IE 6 - 7 only + // getElementById is not reliable as a find shortcut + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var node, i, elems, + elem = context.getElementById( id ); + + if ( elem ) { + + // Verify the id attribute + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + + // Fall back on getElementsByName + elems = context.getElementsByName( id ); + i = 0; + while ( ( elem = elems[ i++ ] ) ) { + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + } + } + + return []; + } + }; + } + + // Tag + Expr.find[ "TAG" ] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( ( elem = results[ i++ ] ) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find[ "CLASS" ] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See https://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( ( support.qsa = rnative.test( document.querySelectorAll ) ) ) { + + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert( function( el ) { + + var input; + + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // https://bugs.jquery.com/ticket/12359 + docElem.appendChild( el ).innerHTML = "" + + ""; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( el.querySelectorAll( "[msallowcapture^='']" ).length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !el.querySelectorAll( "[selected]" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push( "~=" ); + } + + // Support: IE 11+, Edge 15 - 18+ + // IE 11/Edge don't find elements on a `[name='']` query in some cases. + // Adding a temporary attribute to the document before the selection works + // around the issue. + // Interestingly, IE 10 & older don't seem to have the issue. + input = document.createElement( "input" ); + input.setAttribute( "name", "" ); + el.appendChild( input ); + if ( !el.querySelectorAll( "[name='']" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*name" + whitespace + "*=" + + whitespace + "*(?:''|\"\")" ); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !el.querySelectorAll( ":checked" ).length ) { + rbuggyQSA.push( ":checked" ); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibling-combinator selector` fails + if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push( ".#.+[+~]" ); + } + + // Support: Firefox <=3.6 - 5 only + // Old Firefox doesn't throw on a badly-escaped identifier. + el.querySelectorAll( "\\\f" ); + rbuggyQSA.push( "[\\r\\n\\f]" ); + } ); + + assert( function( el ) { + el.innerHTML = "" + + ""; + + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement( "input" ); + input.setAttribute( "type", "hidden" ); + el.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( el.querySelectorAll( "[name=d]" ).length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( el.querySelectorAll( ":enabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: IE9-11+ + // IE's :disabled selector does not pick up the children of disabled fieldsets + docElem.appendChild( el ).disabled = true; + if ( el.querySelectorAll( ":disabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: Opera 10 - 11 only + // Opera 10-11 does not throw on post-comma invalid pseudos + el.querySelectorAll( "*,:x" ); + rbuggyQSA.push( ",.*:" ); + } ); + } + + if ( ( support.matchesSelector = rnative.test( ( matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector ) ) ) ) { + + assert( function( el ) { + + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( el, "*" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( el, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + } ); + } + + if ( !support.cssHas ) { + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Our regular `try-catch` mechanism fails to detect natively-unsupported + // pseudo-classes inside `:has()` (such as `:has(:contains("Foo"))`) + // in browsers that parse the `:has()` argument as a forgiving selector list. + // https://drafts.csswg.org/selectors/#relational now requires the argument + // to be parsed unforgivingly, but browsers have not yet fully adjusted. + rbuggyQSA.push( ":has" ); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( "|" ) ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join( "|" ) ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully self-exclusive + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + + // Support: IE <9 only + // IE doesn't have `contains` on `document` so we need to check for + // `documentElement` presence. + // We need to fall back to `a` when `documentElement` is missing + // as `ownerDocument` of elements within `